Unified · Split
| @@ -1,13 +0,0 @@ | |||
| 1 | { | ||
| 2 | "sources": { | ||
| 3 | "github-v3-rest-api": { | ||
| 4 | "name": "GitHub v3 REST API", | ||
| 5 | "kind": "openapi" | ||
| 6 | }, | ||
| 7 | "axiom-mcp": { | ||
| 8 | "kind": "mcp", | ||
| 9 | "name": "Axiom MCP", | ||
| 10 | "namespace": "axiom.mcp" | ||
| 11 | } | ||
| 12 | } | ||
| 13 | } | ||
| @@ -0,0 +1,45 @@ | |||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | |||
| 3 | const DemoInputSchema = Schema.standardSchemaV1( | ||
| 4 | Schema.Struct({ | ||
| 5 | name: Schema.optional(Schema.String), | ||
| 6 | enthusiasm: Schema.optional(Schema.Number), | ||
| 7 | }), | ||
| 8 | ); | ||
| 9 | |||
| 10 | const DemoOutputSchema = Schema.standardSchemaV1( | ||
| 11 | Schema.Struct({ | ||
| 12 | tool: Schema.String, | ||
| 13 | message: Schema.String, | ||
| 14 | inputEcho: Schema.Struct({ | ||
| 15 | name: Schema.String, | ||
| 16 | enthusiasm: Schema.Number, | ||
| 17 | }), | ||
| 18 | }), | ||
| 19 | ); | ||
| 20 | |||
| 21 | export default { | ||
| 22 | description: "Demo local tool loaded from .executor/tools/demo.ts", | ||
| 23 | inputSchema: DemoInputSchema, | ||
| 24 | outputSchema: DemoOutputSchema, | ||
| 25 | execute: ({ | ||
| 26 | name, | ||
| 27 | enthusiasm, | ||
| 28 | }: { | ||
| 29 | name?: string; | ||
| 30 | enthusiasm?: number; | ||
| 31 | }) => { | ||
| 32 | const trimmedName = name?.trim(); | ||
| 33 | const safeName = trimmedName && trimmedName.length > 0 ? trimmedName : "world"; | ||
| 34 | const safeEnthusiasm = Math.max(1, Math.min(5, Math.round(enthusiasm ?? 1))); | ||
| 35 | |||
| 36 | return { | ||
| 37 | tool: "demo", | ||
| 38 | message: `Hello, ${safeName}${"!".repeat(safeEnthusiasm)}`, | ||
| 39 | inputEcho: { | ||
| 40 | name: safeName, | ||
| 41 | enthusiasm: safeEnthusiasm, | ||
| 42 | }, | ||
| 43 | }; | ||
| 44 | }, | ||
| 45 | }; | ||
| @@ -19,6 +19,8 @@ · 18 unmodified lines | |||
| 19 | .executor/* | 19 | .executor/* |
| 20 | !.executor/executor.json | 20 | !.executor/executor.json |
| 21 | !.executor/executor.jsonc | 21 | !.executor/executor.jsonc |
| 22 | !.executor/tools/ | ||
| 23 | !.executor/tools/** | ||
| 22 | .executor/artifacts/ | 24 | .executor/artifacts/ |
| 23 | .executor/state/ | 25 | .executor/state/ |
| 24 | .executor/types/ | 26 | .executor/types/ |
| @@ -84,4 +86,4 @@ personal-notes/ · 59 unmodified lines | |||
| 84 | .tmp/ | 86 | .tmp/ |
| 85 | executor/.env.executor-push | 87 | executor/.env.executor-push |
| 86 | ignored/ | 88 | ignored/ |
| 87 | agent-notes/ | 89 | agent-notes/ |
| @@ -40,6 +40,9 @@ · 39 unmodified lines | |||
| 40 | "@executor/executor-mcp": "workspace:*", | 40 | "@executor/executor-mcp": "workspace:*", |
| 41 | "@executor/plugin-google-discovery-sdk": "workspace:*", | 41 | "@executor/plugin-google-discovery-sdk": "workspace:*", |
| 42 | "@executor/plugin-graphql-sdk": "workspace:*", | 42 | "@executor/plugin-graphql-sdk": "workspace:*", |
| 43 | "@executor/plugin-keychain-secret-store-sdk": "workspace:*", | ||
| 44 | "@executor/plugin-local-secret-store-sdk": "workspace:*", | ||
| 45 | "@executor/plugin-local-tools-sdk": "workspace:*", | ||
| 43 | "@executor/plugin-mcp-sdk": "workspace:*", | 46 | "@executor/plugin-mcp-sdk": "workspace:*", |
| 44 | "@executor/plugin-openapi-sdk": "workspace:*", | 47 | "@executor/plugin-openapi-sdk": "workspace:*", |
| 45 | "@executor/platform-api": "workspace:*", | 48 | "@executor/platform-api": "workspace:*", |
| @@ -20,6 +20,15 @@ import { · 19 unmodified lines | |||
| 20 | import { | 20 | import { |
| 21 | graphqlSdkPlugin, | 21 | graphqlSdkPlugin, |
| 22 | } from "@executor/plugin-graphql-sdk"; | 22 | } from "@executor/plugin-graphql-sdk"; |
| 23 | import { | ||
| 24 | keychainSecretStoreSdkPlugin, | ||
| 25 | } from "@executor/plugin-keychain-secret-store-sdk"; | ||
| 26 | import { | ||
| 27 | localSecretStoreSdkPlugin, | ||
| 28 | } from "@executor/plugin-local-secret-store-sdk"; | ||
| 29 | import { | ||
| 30 | localToolsSdkPlugin, | ||
| 31 | } from "@executor/plugin-local-tools-sdk"; | ||
| 23 | import { | 32 | import { |
| 24 | mcpSdkPlugin, | 33 | mcpSdkPlugin, |
| 25 | } from "@executor/plugin-mcp-sdk"; | 34 | } from "@executor/plugin-mcp-sdk"; |
| @@ -34,7 +43,9 @@ import { · 8 unmodified lines | |||
| 34 | createExecutorEffect, | 43 | createExecutorEffect, |
| 35 | type ExecutorEffect as Executor, | 44 | type ExecutorEffect as Executor, |
| 36 | } from "@executor/platform-sdk/effect"; | 45 | } from "@executor/platform-sdk/effect"; |
| 37 | import { createLocalExecutorBackend } from "@executor/platform-sdk-file"; | 46 | import { |
| 47 | createLocalExecutorBackend, | ||
| 48 | } from "@executor/platform-sdk-file"; | ||
| 38 | import { | 49 | import { |
| 39 | ExecutionIdSchema, | 50 | ExecutionIdSchema, |
| 40 | type ExecutionEnvelope, | 51 | type ExecutionEnvelope, |
| @@ -300,9 +311,13 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 259 unmodified lines | |||
| 300 | Effect.acquireUseRelease( | 311 | Effect.acquireUseRelease( |
| 301 | createExecutorEffect({ | 312 | createExecutorEffect({ |
| 302 | backend: createLocalExecutorBackend({ | 313 | backend: createLocalExecutorBackend({ |
| 314 | cwd: process.cwd(), | ||
| 303 | localDataDir: CLI_LOCAL_DATA_DIR, | 315 | localDataDir: CLI_LOCAL_DATA_DIR, |
| 304 | }), | 316 | }), |
| 305 | plugins: [ | 317 | plugins: [ |
| 318 | localSecretStoreSdkPlugin, | ||
| 319 | keychainSecretStoreSdkPlugin, | ||
| 320 | localToolsSdkPlugin(), | ||
| 306 | graphqlSdkPlugin({ | 321 | graphqlSdkPlugin({ |
| 307 | storage: createFileGraphqlSourceStorage({ | 322 | storage: createFileGraphqlSourceStorage({ |
| 308 | rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/graphql/sources`, | 323 | rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/graphql/sources`, |
| @@ -12,8 +12,10 @@ · 11 unmodified lines | |||
| 12 | "@executor/plugin-execution-history-react": "workspace:*", | 12 | "@executor/plugin-execution-history-react": "workspace:*", |
| 13 | "@executor/plugin-google-discovery-react": "workspace:*", | 13 | "@executor/plugin-google-discovery-react": "workspace:*", |
| 14 | "@executor/plugin-graphql-react": "workspace:*", | 14 | "@executor/plugin-graphql-react": "workspace:*", |
| 15 | "@executor/plugin-local-tools-react": "workspace:*", | ||
| 15 | "@executor/plugin-mcp-react": "workspace:*", | 16 | "@executor/plugin-mcp-react": "workspace:*", |
| 16 | "@executor/plugin-openapi-react": "workspace:*", | 17 | "@executor/plugin-openapi-react": "workspace:*", |
| 18 | "@executor/plugin-onepassword-react": "workspace:*", | ||
| 17 | "@executor/react": "workspace:*", | 19 | "@executor/react": "workspace:*", |
| 18 | "@executor/server": "workspace:*", | 20 | "@executor/server": "workspace:*", |
| 19 | "@shikijs/langs": "^4.0.1", | 21 | "@shikijs/langs": "^4.0.1", |
| @@ -150,3 +150,11 @@ export function IconArrowLeft({ className, ...props }: IconProps) { · 149 unmodified lines | |||
| 150 | </svg> | 150 | </svg> |
| 151 | ); | 151 | ); |
| 152 | } | 152 | } |
| 153 | |||
| 154 | export function IconMenu({ className, ...props }: IconProps) { | ||
| 155 | return ( | ||
| 156 | <svg viewBox="0 0 16 16" fill="none" className={cn("size-4", className)} {...props}> | ||
| 157 | <path d="M3 4.5h10M3 8h10M3 11.5h10" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" /> | ||
| 158 | </svg> | ||
| 159 | ); | ||
| 160 | } | ||
| @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from "react"; · 2 unmodified lines | |||
| 3 | import { useSources, type Source } from "@executor/react"; | 3 | import { useSources, type Source } from "@executor/react"; |
| 4 | import { sourcePluginsIndexPath } from "@executor/react/plugins"; | 4 | import { sourcePluginsIndexPath } from "@executor/react/plugins"; |
| 5 | import { cn } from "../lib/utils"; | 5 | import { cn } from "../lib/utils"; |
| 6 | import { IconPlus, IconCopy, IconCheck } from "./icons"; | 6 | import { IconPlus, IconCopy, IconCheck, IconClose, IconMenu } from "./icons"; |
| 7 | import { LoadableBlock } from "./loadable"; | 7 | import { LoadableBlock } from "./loadable"; |
| 8 | import { SourceFavicon } from "./source-favicon"; | 8 | import { SourceFavicon } from "./source-favicon"; |
| 9 | import { | 9 | import { |
| @@ -224,6 +224,7 @@ export function AppShell() { · 214 unmodified lines | |||
| 224 | const sources = useSources(); | 224 | const sources = useSources(); |
| 225 | const location = useLocation(); | 225 | const location = useLocation(); |
| 226 | const matchRoute = useMatchRoute(); | 226 | const matchRoute = useMatchRoute(); |
| 227 | const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); | ||
| 227 | const isHome = matchRoute({ to: "/" }); | 228 | const isHome = matchRoute({ to: "/" }); |
| 228 | const isSecrets = matchRoute({ to: "/secrets" }); | 229 | const isSecrets = matchRoute({ to: "/secrets" }); |
| 229 | const { latestVersion, updateAvailable, channel } = useLatestVersion(VITE_APP_VERSION); | 230 | const { latestVersion, updateAvailable, channel } = useLatestVersion(VITE_APP_VERSION); |
| @@ -234,11 +235,125 @@ export function AppShell() { · 4 unmodified lines | |||
| 234 | ({ route }) => route.nav?.section === "sources", | 235 | ({ route }) => route.nav?.section === "sources", |
| 235 | ); | 236 | ); |
| 236 | 237 | ||
| 238 | useEffect(() => { | ||
| 239 | setMobileSidebarOpen(false); | ||
| 240 | }, [location.pathname]); | ||
| 241 | |||
| 242 | useEffect(() => { | ||
| 243 | if (!mobileSidebarOpen) { | ||
| 244 | return; | ||
| 245 | } | ||
| 246 | |||
| 247 | const previousOverflow = document.body.style.overflow; | ||
| 248 | document.body.style.overflow = "hidden"; | ||
| 249 | return () => { | ||
| 250 | document.body.style.overflow = previousOverflow; | ||
| 251 | }; | ||
| 252 | }, [mobileSidebarOpen]); | ||
| 253 | |||
| 237 | return ( | 254 | return ( |
| 238 | <div className="flex h-screen overflow-hidden"> | 255 | <div className="flex h-screen overflow-hidden"> |
| 239 | {/* Sidebar */} | 256 | <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56"> |
| 240 | <aside className="flex w-52 shrink-0 flex-col border-r border-sidebar-border bg-sidebar lg:w-56"> | 257 | <SidebarContent |
| 241 | {/* Brand */} | 258 | isHome={!!isHome} |
| 259 | isSecrets={!!isSecrets} | ||
| 260 | latestVersion={latestVersion} | ||
| 261 | updateAvailable={updateAvailable} | ||
| 262 | channel={channel} | ||
| 263 | locationPathname={location.pathname} | ||
| 264 | mainPluginNavItems={mainPluginNavItems} | ||
| 265 | sourcePluginNavItems={sourcePluginNavItems} | ||
| 266 | sources={sources} | ||
| 267 | /> | ||
| 268 | </aside> | ||
| 269 | |||
| 270 | {mobileSidebarOpen && ( | ||
| 271 | <div className="fixed inset-0 z-50 flex md:hidden"> | ||
| 272 | <button | ||
| 273 | type="button" | ||
| 274 | aria-label="Close navigation" | ||
| 275 | className="absolute inset-0 bg-black/45 backdrop-blur-[1px]" | ||
| 276 | onClick={() => setMobileSidebarOpen(false)} | ||
| 277 | /> | ||
| 278 | <div className="relative flex h-full w-[84vw] max-w-xs flex-col border-r border-sidebar-border bg-sidebar shadow-2xl"> | ||
| 279 | <div className="flex h-12 shrink-0 items-center justify-between border-b border-sidebar-border px-4"> | ||
| 280 | <Link to="/" className="flex items-center gap-1.5"> | ||
| 281 | <span className="font-display text-base tracking-tight text-foreground"> | ||
| 282 | executor | ||
| 283 | </span> | ||
| 284 | <span className="rounded bg-primary/15 px-1.5 py-0.5 text-[10px] font-medium text-primary"> | ||
| 285 | v3 | ||
| 286 | </span> | ||
| 287 | </Link> | ||
| 288 | <button | ||
| 289 | type="button" | ||
| 290 | aria-label="Close navigation" | ||
| 291 | onClick={() => setMobileSidebarOpen(false)} | ||
| 292 | className="inline-flex size-8 items-center justify-center rounded-md text-sidebar-foreground transition-colors hover:bg-sidebar-active hover:text-foreground" | ||
| 293 | > | ||
| 294 | <IconClose className="size-3.5" /> | ||
| 295 | </button> | ||
| 296 | </div> | ||
| 297 | <SidebarContent | ||
| 298 | isHome={!!isHome} | ||
| 299 | isSecrets={!!isSecrets} | ||
| 300 | latestVersion={latestVersion} | ||
| 301 | updateAvailable={updateAvailable} | ||
| 302 | channel={channel} | ||
| 303 | locationPathname={location.pathname} | ||
| 304 | mainPluginNavItems={mainPluginNavItems} | ||
| 305 | sourcePluginNavItems={sourcePluginNavItems} | ||
| 306 | sources={sources} | ||
| 307 | onNavigate={() => setMobileSidebarOpen(false)} | ||
| 308 | showBrand={false} | ||
| 309 | /> | ||
| 310 | </div> | ||
| 311 | </div> | ||
| 312 | )} | ||
| 313 | |||
| 314 | {/* Main content */} | ||
| 315 | <main className="flex min-h-0 flex-1 flex-col min-w-0 overflow-hidden"> | ||
| 316 | <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background px-4 md:hidden"> | ||
| 317 | <button | ||
| 318 | type="button" | ||
| 319 | aria-label="Open navigation" | ||
| 320 | onClick={() => setMobileSidebarOpen(true)} | ||
| 321 | className="inline-flex size-8 items-center justify-center rounded-md border border-border bg-card text-foreground transition-colors hover:bg-accent/50" | ||
| 322 | > | ||
| 323 | <IconMenu className="size-4" /> | ||
| 324 | </button> | ||
| 325 | <Link to="/" className="flex items-center gap-1.5"> | ||
| 326 | <span className="font-display text-base tracking-tight text-foreground"> | ||
| 327 | executor | ||
| 328 | </span> | ||
| 329 | <span className="rounded bg-primary/15 px-1.5 py-0.5 text-[10px] font-medium text-primary"> | ||
| 330 | v3 | ||
| 331 | </span> | ||
| 332 | </Link> | ||
| 333 | <div className="w-8 shrink-0" /> | ||
| 334 | </div> | ||
| 335 | <Outlet /> | ||
| 336 | </main> | ||
| 337 | </div> | ||
| 338 | ); | ||
| 339 | } | ||
| 340 | |||
| 341 | function SidebarContent(props: { | ||
| 342 | isHome: boolean; | ||
| 343 | isSecrets: boolean; | ||
| 344 | latestVersion: string | null; | ||
| 345 | updateAvailable: boolean; | ||
| 346 | channel: UpdateChannel; | ||
| 347 | locationPathname: string; | ||
| 348 | mainPluginNavItems: typeof registeredFrontendPluginNavRoutes; | ||
| 349 | sourcePluginNavItems: typeof registeredFrontendPluginNavRoutes; | ||
| 350 | sources: ReturnType<typeof useSources>; | ||
| 351 | onNavigate?: () => void; | ||
| 352 | showBrand?: boolean; | ||
| 353 | }) { | ||
| 354 | return ( | ||
| 355 | <> | ||
| 356 | {props.showBrand !== false && ( | ||
| 242 | <div className="flex h-12 shrink-0 items-center border-b border-sidebar-border px-4"> | 357 | <div className="flex h-12 shrink-0 items-center border-b border-sidebar-border px-4"> |
| 243 | <Link to="/" className="flex items-center gap-1.5"> | 358 | <Link to="/" className="flex items-center gap-1.5"> |
| 244 | <span className="font-display text-base tracking-tight text-foreground"> | 359 | <span className="font-display text-base tracking-tight text-foreground"> |
| @@ -249,99 +364,107 @@ export function AppShell() { · 4 unmodified lines | |||
| 249 | </span> | 364 | </span> |
| 250 | </Link> | 365 | </Link> |
| 251 | </div> | 366 | </div> |
| 367 | )} | ||
| 252 | 368 | ||
| 253 | {/* Main nav */} | 369 | <nav className="flex flex-1 flex-col overflow-y-auto p-2"> |
| 254 | <nav className="flex flex-1 flex-col p-2 overflow-y-auto"> | 370 | <NavItem |
| 255 | <NavItem to="/" label="Dashboard" active={!!isHome} /> | 371 | to="/" |
| 256 | <NavItem to="/secrets" label="Secrets" active={!!isSecrets} /> | 372 | label="Dashboard" |
| 257 | {mainPluginNavItems.map(({ plugin, route, to }) => ( | 373 | active={props.isHome} |
| 258 | <NavItem | 374 | onNavigate={props.onNavigate} |
| 259 | key={`${plugin.key}:${route.key}`} | 375 | /> |
| 260 | to={to} | 376 | <NavItem |
| 261 | label={route.nav?.label ?? route.key} | 377 | to="/secrets" |
| 262 | active={location.pathname === to || location.pathname.startsWith(`${to}/`)} | 378 | label="Secrets" |
| 263 | /> | 379 | active={props.isSecrets} |
| 264 | ))} | 380 | onNavigate={props.onNavigate} |
| 265 | 381 | /> | |
| 266 | {/* Sources */} | 382 | {props.mainPluginNavItems.map(({ plugin, route, to }) => ( |
| 267 | <div className="mt-5 mb-1 px-2.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground/50"> | 383 | <NavItem |
| 268 | <div className="flex items-center justify-between gap-2"> | 384 | key={`${plugin.key}:${route.key}`} |
| 269 | <span>Sources</span> | 385 | to={to} |
| 270 | <Link | 386 | label={route.nav?.label ?? route.key} |
| 271 | to={sourcePluginsIndexPath} | 387 | active={ |
| 272 | className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-[10px] font-medium normal-case tracking-normal text-primary transition-colors hover:bg-sidebar-active hover:text-foreground" | 388 | props.locationPathname === to |
| 273 | > | 389 | || props.locationPathname.startsWith(`${to}/`) |
| 274 | <IconPlus className="size-3" /> | ||
| 275 | Add | ||
| 276 | </Link> | ||
| 277 | </div> | ||
| 278 | </div> | ||
| 279 | {sourcePluginNavItems.length > 0 && ( | ||
| 280 | <div className="mb-2 flex flex-col gap-px"> | ||
| 281 | {sourcePluginNavItems.map(({ plugin, route, to }) => ( | ||
| 282 | <NavItem | ||
| 283 | key={`${plugin.key}:${route.key}`} | ||
| 284 | to={to} | ||
| 285 | label={route.nav?.label ?? route.key} | ||
| 286 | active={ | ||
| 287 | location.pathname === to | ||
| 288 | || location.pathname.startsWith(`${to}/`) | ||
| 289 | } | ||
| 290 | /> | ||
| 291 | ))} | ||
| 292 | </div> | ||
| 293 | )} | ||
| 294 | <LoadableBlock loadable={sources} loading="Loading..."> | ||
| 295 | {(items) => | ||
| 296 | !Array.isArray(items) ? ( | ||
| 297 | <div className="px-2.5 py-2 text-[11px] leading-relaxed text-destructive"> | ||
| 298 | Sources returned an unexpected payload. | ||
| 299 | </div> | ||
| 300 | ) : items.length === 0 ? ( | ||
| 301 | <div className="px-2.5 py-2 text-[11px] leading-relaxed text-muted-foreground/40"> | ||
| 302 | No sources yet | ||
| 303 | </div> | ||
| 304 | ) : ( | ||
| 305 | <div className="flex flex-col gap-px"> | ||
| 306 | {items.map((source) => ( | ||
| 307 | <SourceItem | ||
| 308 | key={source.id} | ||
| 309 | pathname={location.pathname} | ||
| 310 | source={source} | ||
| 311 | /> | ||
| 312 | ))} | ||
| 313 | </div> | ||
| 314 | ) | ||
| 315 | } | 390 | } |
| 316 | </LoadableBlock> | 391 | onNavigate={props.onNavigate} |
| 317 | </nav> | 392 | /> |
| 318 | 393 | ))} | |
| 319 | {/* Update available */} | 394 | |
| 320 | {updateAvailable && latestVersion && ( | 395 | <div className="mt-5 mb-1 px-2.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground/50"> |
| 321 | <UpdateCard latestVersion={latestVersion} channel={channel} /> | 396 | <div className="flex items-center justify-between gap-2"> |
| 322 | )} | 397 | <span>Sources</span> |
| 323 | 398 | <Link | |
| 324 | {/* Footer */} | 399 | to={sourcePluginsIndexPath} |
| 325 | <div className="shrink-0 border-t border-sidebar-border px-4 py-2.5"> | 400 | onClick={props.onNavigate} |
| 326 | <div className="flex items-center justify-between text-[10px] leading-none"> | 401 | className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-[10px] font-medium normal-case tracking-normal text-primary transition-colors hover:bg-sidebar-active hover:text-foreground" |
| 327 | <span className="text-muted-foreground/70 tabular-nums">v{VITE_APP_VERSION}</span> | ||
| 328 | <a | ||
| 329 | href={VITE_GITHUB_URL} | ||
| 330 | target="_blank" | ||
| 331 | rel="noreferrer" | ||
| 332 | className="text-muted-foreground/70 transition-colors hover:text-foreground" | ||
| 333 | > | 402 | > |
| 334 | GitHub | 403 | <IconPlus className="size-3" /> |
| 335 | </a> | 404 | Add |
| 405 | </Link> | ||
| 336 | </div> | 406 | </div> |
| 337 | </div> | 407 | </div> |
| 338 | </aside> | 408 | {props.sourcePluginNavItems.length > 0 && ( |
| 409 | <div className="mb-2 flex flex-col gap-px"> | ||
| 410 | {props.sourcePluginNavItems.map(({ plugin, route, to }) => ( | ||
| 411 | <NavItem | ||
| 412 | key={`${plugin.key}:${route.key}`} | ||
| 413 | to={to} | ||
| 414 | label={route.nav?.label ?? route.key} | ||
| 415 | active={ | ||
| 416 | props.locationPathname === to | ||
| 417 | || props.locationPathname.startsWith(`${to}/`) | ||
| 418 | } | ||
| 419 | onNavigate={props.onNavigate} | ||
| 420 | /> | ||
| 421 | ))} | ||
| 422 | </div> | ||
| 423 | )} | ||
| 424 | <LoadableBlock loadable={props.sources} loading="Loading..."> | ||
| 425 | {(items) => | ||
| 426 | !Array.isArray(items) ? ( | ||
| 427 | <div className="px-2.5 py-2 text-[11px] leading-relaxed text-destructive"> | ||
| 428 | Sources returned an unexpected payload. | ||
| 429 | </div> | ||
| 430 | ) : items.length === 0 ? ( | ||
| 431 | <div className="px-2.5 py-2 text-[11px] leading-relaxed text-muted-foreground/40"> | ||
| 432 | No sources yet | ||
| 433 | </div> | ||
| 434 | ) : ( | ||
| 435 | <div className="flex flex-col gap-px"> | ||
| 436 | {items.map((source) => ( | ||
| 437 | <SourceItem | ||
| 438 | key={source.id} | ||
| 439 | pathname={props.locationPathname} | ||
| 440 | source={source} | ||
| 441 | onNavigate={props.onNavigate} | ||
| 442 | /> | ||
| 443 | ))} | ||
| 444 | </div> | ||
| 445 | ) | ||
| 446 | } | ||
| 447 | </LoadableBlock> | ||
| 448 | </nav> | ||
| 449 | |||
| 450 | {props.updateAvailable && props.latestVersion && ( | ||
| 451 | <UpdateCard latestVersion={props.latestVersion} channel={props.channel} /> | ||
| 452 | )} | ||
| 339 | 453 | ||
| 340 | {/* Main content */} | 454 | <div className="shrink-0 border-t border-sidebar-border px-4 py-2.5"> |
| 341 | <main className="flex min-h-0 flex-1 flex-col min-w-0 overflow-hidden"> | 455 | <div className="flex items-center justify-between text-[10px] leading-none"> |
| 342 | <Outlet /> | 456 | <span className="text-muted-foreground/70 tabular-nums">v{VITE_APP_VERSION}</span> |
| 343 | </main> | 457 | <a |
| 344 | </div> | 458 | href={VITE_GITHUB_URL} |
| 459 | target="_blank" | ||
| 460 | rel="noreferrer" | ||
| 461 | className="text-muted-foreground/70 transition-colors hover:text-foreground" | ||
| 462 | > | ||
| 463 | GitHub | ||
| 464 | </a> | ||
| 465 | </div> | ||
| 466 | </div> | ||
| 467 | </> | ||
| 345 | ); | 468 | ); |
| 346 | } | 469 | } |
| 347 | 470 | ||
| @@ -350,6 +473,7 @@ export function AppShell() { · 2 unmodified lines | |||
| 350 | function SourceItem(props: { | 473 | function SourceItem(props: { |
| 351 | pathname: string; | 474 | pathname: string; |
| 352 | source: Source; | 475 | source: Source; |
| 476 | onNavigate?: () => void; | ||
| 353 | }) { | 477 | }) { |
| 354 | const paths = getSourceFrontendPaths(props.source.kind); | 478 | const paths = getSourceFrontendPaths(props.source.kind); |
| 355 | 479 | ||
| @@ -377,6 +501,7 @@ function SourceItem(props: { · 21 unmodified lines | |||
| 377 | <Link | 501 | <Link |
| 378 | to={detailPath} | 502 | to={detailPath} |
| 379 | search={{ tab: "model" }} | 503 | search={{ tab: "model" }} |
| 504 | onClick={props.onNavigate} | ||
| 380 | className={cn( | 505 | className={cn( |
| 381 | "group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors", | 506 | "group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors", |
| 382 | active | 507 | active |
| @@ -398,10 +523,16 @@ function SourceItem(props: { · 15 unmodified lines | |||
| 398 | 523 | ||
| 399 | // ── NavItem ────────────────────────────────────────────────────────────── | 524 | // ── NavItem ────────────────────────────────────────────────────────────── |
| 400 | 525 | ||
| 401 | function NavItem(props: { to: string; label: string; active: boolean }) { | 526 | function NavItem(props: { |
| 527 | to: string; | ||
| 528 | label: string; | ||
| 529 | active: boolean; | ||
| 530 | onNavigate?: () => void; | ||
| 531 | }) { | ||
| 402 | return ( | 532 | return ( |
| 403 | <Link | 533 | <Link |
| 404 | to={props.to} | 534 | to={props.to} |
| 535 | onClick={props.onNavigate} | ||
| 405 | className={cn( | 536 | className={cn( |
| 406 | "flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-[13px] transition-colors", | 537 | "flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-[13px] transition-colors", |
| 407 | props.active | 538 | props.active |
| @@ -11,12 +11,18 @@ import { · 10 unmodified lines | |||
| 11 | import { | 11 | import { |
| 12 | GraphqlReactPlugin, | 12 | GraphqlReactPlugin, |
| 13 | } from "@executor/plugin-graphql-react"; | 13 | } from "@executor/plugin-graphql-react"; |
| 14 | import { | ||
| 15 | LocalToolsReactPlugin, | ||
| 16 | } from "@executor/plugin-local-tools-react"; | ||
| 14 | import { | 17 | import { |
| 15 | McpReactPlugin, | 18 | McpReactPlugin, |
| 16 | } from "@executor/plugin-mcp-react"; | 19 | } from "@executor/plugin-mcp-react"; |
| 17 | import { | 20 | import { |
| 18 | OpenApiReactPlugin, | 21 | OpenApiReactPlugin, |
| 19 | } from "@executor/plugin-openapi-react"; | 22 | } from "@executor/plugin-openapi-react"; |
| 23 | import { | ||
| 24 | OnePasswordReactPlugin, | ||
| 25 | } from "@executor/plugin-onepassword-react"; | ||
| 20 | import { | 26 | import { |
| 21 | createExecutorPluginPaths, | 27 | createExecutorPluginPaths, |
| 22 | createSourcePluginPaths, | 28 | createSourcePluginPaths, |
| @@ -26,10 +32,12 @@ import { · 3 unmodified lines | |||
| 26 | 32 | ||
| 27 | const frontendPlugins = [ | 33 | const frontendPlugins = [ |
| 28 | ExecutionHistoryReactPlugin, | 34 | ExecutionHistoryReactPlugin, |
| 35 | LocalToolsReactPlugin, | ||
| 29 | McpReactPlugin, | 36 | McpReactPlugin, |
| 30 | GraphqlReactPlugin, | 37 | GraphqlReactPlugin, |
| 31 | GoogleDiscoveryReactPlugin, | 38 | GoogleDiscoveryReactPlugin, |
| 32 | OpenApiReactPlugin, | 39 | OpenApiReactPlugin, |
| 40 | OnePasswordReactPlugin, | ||
| 33 | ] as const satisfies readonly ExecutorFrontendPlugin[]; | 41 | ] as const satisfies readonly ExecutorFrontendPlugin[]; |
| 34 | 42 | ||
| 35 | const frontendPluginRegistry = registerExecutorFrontendPlugins(frontendPlugins); | 43 | const frontendPluginRegistry = registerExecutorFrontendPlugins(frontendPlugins); |
| @@ -81,3 +89,6 @@ export const getSourceFrontendIconUrl = (source: Source) => · 45 unmodified lines | |||
| 81 | source.kind === "google-discovery" | 89 | source.kind === "google-discovery" |
| 82 | ? getGoogleDiscoveryIconUrl(source) | 90 | ? getGoogleDiscoveryIconUrl(source) |
| 83 | : null; | 91 | : null; |
| 92 | |||
| 93 | export const getSecretStoreFrontendPlugin = (kind: string) => | ||
| 94 | registeredFrontendPlugins.find((plugin) => plugin.secretStore?.kind === kind) ?? null; | ||
| @@ -1,261 +1,820 @@ | |||
| 1 | import { useEffect, useState, type ReactNode } from "react"; | 1 | import { useCallback, useEffect, useState, type ReactNode } from "react"; |
| 2 | import { | 2 | import { |
| 3 | type BrowseSecretStoreResult, | ||
| 4 | getExecutorApiBaseUrl, | ||
| 3 | type InstanceConfig, | 5 | type InstanceConfig, |
| 4 | type SecretListItem, | ||
| 5 | type Loadable, | 6 | type Loadable, |
| 6 | useSecrets, | 7 | type SecretListItem, |
| 8 | type SecretStore, | ||
| 7 | useCreateSecret, | 9 | useCreateSecret, |
| 8 | useUpdateSecret, | 10 | useCreateSecretStore, |
| 9 | useDeleteSecret, | 11 | useDeleteSecret, |
| 12 | useDeleteSecretStore, | ||
| 13 | useExecutorMutation, | ||
| 10 | useInstanceConfig, | 14 | useInstanceConfig, |
| 15 | useRefreshSecrets, | ||
| 16 | useSecretStores, | ||
| 17 | useSecrets, | ||
| 18 | useUpdateSecret, | ||
| 11 | } from "@executor/react"; | 19 | } from "@executor/react"; |
| 20 | |||
| 12 | import { LoadableBlock } from "../components/loadable"; | 21 | import { LoadableBlock } from "../components/loadable"; |
| 13 | import { Button } from "../components/ui/button"; | ||
| 14 | import { Badge } from "../components/ui/badge"; | 22 | import { Badge } from "../components/ui/badge"; |
| 15 | import { IconPlus, IconPencil, IconTrash, IconSpinner } from "../components/icons"; | 23 | import { Button } from "../components/ui/button"; |
| 24 | import { | ||
| 25 | IconPencil, | ||
| 26 | IconPlus, | ||
| 27 | IconSpinner, | ||
| 28 | IconTrash, | ||
| 29 | } from "../components/icons"; | ||
| 16 | import { cn } from "../lib/utils"; | 30 | import { cn } from "../lib/utils"; |
| 31 | import { getSecretStoreFrontendPlugin } from "../plugins"; | ||
| 17 | 32 | ||
| 18 | // --------------------------------------------------------------------------- | 33 | const creatableStorePluginsFromConfig = (config: Loadable<InstanceConfig>) => |
| 19 | // Page | ||
| 20 | // --------------------------------------------------------------------------- | ||
| 21 | |||
| 22 | const storableProvidersFromConfig = (config: Loadable<InstanceConfig>) => | ||
| 23 | config.status === "ready" | 34 | config.status === "ready" |
| 24 | ? config.data.secretProviders.filter((provider) => provider.canStore) | 35 | ? config.data.secretStorePlugins.filter((plugin) => plugin.canCreate) |
| 25 | : []; | 36 | : []; |
| 26 | 37 | ||
| 27 | const providerLabelForId = (config: Loadable<InstanceConfig>, providerId: string): string => | 38 | const canManageStoreKind = ( |
| 39 | config: Loadable<InstanceConfig>, | ||
| 40 | kind: string, | ||
| 41 | ): boolean => | ||
| 28 | config.status === "ready" | 42 | config.status === "ready" |
| 29 | ? config.data.secretProviders.find((provider) => provider.id === providerId)?.name ?? providerId | 43 | ? config.data.secretStorePlugins.some((plugin) => |
| 30 | : providerId; | 44 | plugin.kind === kind && plugin.canCreate |
| 45 | ) | ||
| 46 | : false; | ||
| 47 | |||
| 48 | const defaultStoreLabel = ( | ||
| 49 | config: Loadable<InstanceConfig>, | ||
| 50 | stores: Loadable<ReadonlyArray<SecretStore>>, | ||
| 51 | ): string | null => { | ||
| 52 | if ( | ||
| 53 | config.status !== "ready" | ||
| 54 | || stores.status !== "ready" | ||
| 55 | || config.data.defaultSecretStoreId === null | ||
| 56 | ) { | ||
| 57 | return null; | ||
| 58 | } | ||
| 59 | |||
| 60 | return stores.data.find((store) => | ||
| 61 | store.id === config.data.defaultSecretStoreId | ||
| 62 | )?.name ?? null; | ||
| 63 | }; | ||
| 64 | |||
| 65 | type SecretStoreBrowserEntry = BrowseSecretStoreResult["entries"][number]; | ||
| 66 | type SecretStoreBrowserCrumb = { | ||
| 67 | key: string; | ||
| 68 | label: string; | ||
| 69 | }; | ||
| 70 | |||
| 71 | const canBrowseAndImportStore = (store: SecretStore | null): boolean => | ||
| 72 | store?.capabilities.canBrowseSecrets === true | ||
| 73 | && store.capabilities.canImportSecrets === true; | ||
| 31 | 74 | ||
| 32 | export function SecretsPage() { | 75 | export function SecretsPage() { |
| 33 | const instanceConfig = useInstanceConfig(); | 76 | const instanceConfig = useInstanceConfig(); |
| 77 | const secretStores = useSecretStores(); | ||
| 34 | const secrets = useSecrets(); | 78 | const secrets = useSecrets(); |
| 35 | const [showCreate, setShowCreate] = useState(false); | 79 | const [showCreateStore, setShowCreateStore] = useState(false); |
| 80 | const [showCreateSecret, setShowCreateSecret] = useState(false); | ||
| 36 | const [editingId, setEditingId] = useState<string | null>(null); | 81 | const [editingId, setEditingId] = useState<string | null>(null); |
| 37 | const storableProviders = storableProvidersFromConfig(instanceConfig); | 82 | |
| 38 | const defaultProviderLabel = | 83 | const creatableStorePlugins = creatableStorePluginsFromConfig(instanceConfig); |
| 39 | instanceConfig.status === "ready" | 84 | const defaultLabel = defaultStoreLabel(instanceConfig, secretStores); |
| 40 | ? providerLabelForId(instanceConfig, instanceConfig.data.defaultSecretStoreProvider) | ||
| 41 | : null; | ||
| 42 | 85 | ||
| 43 | return ( | 86 | return ( |
| 44 | <div className="min-h-0 flex-1 overflow-y-auto"> | 87 | <div className="min-h-0 flex-1 overflow-y-auto"> |
| 45 | <div className="mx-auto max-w-3xl px-6 py-10 lg:px-10 lg:py-14"> | 88 | <div className="mx-auto flex max-w-4xl flex-col gap-8 px-6 py-10 lg:px-10 lg:py-14"> |
| 46 | {/* Header */} | 89 | <div className="flex flex-wrap items-end justify-between gap-4"> |
| 47 | <div className="flex items-end justify-between mb-8"> | ||
| 48 | <div> | 90 | <div> |
| 49 | <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl"> | 91 | <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl"> |
| 50 | Secrets | 92 | Secrets |
| 51 | </h1> | 93 | </h1> |
| 52 | <p className="mt-1.5 text-[14px] text-muted-foreground"> | 94 | <p className="mt-1.5 text-[14px] text-muted-foreground"> |
| 53 | Tokens and credentials stored by executor. | 95 | Manage secret stores and the secrets linked into sources. |
| 54 | {defaultProviderLabel ? ` New secrets default to ${defaultProviderLabel}.` : ""} | 96 | {defaultLabel ? ` New secrets default to ${defaultLabel}.` : ""} |
| 55 | </p> | 97 | </p> |
| 56 | </div> | 98 | </div> |
| 57 | <Button size="sm" onClick={() => { setShowCreate(true); setEditingId(null); }}> | 99 | <div className="flex gap-2"> |
| 58 | <IconPlus className="size-3.5" /> | 100 | <Button |
| 59 | Add secret | 101 | size="sm" |
| 60 | </Button> | 102 | variant="outline" |
| 103 | onClick={() => { | ||
| 104 | setShowCreateStore(true); | ||
| 105 | }} | ||
| 106 | disabled={creatableStorePlugins.length === 0} | ||
| 107 | > | ||
| 108 | <IconPlus className="size-3.5" /> | ||
| 109 | Add store | ||
| 110 | </Button> | ||
| 111 | <Button | ||
| 112 | size="sm" | ||
| 113 | onClick={() => { | ||
| 114 | setShowCreateSecret(true); | ||
| 115 | setEditingId(null); | ||
| 116 | }} | ||
| 117 | > | ||
| 118 | <IconPlus className="size-3.5" /> | ||
| 119 | Add secret | ||
| 120 | </Button> | ||
| 121 | </div> | ||
| 61 | </div> | 122 | </div> |
| 62 | 123 | ||
| 63 | {/* Create form */} | 124 | <section className="space-y-4"> |
| 64 | {showCreate && ( | 125 | <div> |
| 65 | <CreateSecretForm | 126 | <h2 className="text-sm font-semibold text-foreground">Stores</h2> |
| 66 | onClose={() => setShowCreate(false)} | 127 | <p className="mt-1 text-[13px] text-muted-foreground"> |
| 67 | className="mb-6" | 128 | Local and external backends that hold managed secrets. |
| 68 | providerOptions={storableProviders} | 129 | </p> |
| 69 | defaultProviderId={ | 130 | </div> |
| 70 | instanceConfig.status === "ready" | 131 | |
| 71 | ? instanceConfig.data.defaultSecretStoreProvider | 132 | {showCreateStore && ( |
| 72 | : null | 133 | <CreateSecretStoreForm |
| 134 | className="mb-2" | ||
| 135 | onClose={() => setShowCreateStore(false)} | ||
| 136 | pluginOptions={creatableStorePlugins} | ||
| 137 | secrets={secrets} | ||
| 138 | /> | ||
| 139 | )} | ||
| 140 | |||
| 141 | <LoadableBlock loadable={secretStores} loading="Loading stores..."> | ||
| 142 | {(items) => | ||
| 143 | items.length === 0 ? ( | ||
| 144 | <SectionEmptyState | ||
| 145 | title="No secret stores yet" | ||
| 146 | description="Add a store to connect an external backend like 1Password." | ||
| 147 | /> | ||
| 148 | ) : ( | ||
| 149 | <div className="grid gap-3 md:grid-cols-2"> | ||
| 150 | {items.map((store) => ( | ||
| 151 | <SecretStoreCard | ||
| 152 | key={store.id} | ||
| 153 | store={store} | ||
| 154 | canManage={canManageStoreKind(instanceConfig, store.kind)} | ||
| 155 | /> | ||
| 156 | ))} | ||
| 157 | </div> | ||
| 158 | ) | ||
| 73 | } | 159 | } |
| 74 | /> | 160 | </LoadableBlock> |
| 75 | )} | 161 | </section> |
| 76 | 162 | ||
| 77 | {/* Secrets table */} | 163 | <section className="space-y-4"> |
| 78 | <LoadableBlock loadable={secrets} loading="Loading secrets..."> | 164 | <div> |
| 79 | {(items) => | 165 | <h2 className="text-sm font-semibold text-foreground">Managed Secrets</h2> |
| 80 | items.length === 0 && !showCreate ? ( | 166 | <p className="mt-1 text-[13px] text-muted-foreground"> |
| 81 | <EmptyState onAdd={() => setShowCreate(true)} /> | 167 | Secret records stay stable even if their underlying store handle changes. |
| 82 | ) : ( | 168 | </p> |
| 83 | <div className="rounded-xl border border-border bg-card/80 divide-y divide-border overflow-hidden"> | 169 | </div> |
| 84 | {items.map((secret) => ( | 170 | |
| 85 | <SecretRow | 171 | {showCreateSecret && ( |
| 86 | key={secret.id} | 172 | <CreateSecretForm |
| 87 | secret={secret} | 173 | className="mb-2" |
| 88 | providerLabel={providerLabelForId(instanceConfig, secret.providerId)} | 174 | onClose={() => setShowCreateSecret(false)} |
| 89 | isEditing={editingId === secret.id} | 175 | storeOptions={secretStores.status === "ready" ? secretStores.data : []} |
| 90 | onEdit={() => setEditingId(editingId === secret.id ? null : secret.id)} | 176 | defaultStoreId={ |
| 91 | onCancelEdit={() => setEditingId(null)} | 177 | instanceConfig.status === "ready" |
| 92 | /> | 178 | ? instanceConfig.data.defaultSecretStoreId |
| 93 | ))} | 179 | : null |
| 94 | {items.length === 0 && ( | 180 | } |
| 95 | <div className="px-5 py-8 text-center text-[13px] text-muted-foreground/60"> | 181 | /> |
| 96 | No secrets yet. Add one above. | 182 | )} |
| 97 | </div> | 183 | |
| 98 | )} | 184 | <LoadableBlock loadable={secrets} loading="Loading secrets..."> |
| 99 | </div> | 185 | {(items) => |
| 100 | ) | 186 | items.length === 0 && !showCreateSecret ? ( |
| 101 | } | 187 | <SectionEmptyState |
| 102 | </LoadableBlock> | 188 | title="No secrets stored" |
| 189 | description="Add a secret to use it in source authentication and runtime resolution." | ||
| 190 | actionLabel="Add secret" | ||
| 191 | onAction={() => { | ||
| 192 | setShowCreateSecret(true); | ||
| 193 | setEditingId(null); | ||
| 194 | }} | ||
| 195 | /> | ||
| 196 | ) : ( | ||
| 197 | <div className="overflow-hidden rounded-xl border border-border bg-card/80"> | ||
| 198 | {items.map((secret, index) => ( | ||
| 199 | <div | ||
| 200 | key={secret.id} | ||
| 201 | className={cn( | ||
| 202 | "px-5 py-3.5", | ||
| 203 | index > 0 && "border-t border-border", | ||
| 204 | )} | ||
| 205 | > | ||
| 206 | <SecretRow | ||
| 207 | secret={secret} | ||
| 208 | isEditing={editingId === secret.id} | ||
| 209 | onEdit={() => | ||
| 210 | setEditingId(editingId === secret.id ? null : secret.id)} | ||
| 211 | onCancelEdit={() => setEditingId(null)} | ||
| 212 | /> | ||
| 213 | </div> | ||
| 214 | ))} | ||
| 215 | </div> | ||
| 216 | ) | ||
| 217 | } | ||
| 218 | </LoadableBlock> | ||
| 219 | </section> | ||
| 103 | </div> | 220 | </div> |
| 104 | </div> | 221 | </div> |
| 105 | ); | 222 | ); |
| 106 | } | 223 | } |
| 107 | 224 | ||
| 108 | // --------------------------------------------------------------------------- | 225 | function CreateSecretStoreForm(props: { |
| 109 | // Empty state | 226 | className?: string; |
| 110 | // --------------------------------------------------------------------------- | 227 | onClose: () => void; |
| 228 | pluginOptions: ReadonlyArray<{ | ||
| 229 | kind: string; | ||
| 230 | displayName: string; | ||
| 231 | canCreate: boolean; | ||
| 232 | }>; | ||
| 233 | secrets: Loadable<ReadonlyArray<SecretListItem>>; | ||
| 234 | }) { | ||
| 235 | const createSecretStore = useCreateSecretStore(); | ||
| 236 | const [kind, setKind] = useState(props.pluginOptions[0]?.kind ?? ""); | ||
| 237 | const [name, setName] = useState(""); | ||
| 238 | const [error, setError] = useState<string | null>(null); | ||
| 239 | const selectedFrontendPlugin = getSecretStoreFrontendPlugin(kind); | ||
| 240 | const StoreCreateForm = selectedFrontendPlugin?.secretStore?.CreateStoreForm; | ||
| 241 | |||
| 242 | useEffect(() => { | ||
| 243 | if (kind.length > 0 || props.pluginOptions.length === 0) { | ||
| 244 | return; | ||
| 245 | } | ||
| 246 | setKind(props.pluginOptions[0]!.kind); | ||
| 247 | }, [kind, props.pluginOptions]); | ||
| 248 | |||
| 249 | const handleSubmit = async () => { | ||
| 250 | setError(null); | ||
| 251 | |||
| 252 | const trimmedName = name.trim(); | ||
| 253 | if (!trimmedName) { | ||
| 254 | setError("Store name is required."); | ||
| 255 | return; | ||
| 256 | } | ||
| 257 | |||
| 258 | try { | ||
| 259 | await createSecretStore.mutateAsync({ | ||
| 260 | kind, | ||
| 261 | name: trimmedName, | ||
| 262 | config: {}, | ||
| 263 | }); | ||
| 264 | props.onClose(); | ||
| 265 | } catch (cause) { | ||
| 266 | setError( | ||
| 267 | cause instanceof Error ? cause.message : "Failed creating secret store.", | ||
| 268 | ); | ||
| 269 | } | ||
| 270 | }; | ||
| 111 | 271 | ||
| 112 | function EmptyState(props: { onAdd: () => void }) { | ||
| 113 | return ( | 272 | return ( |
| 114 | <div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-20"> | 273 | <FormCard |
| 115 | <div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground mb-4"> | 274 | className={props.className} |
| 116 | <KeyIcon className="size-5" /> | 275 | title="New secret store" |
| 117 | </div> | 276 | onClose={props.onClose} |
| 118 | <p className="text-[14px] font-medium text-foreground/70 mb-1"> | 277 | > |
| 119 | No secrets stored | 278 | {error && <ErrorBanner>{error}</ErrorBanner>} |
| 120 | </p> | 279 | {props.pluginOptions.length === 0 ? ( |
| 121 | <p className="text-[13px] text-muted-foreground/60 mb-5"> | 280 | <div className="rounded-lg border border-border bg-background/40 px-4 py-3 text-[13px] text-muted-foreground"> |
| 122 | Secrets let you securely provide tokens for source authentication without putting them in workspace config. | 281 | No external secret store plugins are available in this runtime. |
| 123 | </p> | 282 | </div> |
| 124 | <Button size="sm" onClick={props.onAdd}> | 283 | ) : ( |
| 125 | <IconPlus className="size-3.5" /> | 284 | <> |
| 126 | Add secret | 285 | <div className="grid gap-4 sm:grid-cols-2"> |
| 127 | </Button> | 286 | <FieldLabel label="Store type"> |
| 128 | </div> | 287 | <select |
| 288 | value={kind} | ||
| 289 | onChange={(event) => setKind(event.target.value)} | ||
| 290 | className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 291 | > | ||
| 292 | {props.pluginOptions.map((plugin) => ( | ||
| 293 | <option key={plugin.kind} value={plugin.kind}> | ||
| 294 | {plugin.displayName} | ||
| 295 | </option> | ||
| 296 | ))} | ||
| 297 | </select> | ||
| 298 | </FieldLabel> | ||
| 299 | {!StoreCreateForm && ( | ||
| 300 | <FieldLabel label="Name"> | ||
| 301 | <input | ||
| 302 | value={name} | ||
| 303 | onChange={(event) => setName(event.target.value)} | ||
| 304 | placeholder="Secret store" | ||
| 305 | className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 306 | autoFocus | ||
| 307 | /> | ||
| 308 | </FieldLabel> | ||
| 309 | )} | ||
| 310 | </div> | ||
| 311 | |||
| 312 | {StoreCreateForm ? ( | ||
| 313 | <StoreCreateForm | ||
| 314 | isSubmitting={createSecretStore.status === "pending"} | ||
| 315 | onCancel={props.onClose} | ||
| 316 | onSubmit={async (input) => { | ||
| 317 | await createSecretStore.mutateAsync({ | ||
| 318 | kind, | ||
| 319 | name: input.name, | ||
| 320 | config: input.config, | ||
| 321 | }); | ||
| 322 | props.onClose(); | ||
| 323 | }} | ||
| 324 | secrets={props.secrets} | ||
| 325 | /> | ||
| 326 | ) : ( | ||
| 327 | <div className="flex justify-end"> | ||
| 328 | <Button | ||
| 329 | size="sm" | ||
| 330 | onClick={handleSubmit} | ||
| 331 | disabled={createSecretStore.status === "pending"} | ||
| 332 | > | ||
| 333 | {createSecretStore.status === "pending" | ||
| 334 | ? <IconSpinner className="size-3.5" /> | ||
| 335 | : <IconPlus className="size-3.5" />} | ||
| 336 | Create store | ||
| 337 | </Button> | ||
| 338 | </div> | ||
| 339 | )} | ||
| 340 | </> | ||
| 341 | )} | ||
| 342 | </FormCard> | ||
| 129 | ); | 343 | ); |
| 130 | } | 344 | } |
| 131 | 345 | ||
| 132 | // --------------------------------------------------------------------------- | ||
| 133 | // Create form | ||
| 134 | // --------------------------------------------------------------------------- | ||
| 135 | |||
| 136 | function CreateSecretForm(props: { | 346 | function CreateSecretForm(props: { |
| 137 | onClose: () => void; | ||
| 138 | className?: string; | 347 | className?: string; |
| 139 | providerOptions: ReadonlyArray<{ id: string; name: string }>; | 348 | onClose: () => void; |
| 140 | defaultProviderId: string | null; | 349 | storeOptions: ReadonlyArray<SecretStore>; |
| 350 | defaultStoreId: string | null; | ||
| 141 | }) { | 351 | }) { |
| 142 | const createSecret = useCreateSecret(); | 352 | const createSecret = useCreateSecret(); |
| 353 | const refreshSecrets = useRefreshSecrets(); | ||
| 354 | const [mode, setMode] = useState<"create" | "import">("create"); | ||
| 143 | const [name, setName] = useState(""); | 355 | const [name, setName] = useState(""); |
| 144 | const [value, setValue] = useState(""); | 356 | const [value, setValue] = useState(""); |
| 145 | const [providerId, setProviderId] = useState(props.defaultProviderId ?? ""); | 357 | const [storeId, setStoreId] = useState(props.defaultStoreId ?? ""); |
| 358 | const [browserStack, setBrowserStack] = useState<ReadonlyArray<SecretStoreBrowserCrumb>>( | ||
| 359 | [], | ||
| 360 | ); | ||
| 361 | const [browserQuery, setBrowserQuery] = useState(""); | ||
| 362 | const [browserEntries, setBrowserEntries] = useState< | ||
| 363 | ReadonlyArray<SecretStoreBrowserEntry> | ||
| 364 | >([]); | ||
| 365 | const [browserStatus, setBrowserStatus] = useState<"idle" | "pending">("idle"); | ||
| 366 | const [browserError, setBrowserError] = useState<string | null>(null); | ||
| 367 | const [selectedImportKey, setSelectedImportKey] = useState(""); | ||
| 368 | const [selectedImportLabel, setSelectedImportLabel] = useState(""); | ||
| 146 | const [error, setError] = useState<string | null>(null); | 369 | const [error, setError] = useState<string | null>(null); |
| 370 | const selectedStore = props.storeOptions.find((store) => store.id === storeId) ?? null; | ||
| 371 | const canImportSecrets = canBrowseAndImportStore(selectedStore); | ||
| 372 | const canCreateSecrets = selectedStore?.capabilities.canCreateSecrets !== false; | ||
| 373 | const currentParentKey = browserStack[browserStack.length - 1]?.key ?? null; | ||
| 374 | |||
| 375 | const browseStore = useCallback( | ||
| 376 | async (input: { | ||
| 377 | storeId: string; | ||
| 378 | parentKey?: string | null; | ||
| 379 | query?: string | null; | ||
| 380 | }) => { | ||
| 381 | const response = await fetch( | ||
| 382 | `${getExecutorApiBaseUrl()}/v1/local/secret-stores/${input.storeId}/browse`, | ||
| 383 | { | ||
| 384 | method: "POST", | ||
| 385 | headers: { | ||
| 386 | "content-type": "application/json", | ||
| 387 | }, | ||
| 388 | body: JSON.stringify({ | ||
| 389 | ...(input.parentKey ? { parentKey: input.parentKey } : {}), | ||
| 390 | ...(input.query?.trim() ? { query: input.query.trim() } : {}), | ||
| 391 | }), | ||
| 392 | }, | ||
| 393 | ); | ||
| 394 | |||
| 395 | if (!response.ok) { | ||
| 396 | let message = "Failed browsing secret store."; | ||
| 397 | try { | ||
| 398 | const payload = await response.json() as { | ||
| 399 | message?: string; | ||
| 400 | details?: string; | ||
| 401 | }; | ||
| 402 | message = payload.message ?? payload.details ?? message; | ||
| 403 | } catch { | ||
| 404 | // ignore response parsing errors | ||
| 405 | } | ||
| 406 | throw new Error(message); | ||
| 407 | } | ||
| 408 | |||
| 409 | return await response.json() as BrowseSecretStoreResult; | ||
| 410 | }, | ||
| 411 | [], | ||
| 412 | ); | ||
| 413 | const importSecretMutation = useExecutorMutation<void, { id: string }>(async () => { | ||
| 414 | const response = await fetch( | ||
| 415 | `${getExecutorApiBaseUrl()}/v1/local/secret-stores/${storeId}/import`, | ||
| 416 | { | ||
| 417 | method: "POST", | ||
| 418 | headers: { | ||
| 419 | "content-type": "application/json", | ||
| 420 | }, | ||
| 421 | body: JSON.stringify({ | ||
| 422 | selectionKey: selectedImportKey, | ||
| 423 | ...(name.trim() ? { name: name.trim() } : {}), | ||
| 424 | }), | ||
| 425 | }, | ||
| 426 | ); | ||
| 427 | |||
| 428 | if (!response.ok) { | ||
| 429 | let message = "Failed importing secret."; | ||
| 430 | try { | ||
| 431 | const payload = await response.json() as { | ||
| 432 | message?: string; | ||
| 433 | details?: string; | ||
| 434 | }; | ||
| 435 | message = payload.message ?? payload.details ?? message; | ||
| 436 | } catch { | ||
| 437 | // ignore response parsing errors | ||
| 438 | } | ||
| 439 | throw new Error(message); | ||
| 440 | } | ||
| 441 | |||
| 442 | return await response.json() as { id: string }; | ||
| 443 | }); | ||
| 147 | 444 | ||
| 148 | useEffect(() => { | 445 | useEffect(() => { |
| 149 | if (providerId.length > 0) { | 446 | if (storeId.length > 0) { |
| 150 | return; | 447 | return; |
| 151 | } | 448 | } |
| 152 | if (props.defaultProviderId) { | 449 | if (props.defaultStoreId) { |
| 153 | setProviderId(props.defaultProviderId); | 450 | setStoreId(props.defaultStoreId); |
| 451 | return; | ||
| 452 | } | ||
| 453 | if (props.storeOptions.length > 0) { | ||
| 454 | setStoreId(props.storeOptions[0]!.id); | ||
| 154 | } | 455 | } |
| 155 | }, [props.defaultProviderId, providerId]); | 456 | }, [props.defaultStoreId, props.storeOptions, storeId]); |
| 156 | 457 | ||
| 157 | const handleSubmit = async () => { | 458 | useEffect(() => { |
| 158 | setError(null); | 459 | setBrowserStack([]); |
| 159 | const trimmedName = name.trim(); | 460 | setBrowserQuery(""); |
| 160 | if (!trimmedName) { | 461 | setBrowserEntries([]); |
| 161 | setError("Name is required."); | 462 | setBrowserStatus("idle"); |
| 463 | setBrowserError(null); | ||
| 464 | setSelectedImportKey(""); | ||
| 465 | setSelectedImportLabel(""); | ||
| 466 | if (canImportSecrets && !canCreateSecrets) { | ||
| 467 | setMode("import"); | ||
| 468 | return; | ||
| 469 | } | ||
| 470 | if (!canImportSecrets && canCreateSecrets) { | ||
| 471 | setMode("create"); | ||
| 162 | return; | 472 | return; |
| 163 | } | 473 | } |
| 164 | if (!value) { | 474 | if (canImportSecrets) { |
| 165 | setError("Value is required."); | 475 | setMode("import"); |
| 166 | return; | 476 | return; |
| 167 | } | 477 | } |
| 478 | setMode("create"); | ||
| 479 | }, [canCreateSecrets, canImportSecrets, selectedStore?.id]); | ||
| 168 | 480 | ||
| 169 | try { | 481 | useEffect(() => { |
| 170 | await createSecret.mutateAsync({ | 482 | if (mode !== "import" || !canImportSecrets || !storeId) { |
| 171 | name: trimmedName, | 483 | return; |
| 172 | value, | 484 | } |
| 173 | ...(providerId ? { providerId } : {}), | 485 | |
| 486 | let active = true; | ||
| 487 | setBrowserStatus("pending"); | ||
| 488 | void browseStore({ | ||
| 489 | storeId, | ||
| 490 | parentKey: currentParentKey, | ||
| 491 | query: browserQuery, | ||
| 492 | }) | ||
| 493 | .then((result) => { | ||
| 494 | if (!active) { | ||
| 495 | return; | ||
| 496 | } | ||
| 497 | setBrowserEntries(result.entries); | ||
| 498 | setBrowserError(null); | ||
| 499 | setBrowserStatus("idle"); | ||
| 500 | }) | ||
| 501 | .catch((cause) => { | ||
| 502 | if (!active) { | ||
| 503 | return; | ||
| 504 | } | ||
| 505 | setBrowserEntries([]); | ||
| 506 | setBrowserError( | ||
| 507 | cause instanceof Error ? cause.message : "Failed browsing secret store.", | ||
| 508 | ); | ||
| 509 | setBrowserStatus("idle"); | ||
| 174 | }); | 510 | }); |
| 511 | |||
| 512 | return () => { | ||
| 513 | active = false; | ||
| 514 | }; | ||
| 515 | }, [browserQuery, browseStore, canImportSecrets, currentParentKey, mode, storeId]); | ||
| 516 | |||
| 517 | useEffect(() => { | ||
| 518 | if (mode !== "import" || !selectedImportLabel || name.trim().length > 0) { | ||
| 519 | return; | ||
| 520 | } | ||
| 521 | setName(selectedImportLabel); | ||
| 522 | }, [mode, name, selectedImportLabel]); | ||
| 523 | |||
| 524 | const handleBrowserEntryClick = (entry: SecretStoreBrowserEntry) => { | ||
| 525 | setError(null); | ||
| 526 | if (entry.kind === "group") { | ||
| 527 | setSelectedImportKey(""); | ||
| 528 | setSelectedImportLabel(""); | ||
| 529 | setBrowserStack((current) => [...current, { | ||
| 530 | key: entry.key, | ||
| 531 | label: entry.label, | ||
| 532 | }]); | ||
| 533 | setBrowserQuery(""); | ||
| 534 | return; | ||
| 535 | } | ||
| 536 | |||
| 537 | setSelectedImportKey(entry.key); | ||
| 538 | setSelectedImportLabel(entry.label); | ||
| 539 | }; | ||
| 540 | |||
| 541 | const handleSubmit = async () => { | ||
| 542 | setError(null); | ||
| 543 | const trimmedName = name.trim(); | ||
| 544 | |||
| 545 | try { | ||
| 546 | if (mode === "import") { | ||
| 547 | if (!selectedImportKey) { | ||
| 548 | setError("Select a secret to import."); | ||
| 549 | return; | ||
| 550 | } | ||
| 551 | |||
| 552 | await importSecretMutation.mutateAsync(); | ||
| 553 | refreshSecrets(); | ||
| 554 | } else { | ||
| 555 | if (!trimmedName) { | ||
| 556 | setError("Name is required."); | ||
| 557 | return; | ||
| 558 | } | ||
| 559 | if (!value) { | ||
| 560 | setError("Value is required."); | ||
| 561 | return; | ||
| 562 | } | ||
| 563 | |||
| 564 | await createSecret.mutateAsync({ | ||
| 565 | name: trimmedName, | ||
| 566 | value, | ||
| 567 | ...(storeId ? { storeId } : {}), | ||
| 568 | }); | ||
| 569 | } | ||
| 570 | |||
| 175 | props.onClose(); | 571 | props.onClose(); |
| 176 | } catch (err) { | 572 | } catch (cause) { |
| 177 | setError(err instanceof Error ? err.message : "Failed creating secret."); | 573 | setError(cause instanceof Error ? cause.message : "Failed creating secret."); |
| 178 | } | 574 | } |
| 179 | }; | 575 | }; |
| 180 | 576 | ||
| 181 | return ( | 577 | return ( |
| 182 | <div className={cn("rounded-xl border border-primary/20 bg-card/80", props.className)}> | 578 | <FormCard |
| 183 | <div className="border-b border-border px-5 py-3 flex items-center justify-between"> | 579 | className={props.className} |
| 184 | <h2 className="text-sm font-semibold text-foreground">New secret</h2> | 580 | title="New managed secret" |
| 185 | <button | 581 | onClose={props.onClose} |
| 186 | type="button" | 582 | > |
| 187 | onClick={props.onClose} | 583 | {error && <ErrorBanner>{error}</ErrorBanner>} |
| 188 | className="text-[12px] text-muted-foreground hover:text-foreground transition-colors" | 584 | <div className="grid gap-4 sm:grid-cols-2"> |
| 189 | > | 585 | <FieldLabel label="Store"> |
| 190 | Cancel | 586 | <select |
| 191 | </button> | 587 | value={storeId} |
| 192 | </div> | 588 | onChange={(event) => setStoreId(event.target.value)} |
| 193 | <div className="p-5 space-y-4"> | 589 | className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 194 | {error && ( | 590 | > |
| 195 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-[13px] text-destructive"> | 591 | {props.storeOptions.map((store) => ( |
| 196 | {error} | 592 | <option key={store.id} value={store.id}> |
| 197 | </div> | 593 | {store.name} |
| 198 | )} | 594 | </option> |
| 199 | <div className="grid gap-4 sm:grid-cols-2"> | 595 | ))} |
| 200 | <FieldLabel label="Name"> | 596 | </select> |
| 201 | <input | 597 | </FieldLabel> |
| 202 | value={name} | 598 | {canImportSecrets && canCreateSecrets && ( |
| 203 | onChange={(e) => setName(e.target.value)} | 599 | <FieldLabel label="Mode"> |
| 204 | placeholder="GitHub PAT" | 600 | <select |
| 205 | className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" | 601 | value={mode} |
| 206 | autoFocus | 602 | onChange={(event) => setMode(event.target.value as "create" | "import")} |
| 207 | /> | 603 | className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 604 | > | ||
| 605 | <option value="import">Import from store</option> | ||
| 606 | <option value="create">Create new secret</option> | ||
| 607 | </select> | ||
| 208 | </FieldLabel> | 608 | </FieldLabel> |
| 609 | )} | ||
| 610 | <FieldLabel label="Name"> | ||
| 611 | <input | ||
| 612 | value={name} | ||
| 613 | onChange={(event) => setName(event.target.value)} | ||
| 614 | placeholder={mode === "import" ? "Optional override name" : "GitHub PAT"} | ||
| 615 | className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 616 | autoFocus | ||
| 617 | /> | ||
| 618 | </FieldLabel> | ||
| 619 | {mode === "create" && ( | ||
| 209 | <FieldLabel label="Value"> | 620 | <FieldLabel label="Value"> |
| 210 | <input | 621 | <input |
| 211 | type="password" | 622 | type="password" |
| 212 | value={value} | 623 | value={value} |
| 213 | onChange={(e) => setValue(e.target.value)} | 624 | onChange={(event) => setValue(event.target.value)} |
| 214 | placeholder="ghp_..." | 625 | placeholder="ghp_..." |
| 215 | className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" | 626 | className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 216 | /> | 627 | /> |
| 217 | </FieldLabel> | 628 | </FieldLabel> |
| 218 | <FieldLabel label="Store in"> | 629 | )} |
| 219 | <select | 630 | </div> |
| 220 | value={providerId} | 631 | {mode === "import" && canImportSecrets && ( |
| 221 | onChange={(e) => setProviderId(e.target.value)} | 632 | <div className="mt-4 space-y-4"> |
| 222 | className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 633 | <div className="flex flex-wrap items-center gap-2"> |
| 223 | > | 634 | {browserStack.length > 0 && ( |
| 224 | {props.providerOptions.map((provider) => ( | 635 | <Button |
| 225 | <option key={provider.id} value={provider.id}> | 636 | size="sm" |
| 226 | {provider.name} | 637 | variant="outline" |
| 227 | </option> | 638 | onClick={() => { |
| 639 | setSelectedImportKey(""); | ||
| 640 | setSelectedImportLabel(""); | ||
| 641 | setBrowserStack((current) => current.slice(0, -1)); | ||
| 642 | }} | ||
| 643 | > | ||
| 644 | Back | ||
| 645 | </Button> | ||
| 646 | )} | ||
| 647 | <input | ||
| 648 | value={browserQuery} | ||
| 649 | onChange={(event) => setBrowserQuery(event.target.value)} | ||
| 650 | placeholder="Search this store" | ||
| 651 | className="h-9 min-w-0 flex-1 rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 652 | /> | ||
| 653 | </div> | ||
| 654 | {browserStack.length > 0 && ( | ||
| 655 | <div className="text-[11px] text-muted-foreground"> | ||
| 656 | {browserStack.map((crumb, index) => ( | ||
| 657 | <span key={crumb.key}> | ||
| 658 | {index > 0 ? " / " : ""} | ||
| 659 | {crumb.label} | ||
| 660 | </span> | ||
| 228 | ))} | 661 | ))} |
| 229 | </select> | 662 | </div> |
| 230 | </FieldLabel> | 663 | )} |
| 664 | <div className="rounded-xl border border-border bg-background/40"> | ||
| 665 | {browserStatus === "pending" ? ( | ||
| 666 | <div className="flex items-center gap-2 px-4 py-3 text-[13px] text-muted-foreground"> | ||
| 667 | <IconSpinner className="size-3.5" /> | ||
| 668 | Loading store contents... | ||
| 669 | </div> | ||
| 670 | ) : browserEntries.length === 0 ? ( | ||
| 671 | <div className="px-4 py-3 text-[13px] text-muted-foreground"> | ||
| 672 | {browserError ?? "No importable secrets found here."} | ||
| 673 | </div> | ||
| 674 | ) : ( | ||
| 675 | browserEntries.map((entry, index) => ( | ||
| 676 | <button | ||
| 677 | key={entry.key} | ||
| 678 | type="button" | ||
| 679 | onClick={() => handleBrowserEntryClick(entry)} | ||
| 680 | className={cn( | ||
| 681 | "flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/50", | ||
| 682 | index > 0 && "border-t border-border", | ||
| 683 | selectedImportKey === entry.key && "bg-accent/60", | ||
| 684 | )} | ||
| 685 | > | ||
| 686 | <div className="min-w-0"> | ||
| 687 | <div className="truncate text-[13px] font-medium text-foreground"> | ||
| 688 | {entry.label} | ||
| 689 | </div> | ||
| 690 | {entry.description && ( | ||
| 691 | <div className="truncate text-[11px] text-muted-foreground"> | ||
| 692 | {entry.description} | ||
| 693 | </div> | ||
| 694 | )} | ||
| 695 | </div> | ||
| 696 | <Badge variant="outline"> | ||
| 697 | {entry.kind === "group" ? "Open" : "Secret"} | ||
| 698 | </Badge> | ||
| 699 | </button> | ||
| 700 | )) | ||
| 701 | )} | ||
| 702 | </div> | ||
| 703 | {browserError && browserEntries.length > 0 && ( | ||
| 704 | <div className="text-[12px] text-destructive">{browserError}</div> | ||
| 705 | )} | ||
| 231 | </div> | 706 | </div> |
| 232 | <div className="flex justify-end"> | 707 | )} |
| 233 | <Button | 708 | <div className="flex justify-end"> |
| 234 | size="sm" | 709 | <Button |
| 235 | onClick={handleSubmit} | 710 | size="sm" |
| 236 | disabled={createSecret.status === "pending"} | 711 | onClick={handleSubmit} |
| 237 | > | 712 | disabled={createSecret.status === "pending" || importSecretMutation.status === "pending"} |
| 238 | {createSecret.status === "pending" ? <IconSpinner className="size-3.5" /> : <IconPlus className="size-3.5" />} | 713 | > |
| 239 | Store secret | 714 | {(createSecret.status === "pending" || importSecretMutation.status === "pending") |
| 240 | </Button> | 715 | ? <IconSpinner className="size-3.5" /> |
| 716 | : <IconPlus className="size-3.5" />} | ||
| 717 | {mode === "import" ? "Import secret" : "Store secret"} | ||
| 718 | </Button> | ||
| 719 | </div> | ||
| 720 | </FormCard> | ||
| 721 | ); | ||
| 722 | } | ||
| 723 | |||
| 724 | function SecretStoreCard(props: { | ||
| 725 | store: SecretStore; | ||
| 726 | canManage: boolean; | ||
| 727 | }) { | ||
| 728 | const deleteSecretStore = useDeleteSecretStore(); | ||
| 729 | const [confirmDelete, setConfirmDelete] = useState(false); | ||
| 730 | const [isDeleting, setIsDeleting] = useState(false); | ||
| 731 | |||
| 732 | const handleDelete = async () => { | ||
| 733 | setIsDeleting(true); | ||
| 734 | try { | ||
| 735 | await deleteSecretStore.mutateAsync(props.store.id); | ||
| 736 | } catch { | ||
| 737 | // refresh state will keep the store visible | ||
| 738 | } finally { | ||
| 739 | setIsDeleting(false); | ||
| 740 | setConfirmDelete(false); | ||
| 741 | } | ||
| 742 | }; | ||
| 743 | |||
| 744 | return ( | ||
| 745 | <div className="rounded-xl border border-border bg-card/80 p-4"> | ||
| 746 | <div className="flex items-start justify-between gap-3"> | ||
| 747 | <div className="min-w-0"> | ||
| 748 | <div className="flex flex-wrap items-center gap-2"> | ||
| 749 | <span className="truncate text-[13px] font-medium text-foreground"> | ||
| 750 | {props.store.name} | ||
| 751 | </span> | ||
| 752 | <Badge variant="outline" className="text-[9px] uppercase"> | ||
| 753 | {props.store.kind} | ||
| 754 | </Badge> | ||
| 755 | <Badge variant="outline" className="text-[9px]"> | ||
| 756 | {props.store.status} | ||
| 757 | </Badge> | ||
| 758 | </div> | ||
| 759 | <div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground/55"> | ||
| 760 | <span className="font-mono">{props.store.id}</span> | ||
| 761 | <span>Created {formatDate(props.store.createdAt)}</span> | ||
| 762 | </div> | ||
| 241 | </div> | 763 | </div> |
| 764 | {props.canManage ? ( | ||
| 765 | confirmDelete ? ( | ||
| 766 | <div className="flex items-center gap-1"> | ||
| 767 | <button | ||
| 768 | type="button" | ||
| 769 | onClick={() => setConfirmDelete(false)} | ||
| 770 | disabled={isDeleting} | ||
| 771 | className="rounded-md px-2 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:opacity-50" | ||
| 772 | > | ||
| 773 | Cancel | ||
| 774 | </button> | ||
| 775 | <button | ||
| 776 | type="button" | ||
| 777 | onClick={() => { | ||
| 778 | void handleDelete(); | ||
| 779 | }} | ||
| 780 | disabled={isDeleting} | ||
| 781 | className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-destructive transition-colors hover:bg-destructive/8 disabled:opacity-50" | ||
| 782 | > | ||
| 783 | {isDeleting | ||
| 784 | ? <IconSpinner className="size-3" /> | ||
| 785 | : <IconTrash className="size-3" />} | ||
| 786 | Delete | ||
| 787 | </button> | ||
| 788 | </div> | ||
| 789 | ) : ( | ||
| 790 | <button | ||
| 791 | type="button" | ||
| 792 | onClick={() => setConfirmDelete(true)} | ||
| 793 | disabled={isDeleting} | ||
| 794 | className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-destructive/8 hover:text-destructive disabled:opacity-50" | ||
| 795 | > | ||
| 796 | {isDeleting | ||
| 797 | ? <IconSpinner className="size-3" /> | ||
| 798 | : <IconTrash className="size-3" />} | ||
| 799 | Remove | ||
| 800 | </button> | ||
| 801 | ) | ||
| 802 | ) : ( | ||
| 803 | <span className="text-[10px] font-medium uppercase tracking-[0.12em] text-muted-foreground/45"> | ||
| 804 | Built-in | ||
| 805 | </span> | ||
| 806 | )} | ||
| 242 | </div> | 807 | </div> |
| 243 | </div> | 808 | </div> |
| 244 | ); | 809 | ); |
| 245 | } | 810 | } |
| 246 | 811 | ||
| 247 | // --------------------------------------------------------------------------- | ||
| 248 | // Secret row | ||
| 249 | // --------------------------------------------------------------------------- | ||
| 250 | |||
| 251 | function SecretRow(props: { | 812 | function SecretRow(props: { |
| 252 | secret: SecretListItem; | 813 | secret: SecretListItem; |
| 253 | providerLabel: string; | ||
| 254 | isEditing: boolean; | 814 | isEditing: boolean; |
| 255 | onEdit: () => void; | 815 | onEdit: () => void; |
| 256 | onCancelEdit: () => void; | 816 | onCancelEdit: () => void; |
| 257 | }) { | 817 | }) { |
| 258 | const { secret, providerLabel, isEditing } = props; | ||
| 259 | const deleteSecret = useDeleteSecret(); | 818 | const deleteSecret = useDeleteSecret(); |
| 260 | const [isDeleting, setIsDeleting] = useState(false); | 819 | const [isDeleting, setIsDeleting] = useState(false); |
| 261 | const [confirmDelete, setConfirmDelete] = useState(false); | 820 | const [confirmDelete, setConfirmDelete] = useState(false); |
| @@ -263,72 +822,60 @@ function SecretRow(props: { · 1 unmodified line | |||
| 263 | const handleDelete = async () => { | 822 | const handleDelete = async () => { |
| 264 | setIsDeleting(true); | 823 | setIsDeleting(true); |
| 265 | try { | 824 | try { |
| 266 | await deleteSecret.mutateAsync(secret.id); | 825 | await deleteSecret.mutateAsync(props.secret.id); |
| 267 | } catch { | 826 | } catch { |
| 268 | // refresh will show the secret still there | 827 | // refresh state will keep the secret visible |
| 269 | } finally { | 828 | } finally { |
| 270 | setConfirmDelete(false); | ||
| 271 | setIsDeleting(false); | 829 | setIsDeleting(false); |
| 830 | setConfirmDelete(false); | ||
| 272 | } | 831 | } |
| 273 | }; | 832 | }; |
| 274 | 833 | ||
| 275 | const createdDate = new Date(secret.createdAt).toLocaleDateString(undefined, { | ||
| 276 | month: "short", | ||
| 277 | day: "numeric", | ||
| 278 | year: "numeric", | ||
| 279 | }); | ||
| 280 | |||
| 281 | return ( | 834 | return ( |
| 282 | <div className="px-5 py-3.5"> | 835 | <> |
| 283 | <div className="flex items-center justify-between gap-4"> | 836 | <div className="flex items-center justify-between gap-4"> |
| 284 | <div className="flex items-center gap-3 min-w-0"> | 837 | <div className="min-w-0"> |
| 285 | <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-primary"> | 838 | <div className="flex flex-wrap items-center gap-2"> |
| 286 | <KeyIcon className="size-3.5" /> | 839 | <span className="truncate text-[13px] font-medium text-foreground"> |
| 840 | {props.secret.name || "Unnamed secret"} | ||
| 841 | </span> | ||
| 842 | <Badge variant="outline" className="text-[9px] shrink-0"> | ||
| 843 | {props.secret.purpose.replace(/_/g, " ")} | ||
| 844 | </Badge> | ||
| 845 | <Badge variant="outline" className="text-[9px] shrink-0"> | ||
| 846 | {props.secret.storeName} | ||
| 847 | </Badge> | ||
| 848 | <Badge variant="outline" className="text-[9px] shrink-0 uppercase"> | ||
| 849 | {props.secret.storeKind} | ||
| 850 | </Badge> | ||
| 287 | </div> | 851 | </div> |
| 288 | <div className="min-w-0"> | 852 | <div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground/50"> |
| 289 | <div className="flex items-center gap-2"> | 853 | <span className="font-mono">{props.secret.id}</span> |
| 290 | <span className="text-[13px] font-medium text-foreground truncate"> | 854 | <span>{formatDate(props.secret.createdAt)}</span> |
| 291 | {secret.name || "Unnamed secret"} | ||
| 292 | </span> | ||
| 293 | <Badge variant="outline" className="text-[9px] shrink-0"> | ||
| 294 | {secret.purpose.replace(/_/g, " ")} | ||
| 295 | </Badge> | ||
| 296 | <Badge variant="outline" className="text-[9px] shrink-0"> | ||
| 297 | {providerLabel} | ||
| 298 | </Badge> | ||
| 299 | </div> | ||
| 300 | <div className="flex items-center gap-2 mt-0.5"> | ||
| 301 | <span className="text-[11px] font-mono text-muted-foreground/50 truncate"> | ||
| 302 | {secret.id} | ||
| 303 | </span> | ||
| 304 | <span className="text-[11px] text-muted-foreground/40"> | ||
| 305 | {createdDate} | ||
| 306 | </span> | ||
| 307 | </div> | ||
| 308 | {secret.linkedSources.length > 0 && ( | ||
| 309 | <div className="flex items-center gap-1.5 mt-1 flex-wrap"> | ||
| 310 | <span className="text-[10px] text-muted-foreground/50">Used by</span> | ||
| 311 | {secret.linkedSources.map((ls) => ( | ||
| 312 | <span | ||
| 313 | key={ls.sourceId} | ||
| 314 | className="inline-flex items-center gap-1 rounded-md bg-accent/60 px-1.5 py-0.5 text-[10px] font-medium text-foreground/70" | ||
| 315 | > | ||
| 316 | {ls.sourceName} | ||
| 317 | </span> | ||
| 318 | ))} | ||
| 319 | </div> | ||
| 320 | )} | ||
| 321 | </div> | 855 | </div> |
| 856 | {props.secret.linkedSources.length > 0 && ( | ||
| 857 | <div className="mt-1 flex flex-wrap items-center gap-1.5"> | ||
| 858 | <span className="text-[10px] text-muted-foreground/50">Used by</span> | ||
| 859 | {props.secret.linkedSources.map((linkedSource) => ( | ||
| 860 | <span | ||
| 861 | key={linkedSource.sourceId} | ||
| 862 | className="inline-flex items-center rounded-md bg-accent/60 px-1.5 py-0.5 text-[10px] font-medium text-foreground/70" | ||
| 863 | > | ||
| 864 | {linkedSource.sourceName} | ||
| 865 | </span> | ||
| 866 | ))} | ||
| 867 | </div> | ||
| 868 | )} | ||
| 322 | </div> | 869 | </div> |
| 323 | <div className="flex items-center gap-1.5 shrink-0"> | 870 | <div className="flex shrink-0 items-center gap-1.5"> |
| 324 | <button | 871 | <button |
| 325 | type="button" | 872 | type="button" |
| 326 | onClick={props.onEdit} | 873 | onClick={props.onEdit} |
| 327 | className={cn( | 874 | className={cn( |
| 328 | "inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium transition-colors", | 875 | "inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium transition-colors", |
| 329 | isEditing | 876 | props.isEditing |
| 330 | ? "bg-primary/10 text-primary" | 877 | ? "bg-primary/10 text-primary" |
| 331 | : "text-muted-foreground hover:text-foreground hover:bg-accent/50", | 878 | : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", |
| 332 | )} | 879 | )} |
| 333 | > | 880 | > |
| 334 | <IconPencil className="size-3" /> | 881 | <IconPencil className="size-3" /> |
| @@ -336,14 +883,11 @@ function SecretRow(props: { · 1 unmodified line | |||
| 336 | </button> | 883 | </button> |
| 337 | {confirmDelete ? ( | 884 | {confirmDelete ? ( |
| 338 | <> | 885 | <> |
| 339 | <span className="text-[10px] font-medium text-destructive"> | ||
| 340 | Confirm delete? | ||
| 341 | </span> | ||
| 342 | <button | 886 | <button |
| 343 | type="button" | 887 | type="button" |
| 344 | onClick={() => setConfirmDelete(false)} | 888 | onClick={() => setConfirmDelete(false)} |
| 345 | disabled={isDeleting} | 889 | disabled={isDeleting} |
| 346 | className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:opacity-50" | 890 | className="rounded-md px-2 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:opacity-50" |
| 347 | > | 891 | > |
| 348 | Cancel | 892 | Cancel |
| 349 | </button> | 893 | </button> |
| @@ -355,8 +899,10 @@ function SecretRow(props: { · 5 unmodified lines | |||
| 355 | disabled={isDeleting} | 899 | disabled={isDeleting} |
| 356 | className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-destructive transition-colors hover:bg-destructive/8 disabled:opacity-50" | 900 | className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-destructive transition-colors hover:bg-destructive/8 disabled:opacity-50" |
| 357 | > | 901 | > |
| 358 | {isDeleting ? <IconSpinner className="size-3" /> : <IconTrash className="size-3" />} | 902 | {isDeleting |
| 359 | {isDeleting ? "Deleting..." : "Delete"} | 903 | ? <IconSpinner className="size-3" /> |
| 904 | : <IconTrash className="size-3" />} | ||
| 905 | Delete | ||
| 360 | </button> | 906 | </button> |
| 361 | </> | 907 | </> |
| 362 | ) : ( | 908 | ) : ( |
| @@ -364,31 +910,28 @@ function SecretRow(props: { · 1 unmodified line | |||
| 364 | type="button" | 910 | type="button" |
| 365 | onClick={() => setConfirmDelete(true)} | 911 | onClick={() => setConfirmDelete(true)} |
| 366 | disabled={isDeleting} | 912 | disabled={isDeleting} |
| 367 | className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:text-destructive hover:bg-destructive/8 disabled:opacity-50" | 913 | className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-destructive/8 hover:text-destructive disabled:opacity-50" |
| 368 | > | 914 | > |
| 369 | {isDeleting ? <IconSpinner className="size-3" /> : <IconTrash className="size-3" />} | 915 | {isDeleting |
| 916 | ? <IconSpinner className="size-3" /> | ||
| 917 | : <IconTrash className="size-3" />} | ||
| 370 | Delete | 918 | Delete |
| 371 | </button> | 919 | </button> |
| 372 | )} | 920 | )} |
| 373 | </div> | 921 | </div> |
| 374 | </div> | 922 | </div> |
| 375 | 923 | ||
| 376 | {/* Inline edit */} | 924 | {props.isEditing && ( |
| 377 | {isEditing && ( | 925 | <EditSecretForm secret={props.secret} onClose={props.onCancelEdit} /> |
| 378 | <EditSecretForm | ||
| 379 | secret={secret} | ||
| 380 | onClose={props.onCancelEdit} | ||
| 381 | /> | ||
| 382 | )} | 926 | )} |
| 383 | </div> | 927 | </> |
| 384 | ); | 928 | ); |
| 385 | } | 929 | } |
| 386 | 930 | ||
| 387 | // --------------------------------------------------------------------------- | 931 | function EditSecretForm(props: { |
| 388 | // Edit form (inline) | 932 | secret: SecretListItem; |
| 389 | // --------------------------------------------------------------------------- | 933 | onClose: () => void; |
| 390 | 934 | }) { | |
| 391 | function EditSecretForm(props: { secret: SecretListItem; onClose: () => void }) { | ||
| 392 | const updateSecret = useUpdateSecret(); | 935 | const updateSecret = useUpdateSecret(); |
| 393 | const [name, setName] = useState(props.secret.name ?? ""); | 936 | const [name, setName] = useState(props.secret.name ?? ""); |
| 394 | const [value, setValue] = useState(""); | 937 | const [value, setValue] = useState(""); |
| @@ -416,39 +959,35 @@ function EditSecretForm(props: { secret: SecretListItem; onClose: () => void }) · 21 unmodified lines | |||
| 416 | payload, | 959 | payload, |
| 417 | }); | 960 | }); |
| 418 | props.onClose(); | 961 | props.onClose(); |
| 419 | } catch (err) { | 962 | } catch (cause) { |
| 420 | setError(err instanceof Error ? err.message : "Failed updating secret."); | 963 | setError(cause instanceof Error ? cause.message : "Failed updating secret."); |
| 421 | } | 964 | } |
| 422 | }; | 965 | }; |
| 423 | 966 | ||
| 424 | return ( | 967 | return ( |
| 425 | <div className="mt-3 pt-3 border-t border-border/50"> | 968 | <div className="mt-3 border-t border-border/50 pt-3"> |
| 426 | {error && ( | 969 | {error && <ErrorBanner className="mb-3">{error}</ErrorBanner>} |
| 427 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-[13px] text-destructive mb-3"> | ||
| 428 | {error} | ||
| 429 | </div> | ||
| 430 | )} | ||
| 431 | <div className="grid gap-3 sm:grid-cols-2"> | 970 | <div className="grid gap-3 sm:grid-cols-2"> |
| 432 | <FieldLabel label="Name"> | 971 | <FieldLabel label="Name"> |
| 433 | <input | 972 | <input |
| 434 | value={name} | 973 | value={name} |
| 435 | onChange={(e) => setName(e.target.value)} | 974 | onChange={(event) => setName(event.target.value)} |
| 436 | placeholder="Secret name" | 975 | placeholder="Secret name" |
| 437 | className="h-8 w-full rounded-lg border border-input bg-background px-3 text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" | 976 | className="h-8 w-full rounded-lg border border-input bg-background px-3 text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 438 | autoFocus | 977 | autoFocus |
| 439 | /> | 978 | /> |
| 440 | </FieldLabel> | 979 | </FieldLabel> |
| 441 | <FieldLabel label="New value (leave empty to keep current)"> | 980 | <FieldLabel label="New value"> |
| 442 | <input | 981 | <input |
| 443 | type="password" | 982 | type="password" |
| 444 | value={value} | 983 | value={value} |
| 445 | onChange={(e) => setValue(e.target.value)} | 984 | onChange={(event) => setValue(event.target.value)} |
| 446 | placeholder="Leave empty to keep existing" | 985 | placeholder="Leave empty to keep existing" |
| 447 | className="h-8 w-full rounded-lg border border-input bg-background px-3 font-mono text-[11px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" | 986 | className="h-8 w-full rounded-lg border border-input bg-background px-3 font-mono text-[11px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 448 | /> | 987 | /> |
| 449 | </FieldLabel> | 988 | </FieldLabel> |
| 450 | </div> | 989 | </div> |
| 451 | <div className="flex items-center justify-end gap-2 mt-3"> | 990 | <div className="mt-3 flex items-center justify-end gap-2"> |
| 452 | <button | 991 | <button |
| 453 | type="button" | 992 | type="button" |
| 454 | onClick={props.onClose} | 993 | onClick={props.onClose} |
| @@ -469,29 +1008,84 @@ function EditSecretForm(props: { secret: SecretListItem; onClose: () => void }) · 14 unmodified lines | |||
| 469 | ); | 1008 | ); |
| 470 | } | 1009 | } |
| 471 | 1010 | ||
| 472 | // --------------------------------------------------------------------------- | 1011 | function FormCard(props: { |
| 473 | // Shared | 1012 | title: string; |
| 474 | // --------------------------------------------------------------------------- | 1013 | onClose: () => void; |
| 1014 | className?: string; | ||
| 1015 | children: ReactNode; | ||
| 1016 | }) { | ||
| 1017 | return ( | ||
| 1018 | <div className={cn("rounded-xl border border-primary/20 bg-card/80", props.className)}> | ||
| 1019 | <div className="flex items-center justify-between border-b border-border px-5 py-3"> | ||
| 1020 | <h3 className="text-sm font-semibold text-foreground">{props.title}</h3> | ||
| 1021 | <button | ||
| 1022 | type="button" | ||
| 1023 | onClick={props.onClose} | ||
| 1024 | className="text-[12px] text-muted-foreground transition-colors hover:text-foreground" | ||
| 1025 | > | ||
| 1026 | Cancel | ||
| 1027 | </button> | ||
| 1028 | </div> | ||
| 1029 | <div className="space-y-4 p-5">{props.children}</div> | ||
| 1030 | </div> | ||
| 1031 | ); | ||
| 1032 | } | ||
| 475 | 1033 | ||
| 476 | function FieldLabel(props: { label: string; children: ReactNode }) { | 1034 | function ErrorBanner(props: { |
| 1035 | children: ReactNode; | ||
| 1036 | className?: string; | ||
| 1037 | }) { | ||
| 477 | return ( | 1038 | return ( |
| 478 | <label className="block space-y-1"> | 1039 | <div |
| 479 | <span className="text-[11px] font-medium text-muted-foreground">{props.label}</span> | 1040 | className={cn( |
| 1041 | "rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-[13px] text-destructive", | ||
| 1042 | props.className, | ||
| 1043 | )} | ||
| 1044 | > | ||
| 480 | {props.children} | 1045 | {props.children} |
| 481 | </label> | 1046 | </div> |
| 1047 | ); | ||
| 1048 | } | ||
| 1049 | |||
| 1050 | function SectionEmptyState(props: { | ||
| 1051 | title: string; | ||
| 1052 | description: string; | ||
| 1053 | actionLabel?: string; | ||
| 1054 | onAction?: () => void; | ||
| 1055 | }) { | ||
| 1056 | return ( | ||
| 1057 | <div className="rounded-xl border border-dashed border-border px-6 py-10 text-center"> | ||
| 1058 | <p className="text-[14px] font-medium text-foreground/75">{props.title}</p> | ||
| 1059 | <p className="mt-1 text-[13px] text-muted-foreground">{props.description}</p> | ||
| 1060 | {props.actionLabel && props.onAction && ( | ||
| 1061 | <div className="mt-4 flex justify-center"> | ||
| 1062 | <Button size="sm" onClick={props.onAction}> | ||
| 1063 | <IconPlus className="size-3.5" /> | ||
| 1064 | {props.actionLabel} | ||
| 1065 | </Button> | ||
| 1066 | </div> | ||
| 1067 | )} | ||
| 1068 | </div> | ||
| 482 | ); | 1069 | ); |
| 483 | } | 1070 | } |
| 484 | 1071 | ||
| 485 | function KeyIcon(props: { className?: string }) { | 1072 | function FieldLabel(props: { |
| 1073 | label: string; | ||
| 1074 | children: ReactNode; | ||
| 1075 | }) { | ||
| 486 | return ( | 1076 | return ( |
| 487 | <svg viewBox="0 0 16 16" fill="none" className={cn("size-4", props.className)}> | 1077 | <label className="block space-y-1"> |
| 488 | <path | 1078 | <span className="text-[11px] font-medium text-muted-foreground"> |
| 489 | d="M10 6a4 4 0 10-4.9 3.9L2 13v1.5h2V13h1.5v-1.5H7l.6-.6A4 4 0 0010 6z" | 1079 | {props.label} |
| 490 | stroke="currentColor" | 1080 | </span> |
| 491 | strokeWidth="1.3" | 1081 | {props.children} |
| 492 | strokeLinejoin="round" | 1082 | </label> |
| 493 | /> | ||
| 494 | <circle cx="10.5" cy="5.5" r="1" fill="currentColor" /> | ||
| 495 | </svg> | ||
| 496 | ); | 1083 | ); |
| 497 | } | 1084 | } |
| 1085 | |||
| 1086 | const formatDate = (value: number): string => | ||
| 1087 | new Date(value).toLocaleDateString(undefined, { | ||
| 1088 | month: "short", | ||
| 1089 | day: "numeric", | ||
| 1090 | year: "numeric", | ||
| 1091 | }); | ||
| @@ -62,6 +62,10 @@ export default defineConfig({ · 61 unmodified lines | |||
| 62 | }, | 62 | }, |
| 63 | server: { | 63 | server: { |
| 64 | port: 8788, | 64 | port: 8788, |
| 65 | allowedHosts: [ | ||
| 66 | "rhyss-laptop.tail5665af.ts.net", | ||
| 67 | ".tail5665af.ts.net", | ||
| 68 | ], | ||
| 65 | watch: { | 69 | watch: { |
| 66 | ignored: [ | 70 | ignored: [ |
| 67 | "!**/node_modules/@executor/**", | 71 | "!**/node_modules/@executor/**", |
| @@ -33,6 +33,9 @@ · 32 unmodified lines | |||
| 33 | "@executor/platform-sdk-file": "workspace:*", | 33 | "@executor/platform-sdk-file": "workspace:*", |
| 34 | "@executor/plugin-google-discovery-sdk": "workspace:*", | 34 | "@executor/plugin-google-discovery-sdk": "workspace:*", |
| 35 | "@executor/plugin-graphql-sdk": "workspace:*", | 35 | "@executor/plugin-graphql-sdk": "workspace:*", |
| 36 | "@executor/plugin-keychain-secret-store-sdk": "workspace:*", | ||
| 37 | "@executor/plugin-local-secret-store-sdk": "workspace:*", | ||
| 38 | "@executor/plugin-local-tools-sdk": "workspace:*", | ||
| 36 | "@executor/plugin-mcp-sdk": "workspace:*", | 39 | "@executor/plugin-mcp-sdk": "workspace:*", |
| 37 | "@executor/plugin-openapi-sdk": "workspace:*", | 40 | "@executor/plugin-openapi-sdk": "workspace:*", |
| 38 | "@executor/server": "workspace:*", | 41 | "@executor/server": "workspace:*", |
| @@ -57,7 +60,9 @@ · 18 unmodified lines | |||
| 57 | "@executor/plugin-execution-history-react": "workspace:*", | 60 | "@executor/plugin-execution-history-react": "workspace:*", |
| 58 | "@executor/plugin-google-discovery-react": "workspace:*", | 61 | "@executor/plugin-google-discovery-react": "workspace:*", |
| 59 | "@executor/plugin-graphql-react": "workspace:*", | 62 | "@executor/plugin-graphql-react": "workspace:*", |
| 63 | "@executor/plugin-local-tools-react": "workspace:*", | ||
| 60 | "@executor/plugin-mcp-react": "workspace:*", | 64 | "@executor/plugin-mcp-react": "workspace:*", |
| 65 | "@executor/plugin-onepassword-react": "workspace:*", | ||
| 61 | "@executor/plugin-openapi-react": "workspace:*", | 66 | "@executor/plugin-openapi-react": "workspace:*", |
| 62 | "@executor/react": "workspace:*", | 67 | "@executor/react": "workspace:*", |
| 63 | "@executor/server": "workspace:*", | 68 | "@executor/server": "workspace:*", |
| @@ -373,9 +378,15 @@ · 309 unmodified lines | |||
| 373 | "@executor/plugin-graphql-http": "workspace:*", | 378 | "@executor/plugin-graphql-http": "workspace:*", |
| 374 | "@executor/plugin-graphql-sdk": "workspace:*", | 379 | "@executor/plugin-graphql-sdk": "workspace:*", |
| 375 | "@executor/plugin-graphql-shared": "workspace:*", | 380 | "@executor/plugin-graphql-shared": "workspace:*", |
| 381 | "@executor/plugin-keychain-secret-store-sdk": "workspace:*", | ||
| 382 | "@executor/plugin-local-secret-store-sdk": "workspace:*", | ||
| 383 | "@executor/plugin-local-tools-sdk": "workspace:*", | ||
| 376 | "@executor/plugin-mcp-http": "workspace:*", | 384 | "@executor/plugin-mcp-http": "workspace:*", |
| 377 | "@executor/plugin-mcp-sdk": "workspace:*", | 385 | "@executor/plugin-mcp-sdk": "workspace:*", |
| 378 | "@executor/plugin-mcp-shared": "workspace:*", | 386 | "@executor/plugin-mcp-shared": "workspace:*", |
| 387 | "@executor/plugin-onepassword-http": "workspace:*", | ||
| 388 | "@executor/plugin-onepassword-sdk": "workspace:*", | ||
| 389 | "@executor/plugin-onepassword-shared": "workspace:*", | ||
| 379 | "@executor/plugin-openapi-http": "workspace:*", | 390 | "@executor/plugin-openapi-http": "workspace:*", |
| 380 | "@executor/plugin-openapi-sdk": "workspace:*", | 391 | "@executor/plugin-openapi-sdk": "workspace:*", |
| 381 | "@executor/plugin-openapi-shared": "workspace:*", | 392 | "@executor/plugin-openapi-shared": "workspace:*", |
| @@ -550,6 +561,58 @@ · 168 unmodified lines | |||
| 550 | "bun-types": "catalog:", | 561 | "bun-types": "catalog:", |
| 551 | }, | 562 | }, |
| 552 | }, | 563 | }, |
| 564 | "plugins/keychain-secret-store/sdk": { | ||
| 565 | "name": "@executor/plugin-keychain-secret-store-sdk", | ||
| 566 | "version": "0.0.0", | ||
| 567 | "dependencies": { | ||
| 568 | "@executor/platform-sdk": "workspace:*", | ||
| 569 | "effect": "catalog:", | ||
| 570 | }, | ||
| 571 | "devDependencies": { | ||
| 572 | "@types/node": "catalog:", | ||
| 573 | "bun-types": "catalog:", | ||
| 574 | }, | ||
| 575 | }, | ||
| 576 | "plugins/local-secret-store/sdk": { | ||
| 577 | "name": "@executor/plugin-local-secret-store-sdk", | ||
| 578 | "version": "0.0.0", | ||
| 579 | "dependencies": { | ||
| 580 | "@executor/platform-sdk": "workspace:*", | ||
| 581 | "effect": "catalog:", | ||
| 582 | }, | ||
| 583 | "devDependencies": { | ||
| 584 | "@types/node": "catalog:", | ||
| 585 | "bun-types": "catalog:", | ||
| 586 | }, | ||
| 587 | }, | ||
| 588 | "plugins/local-tools/react": { | ||
| 589 | "name": "@executor/plugin-local-tools-react", | ||
| 590 | "version": "0.0.0", | ||
| 591 | "dependencies": { | ||
| 592 | "@executor/react": "workspace:*", | ||
| 593 | "react": "^19.2.0", | ||
| 594 | }, | ||
| 595 | "devDependencies": { | ||
| 596 | "@types/node": "catalog:", | ||
| 597 | "@types/react": "^19.2.10", | ||
| 598 | "bun-types": "catalog:", | ||
| 599 | }, | ||
| 600 | }, | ||
| 601 | "plugins/local-tools/sdk": { | ||
| 602 | "name": "@executor/plugin-local-tools-sdk", | ||
| 603 | "version": "0.0.0", | ||
| 604 | "dependencies": { | ||
| 605 | "@executor/codemode-core": "workspace:*", | ||
| 606 | "@executor/ir": "workspace:*", | ||
| 607 | "@executor/platform-sdk": "workspace:*", | ||
| 608 | "@executor/source-core": "workspace:*", | ||
| 609 | "effect": "catalog:", | ||
| 610 | }, | ||
| 611 | "devDependencies": { | ||
| 612 | "@types/node": "catalog:", | ||
| 613 | "bun-types": "catalog:", | ||
| 614 | }, | ||
| 615 | }, | ||
| 553 | "plugins/mcp/http": { | 616 | "plugins/mcp/http": { |
| 554 | "name": "@executor/plugin-mcp-http", | 617 | "name": "@executor/plugin-mcp-http", |
| 555 | "version": "0.0.0", | 618 | "version": "0.0.0", |
| @@ -614,6 +677,62 @@ · 58 unmodified lines | |||
| 614 | "bun-types": "catalog:", | 677 | "bun-types": "catalog:", |
| 615 | }, | 678 | }, |
| 616 | }, | 679 | }, |
| 680 | "plugins/onepassword/http": { | ||
| 681 | "name": "@executor/plugin-onepassword-http", | ||
| 682 | "version": "0.0.0", | ||
| 683 | "dependencies": { | ||
| 684 | "@effect/platform": "catalog:", | ||
| 685 | "@executor/platform-api": "workspace:*", | ||
| 686 | "@executor/platform-sdk": "workspace:*", | ||
| 687 | "@executor/plugin-onepassword-shared": "workspace:*", | ||
| 688 | "effect": "catalog:", | ||
| 689 | }, | ||
| 690 | "devDependencies": { | ||
| 691 | "@types/node": "catalog:", | ||
| 692 | "bun-types": "catalog:", | ||
| 693 | }, | ||
| 694 | }, | ||
| 695 | "plugins/onepassword/react": { | ||
| 696 | "name": "@executor/plugin-onepassword-react", | ||
| 697 | "version": "0.0.0", | ||
| 698 | "dependencies": { | ||
| 699 | "@executor/plugin-onepassword-shared": "workspace:*", | ||
| 700 | "@executor/react": "workspace:*", | ||
| 701 | "react": "^19.2.0", | ||
| 702 | }, | ||
| 703 | "devDependencies": { | ||
| 704 | "@types/node": "catalog:", | ||
| 705 | "@types/react": "^19.2.10", | ||
| 706 | "bun-types": "catalog:", | ||
| 707 | }, | ||
| 708 | }, | ||
| 709 | "plugins/onepassword/sdk": { | ||
| 710 | "name": "@executor/plugin-onepassword-sdk", | ||
| 711 | "version": "0.0.0", | ||
| 712 | "dependencies": { | ||
| 713 | "@1password/sdk": "0.4.1-beta.1", | ||
| 714 | "@executor/platform-sdk": "workspace:*", | ||
| 715 | "@executor/plugin-onepassword-shared": "workspace:*", | ||
| 716 | "effect": "catalog:", | ||
| 717 | }, | ||
| 718 | "devDependencies": { | ||
| 719 | "@types/node": "catalog:", | ||
| 720 | "bun-types": "catalog:", | ||
| 721 | }, | ||
| 722 | }, | ||
| 723 | "plugins/onepassword/shared": { | ||
| 724 | "name": "@executor/plugin-onepassword-shared", | ||
| 725 | "version": "0.0.0", | ||
| 726 | "dependencies": { | ||
| 727 | "@effect/platform": "catalog:", | ||
| 728 | "@executor/platform-sdk": "workspace:*", | ||
| 729 | "effect": "catalog:", | ||
| 730 | }, | ||
| 731 | "devDependencies": { | ||
| 732 | "@types/node": "catalog:", | ||
| 733 | "bun-types": "catalog:", | ||
| 734 | }, | ||
| 735 | }, | ||
| 617 | "plugins/openapi/http": { | 736 | "plugins/openapi/http": { |
| 618 | "name": "@executor/plugin-openapi-http", | 737 | "name": "@executor/plugin-openapi-http", |
| 619 | "version": "0.0.0", | 738 | "version": "0.0.0", |
| @@ -692,6 +811,10 @@ · 72 unmodified lines | |||
| 692 | "zod": "^4.3.6", | 811 | "zod": "^4.3.6", |
| 693 | }, | 812 | }, |
| 694 | "packages": { | 813 | "packages": { |
| 814 | "@1password/sdk": ["@1password/sdk@0.4.1-beta.1", "", { "dependencies": { "@1password/sdk-core": "0.4.1-beta.1" } }, "sha512-QWY6eIIEs9at6kAFbgjZ1vVxg7ygp/MwXJ6/BoBdPkqIk8vfulXu4pjDsVQ87HhddJAD5LVGBXSbQIa+svI3bA=="], | ||
| 815 | |||
| 816 | "@1password/sdk-core": ["@1password/sdk-core@0.4.1-beta.1", "", {}, "sha512-/otbg1JVhsEn6oUIeReoT9TmFr8J7KBwr9UuRVfJFwwGG3bHPF8ewT+LhRimQeJtypqQ69ZVuOYkxknD4iQHxw=="], | ||
| 817 | |||
| 695 | "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.66", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SIQ0YY0iMuv+07HLsZ+bB990zUJ6S4ujORAh+Jv1V2KGNn73qQKnGO0JBk+w+Res8YqOFSycwDoWcFlQrVxS4A=="], | 818 | "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.66", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SIQ0YY0iMuv+07HLsZ+bB990zUJ6S4ujORAh+Jv1V2KGNn73qQKnGO0JBk+w+Res8YqOFSycwDoWcFlQrVxS4A=="], |
| 696 | 819 | ||
| 697 | "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], | 820 | "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], |
| @@ -922,6 +1045,14 @@ · 224 unmodified lines | |||
| 922 | 1045 | ||
| 923 | "@executor/plugin-graphql-shared": ["@executor/plugin-graphql-shared@workspace:plugins/graphql/shared"], | 1046 | "@executor/plugin-graphql-shared": ["@executor/plugin-graphql-shared@workspace:plugins/graphql/shared"], |
| 924 | 1047 | ||
| 1048 | "@executor/plugin-keychain-secret-store-sdk": ["@executor/plugin-keychain-secret-store-sdk@workspace:plugins/keychain-secret-store/sdk"], | ||
| 1049 | |||
| 1050 | "@executor/plugin-local-secret-store-sdk": ["@executor/plugin-local-secret-store-sdk@workspace:plugins/local-secret-store/sdk"], | ||
| 1051 | |||
| 1052 | "@executor/plugin-local-tools-react": ["@executor/plugin-local-tools-react@workspace:plugins/local-tools/react"], | ||
| 1053 | |||
| 1054 | "@executor/plugin-local-tools-sdk": ["@executor/plugin-local-tools-sdk@workspace:plugins/local-tools/sdk"], | ||
| 1055 | |||
| 925 | "@executor/plugin-mcp-http": ["@executor/plugin-mcp-http@workspace:plugins/mcp/http"], | 1056 | "@executor/plugin-mcp-http": ["@executor/plugin-mcp-http@workspace:plugins/mcp/http"], |
| 926 | 1057 | ||
| 927 | "@executor/plugin-mcp-react": ["@executor/plugin-mcp-react@workspace:plugins/mcp/react"], | 1058 | "@executor/plugin-mcp-react": ["@executor/plugin-mcp-react@workspace:plugins/mcp/react"], |
| @@ -930,6 +1061,14 @@ · 2 unmodified lines | |||
| 930 | 1061 | ||
| 931 | "@executor/plugin-mcp-shared": ["@executor/plugin-mcp-shared@workspace:plugins/mcp/shared"], | 1062 | "@executor/plugin-mcp-shared": ["@executor/plugin-mcp-shared@workspace:plugins/mcp/shared"], |
| 932 | 1063 | ||
| 1064 | "@executor/plugin-onepassword-http": ["@executor/plugin-onepassword-http@workspace:plugins/onepassword/http"], | ||
| 1065 | |||
| 1066 | "@executor/plugin-onepassword-react": ["@executor/plugin-onepassword-react@workspace:plugins/onepassword/react"], | ||
| 1067 | |||
| 1068 | "@executor/plugin-onepassword-sdk": ["@executor/plugin-onepassword-sdk@workspace:plugins/onepassword/sdk"], | ||
| 1069 | |||
| 1070 | "@executor/plugin-onepassword-shared": ["@executor/plugin-onepassword-shared@workspace:plugins/onepassword/shared"], | ||
| 1071 | |||
| 933 | "@executor/plugin-openapi-http": ["@executor/plugin-openapi-http@workspace:plugins/openapi/http"], | 1072 | "@executor/plugin-openapi-http": ["@executor/plugin-openapi-http@workspace:plugins/openapi/http"], |
| 934 | 1073 | ||
| 935 | "@executor/plugin-openapi-react": ["@executor/plugin-openapi-react@workspace:plugins/openapi/react"], | 1074 | "@executor/plugin-openapi-react": ["@executor/plugin-openapi-react@workspace:plugins/openapi/react"], |
| @@ -27,6 +27,7 @@ import type { · 26 unmodified lines | |||
| 27 | LocalInstallation, | 27 | LocalInstallation, |
| 28 | SecretMaterial, | 28 | SecretMaterial, |
| 29 | SecretRef, | 29 | SecretRef, |
| 30 | SecretStore, | ||
| 30 | } from "@executor/platform-sdk/schema"; | 31 | } from "@executor/platform-sdk/schema"; |
| 31 | import { | 32 | import { |
| 32 | ScopeIdSchema, | 33 | ScopeIdSchema, |
| @@ -57,14 +58,15 @@ type SourceArtifactBuildInput = Parameters< · 24 unmodified lines | |||
| 57 | 58 | ||
| 58 | type SecretMaterialSummary = { | 59 | type SecretMaterialSummary = { |
| 59 | id: string; | 60 | id: string; |
| 60 | providerId: string; | 61 | storeId: string; |
| 61 | name: string | null; | 62 | name: string | null; |
| 62 | purpose: string; | 63 | purpose: string; |
| 63 | createdAt: number; | 64 | createdAt: number; |
| 64 | updatedAt: number; | 65 | updatedAt: number; |
| 65 | }; | 66 | }; |
| 66 | 67 | ||
| 67 | const SQLITE_SECRET_PROVIDER_ID = "sqlite"; | 68 | const SQLITE_SECRET_STORE_KIND = "sqlite"; |
| 69 | const SQLITE_SECRET_STORE_ID = "sts_sqlite"; | ||
| 68 | 70 | ||
| 69 | const installations = sqliteTable("installations", { | 71 | const installations = sqliteTable("installations", { |
| 70 | key: text("key").primaryKey(), | 72 | key: text("key").primaryKey(), |
| @@ -250,8 +252,39 @@ const openSqliteStore = (databasePath: string) => { · 179 unmodified lines | |||
| 250 | 252 | ||
| 251 | type SqliteStore = ReturnType<typeof openSqliteStore>; | 253 | type SqliteStore = ReturnType<typeof openSqliteStore>; |
| 252 | 254 | ||
| 253 | const createStorageDomains = (store: SqliteStore) => ({ | 255 | const createStorageDomains = ( |
| 256 | store: SqliteStore, | ||
| 257 | installation: LocalInstallation, | ||
| 258 | ) => ({ | ||
| 254 | secrets: { | 259 | secrets: { |
| 260 | secretStores: { | ||
| 261 | getById: (id: SecretStore["id"]) => | ||
| 262 | id === SQLITE_SECRET_STORE_ID | ||
| 263 | ? { | ||
| 264 | id: SQLITE_SECRET_STORE_ID as SecretStore["id"], | ||
| 265 | scopeId: installation.scopeId, | ||
| 266 | kind: SQLITE_SECRET_STORE_KIND, | ||
| 267 | name: "SQLite Store", | ||
| 268 | status: "connected" as const, | ||
| 269 | enabled: true, | ||
| 270 | createdAt: 0, | ||
| 271 | updatedAt: 0, | ||
| 272 | } | ||
| 273 | : null, | ||
| 274 | listAll: () => [{ | ||
| 275 | id: SQLITE_SECRET_STORE_ID, | ||
| 276 | scopeId: installation.scopeId, | ||
| 277 | kind: SQLITE_SECRET_STORE_KIND, | ||
| 278 | name: "SQLite Store", | ||
| 279 | status: "connected", | ||
| 280 | enabled: true, | ||
| 281 | createdAt: 0, | ||
| 282 | updatedAt: 0, | ||
| 283 | }], | ||
| 284 | upsert: (_secretStore: SecretStore) => {}, | ||
| 285 | updateById: (_id: SecretStore["id"], _update: Partial<Pick<SecretStore, "name" | "status" | "enabled">>) => null, | ||
| 286 | removeById: (_id: SecretStore["id"]) => false, | ||
| 287 | }, | ||
| 255 | getById: (id: SecretMaterial["id"]) => { | 288 | getById: (id: SecretMaterial["id"]) => { |
| 256 | const row = store.db.select().from(secretMaterials).where(eq(secretMaterials.id, id)).get(); | 289 | const row = store.db.select().from(secretMaterials).where(eq(secretMaterials.id, id)).get(); |
| 257 | return row ? parseJson<SecretMaterial>(row.json) : null; | 290 | return row ? parseJson<SecretMaterial>(row.json) : null; |
| @@ -259,7 +292,7 @@ const createStorageDomains = (store: SqliteStore) => ({ · 1 unmodified line | |||
| 259 | listAll: (): readonly SecretMaterialSummary[] => | 292 | listAll: (): readonly SecretMaterialSummary[] => |
| 260 | store.db.select().from(secretMaterials).all().map((row) => ({ | 293 | store.db.select().from(secretMaterials).all().map((row) => ({ |
| 261 | id: row.id, | 294 | id: row.id, |
| 262 | providerId: row.providerId, | 295 | storeId: row.providerId, |
| 263 | name: row.name, | 296 | name: row.name, |
| 264 | purpose: row.purpose, | 297 | purpose: row.purpose, |
| 265 | createdAt: row.createdAt, | 298 | createdAt: row.createdAt, |
| @@ -268,7 +301,7 @@ const createStorageDomains = (store: SqliteStore) => ({ · 2 unmodified lines | |||
| 268 | upsert: (material: SecretMaterial) => | 301 | upsert: (material: SecretMaterial) => |
| 269 | store.db.insert(secretMaterials).values({ | 302 | store.db.insert(secretMaterials).values({ |
| 270 | id: material.id, | 303 | id: material.id, |
| 271 | providerId: material.providerId, | 304 | providerId: material.storeId, |
| 272 | name: material.name, | 305 | name: material.name, |
| 273 | purpose: material.purpose, | 306 | purpose: material.purpose, |
| 274 | createdAt: material.createdAt, | 307 | createdAt: material.createdAt, |
| @@ -277,7 +310,7 @@ const createStorageDomains = (store: SqliteStore) => ({ · 2 unmodified lines | |||
| 277 | }).onConflictDoUpdate({ | 310 | }).onConflictDoUpdate({ |
| 278 | target: secretMaterials.id, | 311 | target: secretMaterials.id, |
| 279 | set: { | 312 | set: { |
| 280 | providerId: material.providerId, | 313 | providerId: material.storeId, |
| 281 | name: material.name, | 314 | name: material.name, |
| 282 | purpose: material.purpose, | 315 | purpose: material.purpose, |
| 283 | createdAt: material.createdAt, | 316 | createdAt: material.createdAt, |
| @@ -299,7 +332,7 @@ const createStorageDomains = (store: SqliteStore) => ({ · 15 unmodified lines | |||
| 299 | updatedAt: Date.now(), | 332 | updatedAt: Date.now(), |
| 300 | }; | 333 | }; |
| 301 | store.db.update(secretMaterials).set({ | 334 | store.db.update(secretMaterials).set({ |
| 302 | providerId: next.providerId, | 335 | providerId: next.storeId, |
| 303 | name: next.name, | 336 | name: next.name, |
| 304 | purpose: next.purpose, | 337 | purpose: next.purpose, |
| 305 | createdAt: next.createdAt, | 338 | createdAt: next.createdAt, |
| @@ -308,7 +341,7 @@ const createStorageDomains = (store: SqliteStore) => ({ · 2 unmodified lines | |||
| 308 | }).where(eq(secretMaterials.id, id)).run(); | 341 | }).where(eq(secretMaterials.id, id)).run(); |
| 309 | return { | 342 | return { |
| 310 | id: next.id, | 343 | id: next.id, |
| 311 | providerId: next.providerId, | 344 | storeId: next.storeId, |
| 312 | name: next.name, | 345 | name: next.name, |
| 313 | purpose: next.purpose, | 346 | purpose: next.purpose, |
| 314 | createdAt: next.createdAt, | 347 | createdAt: next.createdAt, |
| @@ -471,7 +504,19 @@ export const createSqliteExecutorBackend = ( · 156 unmodified lines | |||
| 471 | return createExecutorBackend({ | 504 | return createExecutorBackend({ |
| 472 | loadRepositories: () => { | 505 | loadRepositories: () => { |
| 473 | const store = openSqliteStore(databasePath); | 506 | const store = openSqliteStore(databasePath); |
| 474 | const { secrets, executions } = createStorageDomains(store); | 507 | const installation = (() => { |
| 508 | const row = store.db.select().from(installations).where(eq(installations.key, "active")).get(); | ||
| 509 | return row | ||
| 510 | ? { | ||
| 511 | scopeId: row.scopeId as LocalInstallation["scopeId"], | ||
| 512 | actorScopeId: row.actorScopeId as LocalInstallation["actorScopeId"], | ||
| 513 | resolutionScopeIds: parseJson<LocalInstallation["resolutionScopeIds"]>( | ||
| 514 | row.resolutionScopeIdsJson, | ||
| 515 | ), | ||
| 516 | } | ||
| 517 | : createInstallation(options); | ||
| 518 | })(); | ||
| 519 | const { secrets, executions } = createStorageDomains(store, installation); | ||
| 475 | 520 | ||
| 476 | return { | 521 | return { |
| 477 | scope: { | 522 | scope: { |
| @@ -484,30 +529,9 @@ export const createSqliteExecutorBackend = ( · 6 unmodified lines | |||
| 484 | }, | 529 | }, |
| 485 | installation: { | 530 | installation: { |
| 486 | load: () => { | 531 | load: () => { |
| 487 | const row = store.db.select().from(installations).where(eq(installations.key, "active")).get(); | 532 | return installation; |
| 488 | return row | ||
| 489 | ? { | ||
| 490 | scopeId: row.scopeId as LocalInstallation["scopeId"], | ||
| 491 | actorScopeId: row.actorScopeId as LocalInstallation["actorScopeId"], | ||
| 492 | resolutionScopeIds: parseJson<LocalInstallation["resolutionScopeIds"]>( | ||
| 493 | row.resolutionScopeIdsJson, | ||
| 494 | ), | ||
| 495 | } | ||
| 496 | : createInstallation(options); | ||
| 497 | }, | 533 | }, |
| 498 | getOrProvision: () => { | 534 | getOrProvision: () => { |
| 499 | const installation = (() => { | ||
| 500 | const row = store.db.select().from(installations).where(eq(installations.key, "active")).get(); | ||
| 501 | return row | ||
| 502 | ? { | ||
| 503 | scopeId: row.scopeId as LocalInstallation["scopeId"], | ||
| 504 | actorScopeId: row.actorScopeId as LocalInstallation["actorScopeId"], | ||
| 505 | resolutionScopeIds: parseJson<LocalInstallation["resolutionScopeIds"]>( | ||
| 506 | row.resolutionScopeIdsJson, | ||
| 507 | ), | ||
| 508 | } | ||
| 509 | : createInstallation(options); | ||
| 510 | })(); | ||
| 511 | store.db.insert(installations).values({ | 535 | store.db.insert(installations).values({ |
| 512 | key: "active", | 536 | key: "active", |
| 513 | scopeId: installation.scopeId, | 537 | scopeId: installation.scopeId, |
| @@ -587,21 +611,22 @@ export const createSqliteExecutorBackend = ( · 73 unmodified lines | |||
| 587 | }, | 611 | }, |
| 588 | secrets: { | 612 | secrets: { |
| 589 | ...secrets, | 613 | ...secrets, |
| 614 | secretStores: secrets.secretStores, | ||
| 590 | resolve: ({ ref }) => { | 615 | resolve: ({ ref }) => { |
| 591 | const material = secrets.getById( | 616 | const material = secrets.getById( |
| 592 | ref.handle as SecretMaterial["id"], | 617 | ref.secretId as SecretMaterial["id"], |
| 593 | ); | 618 | ); |
| 594 | if (!material || material.value === null) { | 619 | if (!material || material.value === null) { |
| 595 | throw new Error(`Missing secret material ${ref.handle}`); | 620 | throw new Error(`Missing secret material ${ref.secretId}`); |
| 596 | } | 621 | } |
| 597 | return material.value; | 622 | return material.value; |
| 598 | }, | 623 | }, |
| 599 | store: ({ purpose, value, name, providerId }) => { | 624 | store: ({ purpose, value, name, storeId }) => { |
| 600 | const now = Date.now(); | 625 | const now = Date.now(); |
| 601 | const id = SecretMaterialIdSchema.make(`secret_${randomUUID()}`); | 626 | const id = SecretMaterialIdSchema.make(`secret_${randomUUID()}`); |
| 602 | const material: SecretMaterial = { | 627 | const material: SecretMaterial = { |
| 603 | id, | 628 | id, |
| 604 | providerId: providerId ?? SQLITE_SECRET_PROVIDER_ID, | 629 | storeId: (storeId ?? SQLITE_SECRET_STORE_ID) as SecretMaterial["storeId"], |
| 605 | handle: id, | 630 | handle: id, |
| 606 | name: name ?? null, | 631 | name: name ?? null, |
| 607 | purpose, | 632 | purpose, |
| @@ -611,19 +636,18 @@ export const createSqliteExecutorBackend = ( · 3 unmodified lines | |||
| 611 | }; | 636 | }; |
| 612 | secrets.upsert(material); | 637 | secrets.upsert(material); |
| 613 | return { | 638 | return { |
| 614 | providerId: material.providerId, | 639 | secretId: material.id, |
| 615 | handle: material.handle, | ||
| 616 | } satisfies SecretRef; | 640 | } satisfies SecretRef; |
| 617 | }, | 641 | }, |
| 618 | delete: (ref) => | 642 | delete: (ref) => |
| 619 | secrets.removeById(ref.handle as SecretMaterial["id"]), | 643 | secrets.removeById(ref.secretId as SecretMaterial["id"]), |
| 620 | update: ({ ref, name, value }) => { | 644 | update: ({ ref, name, value }) => { |
| 621 | const updated = secrets.updateById( | 645 | const updated = secrets.updateById( |
| 622 | ref.handle as SecretMaterial["id"], | 646 | ref.secretId as SecretMaterial["id"], |
| 623 | { name, value }, | 647 | { name, value }, |
| 624 | ); | 648 | ); |
| 625 | if (!updated) { | 649 | if (!updated) { |
| 626 | throw new Error(`Missing secret material ${ref.handle}`); | 650 | throw new Error(`Missing secret material ${ref.secretId}`); |
| 627 | } | 651 | } |
| 628 | return updated; | 652 | return updated; |
| 629 | }, | 653 | }, |
| @@ -632,14 +656,14 @@ export const createSqliteExecutorBackend = ( · 2 unmodified lines | |||
| 632 | instanceConfig: { | 656 | instanceConfig: { |
| 633 | resolve: () => ({ | 657 | resolve: () => ({ |
| 634 | platform: "sqlite-sdk-example", | 658 | platform: "sqlite-sdk-example", |
| 635 | secretProviders: [ | 659 | secretStorePlugins: [ |
| 636 | { | 660 | { |
| 637 | id: SQLITE_SECRET_PROVIDER_ID, | 661 | kind: SQLITE_SECRET_STORE_KIND, |
| 638 | name: "SQLite", | 662 | displayName: "SQLite", |
| 639 | canStore: true, | 663 | canCreate: false, |
| 640 | }, | 664 | }, |
| 641 | ], | 665 | ], |
| 642 | defaultSecretStoreProvider: SQLITE_SECRET_PROVIDER_ID, | 666 | defaultSecretStoreId: SQLITE_SECRET_STORE_ID, |
| 643 | }), | 667 | }), |
| 644 | }, | 668 | }, |
| 645 | close: async () => { | 669 | close: async () => { |
| @@ -0,0 +1,453 @@ | |||
| 1 | import { | ||
| 2 | useAtomSet, | ||
| 3 | } from "@effect-atom/atom-react"; | ||
| 4 | import type { | ||
| 5 | BrowseSecretStoreResult, | ||
| 6 | CreateSecretResult, | ||
| 7 | SecretListItem, | ||
| 8 | } from "@executor/platform-api"; | ||
| 9 | import { useCallback, useEffect, useMemo, useState } from "react"; | ||
| 10 | |||
| 11 | import { | ||
| 12 | getExecutorApiHttpClient, | ||
| 13 | } from "../core/http-client"; | ||
| 14 | import { | ||
| 15 | useCreateSecret, | ||
| 16 | useRefreshSecrets, | ||
| 17 | useSecrets, | ||
| 18 | useSecretStores, | ||
| 19 | } from "../hooks/secrets"; | ||
| 20 | import { | ||
| 21 | useExecutorMutation, | ||
| 22 | } from "../hooks/mutations"; | ||
| 23 | |||
| 24 | type SecretReferenceFieldProps = { | ||
| 25 | label: string; | ||
| 26 | value: string; | ||
| 27 | emptyLabel: string; | ||
| 28 | draftNamePlaceholder: string; | ||
| 29 | draftValuePlaceholder: string; | ||
| 30 | onChange: (value: string) => void; | ||
| 31 | }; | ||
| 32 | |||
| 33 | type BrowserEntry = BrowseSecretStoreResult["entries"][number]; | ||
| 34 | type RemoteSecretEntry = { | ||
| 35 | kind: "remote"; | ||
| 36 | storeId: string; | ||
| 37 | storeName: string; | ||
| 38 | entry: BrowserEntry; | ||
| 39 | }; | ||
| 40 | type ManagedSecretEntry = { | ||
| 41 | kind: "managed"; | ||
| 42 | secret: SecretListItem; | ||
| 43 | }; | ||
| 44 | type UnifiedSecretEntry = ManagedSecretEntry | RemoteSecretEntry; | ||
| 45 | |||
| 46 | const buttonClassName = | ||
| 47 | "inline-flex h-8 items-center justify-center rounded-lg border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50 disabled:pointer-events-none disabled:opacity-50"; | ||
| 48 | |||
| 49 | export function SecretReferenceField(props: SecretReferenceFieldProps) { | ||
| 50 | const secrets = useSecrets(); | ||
| 51 | const secretStores = useSecretStores(); | ||
| 52 | const refreshSecrets = useRefreshSecrets(); | ||
| 53 | const createSecret = useCreateSecret(); | ||
| 54 | const browseSecretStoreMutation = useAtomSet( | ||
| 55 | getExecutorApiHttpClient().mutation("local", "browseSecretStore"), | ||
| 56 | { mode: "promise" }, | ||
| 57 | ); | ||
| 58 | const importSecretFromStoreMutation = useAtomSet( | ||
| 59 | getExecutorApiHttpClient().mutation("local", "importSecretFromStore"), | ||
| 60 | { mode: "promise" }, | ||
| 61 | ); | ||
| 62 | const [draftName, setDraftName] = useState(""); | ||
| 63 | const [draftValue, setDraftValue] = useState(""); | ||
| 64 | const [createError, setCreateError] = useState<string | null>(null); | ||
| 65 | const [showCreate, setShowCreate] = useState(false); | ||
| 66 | const [showFinder, setShowFinder] = useState(false); | ||
| 67 | const [finderQuery, setFinderQuery] = useState(""); | ||
| 68 | const [finderRemoteEntries, setFinderRemoteEntries] = useState< | ||
| 69 | ReadonlyArray<RemoteSecretEntry> | ||
| 70 | >([]); | ||
| 71 | const [finderError, setFinderError] = useState<string | null>(null); | ||
| 72 | const [finderStatus, setFinderStatus] = useState<"idle" | "pending">("idle"); | ||
| 73 | |||
| 74 | const browseSecretStore = useCallback( | ||
| 75 | async (input: { | ||
| 76 | storeId: string; | ||
| 77 | query?: string | null; | ||
| 78 | }) => | ||
| 79 | browseSecretStoreMutation({ | ||
| 80 | path: { | ||
| 81 | storeId: input.storeId, | ||
| 82 | }, | ||
| 83 | payload: { | ||
| 84 | ...(input.query?.trim() ? { query: input.query.trim() } : {}), | ||
| 85 | }, | ||
| 86 | }), | ||
| 87 | [browseSecretStoreMutation], | ||
| 88 | ); | ||
| 89 | |||
| 90 | const importSecretFromStore = useCallback( | ||
| 91 | async (input: { | ||
| 92 | storeId: string; | ||
| 93 | selectionKey: string; | ||
| 94 | }) => | ||
| 95 | importSecretFromStoreMutation({ | ||
| 96 | path: { | ||
| 97 | storeId: input.storeId, | ||
| 98 | }, | ||
| 99 | payload: { | ||
| 100 | selectionKey: input.selectionKey, | ||
| 101 | }, | ||
| 102 | }), | ||
| 103 | [importSecretFromStoreMutation], | ||
| 104 | ); | ||
| 105 | |||
| 106 | const importMutation = useExecutorMutation< | ||
| 107 | { storeId: string; selectionKey: string }, | ||
| 108 | CreateSecretResult | ||
| 109 | >(importSecretFromStore); | ||
| 110 | |||
| 111 | const browsableStores = useMemo( | ||
| 112 | () => | ||
| 113 | secretStores.status === "ready" | ||
| 114 | ? secretStores.data.filter((store) => | ||
| 115 | store.capabilities.canBrowseSecrets | ||
| 116 | && store.capabilities.canImportSecrets | ||
| 117 | ) | ||
| 118 | : [], | ||
| 119 | [secretStores], | ||
| 120 | ); | ||
| 121 | |||
| 122 | const selectedSecretId = useMemo(() => { | ||
| 123 | if (!props.value) { | ||
| 124 | return null; | ||
| 125 | } | ||
| 126 | |||
| 127 | try { | ||
| 128 | const parsed = JSON.parse(props.value) as { secretId?: string }; | ||
| 129 | return typeof parsed.secretId === "string" ? parsed.secretId : null; | ||
| 130 | } catch { | ||
| 131 | return null; | ||
| 132 | } | ||
| 133 | }, [props.value]); | ||
| 134 | |||
| 135 | const selectedSecret = useMemo( | ||
| 136 | () => | ||
| 137 | secrets.status === "ready" && selectedSecretId | ||
| 138 | ? secrets.data.find((secret) => secret.id === selectedSecretId) ?? null | ||
| 139 | : null, | ||
| 140 | [secrets, selectedSecretId], | ||
| 141 | ); | ||
| 142 | |||
| 143 | const filteredManagedSecrets = useMemo(() => { | ||
| 144 | if (secrets.status !== "ready") { | ||
| 145 | return [] as ReadonlyArray<SecretListItem>; | ||
| 146 | } | ||
| 147 | |||
| 148 | const normalizedQuery = finderQuery.trim().toLowerCase(); | ||
| 149 | return secrets.data.filter((secret) => | ||
| 150 | normalizedQuery.length === 0 | ||
| 151 | || (secret.name ?? "").toLowerCase().includes(normalizedQuery) | ||
| 152 | || secret.id.toLowerCase().includes(normalizedQuery) | ||
| 153 | || secret.storeName.toLowerCase().includes(normalizedQuery) | ||
| 154 | || secret.storeKind.toLowerCase().includes(normalizedQuery) | ||
| 155 | ); | ||
| 156 | }, [finderQuery, secrets]); | ||
| 157 | |||
| 158 | const unifiedEntries = useMemo(() => { | ||
| 159 | const managedEntries: ReadonlyArray<UnifiedSecretEntry> = filteredManagedSecrets.map( | ||
| 160 | (secret) => ({ | ||
| 161 | kind: "managed" as const, | ||
| 162 | secret, | ||
| 163 | }), | ||
| 164 | ); | ||
| 165 | |||
| 166 | return [ | ||
| 167 | ...managedEntries, | ||
| 168 | ...finderRemoteEntries, | ||
| 169 | ] satisfies ReadonlyArray<UnifiedSecretEntry>; | ||
| 170 | }, [filteredManagedSecrets, finderRemoteEntries]); | ||
| 171 | |||
| 172 | useEffect(() => { | ||
| 173 | if (!showFinder) { | ||
| 174 | return; | ||
| 175 | } | ||
| 176 | |||
| 177 | if (browsableStores.length === 0) { | ||
| 178 | setFinderRemoteEntries([]); | ||
| 179 | setFinderStatus("idle"); | ||
| 180 | return; | ||
| 181 | } | ||
| 182 | |||
| 183 | let active = true; | ||
| 184 | setFinderStatus("pending"); | ||
| 185 | const timeout = window.setTimeout(() => { | ||
| 186 | void Promise.all( | ||
| 187 | browsableStores.map(async (store) => { | ||
| 188 | const result = await browseSecretStore({ | ||
| 189 | storeId: store.id, | ||
| 190 | query: finderQuery, | ||
| 191 | }); | ||
| 192 | return result.entries | ||
| 193 | .filter((entry) => entry.kind === "secret") | ||
| 194 | .map((entry) => ({ | ||
| 195 | kind: "remote" as const, | ||
| 196 | storeId: store.id, | ||
| 197 | storeName: store.name, | ||
| 198 | entry, | ||
| 199 | })); | ||
| 200 | }), | ||
| 201 | ).then((results) => { | ||
| 202 | if (!active) { | ||
| 203 | return; | ||
| 204 | } | ||
| 205 | setFinderRemoteEntries( | ||
| 206 | results | ||
| 207 | .flat() | ||
| 208 | .sort((left, right) => | ||
| 209 | left.entry.label.localeCompare(right.entry.label) | ||
| 210 | ), | ||
| 211 | ); | ||
| 212 | setFinderError(null); | ||
| 213 | setFinderStatus("idle"); | ||
| 214 | }).catch((cause) => { | ||
| 215 | if (!active) { | ||
| 216 | return; | ||
| 217 | } | ||
| 218 | setFinderRemoteEntries([]); | ||
| 219 | setFinderError( | ||
| 220 | cause instanceof Error ? cause.message : "Failed loading connected secrets.", | ||
| 221 | ); | ||
| 222 | setFinderStatus("idle"); | ||
| 223 | }); | ||
| 224 | }, 250); | ||
| 225 | |||
| 226 | return () => { | ||
| 227 | active = false; | ||
| 228 | window.clearTimeout(timeout); | ||
| 229 | }; | ||
| 230 | }, [browsableStores, browseSecretStore, finderQuery, showFinder]); | ||
| 231 | |||
| 232 | const handleCreate = async () => { | ||
| 233 | const trimmedName = draftName.trim(); | ||
| 234 | if (!trimmedName) { | ||
| 235 | setCreateError("Secret name is required."); | ||
| 236 | return; | ||
| 237 | } | ||
| 238 | if (!draftValue.trim()) { | ||
| 239 | setCreateError("Secret value is required."); | ||
| 240 | return; | ||
| 241 | } | ||
| 242 | |||
| 243 | try { | ||
| 244 | setCreateError(null); | ||
| 245 | const created = await createSecret.mutateAsync({ | ||
| 246 | name: trimmedName, | ||
| 247 | value: draftValue, | ||
| 248 | }); | ||
| 249 | props.onChange(JSON.stringify({ | ||
| 250 | secretId: created.id, | ||
| 251 | })); | ||
| 252 | setDraftName(""); | ||
| 253 | setDraftValue(""); | ||
| 254 | setShowCreate(false); | ||
| 255 | } catch (cause) { | ||
| 256 | setCreateError(cause instanceof Error ? cause.message : "Failed creating secret."); | ||
| 257 | } | ||
| 258 | }; | ||
| 259 | |||
| 260 | const handleSelectManagedSecret = (secret: SecretListItem) => { | ||
| 261 | props.onChange(JSON.stringify({ | ||
| 262 | secretId: secret.id, | ||
| 263 | })); | ||
| 264 | setShowFinder(false); | ||
| 265 | setFinderQuery(""); | ||
| 266 | setFinderError(null); | ||
| 267 | }; | ||
| 268 | |||
| 269 | const handleSelectRemoteSecret = async (entry: RemoteSecretEntry) => { | ||
| 270 | try { | ||
| 271 | const created = await importMutation.mutateAsync({ | ||
| 272 | storeId: entry.storeId, | ||
| 273 | selectionKey: entry.entry.key, | ||
| 274 | }); | ||
| 275 | refreshSecrets(); | ||
| 276 | props.onChange(JSON.stringify({ | ||
| 277 | secretId: created.id, | ||
| 278 | })); | ||
| 279 | setShowFinder(false); | ||
| 280 | setFinderQuery(""); | ||
| 281 | setFinderError(null); | ||
| 282 | } catch (cause) { | ||
| 283 | setFinderError( | ||
| 284 | cause instanceof Error ? cause.message : "Failed linking secret.", | ||
| 285 | ); | ||
| 286 | } | ||
| 287 | }; | ||
| 288 | |||
| 289 | return ( | ||
| 290 | <div className="grid gap-2"> | ||
| 291 | <span className="text-xs font-medium text-foreground">{props.label}</span> | ||
| 292 | |||
| 293 | <button | ||
| 294 | type="button" | ||
| 295 | onClick={() => { | ||
| 296 | setShowCreate(false); | ||
| 297 | setShowFinder((current) => !current); | ||
| 298 | }} | ||
| 299 | className="flex min-h-9 w-full items-center justify-between gap-3 rounded-lg border border-input bg-background px-3 py-2 text-left text-sm text-foreground outline-none transition-colors hover:bg-accent/30 focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 300 | > | ||
| 301 | <div className="min-w-0"> | ||
| 302 | <div className="truncate"> | ||
| 303 | {selectedSecret | ||
| 304 | ? (selectedSecret.name ?? selectedSecret.id) | ||
| 305 | : props.emptyLabel} | ||
| 306 | </div> | ||
| 307 | {selectedSecret && ( | ||
| 308 | <div className="truncate text-[11px] text-muted-foreground"> | ||
| 309 | {selectedSecret.storeName} · {selectedSecret.storeKind} | ||
| 310 | </div> | ||
| 311 | )} | ||
| 312 | </div> | ||
| 313 | <div className="shrink-0 text-[11px] font-medium text-muted-foreground"> | ||
| 314 | {showFinder ? "Close" : "Choose"} | ||
| 315 | </div> | ||
| 316 | </button> | ||
| 317 | |||
| 318 | <div className="flex flex-wrap items-center gap-2"> | ||
| 319 | <button | ||
| 320 | type="button" | ||
| 321 | onClick={() => { | ||
| 322 | setShowFinder(false); | ||
| 323 | setShowCreate((current) => !current); | ||
| 324 | }} | ||
| 325 | className={buttonClassName} | ||
| 326 | > | ||
| 327 | {showCreate ? "Hide create" : "Create new secret"} | ||
| 328 | </button> | ||
| 329 | </div> | ||
| 330 | |||
| 331 | {showFinder && ( | ||
| 332 | <div className="space-y-3 border-l-2 border-border pl-4"> | ||
| 333 | <input | ||
| 334 | value={finderQuery} | ||
| 335 | onChange={(event) => setFinderQuery(event.target.value)} | ||
| 336 | placeholder="Search all secrets" | ||
| 337 | className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 338 | autoFocus | ||
| 339 | /> | ||
| 340 | |||
| 341 | {finderError && ( | ||
| 342 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-3 py-2 text-xs text-destructive"> | ||
| 343 | {finderError} | ||
| 344 | </div> | ||
| 345 | )} | ||
| 346 | |||
| 347 | <div className="max-h-64 space-y-2 overflow-y-auto pr-1"> | ||
| 348 | {finderStatus === "pending" && ( | ||
| 349 | <div className="rounded-lg border border-border px-3 py-2 text-xs text-muted-foreground"> | ||
| 350 | Loading secrets... | ||
| 351 | </div> | ||
| 352 | )} | ||
| 353 | |||
| 354 | {finderStatus !== "pending" && unifiedEntries.length === 0 && !finderError && ( | ||
| 355 | <div className="rounded-lg border border-border px-3 py-2 text-xs text-muted-foreground"> | ||
| 356 | No secrets matched. | ||
| 357 | </div> | ||
| 358 | )} | ||
| 359 | |||
| 360 | {finderStatus !== "pending" && unifiedEntries.map((entry) => ( | ||
| 361 | entry.kind === "managed" ? ( | ||
| 362 | <button | ||
| 363 | key={`managed:${entry.secret.id}`} | ||
| 364 | type="button" | ||
| 365 | onClick={() => handleSelectManagedSecret(entry.secret)} | ||
| 366 | className="flex w-full items-start justify-between gap-3 rounded-lg border border-input bg-background px-3 py-2 text-left transition-colors hover:bg-accent/40" | ||
| 367 | > | ||
| 368 | <div className="min-w-0"> | ||
| 369 | <div className="truncate text-sm text-foreground"> | ||
| 370 | {entry.secret.name ?? entry.secret.id} | ||
| 371 | </div> | ||
| 372 | <div className="truncate text-xs text-muted-foreground"> | ||
| 373 | {entry.secret.storeName} · managed | ||
| 374 | </div> | ||
| 375 | </div> | ||
| 376 | <div className="shrink-0 text-[11px] uppercase tracking-wide text-muted-foreground"> | ||
| 377 | Use | ||
| 378 | </div> | ||
| 379 | </button> | ||
| 380 | ) : ( | ||
| 381 | <button | ||
| 382 | key={`remote:${entry.storeId}:${entry.entry.key}`} | ||
| 383 | type="button" | ||
| 384 | onClick={() => { | ||
| 385 | void handleSelectRemoteSecret(entry); | ||
| 386 | }} | ||
| 387 | className="flex w-full items-start justify-between gap-3 rounded-lg border border-input bg-background px-3 py-2 text-left transition-colors hover:bg-accent/40" | ||
| 388 | > | ||
| 389 | <div className="min-w-0"> | ||
| 390 | <div className="truncate text-sm text-foreground"> | ||
| 391 | {entry.entry.label} | ||
| 392 | </div> | ||
| 393 | <div className="truncate text-xs text-muted-foreground"> | ||
| 394 | {entry.storeName} | ||
| 395 | {entry.entry.description ? ` · ${entry.entry.description}` : ""} | ||
| 396 | </div> | ||
| 397 | </div> | ||
| 398 | <div className="shrink-0 text-[11px] uppercase tracking-wide text-muted-foreground"> | ||
| 399 | {importMutation.status === "pending" ? "Linking" : "Link"} | ||
| 400 | </div> | ||
| 401 | </button> | ||
| 402 | ) | ||
| 403 | ))} | ||
| 404 | </div> | ||
| 405 | </div> | ||
| 406 | )} | ||
| 407 | |||
| 408 | {showCreate && ( | ||
| 409 | <div className="space-y-3 border-l-2 border-border pl-4"> | ||
| 410 | <input | ||
| 411 | value={draftName} | ||
| 412 | onChange={(event) => setDraftName(event.target.value)} | ||
| 413 | placeholder={props.draftNamePlaceholder} | ||
| 414 | className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 415 | /> | ||
| 416 | <textarea | ||
| 417 | value={draftValue} | ||
| 418 | onChange={(event) => setDraftValue(event.target.value)} | ||
| 419 | rows={3} | ||
| 420 | placeholder={props.draftValuePlaceholder} | ||
| 421 | className="w-full rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 422 | /> | ||
| 423 | {createError && ( | ||
| 424 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-3 py-2 text-xs text-destructive"> | ||
| 425 | {createError} | ||
| 426 | </div> | ||
| 427 | )} | ||
| 428 | <div className="flex items-center gap-2"> | ||
| 429 | <button | ||
| 430 | type="button" | ||
| 431 | onClick={() => { | ||
| 432 | setShowCreate(false); | ||
| 433 | setCreateError(null); | ||
| 434 | }} | ||
| 435 | className={buttonClassName} | ||
| 436 | > | ||
| 437 | Cancel | ||
| 438 | </button> | ||
| 439 | <button | ||
| 440 | type="button" | ||
| 441 | onClick={() => { | ||
| 442 | void handleCreate(); | ||
| 443 | }} | ||
| 444 | className={buttonClassName} | ||
| 445 | > | ||
| 446 | {createSecret.status === "pending" ? "Creating..." : "Save secret"} | ||
| 447 | </button> | ||
| 448 | </div> | ||
| 449 | </div> | ||
| 450 | )} | ||
| 451 | </div> | ||
| 452 | ); | ||
| 453 | } | ||
| @@ -2,6 +2,7 @@ import { · 1 unmodified line | |||
| 2 | Atom, | 2 | Atom, |
| 3 | } from "@effect-atom/atom-react"; | 3 | } from "@effect-atom/atom-react"; |
| 4 | import type { | 4 | import type { |
| 5 | SecretStore, | ||
| 5 | InstanceConfig, | 6 | InstanceConfig, |
| 6 | LocalInstallation, | 7 | LocalInstallation, |
| 7 | SecretListItem, | 8 | SecretListItem, |
| @@ -50,6 +51,12 @@ export const secretsAtom = (baseUrl: string = getExecutorApiBaseUrl()) => · 42 unmodified lines | |||
| 50 | timeToLive: "1 minute", | 51 | timeToLive: "1 minute", |
| 51 | }); | 52 | }); |
| 52 | 53 | ||
| 54 | export const secretStoresAtom = (baseUrl: string = getExecutorApiBaseUrl()) => | ||
| 55 | getExecutorApiHttpClient(baseUrl).query("local", "listSecretStores", { | ||
| 56 | reactivityKeys: secretsReactivityKey(), | ||
| 57 | timeToLive: "1 minute", | ||
| 58 | }); | ||
| 59 | |||
| 53 | export const executionsAtom = (workspaceId: Execution["scopeId"]) => | 60 | export const executionsAtom = (workspaceId: Execution["scopeId"]) => |
| 54 | getExecutorApiHttpClient().query("executions", "list", { | 61 | getExecutorApiHttpClient().query("executions", "list", { |
| 55 | path: { | 62 | path: { |
| @@ -168,6 +175,7 @@ export type ExecutorApiAtoms = { · 112 unmodified lines | |||
| 168 | localInstallationAtom: typeof localInstallationAtom; | 175 | localInstallationAtom: typeof localInstallationAtom; |
| 169 | instanceConfigAtom: typeof instanceConfigAtom; | 176 | instanceConfigAtom: typeof instanceConfigAtom; |
| 170 | secretsAtom: typeof secretsAtom; | 177 | secretsAtom: typeof secretsAtom; |
| 178 | secretStoresAtom: typeof secretStoresAtom; | ||
| 171 | executionsAtom: typeof executionsAtom; | 179 | executionsAtom: typeof executionsAtom; |
| 172 | executionAtom: typeof executionAtom; | 180 | executionAtom: typeof executionAtom; |
| 173 | sourcesAtom: typeof sourcesAtom; | 181 | sourcesAtom: typeof sourcesAtom; |
| @@ -181,6 +189,7 @@ export type ExecutorApiAtomValues = { · 7 unmodified lines | |||
| 181 | localInstallation: LocalInstallation; | 189 | localInstallation: LocalInstallation; |
| 182 | instanceConfig: InstanceConfig; | 190 | instanceConfig: InstanceConfig; |
| 183 | secrets: ReadonlyArray<SecretListItem>; | 191 | secrets: ReadonlyArray<SecretListItem>; |
| 192 | secretStores: ReadonlyArray<SecretStore>; | ||
| 184 | executions: ReadonlyArray<Execution>; | 193 | executions: ReadonlyArray<Execution>; |
| 185 | execution: ExecutionEnvelope; | 194 | execution: ExecutionEnvelope; |
| 186 | sources: ReadonlyArray<Source>; | 195 | sources: ReadonlyArray<Source>; |
| @@ -7,10 +7,12 @@ import { · 6 unmodified lines | |||
| 7 | import type * as HttpApi from "@effect/platform/HttpApi"; | 7 | import type * as HttpApi from "@effect/platform/HttpApi"; |
| 8 | import type * as HttpApiGroup from "@effect/platform/HttpApiGroup"; | 8 | import type * as HttpApiGroup from "@effect/platform/HttpApiGroup"; |
| 9 | import { | 9 | import { |
| 10 | ExecutorApi, | ||
| 11 | createExecutorApi, | 10 | createExecutorApi, |
| 12 | type ExecutorHttpApiExtension, | 11 | ExecutorApi, |
| 13 | } from "@executor/platform-api"; | 12 | } from "@executor/platform-api/api"; |
| 13 | import type { | ||
| 14 | ExecutorHttpApiExtension, | ||
| 15 | } from "@executor/platform-api/plugins"; | ||
| 14 | 16 | ||
| 15 | import { getExecutorApiBaseUrl } from "./base-url"; | 17 | import { getExecutorApiBaseUrl } from "./base-url"; |
| 16 | 18 | ||
| @@ -3,10 +3,14 @@ import { · 2 unmodified lines | |||
| 3 | useAtomSet, | 3 | useAtomSet, |
| 4 | } from "@effect-atom/atom-react"; | 4 | } from "@effect-atom/atom-react"; |
| 5 | import type { | 5 | import type { |
| 6 | CreateSecretStorePayload, | ||
| 6 | CreateSecretPayload, | 7 | CreateSecretPayload, |
| 8 | DeleteSecretStoreResult, | ||
| 7 | CreateSecretResult, | 9 | CreateSecretResult, |
| 8 | DeleteSecretResult, | 10 | DeleteSecretResult, |
| 11 | SecretStore, | ||
| 9 | SecretListItem, | 12 | SecretListItem, |
| 13 | UpdateSecretStorePayload, | ||
| 10 | UpdateSecretPayload, | 14 | UpdateSecretPayload, |
| 11 | UpdateSecretResult, | 15 | UpdateSecretResult, |
| 12 | } from "@executor/platform-api"; | 16 | } from "@executor/platform-api"; |
| @@ -14,7 +18,7 @@ import * as React from "react"; · 1 unmodified line | |||
| 14 | 18 | ||
| 15 | import { getExecutorApiBaseUrl } from "../core/base-url"; | 19 | import { getExecutorApiBaseUrl } from "../core/base-url"; |
| 16 | import { getExecutorApiHttpClient } from "../core/http-client"; | 20 | import { getExecutorApiHttpClient } from "../core/http-client"; |
| 17 | import { secretsAtom } from "../core/api-atoms"; | 21 | import { secretStoresAtom, secretsAtom } from "../core/api-atoms"; |
| 18 | import { useLoadableAtom } from "../core/loadable"; | 22 | import { useLoadableAtom } from "../core/loadable"; |
| 19 | import { secretsReactivityKey } from "../core/reactivity"; | 23 | import { secretsReactivityKey } from "../core/reactivity"; |
| 20 | import type { Loadable } from "../core/types"; | 24 | import type { Loadable } from "../core/types"; |
| @@ -23,9 +27,73 @@ import { useExecutorMutation } from "./mutations"; · 2 unmodified lines | |||
| 23 | export const useSecrets = (): Loadable<ReadonlyArray<SecretListItem>> => | 27 | export const useSecrets = (): Loadable<ReadonlyArray<SecretListItem>> => |
| 24 | useLoadableAtom(secretsAtom(getExecutorApiBaseUrl())); | 28 | useLoadableAtom(secretsAtom(getExecutorApiBaseUrl())); |
| 25 | 29 | ||
| 30 | export const useSecretStores = (): Loadable<ReadonlyArray<SecretStore>> => | ||
| 31 | useLoadableAtom(secretStoresAtom(getExecutorApiBaseUrl())); | ||
| 32 | |||
| 26 | export const useRefreshSecrets = (): (() => void) => | 33 | export const useRefreshSecrets = (): (() => void) => |
| 27 | useAtomRefresh(secretsAtom(getExecutorApiBaseUrl())); | 34 | useAtomRefresh(secretsAtom(getExecutorApiBaseUrl())); |
| 28 | 35 | ||
| 36 | export const useRefreshSecretStores = (): (() => void) => | ||
| 37 | useAtomRefresh(secretStoresAtom(getExecutorApiBaseUrl())); | ||
| 38 | |||
| 39 | export const useCreateSecretStore = () => { | ||
| 40 | const mutate = useAtomSet( | ||
| 41 | getExecutorApiHttpClient().mutation("local", "createSecretStore"), | ||
| 42 | { mode: "promise" }, | ||
| 43 | ); | ||
| 44 | |||
| 45 | return useExecutorMutation<CreateSecretStorePayload, SecretStore>( | ||
| 46 | React.useCallback( | ||
| 47 | (payload) => | ||
| 48 | mutate({ | ||
| 49 | payload, | ||
| 50 | reactivityKeys: secretsReactivityKey(), | ||
| 51 | }), | ||
| 52 | [mutate], | ||
| 53 | ), | ||
| 54 | ); | ||
| 55 | }; | ||
| 56 | |||
| 57 | export const useUpdateSecretStore = () => { | ||
| 58 | const mutate = useAtomSet( | ||
| 59 | getExecutorApiHttpClient().mutation("local", "updateSecretStore"), | ||
| 60 | { mode: "promise" }, | ||
| 61 | ); | ||
| 62 | |||
| 63 | return useExecutorMutation< | ||
| 64 | { storeId: string; payload: UpdateSecretStorePayload }, | ||
| 65 | SecretStore | ||
| 66 | >( | ||
| 67 | React.useCallback( | ||
| 68 | (input) => | ||
| 69 | mutate({ | ||
| 70 | path: { storeId: input.storeId }, | ||
| 71 | payload: input.payload, | ||
| 72 | reactivityKeys: secretsReactivityKey(), | ||
| 73 | }), | ||
| 74 | [mutate], | ||
| 75 | ), | ||
| 76 | ); | ||
| 77 | }; | ||
| 78 | |||
| 79 | export const useDeleteSecretStore = () => { | ||
| 80 | const mutate = useAtomSet( | ||
| 81 | getExecutorApiHttpClient().mutation("local", "deleteSecretStore"), | ||
| 82 | { mode: "promise" }, | ||
| 83 | ); | ||
| 84 | |||
| 85 | return useExecutorMutation<string, DeleteSecretStoreResult>( | ||
| 86 | React.useCallback( | ||
| 87 | (storeId) => | ||
| 88 | mutate({ | ||
| 89 | path: { storeId }, | ||
| 90 | reactivityKeys: secretsReactivityKey(), | ||
| 91 | }), | ||
| 92 | [mutate], | ||
| 93 | ), | ||
| 94 | ); | ||
| 95 | }; | ||
| 96 | |||
| 29 | export const useCreateSecret = () => { | 97 | export const useCreateSecret = () => { |
| 30 | const mutate = useAtomSet( | 98 | const mutate = useAtomSet( |
| 31 | getExecutorApiHttpClient().mutation("local", "createSecret"), | 99 | getExecutorApiHttpClient().mutation("local", "createSecret"), |
| @@ -10,12 +10,17 @@ export { · 9 unmodified lines | |||
| 10 | } from "@effect-atom/atom-react"; | 10 | } from "@effect-atom/atom-react"; |
| 11 | 11 | ||
| 12 | export type { | 12 | export type { |
| 13 | BrowseSecretStoreResult, | ||
| 14 | CreateSecretStorePayload, | ||
| 13 | CreateSecretPayload, | 15 | CreateSecretPayload, |
| 14 | CreateSecretResult, | 16 | CreateSecretResult, |
| 17 | DeleteSecretStoreResult, | ||
| 15 | DeleteSecretResult, | 18 | DeleteSecretResult, |
| 16 | InstanceConfig, | 19 | InstanceConfig, |
| 17 | LocalInstallation, | 20 | LocalInstallation, |
| 18 | SecretListItem, | 21 | SecretListItem, |
| 22 | SecretStore, | ||
| 23 | UpdateSecretStorePayload, | ||
| 19 | UpdateSecretPayload, | 24 | UpdateSecretPayload, |
| 20 | UpdateSecretResult, | 25 | UpdateSecretResult, |
| 21 | } from "@executor/platform-api"; | 26 | } from "@executor/platform-api"; |
| @@ -51,6 +56,9 @@ export { · 29 unmodified lines | |||
| 51 | export { | 56 | export { |
| 52 | ExecutorReactProvider, | 57 | ExecutorReactProvider, |
| 53 | } from "./provider"; | 58 | } from "./provider"; |
| 59 | export { | ||
| 60 | SecretReferenceField, | ||
| 61 | } from "./components/secret-reference-field"; | ||
| 54 | export { | 62 | export { |
| 55 | useExecutorMutation, | 63 | useExecutorMutation, |
| 56 | } from "./hooks/mutations"; | 64 | } from "./hooks/mutations"; |
| @@ -60,10 +68,15 @@ export { · 3 unmodified lines | |||
| 60 | useRefreshLocalInstallation, | 68 | useRefreshLocalInstallation, |
| 61 | } from "./hooks/local"; | 69 | } from "./hooks/local"; |
| 62 | export { | 70 | export { |
| 71 | useCreateSecretStore, | ||
| 63 | useCreateSecret, | 72 | useCreateSecret, |
| 73 | useDeleteSecretStore, | ||
| 64 | useDeleteSecret, | 74 | useDeleteSecret, |
| 75 | useRefreshSecretStores, | ||
| 65 | useRefreshSecrets, | 76 | useRefreshSecrets, |
| 77 | useSecretStores, | ||
| 66 | useSecrets, | 78 | useSecrets, |
| 79 | useUpdateSecretStore, | ||
| 67 | useUpdateSecret, | 80 | useUpdateSecret, |
| 68 | } from "./hooks/secrets"; | 81 | } from "./hooks/secrets"; |
| 69 | export { | 82 | export { |
| @@ -5,6 +5,8 @@ export type { · 4 unmodified lines | |||
| 5 | FrontendPluginRouteDefinition, | 5 | FrontendPluginRouteDefinition, |
| 6 | FrontendPluginRouteParams, | 6 | FrontendPluginRouteParams, |
| 7 | FrontendPluginRouteSearch, | 7 | FrontendPluginRouteSearch, |
| 8 | SecretStoreCreateFormProps, | ||
| 9 | SecretStoreFrontendDefinition, | ||
| 8 | SourcePluginNavigation, | 10 | SourcePluginNavigation, |
| 9 | SourcePluginRouteParams, | 11 | SourcePluginRouteParams, |
| 10 | SourcePluginRouteSearch, | 12 | SourcePluginRouteSearch, |
| @@ -28,6 +28,7 @@ export const registerExecutorFrontendPlugins = ( · 27 unmodified lines | |||
| 28 | ) => { | 28 | ) => { |
| 29 | const pluginsByKey = new Map<string, ExecutorFrontendPlugin>(); | 29 | const pluginsByKey = new Map<string, ExecutorFrontendPlugin>(); |
| 30 | const routesByPluginAndKey = new Map<string, RegisteredFrontendPluginRoute>(); | 30 | const routesByPluginAndKey = new Map<string, RegisteredFrontendPluginRoute>(); |
| 31 | const secretStoresByKind = new Map<string, ExecutorFrontendPlugin>(); | ||
| 31 | 32 | ||
| 32 | for (const plugin of plugins) { | 33 | for (const plugin of plugins) { |
| 33 | if (pluginsByKey.has(plugin.key)) { | 34 | if (pluginsByKey.has(plugin.key)) { |
| @@ -36,6 +37,16 @@ export const registerExecutorFrontendPlugins = ( · 2 unmodified lines | |||
| 36 | 37 | ||
| 37 | pluginsByKey.set(plugin.key, plugin); | 38 | pluginsByKey.set(plugin.key, plugin); |
| 38 | 39 | ||
| 40 | if (plugin.secretStore) { | ||
| 41 | if (secretStoresByKind.has(plugin.secretStore.kind)) { | ||
| 42 | throw new Error( | ||
| 43 | `Duplicate frontend secret store registration: ${plugin.secretStore.kind}`, | ||
| 44 | ); | ||
| 45 | } | ||
| 46 | |||
| 47 | secretStoresByKind.set(plugin.secretStore.kind, plugin); | ||
| 48 | } | ||
| 49 | |||
| 39 | const pluginRouteKeys = new Set<string>(); | 50 | const pluginRouteKeys = new Set<string>(); |
| 40 | const pluginRoutePaths = new Set<string>(); | 51 | const pluginRoutePaths = new Set<string>(); |
| 41 | 52 | ||
| @@ -1,9 +1,11 @@ | |||
| 1 | import type { ComponentType } from "react"; | 1 | import type { ComponentType } from "react"; |
| 2 | import type { SecretListItem } from "@executor/platform-api"; | ||
| 2 | 3 | ||
| 3 | import type { | 4 | import type { |
| 4 | ExecutorPluginPaths, | 5 | ExecutorPluginPaths, |
| 5 | SourcePluginPaths, | 6 | SourcePluginPaths, |
| 6 | } from "./paths"; | 7 | } from "./paths"; |
| 8 | import type { Loadable } from "../core/types"; | ||
| 7 | 9 | ||
| 8 | export type FrontendPluginRouteSearch = Record<string, unknown>; | 10 | export type FrontendPluginRouteSearch = Record<string, unknown>; |
| 9 | export type FrontendPluginRouteParams = Readonly<Record<string, string | undefined>>; | 11 | export type FrontendPluginRouteParams = Readonly<Record<string, string | undefined>>; |
| @@ -50,9 +52,25 @@ export type SourcePluginNavigation = { · 40 unmodified lines | |||
| 50 | updateSearch: (search: SourcePluginRouteSearch) => void | Promise<void>; | 52 | updateSearch: (search: SourcePluginRouteSearch) => void | Promise<void>; |
| 51 | }; | 53 | }; |
| 52 | 54 | ||
| 55 | export type SecretStoreCreateFormProps = { | ||
| 56 | isSubmitting: boolean; | ||
| 57 | onCancel: () => void; | ||
| 58 | onSubmit: (input: { | ||
| 59 | name: string; | ||
| 60 | config: Record<string, unknown>; | ||
| 61 | }) => Promise<void>; | ||
| 62 | secrets: Loadable<ReadonlyArray<SecretListItem>>; | ||
| 63 | }; | ||
| 64 | |||
| 65 | export type SecretStoreFrontendDefinition = { | ||
| 66 | kind: string; | ||
| 67 | CreateStoreForm?: ComponentType<SecretStoreCreateFormProps>; | ||
| 68 | }; | ||
| 69 | |||
| 53 | export type ExecutorFrontendPlugin = { | 70 | export type ExecutorFrontendPlugin = { |
| 54 | key: string; | 71 | key: string; |
| 55 | displayName?: string; | 72 | displayName?: string; |
| 56 | description?: string; | 73 | description?: string; |
| 57 | routes?: readonly FrontendPluginRouteDefinition[]; | 74 | routes?: readonly FrontendPluginRouteDefinition[]; |
| 75 | secretStore?: SecretStoreFrontendDefinition; | ||
| 58 | }; | 76 | }; |
| @@ -31,8 +31,16 @@ export { · 30 unmodified lines | |||
| 31 | 31 | ||
| 32 | export { | 32 | export { |
| 33 | LocalApi, | 33 | LocalApi, |
| 34 | type BrowseSecretStorePayload, | ||
| 35 | type BrowseSecretStoreResult, | ||
| 34 | type SecretProvider, | 36 | type SecretProvider, |
| 37 | type SecretStore, | ||
| 38 | type SecretStoreBrowseEntry, | ||
| 39 | type ImportSecretFromStorePayload, | ||
| 35 | type InstanceConfig, | 40 | type InstanceConfig, |
| 41 | type CreateSecretStorePayload, | ||
| 42 | type UpdateSecretStorePayload, | ||
| 43 | type DeleteSecretStoreResult, | ||
| 36 | type SecretListItem, | 44 | type SecretListItem, |
| 37 | type CreateSecretPayload, | 45 | type CreateSecretPayload, |
| 38 | type CreateSecretResult, | 46 | type CreateSecretResult, |
| @@ -1,12 +1,19 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | import { | 2 | import { |
| 3 | BrowseSecretStorePayloadSchema, | ||
| 4 | BrowseSecretStoreResultSchema, | ||
| 3 | CreateSecretPayloadSchema, | 5 | CreateSecretPayloadSchema, |
| 4 | CreateSecretResultSchema, | 6 | CreateSecretResultSchema, |
| 7 | CreateSecretStorePayloadSchema, | ||
| 5 | DeleteSecretResultSchema, | 8 | DeleteSecretResultSchema, |
| 9 | DeleteSecretStoreResultSchema, | ||
| 10 | ImportSecretFromStorePayloadSchema, | ||
| 6 | InstanceConfigSchema, | 11 | InstanceConfigSchema, |
| 7 | SecretListItemSchema, | 12 | SecretListItemSchema, |
| 13 | SecretStoreSchema, | ||
| 8 | UpdateSecretPayloadSchema, | 14 | UpdateSecretPayloadSchema, |
| 9 | UpdateSecretResultSchema, | 15 | UpdateSecretResultSchema, |
| 16 | UpdateSecretStorePayloadSchema, | ||
| 10 | } from "@executor/platform-sdk/contracts"; | 17 | } from "@executor/platform-sdk/contracts"; |
| 11 | import { | 18 | import { |
| 12 | ControlPlaneBadRequestError, | 19 | ControlPlaneBadRequestError, |
| @@ -17,15 +24,23 @@ import { LocalInstallationSchema } from "@executor/platform-sdk/schema"; · 4 unmodified lines | |||
| 17 | import * as Schema from "effect/Schema"; | 24 | import * as Schema from "effect/Schema"; |
| 18 | 25 | ||
| 19 | export type { | 26 | export type { |
| 27 | BrowseSecretStorePayload, | ||
| 28 | BrowseSecretStoreResult, | ||
| 20 | CreateSecretPayload, | 29 | CreateSecretPayload, |
| 21 | CreateSecretResult, | 30 | CreateSecretResult, |
| 22 | DeleteSecretResult, | 31 | DeleteSecretResult, |
| 32 | DeleteSecretStoreResult, | ||
| 33 | CreateSecretStorePayload, | ||
| 34 | ImportSecretFromStorePayload, | ||
| 23 | InstanceConfig, | 35 | InstanceConfig, |
| 24 | SecretLinkedSource, | 36 | SecretLinkedSource, |
| 25 | SecretListItem, | 37 | SecretListItem, |
| 26 | SecretProvider, | 38 | SecretProvider, |
| 39 | SecretStoreBrowseEntry, | ||
| 40 | SecretStore, | ||
| 27 | UpdateSecretPayload, | 41 | UpdateSecretPayload, |
| 28 | UpdateSecretResult, | 42 | UpdateSecretResult, |
| 43 | UpdateSecretStorePayload, | ||
| 29 | } from "@executor/platform-sdk/contracts"; | 44 | } from "@executor/platform-sdk/contracts"; |
| 30 | 45 | ||
| 31 | export class LocalApi extends HttpApiGroup.make("local") | 46 | export class LocalApi extends HttpApiGroup.make("local") |
| @@ -41,6 +56,48 @@ export class LocalApi extends HttpApiGroup.make("local") · 9 unmodified lines | |||
| 41 | .addSuccess(InstanceConfigSchema) | 56 | .addSuccess(InstanceConfigSchema) |
| 42 | .addError(ControlPlaneStorageError), | 57 | .addError(ControlPlaneStorageError), |
| 43 | ) | 58 | ) |
| 59 | .add( | ||
| 60 | HttpApiEndpoint.get("listSecretStores")`/local/secret-stores` | ||
| 61 | .addSuccess(Schema.Array(SecretStoreSchema)) | ||
| 62 | .addError(ControlPlaneStorageError), | ||
| 63 | ) | ||
| 64 | .add( | ||
| 65 | HttpApiEndpoint.post("createSecretStore")`/local/secret-stores` | ||
| 66 | .setPayload(CreateSecretStorePayloadSchema) | ||
| 67 | .addSuccess(SecretStoreSchema) | ||
| 68 | .addError(ControlPlaneBadRequestError) | ||
| 69 | .addError(ControlPlaneStorageError), | ||
| 70 | ) | ||
| 71 | .add( | ||
| 72 | HttpApiEndpoint.patch("updateSecretStore")`/local/secret-stores/${HttpApiSchema.param("storeId", Schema.String)}` | ||
| 73 | .setPayload(UpdateSecretStorePayloadSchema) | ||
| 74 | .addSuccess(SecretStoreSchema) | ||
| 75 | .addError(ControlPlaneBadRequestError) | ||
| 76 | .addError(ControlPlaneNotFoundError) | ||
| 77 | .addError(ControlPlaneStorageError), | ||
| 78 | ) | ||
| 79 | .add( | ||
| 80 | HttpApiEndpoint.del("deleteSecretStore")`/local/secret-stores/${HttpApiSchema.param("storeId", Schema.String)}` | ||
| 81 | .addSuccess(DeleteSecretStoreResultSchema) | ||
| 82 | .addError(ControlPlaneNotFoundError) | ||
| 83 | .addError(ControlPlaneStorageError), | ||
| 84 | ) | ||
| 85 | .add( | ||
| 86 | HttpApiEndpoint.post("browseSecretStore")`/local/secret-stores/${HttpApiSchema.param("storeId", Schema.String)}/browse` | ||
| 87 | .setPayload(BrowseSecretStorePayloadSchema) | ||
| 88 | .addSuccess(BrowseSecretStoreResultSchema) | ||
| 89 | .addError(ControlPlaneBadRequestError) | ||
| 90 | .addError(ControlPlaneNotFoundError) | ||
| 91 | .addError(ControlPlaneStorageError), | ||
| 92 | ) | ||
| 93 | .add( | ||
| 94 | HttpApiEndpoint.post("importSecretFromStore")`/local/secret-stores/${HttpApiSchema.param("storeId", Schema.String)}/import` | ||
| 95 | .setPayload(ImportSecretFromStorePayloadSchema) | ||
| 96 | .addSuccess(CreateSecretResultSchema) | ||
| 97 | .addError(ControlPlaneBadRequestError) | ||
| 98 | .addError(ControlPlaneNotFoundError) | ||
| 99 | .addError(ControlPlaneStorageError), | ||
| 100 | ) | ||
| 44 | .add( | 101 | .add( |
| 45 | HttpApiEndpoint.get("listSecrets")`/local/secrets` | 102 | HttpApiEndpoint.get("listSecrets")`/local/secrets` |
| 46 | .addSuccess(Schema.Array(SecretListItemSchema)) | 103 | .addSuccess(Schema.Array(SecretListItemSchema)) |
| @@ -1,5 +1,9 @@ | |||
| 1 | import { HttpApiBuilder } from "@effect/platform"; | 1 | import { HttpApiBuilder } from "@effect/platform"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import { | ||
| 4 | LocalInstanceConfigService, | ||
| 5 | provideExecutorRuntime, | ||
| 6 | } from "@executor/platform-sdk/runtime"; | ||
| 3 | 7 | ||
| 4 | import { ExecutorApi } from "../api"; | 8 | import { ExecutorApi } from "../api"; |
| 5 | import { ControlPlaneStorageError } from "../errors"; | 9 | import { ControlPlaneStorageError } from "../errors"; |
| @@ -19,18 +23,71 @@ export const ExecutorLocalLive = HttpApiBuilder.group( · 13 unmodified lines | |||
| 19 | handlers | 23 | handlers |
| 20 | .handle("installation", () => | 24 | .handle("installation", () => |
| 21 | Effect.flatMap(getControlPlaneExecutor(), (executor) => | 25 | Effect.flatMap(getControlPlaneExecutor(), (executor) => |
| 22 | executor.local.installation().pipe( | 26 | Effect.succeed(executor.installation) |
| 23 | Effect.mapError(toStorageError("local.installation")), | ||
| 24 | ) | ||
| 25 | ) | 27 | ) |
| 26 | ) | 28 | ) |
| 27 | .handle("config", () => | 29 | .handle("config", () => |
| 28 | Effect.flatMap(getControlPlaneExecutor(), (executor) => | 30 | Effect.flatMap(getControlPlaneExecutor(), (executor) => |
| 29 | executor.local.config().pipe( | 31 | provideExecutorRuntime( |
| 32 | Effect.flatMap( | ||
| 33 | LocalInstanceConfigService, | ||
| 34 | (resolveInstanceConfig) => resolveInstanceConfig(), | ||
| 35 | ), | ||
| 36 | executor.runtime, | ||
| 37 | ).pipe( | ||
| 30 | Effect.mapError(toStorageError("local.config")), | 38 | Effect.mapError(toStorageError("local.config")), |
| 31 | ) | 39 | ) |
| 32 | ) | 40 | ) |
| 33 | ) | 41 | ) |
| 42 | .handle("listSecretStores", () => | ||
| 43 | Effect.flatMap(getControlPlaneExecutor(), (executor) => | ||
| 44 | executor.secretStores.list().pipe( | ||
| 45 | Effect.mapError(toStorageError("local.listSecretStores")), | ||
| 46 | ) | ||
| 47 | ) | ||
| 48 | ) | ||
| 49 | .handle("createSecretStore", ({ payload }) => | ||
| 50 | Effect.flatMap(getControlPlaneExecutor(), (executor) => | ||
| 51 | executor.secretStores.create(payload).pipe( | ||
| 52 | Effect.mapError(toStorageError("local.createSecretStore")), | ||
| 53 | ) | ||
| 54 | ) | ||
| 55 | ) | ||
| 56 | .handle("updateSecretStore", ({ path, payload }) => | ||
| 57 | Effect.flatMap(getControlPlaneExecutor(), (executor) => | ||
| 58 | executor.secretStores.update({ | ||
| 59 | storeId: path.storeId, | ||
| 60 | payload, | ||
| 61 | }).pipe(Effect.mapError(toStorageError("local.updateSecretStore"))) | ||
| 62 | ) | ||
| 63 | ) | ||
| 64 | .handle("deleteSecretStore", ({ path }) => | ||
| 65 | Effect.flatMap(getControlPlaneExecutor(), (executor) => | ||
| 66 | executor.secretStores.remove(path.storeId).pipe( | ||
| 67 | Effect.mapError(toStorageError("local.deleteSecretStore")), | ||
| 68 | ) | ||
| 69 | ) | ||
| 70 | ) | ||
| 71 | .handle("browseSecretStore", ({ path, payload }) => | ||
| 72 | Effect.flatMap(getControlPlaneExecutor(), (executor) => | ||
| 73 | executor.secretStores.browse({ | ||
| 74 | storeId: path.storeId, | ||
| 75 | payload, | ||
| 76 | }).pipe( | ||
| 77 | Effect.mapError(toStorageError("local.browseSecretStore")), | ||
| 78 | ) | ||
| 79 | ) | ||
| 80 | ) | ||
| 81 | .handle("importSecretFromStore", ({ path, payload }) => | ||
| 82 | Effect.flatMap(getControlPlaneExecutor(), (executor) => | ||
| 83 | executor.secretStores.import({ | ||
| 84 | storeId: path.storeId, | ||
| 85 | payload, | ||
| 86 | }).pipe( | ||
| 87 | Effect.mapError(toStorageError("local.importSecretFromStore")), | ||
| 88 | ) | ||
| 89 | ) | ||
| 90 | ) | ||
| 34 | .handle("listSecrets", () => | 91 | .handle("listSecrets", () => |
| 35 | Effect.flatMap(getControlPlaneExecutor(), (executor) => | 92 | Effect.flatMap(getControlPlaneExecutor(), (executor) => |
| 36 | executor.secrets.list().pipe( | 93 | executor.secrets.list().pipe( |
| @@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"; · 7 unmodified lines | |||
| 8 | import { | 8 | import { |
| 9 | loadLocalExecutorConfig, | 9 | loadLocalExecutorConfig, |
| 10 | mergeLocalExecutorConfigs, | 10 | mergeLocalExecutorConfigs, |
| 11 | migrateLegacyLocalExecutorConfigFile, | ||
| 11 | resolveDefaultHomeConfigCandidates, | 12 | resolveDefaultHomeConfigCandidates, |
| 12 | resolveDefaultHomeStateDirectory, | 13 | resolveDefaultHomeStateDirectory, |
| 13 | resolveLocalWorkspaceContext, | 14 | resolveLocalWorkspaceContext, |
| @@ -136,4 +137,54 @@ describe("local-config", () => { · 122 unmodified lines | |||
| 136 | 137 | ||
| 137 | expect(merged?.runtime).toBe("deno"); | 138 | expect(merged?.runtime).toBe("deno"); |
| 138 | }); | 139 | }); |
| 140 | |||
| 141 | it.effect("migrates legacy preset source stubs before strict decode", () => | ||
| 142 | Effect.gen(function* () { | ||
| 143 | const fs = yield* FileSystem.FileSystem; | ||
| 144 | const workspaceRoot = yield* makeWorkspaceRoot(); | ||
| 145 | const homeConfigPath = join(workspaceRoot, "executor-home.jsonc"); | ||
| 146 | |||
| 147 | yield* fs.writeFileString( | ||
| 148 | homeConfigPath, | ||
| 149 | `{ | ||
| 150 | "sources": { | ||
| 151 | "axiom": { | ||
| 152 | "name": "Axiom MCP", | ||
| 153 | "kind": "mcp" | ||
| 154 | }, | ||
| 155 | "stripe-api": { | ||
| 156 | "name": "Stripe API", | ||
| 157 | "namespace": "stripe.api", | ||
| 158 | "kind": "openapi" | ||
| 159 | }, | ||
| 160 | "kept": { | ||
| 161 | "name": "Kept", | ||
| 162 | "kind": "openapi", | ||
| 163 | "connection": { | ||
| 164 | "endpoint": "https://example.com" | ||
| 165 | }, | ||
| 166 | "binding": { | ||
| 167 | "specUrl": "https://example.com/openapi.json" | ||
| 168 | } | ||
| 169 | } | ||
| 170 | } | ||
| 171 | } | ||
| 172 | `, | ||
| 173 | ); | ||
| 174 | |||
| 175 | const changed = yield* migrateLegacyLocalExecutorConfigFile(homeConfigPath); | ||
| 176 | const migratedContent = yield* fs.readFileString(homeConfigPath, "utf8"); | ||
| 177 | const context = yield* resolveLocalWorkspaceContext({ | ||
| 178 | workspaceRoot, | ||
| 179 | homeConfigPath, | ||
| 180 | }); | ||
| 181 | const loaded = yield* loadLocalExecutorConfig(context); | ||
| 182 | |||
| 183 | expect(changed).toBe(true); | ||
| 184 | expect(migratedContent).not.toContain("\"axiom\""); | ||
| 185 | expect(migratedContent).not.toContain("\"stripe-api\""); | ||
| 186 | expect(loaded.config?.sources?.kept?.kind).toBe("openapi"); | ||
| 187 | expect(loaded.config?.sources?.axiom).toBeUndefined(); | ||
| 188 | }).pipe(Effect.provide(NodeFileSystem.layer)), | ||
| 189 | ); | ||
| 139 | }); | 190 | }); |
| @@ -33,6 +33,9 @@ export type FileLoadedExecutorConfig = LoadedLocalExecutorConfig & { · 32 unmodified lines | |||
| 33 | export const encodeLocalExecutorConfig = (config: LocalExecutorConfig): string => | 33 | export const encodeLocalExecutorConfig = (config: LocalExecutorConfig): string => |
| 34 | `${JSON.stringify(config, null, 2)}\n`; | 34 | `${JSON.stringify(config, null, 2)}\n`; |
| 35 | 35 | ||
| 36 | const encodeUnknownConfigDocument = (config: unknown): string => | ||
| 37 | `${JSON.stringify(config, null, 2)}\n`; | ||
| 38 | |||
| 36 | export const resolveConfigRelativePath = (input: { | 39 | export const resolveConfigRelativePath = (input: { |
| 37 | path: string; | 40 | path: string; |
| 38 | scopeRoot: string; | 41 | scopeRoot: string; |
| @@ -223,6 +226,130 @@ const parseJsonc = (input: { path: string; content: string }): LocalExecutorConf · 184 unmodified lines | |||
| 223 | } | 226 | } |
| 224 | }; | 227 | }; |
| 225 | 228 | ||
| 229 | const parseUnknownJsoncDocument = (input: { | ||
| 230 | path: string; | ||
| 231 | content: string; | ||
| 232 | }): unknown => { | ||
| 233 | const errors: JsoncParseError[] = []; | ||
| 234 | |||
| 235 | try { | ||
| 236 | const parsed = parseJsoncDocument(input.content, errors, { | ||
| 237 | allowTrailingComma: true, | ||
| 238 | }); | ||
| 239 | if (errors.length > 0) { | ||
| 240 | throw new LocalExecutorConfigDecodeError({ | ||
| 241 | message: `Invalid executor config at ${input.path}: ${formatJsoncParseErrors(input.content, errors)}`, | ||
| 242 | path: input.path, | ||
| 243 | details: formatJsoncParseErrors(input.content, errors), | ||
| 244 | }); | ||
| 245 | } | ||
| 246 | |||
| 247 | return parsed; | ||
| 248 | } catch (cause) { | ||
| 249 | if (cause instanceof LocalExecutorConfigDecodeError) { | ||
| 250 | throw cause; | ||
| 251 | } | ||
| 252 | throw new LocalExecutorConfigDecodeError({ | ||
| 253 | message: `Invalid executor config at ${input.path}: ${unknownLocalErrorDetails(cause)}`, | ||
| 254 | path: input.path, | ||
| 255 | details: unknownLocalErrorDetails(cause), | ||
| 256 | }); | ||
| 257 | } | ||
| 258 | }; | ||
| 259 | |||
| 260 | const asRecord = (value: unknown): Record<string, unknown> | null => | ||
| 261 | value !== null && typeof value === "object" && !Array.isArray(value) | ||
| 262 | ? value as Record<string, unknown> | ||
| 263 | : null; | ||
| 264 | |||
| 265 | const migrateLegacySourceEntries = ( | ||
| 266 | sources: unknown, | ||
| 267 | ): { | ||
| 268 | sources: unknown; | ||
| 269 | changed: boolean; | ||
| 270 | } => { | ||
| 271 | const sourceMap = asRecord(sources); | ||
| 272 | if (!sourceMap) { | ||
| 273 | return { | ||
| 274 | sources, | ||
| 275 | changed: false, | ||
| 276 | }; | ||
| 277 | } | ||
| 278 | |||
| 279 | let changed = false; | ||
| 280 | const migratedEntries = Object.entries(sourceMap).flatMap(([key, value]) => { | ||
| 281 | const source = asRecord(value); | ||
| 282 | if (!source) { | ||
| 283 | return [[key, value] as const]; | ||
| 284 | } | ||
| 285 | |||
| 286 | const hasConnection = asRecord(source.connection) !== null; | ||
| 287 | const kind = typeof source.kind === "string" ? source.kind : null; | ||
| 288 | const isLegacyPresetStub = | ||
| 289 | kind !== null | ||
| 290 | && hasConnection === false; | ||
| 291 | |||
| 292 | if (isLegacyPresetStub) { | ||
| 293 | changed = true; | ||
| 294 | return []; | ||
| 295 | } | ||
| 296 | |||
| 297 | return [[key, value] as const]; | ||
| 298 | }); | ||
| 299 | |||
| 300 | if (!changed) { | ||
| 301 | return { | ||
| 302 | sources, | ||
| 303 | changed: false, | ||
| 304 | }; | ||
| 305 | } | ||
| 306 | |||
| 307 | return { | ||
| 308 | sources: migratedEntries.length > 0 | ||
| 309 | ? Object.fromEntries(migratedEntries) | ||
| 310 | : undefined, | ||
| 311 | changed: true, | ||
| 312 | }; | ||
| 313 | }; | ||
| 314 | |||
| 315 | const migrateLegacyLocalExecutorConfigDocument = ( | ||
| 316 | value: unknown, | ||
| 317 | ): { | ||
| 318 | value: unknown; | ||
| 319 | changed: boolean; | ||
| 320 | } => { | ||
| 321 | const root = asRecord(value); | ||
| 322 | if (!root) { | ||
| 323 | return { | ||
| 324 | value, | ||
| 325 | changed: false, | ||
| 326 | }; | ||
| 327 | } | ||
| 328 | |||
| 329 | const migratedSources = migrateLegacySourceEntries(root.sources); | ||
| 330 | if (!migratedSources.changed) { | ||
| 331 | return { | ||
| 332 | value, | ||
| 333 | changed: false, | ||
| 334 | }; | ||
| 335 | } | ||
| 336 | |||
| 337 | const nextRoot: Record<string, unknown> = { | ||
| 338 | ...root, | ||
| 339 | }; | ||
| 340 | |||
| 341 | if (migratedSources.sources === undefined) { | ||
| 342 | delete nextRoot.sources; | ||
| 343 | } else { | ||
| 344 | nextRoot.sources = migratedSources.sources; | ||
| 345 | } | ||
| 346 | |||
| 347 | return { | ||
| 348 | value: nextRoot, | ||
| 349 | changed: true, | ||
| 350 | }; | ||
| 351 | }; | ||
| 352 | |||
| 226 | const mergeSourceMaps = ( | 353 | const mergeSourceMaps = ( |
| 227 | base: Record<string, LocalConfigSource> | undefined, | 354 | base: Record<string, LocalConfigSource> | undefined, |
| 228 | extra: Record<string, LocalConfigSource> | undefined, | 355 | extra: Record<string, LocalConfigSource> | undefined, |
| @@ -426,6 +553,51 @@ export const readOptionalLocalExecutorConfig = ( · 197 unmodified lines | |||
| 426 | }); | 553 | }); |
| 427 | }); | 554 | }); |
| 428 | 555 | ||
| 556 | export const migrateLegacyLocalExecutorConfigFile = ( | ||
| 557 | path: string, | ||
| 558 | ): Effect.Effect< | ||
| 559 | boolean, | ||
| 560 | LocalFileSystemError | LocalExecutorConfigDecodeError, | ||
| 561 | FileSystem.FileSystem | ||
| 562 | > => | ||
| 563 | Effect.gen(function* () { | ||
| 564 | const fs = yield* FileSystem.FileSystem; | ||
| 565 | const exists = yield* fs.exists(path).pipe( | ||
| 566 | Effect.mapError(mapFileSystemError(path, "check config path")), | ||
| 567 | ); | ||
| 568 | if (!exists) { | ||
| 569 | return false; | ||
| 570 | } | ||
| 571 | |||
| 572 | const content = yield* fs.readFileString(path, "utf8").pipe( | ||
| 573 | Effect.mapError(mapFileSystemError(path, "read config")), | ||
| 574 | ); | ||
| 575 | const parsed = parseUnknownJsoncDocument({ path, content }); | ||
| 576 | const migrated = migrateLegacyLocalExecutorConfigDocument(parsed); | ||
| 577 | |||
| 578 | if (!migrated.changed) { | ||
| 579 | return false; | ||
| 580 | } | ||
| 581 | |||
| 582 | const decoded = decodeLocalExecutorConfig(migrated.value); | ||
| 583 | yield* fs.writeFileString(path, encodeUnknownConfigDocument(decoded)).pipe( | ||
| 584 | Effect.mapError(mapFileSystemError(path, "write config")), | ||
| 585 | ); | ||
| 586 | return true; | ||
| 587 | }); | ||
| 588 | |||
| 589 | export const migrateLegacyLocalExecutorConfigs = ( | ||
| 590 | context: ResolvedLocalWorkspaceContext, | ||
| 591 | ): Effect.Effect< | ||
| 592 | void, | ||
| 593 | LocalFileSystemError | LocalExecutorConfigDecodeError, | ||
| 594 | FileSystem.FileSystem | ||
| 595 | > => | ||
| 596 | Effect.gen(function* () { | ||
| 597 | yield* migrateLegacyLocalExecutorConfigFile(context.homeConfigPath); | ||
| 598 | yield* migrateLegacyLocalExecutorConfigFile(context.projectConfigPath); | ||
| 599 | }); | ||
| 600 | |||
| 429 | export const loadLocalExecutorConfig = ( | 601 | export const loadLocalExecutorConfig = ( |
| 430 | context: ResolvedLocalWorkspaceContext, | 602 | context: ResolvedLocalWorkspaceContext, |
| 431 | ): Effect.Effect< | 603 | ): Effect.Effect< |
| @@ -12,6 +12,8 @@ import { · 11 unmodified lines | |||
| 12 | ExecutionStepSchema, | 12 | ExecutionStepSchema, |
| 13 | SecretMaterialSchema, | 13 | SecretMaterialSchema, |
| 14 | type SecretMaterial, | 14 | type SecretMaterial, |
| 15 | SecretStoreSchema, | ||
| 16 | type SecretStore, | ||
| 15 | } from "@executor/platform-sdk/schema"; | 17 | } from "@executor/platform-sdk/schema"; |
| 16 | import * as Effect from "effect/Effect"; | 18 | import * as Effect from "effect/Effect"; |
| 17 | import * as Option from "effect/Option"; | 19 | import * as Option from "effect/Option"; |
| @@ -26,9 +28,12 @@ import { · 8 unmodified lines | |||
| 26 | 28 | ||
| 27 | const LOCAL_EXECUTOR_STATE_VERSION = 1 as const; | 29 | const LOCAL_EXECUTOR_STATE_VERSION = 1 as const; |
| 28 | const LOCAL_EXECUTOR_STATE_BASENAME = "executor-state.json"; | 30 | const LOCAL_EXECUTOR_STATE_BASENAME = "executor-state.json"; |
| 31 | const LEGACY_LOCAL_SECRET_STORE_ID = "sts_builtin_local"; | ||
| 32 | const LEGACY_KEYCHAIN_SECRET_STORE_ID = "sts_builtin_keychain"; | ||
| 29 | 33 | ||
| 30 | const LocalExecutorStateSnapshotSchema = Schema.Struct({ | 34 | const LocalExecutorStateSnapshotSchema = Schema.Struct({ |
| 31 | version: Schema.Literal(LOCAL_EXECUTOR_STATE_VERSION), | 35 | version: Schema.Literal(LOCAL_EXECUTOR_STATE_VERSION), |
| 36 | secretStores: Schema.Array(SecretStoreSchema), | ||
| 32 | secretMaterials: Schema.Array(SecretMaterialSchema), | 37 | secretMaterials: Schema.Array(SecretMaterialSchema), |
| 33 | executions: Schema.Array(ExecutionSchema), | 38 | executions: Schema.Array(ExecutionSchema), |
| 34 | executionInteractions: Schema.Array(ExecutionInteractionSchema), | 39 | executionInteractions: Schema.Array(ExecutionInteractionSchema), |
| @@ -48,6 +53,7 @@ const decodeLocalExecutorStateSnapshot = Schema.decodeUnknown( · 13 unmodified lines | |||
| 48 | 53 | ||
| 49 | const defaultLocalExecutorStateSnapshot = (): LocalExecutorStateSnapshot => ({ | 54 | const defaultLocalExecutorStateSnapshot = (): LocalExecutorStateSnapshot => ({ |
| 50 | version: LOCAL_EXECUTOR_STATE_VERSION, | 55 | version: LOCAL_EXECUTOR_STATE_VERSION, |
| 56 | secretStores: [], | ||
| 51 | secretMaterials: [], | 57 | secretMaterials: [], |
| 52 | executions: [], | 58 | executions: [], |
| 53 | executionInteractions: [], | 59 | executionInteractions: [], |
| @@ -64,8 +70,31 @@ const asRecord = ( · 10 unmodified lines | |||
| 64 | ? (value as Record<string, unknown>) | 70 | ? (value as Record<string, unknown>) |
| 65 | : null; | 71 | : null; |
| 66 | 72 | ||
| 73 | const legacySecretStoreIdForProviderId = (providerId: string): string => | ||
| 74 | providerId === "local" | ||
| 75 | ? LEGACY_LOCAL_SECRET_STORE_ID | ||
| 76 | : providerId === "keychain" | ||
| 77 | ? LEGACY_KEYCHAIN_SECRET_STORE_ID | ||
| 78 | : `sts_legacy_${providerId}`; | ||
| 79 | |||
| 80 | const legacySecretStoreNameForProviderId = (providerId: string): string => { | ||
| 81 | switch (providerId) { | ||
| 82 | case "local": | ||
| 83 | return "Local Store"; | ||
| 84 | case "keychain": | ||
| 85 | return process.platform === "darwin" | ||
| 86 | ? "macOS Keychain" | ||
| 87 | : "Desktop Keyring"; | ||
| 88 | default: | ||
| 89 | return `Legacy ${providerId}`; | ||
| 90 | } | ||
| 91 | }; | ||
| 92 | |||
| 67 | const migrateLegacyExecutorStateValue = ( | 93 | const migrateLegacyExecutorStateValue = ( |
| 68 | value: unknown, | 94 | value: unknown, |
| 95 | input: { | ||
| 96 | scopeId: string; | ||
| 97 | }, | ||
| 69 | ): { | 98 | ): { |
| 70 | value: unknown; | 99 | value: unknown; |
| 71 | migrated: boolean; | 100 | migrated: boolean; |
| @@ -73,7 +102,7 @@ const migrateLegacyExecutorStateValue = ( · 1 unmodified line | |||
| 73 | if (Array.isArray(value)) { | 102 | if (Array.isArray(value)) { |
| 74 | let migrated = false; | 103 | let migrated = false; |
| 75 | const next = value.map((item) => { | 104 | const next = value.map((item) => { |
| 76 | const result = migrateLegacyExecutorStateValue(item); | 105 | const result = migrateLegacyExecutorStateValue(item, input); |
| 77 | migrated = migrated || result.migrated; | 106 | migrated = migrated || result.migrated; |
| 78 | return result.value; | 107 | return result.value; |
| 79 | }); | 108 | }); |
| @@ -104,11 +133,72 @@ const migrateLegacyExecutorStateValue = ( · 24 unmodified lines | |||
| 104 | migrated = true; | 133 | migrated = true; |
| 105 | } | 134 | } |
| 106 | 135 | ||
| 107 | const migratedEntry = migrateLegacyExecutorStateValue(entry); | 136 | const migratedEntry = migrateLegacyExecutorStateValue(entry, input); |
| 108 | migrated = migrated || migratedEntry.migrated; | 137 | migrated = migrated || migratedEntry.migrated; |
| 109 | next[nextKey] = migratedEntry.value; | 138 | next[nextKey] = migratedEntry.value; |
| 110 | } | 139 | } |
| 111 | 140 | ||
| 141 | if (next.version === LOCAL_EXECUTOR_STATE_VERSION) { | ||
| 142 | const secretMaterialsValue = Array.isArray(next.secretMaterials) | ||
| 143 | ? next.secretMaterials | ||
| 144 | : []; | ||
| 145 | const secretStoresValue = Array.isArray(next.secretStores) | ||
| 146 | ? next.secretStores | ||
| 147 | : []; | ||
| 148 | const secretStoresById = new Map<string, Record<string, unknown>>(); | ||
| 149 | |||
| 150 | for (const entry of secretStoresValue) { | ||
| 151 | const record = asRecord(entry); | ||
| 152 | if (record !== null && typeof record.id === "string") { | ||
| 153 | secretStoresById.set(record.id, record); | ||
| 154 | } | ||
| 155 | } | ||
| 156 | |||
| 157 | const migratedSecretMaterials = secretMaterialsValue.map((entry) => { | ||
| 158 | const record = asRecord(entry); | ||
| 159 | if (record === null) { | ||
| 160 | return entry; | ||
| 161 | } | ||
| 162 | |||
| 163 | if (typeof record.providerId !== "string" || typeof record.storeId === "string") { | ||
| 164 | return entry; | ||
| 165 | } | ||
| 166 | |||
| 167 | const providerId = record.providerId; | ||
| 168 | const storeId = legacySecretStoreIdForProviderId(providerId); | ||
| 169 | const createdAt = | ||
| 170 | typeof record.createdAt === "number" ? record.createdAt : 0; | ||
| 171 | const updatedAt = | ||
| 172 | typeof record.updatedAt === "number" ? record.updatedAt : createdAt; | ||
| 173 | |||
| 174 | secretStoresById.set(storeId, { | ||
| 175 | id: storeId, | ||
| 176 | scopeId: input.scopeId, | ||
| 177 | name: legacySecretStoreNameForProviderId(providerId), | ||
| 178 | kind: providerId, | ||
| 179 | status: providerId === "local" || providerId === "keychain" | ||
| 180 | ? "connected" | ||
| 181 | : "error", | ||
| 182 | enabled: true, | ||
| 183 | createdAt, | ||
| 184 | updatedAt, | ||
| 185 | }); | ||
| 186 | |||
| 187 | migrated = true; | ||
| 188 | return { | ||
| 189 | ...record, | ||
| 190 | storeId, | ||
| 191 | }; | ||
| 192 | }); | ||
| 193 | |||
| 194 | if (!Array.isArray(next.secretStores)) { | ||
| 195 | migrated = true; | ||
| 196 | } | ||
| 197 | |||
| 198 | next.secretStores = [...secretStoresById.values()]; | ||
| 199 | next.secretMaterials = migratedSecretMaterials; | ||
| 200 | } | ||
| 201 | |||
| 112 | return { value: next, migrated }; | 202 | return { value: next, migrated }; |
| 113 | }; | 203 | }; |
| 114 | 204 | ||
| @@ -168,7 +258,9 @@ const readStateFromDisk = ( · 53 unmodified lines | |||
| 168 | try: () => JSON.parse(content) as unknown, | 258 | try: () => JSON.parse(content) as unknown, |
| 169 | catch: mapFileSystemError(path, "parse executor state"), | 259 | catch: mapFileSystemError(path, "parse executor state"), |
| 170 | }); | 260 | }); |
| 171 | const migrated = migrateLegacyExecutorStateValue(parsed); | 261 | const migrated = migrateLegacyExecutorStateValue(parsed, { |
| 262 | scopeId: deriveLocalInstallation(context).scopeId, | ||
| 263 | }); | ||
| 172 | const decoded = yield* decodeLocalExecutorStateSnapshot(migrated.value).pipe( | 264 | const decoded = yield* decodeLocalExecutorStateSnapshot(migrated.value).pipe( |
| 173 | Effect.mapError(mapFileSystemError(path, "decode executor state")), | 265 | Effect.mapError(mapFileSystemError(path, "decode executor state")), |
| 174 | ); | 266 | ); |
| @@ -233,6 +325,10 @@ export const mergeImportedLocalExecutorStateSnapshot = (input: { · 58 unmodified lines | |||
| 233 | imported: Partial<Omit<LocalExecutorStateSnapshot, "version">>; | 325 | imported: Partial<Omit<LocalExecutorStateSnapshot, "version">>; |
| 234 | }): LocalExecutorStateSnapshot => ({ | 326 | }): LocalExecutorStateSnapshot => ({ |
| 235 | version: LOCAL_EXECUTOR_STATE_VERSION, | 327 | version: LOCAL_EXECUTOR_STATE_VERSION, |
| 328 | secretStores: mergeById( | ||
| 329 | input.current.secretStores, | ||
| 330 | input.imported.secretStores ?? [], | ||
| 331 | ), | ||
| 236 | secretMaterials: mergeById( | 332 | secretMaterials: mergeById( |
| 237 | input.current.secretMaterials, | 333 | input.current.secretMaterials, |
| 238 | input.imported.secretMaterials ?? [], | 334 | input.imported.secretMaterials ?? [], |
| @@ -334,6 +430,81 @@ export const createLocalExecutorStateStore = ( · 95 unmodified lines | |||
| 334 | const stateManager = createStateManager(context, fileSystem); | 430 | const stateManager = createStateManager(context, fileSystem); |
| 335 | 431 | ||
| 336 | return { | 432 | return { |
| 433 | secretStores: { | ||
| 434 | getById: (id: SecretStore["id"]) => | ||
| 435 | stateManager.read((state) => { | ||
| 436 | const store = state.secretStores.find( | ||
| 437 | (candidate) => candidate.id === id, | ||
| 438 | ); | ||
| 439 | return store | ||
| 440 | ? Option.some(cloneValue(store)) | ||
| 441 | : Option.none<SecretStore>(); | ||
| 442 | }), | ||
| 443 | |||
| 444 | listAll: () => | ||
| 445 | stateManager.read((state) => sortByUpdatedAtAndIdDesc(state.secretStores)), | ||
| 446 | |||
| 447 | upsert: (store: SecretStore) => | ||
| 448 | stateManager.mutate((state) => { | ||
| 449 | const nextStores = state.secretStores.filter( | ||
| 450 | (candidate) => candidate.id !== store.id, | ||
| 451 | ); | ||
| 452 | nextStores.push(cloneValue(store)); | ||
| 453 | |||
| 454 | return { | ||
| 455 | state: { | ||
| 456 | ...state, | ||
| 457 | secretStores: nextStores, | ||
| 458 | }, | ||
| 459 | value: undefined, | ||
| 460 | } satisfies StateMutationResult<void>; | ||
| 461 | }), | ||
| 462 | |||
| 463 | updateById: ( | ||
| 464 | id: SecretStore["id"], | ||
| 465 | update: Partial< | ||
| 466 | Pick<SecretStore, "name" | "status" | "enabled"> | ||
| 467 | >, | ||
| 468 | ) => | ||
| 469 | stateManager.mutate((state) => { | ||
| 470 | let updated: SecretStore | null = null; | ||
| 471 | const nextStores = state.secretStores.map((store) => { | ||
| 472 | if (store.id !== id) { | ||
| 473 | return store; | ||
| 474 | } | ||
| 475 | |||
| 476 | updated = { | ||
| 477 | ...store, | ||
| 478 | ...cloneValue(update), | ||
| 479 | updatedAt: Date.now(), | ||
| 480 | } satisfies SecretStore; | ||
| 481 | return updated; | ||
| 482 | }); | ||
| 483 | |||
| 484 | return { | ||
| 485 | state: { | ||
| 486 | ...state, | ||
| 487 | secretStores: nextStores, | ||
| 488 | }, | ||
| 489 | value: updated ? Option.some(cloneValue(updated)) : Option.none<SecretStore>(), | ||
| 490 | } satisfies StateMutationResult<Option.Option<SecretStore>>; | ||
| 491 | }), | ||
| 492 | |||
| 493 | removeById: (id: SecretStore["id"]) => | ||
| 494 | stateManager.mutate((state) => { | ||
| 495 | const nextStores = state.secretStores.filter( | ||
| 496 | (candidate) => candidate.id !== id, | ||
| 497 | ); | ||
| 498 | |||
| 499 | return { | ||
| 500 | state: { | ||
| 501 | ...state, | ||
| 502 | secretStores: nextStores, | ||
| 503 | }, | ||
| 504 | value: nextStores.length !== state.secretStores.length, | ||
| 505 | } satisfies StateMutationResult<boolean>; | ||
| 506 | }), | ||
| 507 | }, | ||
| 337 | secretMaterials: { | 508 | secretMaterials: { |
| 338 | getById: (id: SecretMaterial["id"]) => | 509 | getById: (id: SecretMaterial["id"]) => |
| 339 | stateManager.read((state) => { | 510 | stateManager.read((state) => { |
| @@ -31,6 +31,7 @@ import { · 30 unmodified lines | |||
| 31 | import * as Effect from "effect/Effect"; | 31 | import * as Effect from "effect/Effect"; |
| 32 | import { | 32 | import { |
| 33 | type LoadedLocalExecutorConfig, | 33 | type LoadedLocalExecutorConfig, |
| 34 | migrateLegacyLocalExecutorConfigs, | ||
| 34 | type ResolvedLocalWorkspaceContext, | 35 | type ResolvedLocalWorkspaceContext, |
| 35 | loadLocalExecutorConfig, | 36 | loadLocalExecutorConfig, |
| 36 | resolveConfigRelativePath, | 37 | resolveConfigRelativePath, |
| @@ -44,6 +45,7 @@ import { · 7 unmodified lines | |||
| 44 | createDefaultSecretMaterialResolver, | 45 | createDefaultSecretMaterialResolver, |
| 45 | createDefaultSecretMaterialStorer, | 46 | createDefaultSecretMaterialStorer, |
| 46 | createDefaultSecretMaterialUpdater, | 47 | createDefaultSecretMaterialUpdater, |
| 48 | provisionBuiltinSecretStores, | ||
| 47 | } from "./secret-material-providers"; | 49 | } from "./secret-material-providers"; |
| 48 | import { | 50 | import { |
| 49 | buildLocalSourceArtifact, | 51 | buildLocalSourceArtifact, |
| @@ -52,6 +54,7 @@ import { · 2 unmodified lines | |||
| 52 | writeLocalSourceArtifact, | 54 | writeLocalSourceArtifact, |
| 53 | } from "./source-artifacts"; | 55 | } from "./source-artifacts"; |
| 54 | import { | 56 | import { |
| 57 | deriveLocalInstallation, | ||
| 55 | getOrProvisionLocalInstallation, | 58 | getOrProvisionLocalInstallation, |
| 56 | loadLocalInstallation, | 59 | loadLocalInstallation, |
| 57 | } from "./installation"; | 60 | } from "./installation"; |
| @@ -235,6 +238,10 @@ export const createLocalExecutorRepositoriesEffect = ( · 177 unmodified lines | |||
| 235 | homeStateDirectory: options.homeStateDirectory, | 238 | homeStateDirectory: options.homeStateDirectory, |
| 236 | }), | 239 | }), |
| 237 | ).pipe(Effect.mapError(toError)); | 240 | ).pipe(Effect.mapError(toError)); |
| 241 | yield* bindFileSystem( | ||
| 242 | fileSystem, | ||
| 243 | migrateLegacyLocalExecutorConfigs(workspaceContext), | ||
| 244 | ).pipe(Effect.mapError(toError)); | ||
| 238 | 245 | ||
| 239 | const executorStateStorage = createLocalExecutorStatePersistence( | 246 | const executorStateStorage = createLocalExecutorStatePersistence( |
| 240 | workspaceContext, | 247 | workspaceContext, |
| @@ -245,12 +252,14 @@ export const createLocalExecutorRepositoriesEffect = ( · 4 unmodified lines | |||
| 245 | fileSystem, | 252 | fileSystem, |
| 246 | ); | 253 | ); |
| 247 | const loadedConfig = yield* workspaceConfigStore.load(); | 254 | const loadedConfig = yield* workspaceConfigStore.load(); |
| 255 | yield* provisionBuiltinSecretStores({ | ||
| 256 | executorState: executorStateStorage.executorState, | ||
| 257 | scopeId: deriveLocalInstallation(workspaceContext).scopeId, | ||
| 258 | }); | ||
| 248 | const resolveSecretMaterial: ResolveSecretMaterial = | 259 | const resolveSecretMaterial: ResolveSecretMaterial = |
| 249 | runtimeOptions.resolveSecretMaterial | 260 | runtimeOptions.resolveSecretMaterial |
| 250 | ?? createDefaultSecretMaterialResolver({ | 261 | ?? createDefaultSecretMaterialResolver({ |
| 251 | executorState: executorStateStorage.executorState, | 262 | executorState: executorStateStorage.executorState, |
| 252 | localConfig: loadedConfig.config, | ||
| 253 | workspaceRoot: workspaceContext.workspaceRoot, | ||
| 254 | }); | 263 | }); |
| 255 | 264 | ||
| 256 | return { | 265 | return { |
| @@ -274,16 +283,20 @@ export const createLocalExecutorRepositoriesEffect = ( · 17 unmodified lines | |||
| 274 | createBoundSourceTypeDeclarationsRefresher(workspaceContext), | 283 | createBoundSourceTypeDeclarationsRefresher(workspaceContext), |
| 275 | }, | 284 | }, |
| 276 | secrets: { | 285 | secrets: { |
| 286 | secretStores: executorStateStorage.executorState.secretStores, | ||
| 277 | ...executorStateStorage.executorState.secretMaterials, | 287 | ...executorStateStorage.executorState.secretMaterials, |
| 278 | resolve: resolveSecretMaterial, | 288 | resolve: resolveSecretMaterial, |
| 279 | store: createDefaultSecretMaterialStorer({ | 289 | store: createDefaultSecretMaterialStorer({ |
| 280 | executorState: executorStateStorage.executorState, | 290 | executorState: executorStateStorage.executorState, |
| 291 | resolveSecretMaterial, | ||
| 281 | }), | 292 | }), |
| 282 | delete: createDefaultSecretMaterialDeleter({ | 293 | delete: createDefaultSecretMaterialDeleter({ |
| 283 | executorState: executorStateStorage.executorState, | 294 | executorState: executorStateStorage.executorState, |
| 295 | resolveSecretMaterial, | ||
| 284 | }), | 296 | }), |
| 285 | update: createDefaultSecretMaterialUpdater({ | 297 | update: createDefaultSecretMaterialUpdater({ |
| 286 | executorState: executorStateStorage.executorState, | 298 | executorState: executorStateStorage.executorState, |
| 299 | resolveSecretMaterial, | ||
| 287 | }), | 300 | }), |
| 288 | }, | 301 | }, |
| 289 | executions: { | 302 | executions: { |
| @@ -293,7 +306,9 @@ export const createLocalExecutorRepositoriesEffect = ( · 3 unmodified lines | |||
| 293 | steps: executorStateStorage.executorState.executionSteps, | 306 | steps: executorStateStorage.executorState.executionSteps, |
| 294 | }, | 307 | }, |
| 295 | instanceConfig: { | 308 | instanceConfig: { |
| 296 | resolve: createLocalInstanceConfigResolver(), | 309 | resolve: createLocalInstanceConfigResolver({ |
| 310 | executorState: executorStateStorage.executorState, | ||
| 311 | }), | ||
| 297 | }, | 312 | }, |
| 298 | close: executorStateStorage.close, | 313 | close: executorStateStorage.close, |
| 299 | } satisfies ExecutorBackendRepositories; | 314 | } satisfies ExecutorBackendRepositories; |
| @@ -329,27 +344,32 @@ export const createLocalExecutorEffect = < · 29 unmodified lines | |||
| 329 | ): Effect.Effect< | 344 | ): Effect.Effect< |
| 330 | ExecutorEffect & ExecutorSdkPluginExtensions<TPlugins>, | 345 | ExecutorEffect & ExecutorSdkPluginExtensions<TPlugins>, |
| 331 | Error | 346 | Error |
| 332 | > => | 347 | > => { |
| 333 | createExecutorEffect({ | 348 | return createExecutorEffect({ |
| 334 | backend: createLocalExecutorBackend(options), | 349 | backend: createLocalExecutorBackend(options), |
| 335 | plugins: options.plugins, | 350 | plugins: (options.plugins ?? []) as TPlugins, |
| 336 | executionResolver: options.executionResolver, | 351 | executionResolver: options.executionResolver, |
| 337 | resolveSecretMaterial: options.resolveSecretMaterial, | 352 | resolveSecretMaterial: options.resolveSecretMaterial, |
| 338 | getLocalServerBaseUrl: options.getLocalServerBaseUrl, | 353 | getLocalServerBaseUrl: options.getLocalServerBaseUrl, |
| 339 | } satisfies CreateExecutorEffectOptions & { plugins?: TPlugins }); | 354 | }) as Effect.Effect< |
| 355 | ExecutorEffect & ExecutorSdkPluginExtensions<TPlugins>, | ||
| 356 | Error | ||
| 357 | >; | ||
| 358 | }; | ||
| 340 | 359 | ||
| 341 | export const createLocalExecutor = < | 360 | export const createLocalExecutor = < |
| 342 | const TPlugins extends readonly ExecutorSdkPlugin<any, any>[] = [], | 361 | const TPlugins extends readonly ExecutorSdkPlugin<any, any>[] = [], |
| 343 | >( | 362 | >( |
| 344 | options: CreateLocalExecutorOptions<TPlugins> = {}, | 363 | options: CreateLocalExecutorOptions<TPlugins> = {}, |
| 345 | ): Promise<Executor<TPlugins>> => | 364 | ) => { |
| 346 | createExecutor({ | 365 | return createExecutor({ |
| 347 | backend: createLocalExecutorBackend(options), | 366 | backend: createLocalExecutorBackend(options), |
| 348 | plugins: options.plugins, | 367 | plugins: (options.plugins ?? []) as TPlugins, |
| 349 | executionResolver: options.executionResolver, | 368 | executionResolver: options.executionResolver, |
| 350 | resolveSecretMaterial: options.resolveSecretMaterial, | 369 | resolveSecretMaterial: options.resolveSecretMaterial, |
| 351 | getLocalServerBaseUrl: options.getLocalServerBaseUrl, | 370 | getLocalServerBaseUrl: options.getLocalServerBaseUrl, |
| 352 | }); | 371 | }) as Promise<Executor<TPlugins>>; |
| 372 | }; | ||
| 353 | 373 | ||
| 354 | export { | 374 | export { |
| 355 | deriveLocalInstallation, | 375 | deriveLocalInstallation, |
| @@ -1,115 +1,38 @@ | |||
| 1 | import { randomUUID } from "node:crypto"; | 1 | import { randomUUID } from "node:crypto"; |
| 2 | import { spawn } from "node:child_process"; | ||
| 3 | import { isAbsolute } from "node:path"; | ||
| 4 | import { FileSystem } from "@effect/platform"; | ||
| 5 | import { NodeFileSystem } from "@effect/platform-node"; | ||
| 6 | 2 | ||
| 7 | import { | 3 | import type { |
| 8 | type LocalConfigSecretProvider, | 4 | InstanceConfig, |
| 9 | type LocalExecutorConfig, | 5 | SecretProvider, |
| 10 | type SecretMaterialPurpose, | 6 | } from "@executor/platform-sdk/contracts"; |
| 11 | type SecretRef, | 7 | import type { |
| 12 | type SecretMaterial, | 8 | SecretMaterial, |
| 13 | SecretMaterialIdSchema, | 9 | SecretMaterialPurpose, |
| 10 | SecretRef, | ||
| 11 | SecretStore, | ||
| 12 | SecretStoreId, | ||
| 14 | } from "@executor/platform-sdk/schema"; | 13 | } from "@executor/platform-sdk/schema"; |
| 15 | import * as Effect from "effect/Effect"; | 14 | import * as Effect from "effect/Effect"; |
| 16 | import * as Layer from "effect/Layer"; | 15 | import * as Layer from "effect/Layer"; |
| 17 | import * as Option from "effect/Option"; | 16 | import * as Option from "effect/Option"; |
| 18 | 17 | ||
| 19 | import { | 18 | import { |
| 20 | ExecutorStateStore, | 19 | getSecretStoreContribution, |
| 21 | runtimeEffectError, | 20 | registeredSecretStoreContributions, |
| 22 | LocalInstanceConfigService, | 21 | LocalInstanceConfigService, |
| 22 | runtimeEffectError, | ||
| 23 | SecretMaterialDeleterService, | 23 | SecretMaterialDeleterService, |
| 24 | SecretMaterialResolverService, | 24 | SecretMaterialResolverService, |
| 25 | SecretMaterialStorerService, | 25 | SecretMaterialStorerService, |
| 26 | SecretMaterialUpdaterService, | 26 | SecretMaterialUpdaterService, |
| 27 | fromConfigSecretProviderId, | 27 | type DeleteSecretMaterial, |
| 28 | getRuntimeLocalScopeOption, | ||
| 29 | type ExecutorStateStoreShape, | 28 | type ExecutorStateStoreShape, |
| 30 | type ResolveSecretMaterial, | ||
| 31 | type ResolveInstanceConfig, | 29 | type ResolveInstanceConfig, |
| 32 | type SecretMaterialResolveContext, | 30 | type ResolveSecretMaterial, |
| 33 | type StoreSecretMaterial, | 31 | type StoreSecretMaterial, |
| 34 | type DeleteSecretMaterial, | ||
| 35 | type UpdateSecretMaterial, | 32 | type UpdateSecretMaterial, |
| 36 | } from "@executor/platform-sdk/runtime"; | 33 | } from "@executor/platform-sdk/runtime"; |
| 37 | import { resolveConfigRelativePath } from "./config"; | ||
| 38 | |||
| 39 | export const ENV_SECRET_PROVIDER_ID = "env"; | ||
| 40 | export const PARAMS_SECRET_PROVIDER_ID = "params"; | ||
| 41 | export const KEYCHAIN_SECRET_PROVIDER_ID = "keychain"; | ||
| 42 | export const LOCAL_SECRET_PROVIDER_ID = "local"; | ||
| 43 | |||
| 44 | const toError = (cause: unknown): Error => | ||
| 45 | cause instanceof Error ? cause : new Error(String(cause)); | ||
| 46 | |||
| 47 | export type SecretStoreProviderId = | ||
| 48 | | typeof KEYCHAIN_SECRET_PROVIDER_ID | ||
| 49 | | typeof LOCAL_SECRET_PROVIDER_ID; | ||
| 50 | |||
| 51 | type SecretMaterialProviderRuntime = { | ||
| 52 | executorState: ExecutorStateStoreShape; | ||
| 53 | env: NodeJS.ProcessEnv; | ||
| 54 | dangerouslyAllowEnvSecrets: boolean; | ||
| 55 | keychainServiceName: string; | ||
| 56 | localConfig: LocalExecutorConfig | null; | ||
| 57 | workspaceRoot: string | null; | ||
| 58 | }; | ||
| 59 | |||
| 60 | type SecretMaterialProvider = { | ||
| 61 | resolve: (input: { | ||
| 62 | ref: SecretRef; | ||
| 63 | context: SecretMaterialResolveContext; | ||
| 64 | runtime: SecretMaterialProviderRuntime; | ||
| 65 | }) => Effect.Effect<string, Error, never>; | ||
| 66 | store?: (input: { | ||
| 67 | purpose: SecretMaterialPurpose; | ||
| 68 | value: string; | ||
| 69 | name?: string | null; | ||
| 70 | runtime: SecretMaterialProviderRuntime; | ||
| 71 | }) => Effect.Effect<SecretRef, Error, never>; | ||
| 72 | update?: (input: { | ||
| 73 | ref: SecretRef; | ||
| 74 | name?: string | null; | ||
| 75 | value?: string; | ||
| 76 | runtime: SecretMaterialProviderRuntime; | ||
| 77 | }) => Effect.Effect<{ | ||
| 78 | id: string; | ||
| 79 | providerId: string; | ||
| 80 | name: string | null; | ||
| 81 | purpose: string; | ||
| 82 | createdAt: number; | ||
| 83 | updatedAt: number; | ||
| 84 | }, Error, never>; | ||
| 85 | remove?: (input: { | ||
| 86 | ref: SecretRef; | ||
| 87 | runtime: SecretMaterialProviderRuntime; | ||
| 88 | }) => Effect.Effect<boolean, Error, never>; | ||
| 89 | }; | ||
| 90 | 34 | ||
| 91 | type SecretMaterialProviderRegistry = ReadonlyMap<string, SecretMaterialProvider>; | 35 | const SECRET_STORE_KIND_ENV = "EXECUTOR_SECRET_STORE_PROVIDER"; |
| 92 | |||
| 93 | type SpawnResult = { | ||
| 94 | exitCode: number; | ||
| 95 | stdout: string; | ||
| 96 | stderr: string; | ||
| 97 | }; | ||
| 98 | |||
| 99 | const DEFAULT_KEYCHAIN_SERVICE_NAME = "executor"; | ||
| 100 | const KEYCHAIN_COMMAND_TIMEOUT_MS = 5_000; | ||
| 101 | const DANGEROUSLY_ALLOW_ENV_SECRETS_ENV = "DANGEROUSLY_ALLOW_ENV_SECRETS"; | ||
| 102 | const SECRET_STORE_PROVIDER_ENV = "EXECUTOR_SECRET_STORE_PROVIDER"; | ||
| 103 | const KEYCHAIN_SERVICE_NAME_ENV = "EXECUTOR_KEYCHAIN_SERVICE_NAME"; | ||
| 104 | |||
| 105 | type SecretMaterialSummary = { | ||
| 106 | id: string; | ||
| 107 | providerId: string; | ||
| 108 | name: string | null; | ||
| 109 | purpose: string; | ||
| 110 | createdAt: number; | ||
| 111 | updatedAt: number; | ||
| 112 | }; | ||
| 113 | 36 | ||
| 114 | const trimOrNull = (value: string | null | undefined): string | null => { | 37 | const trimOrNull = (value: string | null | undefined): string | null => { |
| 115 | if (value === null || value === undefined) { | 38 | if (value === null || value === undefined) { |
| @@ -120,1165 +43,414 @@ const trimOrNull = (value: string | null | undefined): string | null => { · 4 unmodified lines | |||
| 120 | return trimmed.length > 0 ? trimmed : null; | 43 | return trimmed.length > 0 ? trimmed : null; |
| 121 | }; | 44 | }; |
| 122 | 45 | ||
| 123 | const parseBooleanEnv = (value: string | undefined): boolean => { | 46 | const builtinSecretStoreContributions = () => |
| 124 | const normalized = trimOrNull(value)?.toLowerCase(); | 47 | registeredSecretStoreContributions() |
| 125 | return normalized === "1" || normalized === "true" || normalized === "yes"; | 48 | .filter((entry) => entry.builtin !== undefined) |
| 126 | }; | 49 | .filter((entry) => entry.builtin?.enabled?.() ?? true) |
| 50 | .map((entry) => ({ | ||
| 51 | contribution: entry, | ||
| 52 | builtin: entry.builtin!, | ||
| 53 | })); | ||
| 54 | |||
| 55 | const resolveStoreContribution = (store: SecretStore) => | ||
| 56 | getSecretStoreContribution(store.kind); | ||
| 57 | |||
| 58 | const loadStoreCapabilities = (store: SecretStore) => | ||
| 59 | resolveStoreContribution(store).getCapabilities({ | ||
| 60 | store, | ||
| 61 | }) as Effect.Effect<{ | ||
| 62 | canCreateSecrets: boolean; | ||
| 63 | canUpdateSecrets: boolean; | ||
| 64 | canDeleteSecrets: boolean; | ||
| 65 | canBrowseSecrets: boolean; | ||
| 66 | canImportSecrets: boolean; | ||
| 67 | }, Error, never>; | ||
| 127 | 68 | ||
| 128 | export const parseSecretStoreProviderId = (value: string | undefined): SecretStoreProviderId | null => { | 69 | const parseDefaultStoreKind = (value: string | undefined): string | null => { |
| 129 | const normalized = trimOrNull(value)?.toLowerCase(); | 70 | const normalized = trimOrNull(value)?.toLowerCase(); |
| 130 | if (normalized === KEYCHAIN_SECRET_PROVIDER_ID) { | 71 | return normalized ?? null; |
| 131 | return KEYCHAIN_SECRET_PROVIDER_ID; | ||
| 132 | } | ||
| 133 | |||
| 134 | if ( | ||
| 135 | normalized === LOCAL_SECRET_PROVIDER_ID | ||
| 136 | ) { | ||
| 137 | return LOCAL_SECRET_PROVIDER_ID; | ||
| 138 | } | ||
| 139 | |||
| 140 | return null; | ||
| 141 | }; | ||
| 142 | |||
| 143 | const resolveDangerouslyAllowEnvSecrets = (value: boolean | undefined): boolean => | ||
| 144 | value ?? parseBooleanEnv(process.env[DANGEROUSLY_ALLOW_ENV_SECRETS_ENV]); | ||
| 145 | |||
| 146 | const resolveKeychainServiceName = (value: string | undefined): string => | ||
| 147 | trimOrNull(value) | ||
| 148 | ?? trimOrNull(process.env[KEYCHAIN_SERVICE_NAME_ENV]) | ||
| 149 | ?? DEFAULT_KEYCHAIN_SERVICE_NAME; | ||
| 150 | |||
| 151 | const ensureNonEmptyString = (value: string | undefined): string | null => { | ||
| 152 | const trimmed = value?.trim(); | ||
| 153 | return trimmed && trimmed.length > 0 ? trimmed : null; | ||
| 154 | }; | 72 | }; |
| 155 | 73 | ||
| 156 | const toSecretMaterialSummary = ( | 74 | const resolveDefaultStoreId = ( |
| 157 | material: Pick<SecretMaterial, "id" | "providerId" | "name" | "purpose" | "createdAt" | "updatedAt">, | 75 | stores: ReadonlyArray<{ |
| 158 | ): SecretMaterialSummary => ({ | 76 | id: string; |
| 159 | id: material.id, | 77 | kind: string; |
| 160 | providerId: material.providerId, | 78 | }>, |
| 161 | name: material.name, | 79 | ): SecretStoreId | null => { |
| 162 | purpose: material.purpose, | 80 | const explicitKind = parseDefaultStoreKind(process.env[SECRET_STORE_KIND_ENV]); |
| 163 | createdAt: material.createdAt, | 81 | if (explicitKind) { |
| 164 | updatedAt: material.updatedAt, | 82 | const explicitStore = stores.find((store) => store.kind === explicitKind); |
| 165 | }); | 83 | if (explicitStore) { |
| 166 | 84 | return explicitStore.id as SecretStoreId; | |
| 167 | const keychainCommandForPlatform = ( | 85 | } |
| 168 | platform: NodeJS.Platform = process.platform, | ||
| 169 | ): string | null => { | ||
| 170 | if (platform === "darwin") { | ||
| 171 | return "security"; | ||
| 172 | } | ||
| 173 | |||
| 174 | if (platform === "linux") { | ||
| 175 | return "secret-tool"; | ||
| 176 | } | ||
| 177 | |||
| 178 | return null; | ||
| 179 | }; | ||
| 180 | |||
| 181 | const runCommand = (input: { | ||
| 182 | command: string; | ||
| 183 | args: ReadonlyArray<string>; | ||
| 184 | stdin?: string; | ||
| 185 | env?: NodeJS.ProcessEnv; | ||
| 186 | operation: string; | ||
| 187 | timeoutMs?: number; | ||
| 188 | }): Effect.Effect<SpawnResult, Error, never> => | ||
| 189 | Effect.tryPromise({ | ||
| 190 | try: () => | ||
| 191 | new Promise<SpawnResult>((resolve, reject) => { | ||
| 192 | const child = spawn(input.command, [...input.args], { | ||
| 193 | stdio: "pipe", | ||
| 194 | env: input.env, | ||
| 195 | }); | ||
| 196 | |||
| 197 | let stdout = ""; | ||
| 198 | let stderr = ""; | ||
| 199 | let settled = false; | ||
| 200 | const timeout = input.timeoutMs === undefined | ||
| 201 | ? null | ||
| 202 | : setTimeout(() => { | ||
| 203 | if (settled) { | ||
| 204 | return; | ||
| 205 | } | ||
| 206 | |||
| 207 | settled = true; | ||
| 208 | child.kill("SIGKILL"); | ||
| 209 | reject( | ||
| 210 | new Error( | ||
| 211 | `${input.operation}: '${input.command}' timed out after ${input.timeoutMs}ms`, | ||
| 212 | ), | ||
| 213 | ); | ||
| 214 | }, input.timeoutMs); | ||
| 215 | |||
| 216 | child.stdout.on("data", (chunk) => { | ||
| 217 | stdout += chunk.toString("utf8"); | ||
| 218 | }); | ||
| 219 | |||
| 220 | child.stderr.on("data", (chunk) => { | ||
| 221 | stderr += chunk.toString("utf8"); | ||
| 222 | }); | ||
| 223 | |||
| 224 | child.on("error", (error) => { | ||
| 225 | if (settled) { | ||
| 226 | return; | ||
| 227 | } | ||
| 228 | settled = true; | ||
| 229 | if (timeout) { | ||
| 230 | clearTimeout(timeout); | ||
| 231 | } | ||
| 232 | reject(new Error(`${input.operation}: failed spawning '${input.command}': ${error.message}`)); | ||
| 233 | }); | ||
| 234 | |||
| 235 | child.on("close", (code) => { | ||
| 236 | if (settled) { | ||
| 237 | return; | ||
| 238 | } | ||
| 239 | settled = true; | ||
| 240 | if (timeout) { | ||
| 241 | clearTimeout(timeout); | ||
| 242 | } | ||
| 243 | resolve({ | ||
| 244 | exitCode: code ?? 0, | ||
| 245 | stdout, | ||
| 246 | stderr, | ||
| 247 | }); | ||
| 248 | }); | ||
| 249 | |||
| 250 | if (input.stdin !== undefined) { | ||
| 251 | child.stdin.write(input.stdin); | ||
| 252 | } | ||
| 253 | |||
| 254 | child.stdin.end(); | ||
| 255 | }), | ||
| 256 | catch: (cause) => | ||
| 257 | cause instanceof Error | ||
| 258 | ? cause | ||
| 259 | : new Error(`${input.operation}: command execution failed: ${String(cause)}`), | ||
| 260 | }); | ||
| 261 | |||
| 262 | const ensureCommandSuccess = (input: { | ||
| 263 | result: SpawnResult; | ||
| 264 | operation: string; | ||
| 265 | message: string; | ||
| 266 | }): Effect.Effect<SpawnResult, Error, never> => { | ||
| 267 | if (input.result.exitCode === 0) { | ||
| 268 | return Effect.succeed(input.result); | ||
| 269 | } | 86 | } |
| 270 | 87 | ||
| 271 | const details = ensureNonEmptyString(input.result.stderr) | 88 | const prioritizedBuiltins = builtinSecretStoreContributions() |
| 272 | ?? ensureNonEmptyString(input.result.stdout) | 89 | .sort((left, right) => |
| 273 | ?? "command returned non-zero exit code"; | 90 | (right.builtin.defaultPriority ?? 0) - (left.builtin.defaultPriority ?? 0) |
| 274 | 91 | ); | |
| 275 | return Effect.fail(runtimeEffectError("local/secret-material-providers", `${input.operation}: ${input.message}: ${details}`)); | ||
| 276 | }; | ||
| 277 | |||
| 278 | const commandAvailabilityCache = new Map<string, Promise<boolean>>(); | ||
| 279 | |||
| 280 | const commandExists = (command: string): Effect.Effect<boolean, Error, never> => | ||
| 281 | Effect.tryPromise({ | ||
| 282 | try: async () => { | ||
| 283 | const cached = commandAvailabilityCache.get(command); | ||
| 284 | if (cached) { | ||
| 285 | return cached; | ||
| 286 | } | ||
| 287 | |||
| 288 | const probe = new Promise<boolean>((resolve) => { | ||
| 289 | const child = spawn(command, ["--help"], { | ||
| 290 | stdio: "ignore", | ||
| 291 | }); | ||
| 292 | const timeout = setTimeout(() => { | ||
| 293 | child.kill("SIGKILL"); | ||
| 294 | resolve(false); | ||
| 295 | }, 2_000); | ||
| 296 | |||
| 297 | child.on("error", () => { | ||
| 298 | clearTimeout(timeout); | ||
| 299 | resolve(false); | ||
| 300 | }); | ||
| 301 | child.on("close", () => { | ||
| 302 | clearTimeout(timeout); | ||
| 303 | resolve(true); | ||
| 304 | }); | ||
| 305 | }); | ||
| 306 | |||
| 307 | commandAvailabilityCache.set(command, probe); | ||
| 308 | const available = await probe; | ||
| 309 | if (!available) { | ||
| 310 | commandAvailabilityCache.delete(command); | ||
| 311 | } | ||
| 312 | return available; | ||
| 313 | }, | ||
| 314 | catch: (cause) => | ||
| 315 | cause instanceof Error | ||
| 316 | ? cause | ||
| 317 | : new Error(`command.exists: failed checking '${command}': ${String(cause)}`), | ||
| 318 | }); | ||
| 319 | |||
| 320 | const isKeychainProviderAvailable = ( | ||
| 321 | platform: NodeJS.Platform = process.platform, | ||
| 322 | ): Effect.Effect<boolean, Error, never> => { | ||
| 323 | const command = keychainCommandForPlatform(platform); | ||
| 324 | return command === null ? Effect.succeed(false) : commandExists(command); | ||
| 325 | }; | ||
| 326 | |||
| 327 | export const resolveDefaultSecretStoreProviderId = (input: { | ||
| 328 | storeProviderId?: SecretStoreProviderId; | ||
| 329 | env?: NodeJS.ProcessEnv; | ||
| 330 | platform?: NodeJS.Platform; | ||
| 331 | } = {}): Effect.Effect<SecretStoreProviderId, never, never> => { | ||
| 332 | const localProviderId = LOCAL_SECRET_PROVIDER_ID as SecretStoreProviderId; | ||
| 333 | const explicit = | ||
| 334 | input.storeProviderId | ||
| 335 | ?? parseSecretStoreProviderId((input.env ?? process.env)[SECRET_STORE_PROVIDER_ENV]); | ||
| 336 | if (explicit) { | ||
| 337 | return Effect.succeed(explicit); | ||
| 338 | } | ||
| 339 | 92 | ||
| 340 | if ((input.platform ?? process.platform) !== "darwin") { | 93 | for (const entry of prioritizedBuiltins) { |
| 341 | return Effect.succeed(localProviderId); | 94 | const builtinStore = stores.find((store) => store.id === entry.builtin.storeId); |
| 95 | if (builtinStore) { | ||
| 96 | return builtinStore.id as SecretStoreId; | ||
| 97 | } | ||
| 342 | } | 98 | } |
| 343 | 99 | ||
| 344 | return isKeychainProviderAvailable(input.platform).pipe( | 100 | return (stores[0]?.id as SecretStoreId | undefined) ?? null; |
| 345 | Effect.map((available): SecretStoreProviderId => | ||
| 346 | available ? KEYCHAIN_SECRET_PROVIDER_ID : localProviderId), | ||
| 347 | Effect.catchAll(() => Effect.succeed(localProviderId)), | ||
| 348 | ) as Effect.Effect<SecretStoreProviderId, never, never>; | ||
| 349 | }; | 101 | }; |
| 350 | 102 | ||
| 351 | const loadStoredSecretMaterial = (input: { | 103 | export const provisionBuiltinSecretStores = (input: { |
| 352 | id: string; | 104 | executorState: ExecutorStateStoreShape; |
| 353 | runtime: SecretMaterialProviderRuntime; | 105 | scopeId: string; |
| 354 | operation: string; | ||
| 355 | }) => | ||
| 356 | Effect.gen(function* () { | ||
| 357 | const materialId = SecretMaterialIdSchema.make(input.id); | ||
| 358 | const stored = yield* input.runtime.executorState.secretMaterials.getById(materialId); | ||
| 359 | if (Option.isNone(stored)) { | ||
| 360 | return yield* runtimeEffectError("local/secret-material-providers", `${input.operation}: secret material not found: ${input.id}`); | ||
| 361 | } | ||
| 362 | |||
| 363 | return stored.value; | ||
| 364 | }); | ||
| 365 | |||
| 366 | const createSecretMaterialMetadata = (input: { | ||
| 367 | providerId: SecretStoreProviderId; | ||
| 368 | providerHandle: string; | ||
| 369 | purpose: SecretMaterialPurpose; | ||
| 370 | value: string | null; | ||
| 371 | name?: string | null; | ||
| 372 | runtime: SecretMaterialProviderRuntime; | ||
| 373 | }) => | 106 | }) => |
| 374 | Effect.gen(function* () { | 107 | Effect.gen(function* () { |
| 375 | const now = Date.now(); | 108 | const now = Date.now(); |
| 376 | const id = SecretMaterialIdSchema.make(`sec_${randomUUID()}`); | ||
| 377 | yield* input.runtime.executorState.secretMaterials.upsert({ | ||
| 378 | id, | ||
| 379 | providerId: input.providerId, | ||
| 380 | handle: input.providerHandle, | ||
| 381 | name: trimOrNull(input.name), | ||
| 382 | purpose: input.purpose, | ||
| 383 | value: input.value, | ||
| 384 | createdAt: now, | ||
| 385 | updatedAt: now, | ||
| 386 | }); | ||
| 387 | |||
| 388 | return { | ||
| 389 | providerId: input.providerId, | ||
| 390 | handle: id, | ||
| 391 | } satisfies SecretRef; | ||
| 392 | }); | ||
| 393 | |||
| 394 | const loadManagedKeychainRef = (input: { | ||
| 395 | ref: SecretRef; | ||
| 396 | runtime: SecretMaterialProviderRuntime; | ||
| 397 | operation: string; | ||
| 398 | }) => | ||
| 399 | Effect.gen(function* () { | ||
| 400 | const material = yield* loadStoredSecretMaterial({ | ||
| 401 | id: input.ref.handle, | ||
| 402 | runtime: input.runtime, | ||
| 403 | operation: input.operation, | ||
| 404 | }); | ||
| 405 | if (material.providerId !== KEYCHAIN_SECRET_PROVIDER_ID) { | ||
| 406 | return yield* runtimeEffectError("local/secret-material-providers", | ||
| 407 | `${input.operation}: secret ${material.id} is stored in provider '${material.providerId}', not '${KEYCHAIN_SECRET_PROVIDER_ID}'`, | ||
| 408 | ); | ||
| 409 | } | ||
| 410 | |||
| 411 | return { | ||
| 412 | providerHandle: material.handle, | ||
| 413 | material, | ||
| 414 | }; | ||
| 415 | }); | ||
| 416 | |||
| 417 | const readKeychainSecretValue = (input: { | ||
| 418 | providerHandle: string; | ||
| 419 | runtime: SecretMaterialProviderRuntime; | ||
| 420 | }) => { | ||
| 421 | switch (process.platform) { | ||
| 422 | case "darwin": | ||
| 423 | return runCommand({ | ||
| 424 | command: "security", | ||
| 425 | args: [ | ||
| 426 | "find-generic-password", | ||
| 427 | "-a", | ||
| 428 | input.providerHandle, | ||
| 429 | "-s", | ||
| 430 | input.runtime.keychainServiceName, | ||
| 431 | "-w", | ||
| 432 | ], | ||
| 433 | operation: "keychain.get", | ||
| 434 | timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS, | ||
| 435 | }).pipe( | ||
| 436 | Effect.flatMap((result) => | ||
| 437 | ensureCommandSuccess({ | ||
| 438 | result, | ||
| 439 | operation: "keychain.get", | ||
| 440 | message: "Failed loading secret from macOS keychain", | ||
| 441 | }), | ||
| 442 | ), | ||
| 443 | Effect.map((result) => result.stdout.trimEnd()), | ||
| 444 | ); | ||
| 445 | case "linux": | ||
| 446 | return runCommand({ | ||
| 447 | command: "secret-tool", | ||
| 448 | args: [ | ||
| 449 | "lookup", | ||
| 450 | "service", | ||
| 451 | input.runtime.keychainServiceName, | ||
| 452 | "account", | ||
| 453 | input.providerHandle, | ||
| 454 | ], | ||
| 455 | operation: "keychain.get", | ||
| 456 | timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS, | ||
| 457 | }).pipe( | ||
| 458 | Effect.flatMap((result) => | ||
| 459 | ensureCommandSuccess({ | ||
| 460 | result, | ||
| 461 | operation: "keychain.get", | ||
| 462 | message: "Failed loading secret from desktop keyring", | ||
| 463 | }), | ||
| 464 | ), | ||
| 465 | Effect.map((result) => result.stdout.trimEnd()), | ||
| 466 | ); | ||
| 467 | default: | ||
| 468 | return Effect.fail( | ||
| 469 | runtimeEffectError("local/secret-material-providers", `keychain.get: keychain provider is unsupported on platform '${process.platform}'`), | ||
| 470 | ); | ||
| 471 | } | ||
| 472 | }; | ||
| 473 | 109 | ||
| 474 | const writeKeychainSecretValue = (input: { | 110 | for (const entry of builtinSecretStoreContributions()) { |
| 475 | providerHandle: string; | 111 | const existing = yield* input.executorState.secretStores.getById( |
| 476 | name?: string | null; | 112 | entry.builtin.storeId as SecretStore["id"], |
| 477 | value: string; | ||
| 478 | runtime: SecretMaterialProviderRuntime; | ||
| 479 | }) => { | ||
| 480 | const secretName = trimOrNull(input.name); | ||
| 481 | |||
| 482 | switch (process.platform) { | ||
| 483 | case "darwin": | ||
| 484 | return runCommand({ | ||
| 485 | command: "security", | ||
| 486 | args: [ | ||
| 487 | "add-generic-password", | ||
| 488 | "-a", | ||
| 489 | input.providerHandle, | ||
| 490 | "-s", | ||
| 491 | input.runtime.keychainServiceName, | ||
| 492 | "-w", | ||
| 493 | input.value, | ||
| 494 | ...(secretName ? ["-l", secretName] : []), | ||
| 495 | "-U", | ||
| 496 | ], | ||
| 497 | operation: "keychain.put", | ||
| 498 | timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS, | ||
| 499 | }).pipe( | ||
| 500 | Effect.flatMap((result) => | ||
| 501 | ensureCommandSuccess({ | ||
| 502 | result, | ||
| 503 | operation: "keychain.put", | ||
| 504 | message: "Failed storing secret in macOS keychain", | ||
| 505 | }), | ||
| 506 | ), | ||
| 507 | ); | ||
| 508 | case "linux": | ||
| 509 | return runCommand({ | ||
| 510 | command: "secret-tool", | ||
| 511 | args: [ | ||
| 512 | "store", | ||
| 513 | "--label", | ||
| 514 | secretName ?? input.runtime.keychainServiceName, | ||
| 515 | "service", | ||
| 516 | input.runtime.keychainServiceName, | ||
| 517 | "account", | ||
| 518 | input.providerHandle, | ||
| 519 | ], | ||
| 520 | stdin: input.value, | ||
| 521 | operation: "keychain.put", | ||
| 522 | timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS, | ||
| 523 | }).pipe( | ||
| 524 | Effect.flatMap((result) => | ||
| 525 | ensureCommandSuccess({ | ||
| 526 | result, | ||
| 527 | operation: "keychain.put", | ||
| 528 | message: "Failed storing secret in desktop keyring", | ||
| 529 | }), | ||
| 530 | ), | ||
| 531 | ); | ||
| 532 | default: | ||
| 533 | return Effect.fail( | ||
| 534 | runtimeEffectError("local/secret-material-providers", `keychain.put: keychain provider is unsupported on platform '${process.platform}'`), | ||
| 535 | ); | 113 | ); |
| 536 | } | 114 | if (Option.isSome(existing)) { |
| 537 | }; | 115 | continue; |
| 538 | |||
| 539 | const deleteKeychainSecretValue = (input: { | ||
| 540 | providerHandle: string; | ||
| 541 | runtime: SecretMaterialProviderRuntime; | ||
| 542 | }) => { | ||
| 543 | switch (process.platform) { | ||
| 544 | case "darwin": | ||
| 545 | return runCommand({ | ||
| 546 | command: "security", | ||
| 547 | args: [ | ||
| 548 | "delete-generic-password", | ||
| 549 | "-a", | ||
| 550 | input.providerHandle, | ||
| 551 | "-s", | ||
| 552 | input.runtime.keychainServiceName, | ||
| 553 | ], | ||
| 554 | operation: "keychain.delete", | ||
| 555 | timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS, | ||
| 556 | }).pipe( | ||
| 557 | Effect.map((result) => result.exitCode === 0), | ||
| 558 | ); | ||
| 559 | case "linux": | ||
| 560 | return runCommand({ | ||
| 561 | command: "secret-tool", | ||
| 562 | args: [ | ||
| 563 | "clear", | ||
| 564 | "service", | ||
| 565 | input.runtime.keychainServiceName, | ||
| 566 | "account", | ||
| 567 | input.providerHandle, | ||
| 568 | ], | ||
| 569 | operation: "keychain.delete", | ||
| 570 | timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS, | ||
| 571 | }).pipe( | ||
| 572 | Effect.map((result) => result.exitCode === 0), | ||
| 573 | ); | ||
| 574 | default: | ||
| 575 | return Effect.fail( | ||
| 576 | runtimeEffectError("local/secret-material-providers", `keychain.delete: keychain provider is unsupported on platform '${process.platform}'`), | ||
| 577 | ); | ||
| 578 | } | ||
| 579 | }; | ||
| 580 | |||
| 581 | const createParamsSecretMaterialProvider = (): SecretMaterialProvider => ({ | ||
| 582 | resolve: ({ ref, context }) => { | ||
| 583 | const value = ensureNonEmptyString(context.params?.[ref.handle]); | ||
| 584 | if (value === null) { | ||
| 585 | return Effect.fail(runtimeEffectError("local/secret-material-providers", `Secret parameter ${ref.handle} is not set`)); | ||
| 586 | } | ||
| 587 | |||
| 588 | return Effect.succeed(value); | ||
| 589 | }, | ||
| 590 | |||
| 591 | remove: () => Effect.succeed(false), | ||
| 592 | }); | ||
| 593 | |||
| 594 | const createEnvSecretMaterialProvider = (): SecretMaterialProvider => ({ | ||
| 595 | resolve: ({ ref, runtime }) => { | ||
| 596 | if (!runtime.dangerouslyAllowEnvSecrets) { | ||
| 597 | return Effect.fail( | ||
| 598 | runtimeEffectError("local/secret-material-providers", | ||
| 599 | `Env-backed secrets are disabled. Set ${DANGEROUSLY_ALLOW_ENV_SECRETS_ENV}=true to allow provider '${ENV_SECRET_PROVIDER_ID}'.`, | ||
| 600 | ), | ||
| 601 | ); | ||
| 602 | } | ||
| 603 | |||
| 604 | const value = ensureNonEmptyString(runtime.env[ref.handle]); | ||
| 605 | if (value === null) { | ||
| 606 | return Effect.fail(runtimeEffectError("local/secret-material-providers", `Environment variable ${ref.handle} is not set`)); | ||
| 607 | } | ||
| 608 | |||
| 609 | return Effect.succeed(value); | ||
| 610 | }, | ||
| 611 | |||
| 612 | remove: () => Effect.succeed(false), | ||
| 613 | }); | ||
| 614 | |||
| 615 | const createLocalSecretMaterialProvider = (): SecretMaterialProvider => ({ | ||
| 616 | resolve: ({ ref, runtime }) => | ||
| 617 | Effect.gen(function* () { | ||
| 618 | const stored = yield* loadStoredSecretMaterial({ | ||
| 619 | id: ref.handle, | ||
| 620 | runtime, | ||
| 621 | operation: "local.get", | ||
| 622 | }); | ||
| 623 | if (stored.providerId !== LOCAL_SECRET_PROVIDER_ID) { | ||
| 624 | return yield* runtimeEffectError("local/secret-material-providers", | ||
| 625 | `local.get: secret ${stored.id} is stored in provider '${stored.providerId}', not '${LOCAL_SECRET_PROVIDER_ID}'`, | ||
| 626 | ); | ||
| 627 | } | 116 | } |
| 628 | if (stored.value === null) { | ||
| 629 | return yield* runtimeEffectError("local/secret-material-providers", `local.get: secret ${stored.id} does not have a local value`); | ||
| 630 | } | ||
| 631 | |||
| 632 | return stored.value; | ||
| 633 | }), | ||
| 634 | |||
| 635 | store: ({ purpose, value, name, runtime }) => | ||
| 636 | createSecretMaterialMetadata({ | ||
| 637 | providerId: LOCAL_SECRET_PROVIDER_ID, | ||
| 638 | providerHandle: `local:${randomUUID()}`, | ||
| 639 | purpose, | ||
| 640 | value, | ||
| 641 | name, | ||
| 642 | runtime, | ||
| 643 | }), | ||
| 644 | |||
| 645 | update: ({ ref, name, value, runtime }) => | ||
| 646 | Effect.gen(function* () { | ||
| 647 | const stored = yield* loadStoredSecretMaterial({ | ||
| 648 | id: ref.handle, | ||
| 649 | runtime, | ||
| 650 | operation: "local.update", | ||
| 651 | }); | ||
| 652 | if (stored.providerId !== LOCAL_SECRET_PROVIDER_ID) { | ||
| 653 | return yield* runtimeEffectError("local/secret-material-providers", | ||
| 654 | `local.update: secret ${stored.id} is stored in provider '${stored.providerId}', not '${LOCAL_SECRET_PROVIDER_ID}'`, | ||
| 655 | ); | ||
| 656 | } | ||
| 657 | |||
| 658 | if (name === undefined && value === undefined) { | ||
| 659 | return toSecretMaterialSummary(stored); | ||
| 660 | } | ||
| 661 | |||
| 662 | const updated = yield* runtime.executorState.secretMaterials.updateById( | ||
| 663 | stored.id, | ||
| 664 | { | ||
| 665 | ...(name !== undefined ? { name } : {}), | ||
| 666 | ...(value !== undefined ? { value } : {}), | ||
| 667 | }, | ||
| 668 | ); | ||
| 669 | if (Option.isNone(updated)) { | ||
| 670 | return yield* runtimeEffectError("local/secret-material-providers", `local.update: secret material not found: ${stored.id}`); | ||
| 671 | } | ||
| 672 | |||
| 673 | return { | ||
| 674 | id: updated.value.id, | ||
| 675 | providerId: updated.value.providerId, | ||
| 676 | name: updated.value.name, | ||
| 677 | purpose: updated.value.purpose, | ||
| 678 | createdAt: updated.value.createdAt, | ||
| 679 | updatedAt: updated.value.updatedAt, | ||
| 680 | } satisfies SecretMaterialSummary; | ||
| 681 | }), | ||
| 682 | |||
| 683 | remove: ({ ref, runtime }) => | ||
| 684 | Effect.gen(function* () { | ||
| 685 | const materialId = SecretMaterialIdSchema.make(ref.handle); | ||
| 686 | return yield* runtime.executorState.secretMaterials.removeById(materialId); | ||
| 687 | }), | ||
| 688 | }); | ||
| 689 | |||
| 690 | const createKeychainSecretMaterialProvider = (): SecretMaterialProvider => ({ | ||
| 691 | resolve: ({ ref, runtime }) => | ||
| 692 | Effect.gen(function* () { | ||
| 693 | const loaded = yield* loadManagedKeychainRef({ | ||
| 694 | ref, | ||
| 695 | runtime, | ||
| 696 | operation: "keychain.get", | ||
| 697 | }); | ||
| 698 | |||
| 699 | return yield* readKeychainSecretValue({ | ||
| 700 | providerHandle: loaded.providerHandle, | ||
| 701 | runtime, | ||
| 702 | }); | ||
| 703 | }), | ||
| 704 | |||
| 705 | store: ({ purpose, value, name, runtime }) => | ||
| 706 | Effect.gen(function* () { | ||
| 707 | const providerHandle = randomUUID(); | ||
| 708 | yield* writeKeychainSecretValue({ | ||
| 709 | providerHandle, | ||
| 710 | name, | ||
| 711 | value, | ||
| 712 | runtime, | ||
| 713 | }); | ||
| 714 | |||
| 715 | return yield* createSecretMaterialMetadata({ | ||
| 716 | providerId: KEYCHAIN_SECRET_PROVIDER_ID, | ||
| 717 | providerHandle, | ||
| 718 | purpose, | ||
| 719 | value: null, | ||
| 720 | name, | ||
| 721 | runtime, | ||
| 722 | }); | ||
| 723 | }), | ||
| 724 | |||
| 725 | update: ({ ref, name, value, runtime }) => | ||
| 726 | Effect.gen(function* () { | ||
| 727 | const loaded = yield* loadManagedKeychainRef({ | ||
| 728 | ref, | ||
| 729 | runtime, | ||
| 730 | operation: "keychain.update", | ||
| 731 | }); | ||
| 732 | |||
| 733 | if (name === undefined && value === undefined) { | ||
| 734 | return toSecretMaterialSummary(loaded.material); | ||
| 735 | } | ||
| 736 | |||
| 737 | const nextName = name ?? loaded.material.name; | ||
| 738 | const nextValue = value | ||
| 739 | ?? (yield* readKeychainSecretValue({ | ||
| 740 | providerHandle: loaded.providerHandle, | ||
| 741 | runtime, | ||
| 742 | })); | ||
| 743 | |||
| 744 | yield* writeKeychainSecretValue({ | ||
| 745 | providerHandle: loaded.providerHandle, | ||
| 746 | name: nextName, | ||
| 747 | value: nextValue, | ||
| 748 | runtime, | ||
| 749 | }); | ||
| 750 | |||
| 751 | const updated = yield* runtime.executorState.secretMaterials.updateById( | ||
| 752 | loaded.material.id, | ||
| 753 | { | ||
| 754 | name: nextName, | ||
| 755 | }, | ||
| 756 | ); | ||
| 757 | if (Option.isNone(updated)) { | ||
| 758 | return yield* runtimeEffectError("local/secret-material-providers", `keychain.update: secret material not found: ${loaded.material.id}`); | ||
| 759 | } | ||
| 760 | |||
| 761 | return { | ||
| 762 | id: updated.value.id, | ||
| 763 | providerId: updated.value.providerId, | ||
| 764 | name: updated.value.name, | ||
| 765 | purpose: updated.value.purpose, | ||
| 766 | createdAt: updated.value.createdAt, | ||
| 767 | updatedAt: updated.value.updatedAt, | ||
| 768 | } satisfies SecretMaterialSummary; | ||
| 769 | }), | ||
| 770 | 117 | ||
| 771 | remove: ({ ref, runtime }) => | 118 | yield* input.executorState.secretStores.upsert({ |
| 772 | Effect.gen(function* () { | 119 | id: entry.builtin.storeId as SecretStoreId, |
| 773 | const loaded = yield* loadManagedKeychainRef({ | 120 | scopeId: input.scopeId as SecretStore["scopeId"], |
| 774 | ref, | 121 | ...entry.builtin.createStore(), |
| 775 | runtime, | 122 | createdAt: now, |
| 776 | operation: "keychain.delete", | 123 | updatedAt: now, |
| 777 | }); | ||
| 778 | const deleted = yield* deleteKeychainSecretValue({ | ||
| 779 | providerHandle: loaded.providerHandle, | ||
| 780 | runtime, | ||
| 781 | }); | 124 | }); |
| 782 | |||
| 783 | if (!deleted) { | ||
| 784 | return false; | ||
| 785 | } | ||
| 786 | |||
| 787 | return yield* runtime.executorState.secretMaterials.removeById(loaded.material.id); | ||
| 788 | }), | ||
| 789 | }); | ||
| 790 | |||
| 791 | const createSecretMaterialProviderRegistry = (): SecretMaterialProviderRegistry => | ||
| 792 | new Map([ | ||
| 793 | [PARAMS_SECRET_PROVIDER_ID, createParamsSecretMaterialProvider()], | ||
| 794 | [ENV_SECRET_PROVIDER_ID, createEnvSecretMaterialProvider()], | ||
| 795 | [KEYCHAIN_SECRET_PROVIDER_ID, createKeychainSecretMaterialProvider()], | ||
| 796 | [LOCAL_SECRET_PROVIDER_ID, createLocalSecretMaterialProvider()], | ||
| 797 | ]); | ||
| 798 | |||
| 799 | const getSecretMaterialProvider = (input: { | ||
| 800 | providers: SecretMaterialProviderRegistry; | ||
| 801 | providerId: string; | ||
| 802 | }): Effect.Effect<SecretMaterialProvider, Error, never> => { | ||
| 803 | const provider = input.providers.get(input.providerId); | ||
| 804 | if (provider) { | ||
| 805 | return Effect.succeed(provider); | ||
| 806 | } | ||
| 807 | |||
| 808 | return Effect.fail(runtimeEffectError("local/secret-material-providers", `Unsupported secret provider: ${input.providerId}`)); | ||
| 809 | }; | ||
| 810 | |||
| 811 | const createSecretMaterialProviderRuntime = (input: { | ||
| 812 | executorState: ExecutorStateStoreShape; | ||
| 813 | dangerouslyAllowEnvSecrets?: boolean; | ||
| 814 | keychainServiceName?: string; | ||
| 815 | localConfig?: LocalExecutorConfig | null; | ||
| 816 | workspaceRoot?: string | null; | ||
| 817 | }): SecretMaterialProviderRuntime => ({ | ||
| 818 | executorState: input.executorState, | ||
| 819 | env: process.env, | ||
| 820 | dangerouslyAllowEnvSecrets: resolveDangerouslyAllowEnvSecrets(input.dangerouslyAllowEnvSecrets), | ||
| 821 | keychainServiceName: resolveKeychainServiceName(input.keychainServiceName), | ||
| 822 | localConfig: input.localConfig ?? null, | ||
| 823 | workspaceRoot: input.workspaceRoot ?? null, | ||
| 824 | }); | ||
| 825 | |||
| 826 | const isRegularFilePath = ( | ||
| 827 | path: string, | ||
| 828 | allowSymlink: boolean, | ||
| 829 | ): Effect.Effect<boolean, Error, FileSystem.FileSystem> => | ||
| 830 | Effect.gen(function* () { | ||
| 831 | const fs = yield* FileSystem.FileSystem; | ||
| 832 | const stat = yield* fs.stat(path).pipe(Effect.mapError(toError)); | ||
| 833 | if (stat.type === "SymbolicLink") { | ||
| 834 | if (!allowSymlink) { | ||
| 835 | return false; | ||
| 836 | } | ||
| 837 | |||
| 838 | const resolvedPath = yield* fs.realPath(path).pipe(Effect.mapError(toError)); | ||
| 839 | const targetStat = yield* fs.stat(resolvedPath).pipe(Effect.mapError(toError)); | ||
| 840 | return targetStat.type === "File"; | ||
| 841 | } | 125 | } |
| 842 | |||
| 843 | return stat.type === "File"; | ||
| 844 | }); | 126 | }); |
| 845 | 127 | ||
| 846 | const ensureTrustedDir = ( | 128 | const loadMaterialByRef = (input: { |
| 847 | path: string, | 129 | executorState: ExecutorStateStoreShape; |
| 848 | trustedDirs: readonly string[] | undefined, | ||
| 849 | ): Effect.Effect<boolean, Error, FileSystem.FileSystem> => | ||
| 850 | Effect.gen(function* () { | ||
| 851 | if (!trustedDirs || trustedDirs.length === 0) { | ||
| 852 | return true; | ||
| 853 | } | ||
| 854 | |||
| 855 | const fs = yield* FileSystem.FileSystem; | ||
| 856 | const real = yield* fs.realPath(path).pipe(Effect.mapError(toError)); | ||
| 857 | |||
| 858 | for (const dir of trustedDirs) { | ||
| 859 | const trusted = yield* fs.realPath(dir).pipe(Effect.mapError(toError)); | ||
| 860 | if (real === trusted || real.startsWith(`${trusted}/`)) { | ||
| 861 | return true; | ||
| 862 | } | ||
| 863 | } | ||
| 864 | |||
| 865 | return false; | ||
| 866 | }); | ||
| 867 | |||
| 868 | const readFileSecretValue = (input: { | ||
| 869 | provider: Extract<LocalConfigSecretProvider, { source: "file" }>; | ||
| 870 | ref: SecretRef; | 130 | ref: SecretRef; |
| 871 | workspaceRoot: string; | 131 | }) => |
| 872 | }): Effect.Effect<string, Error, FileSystem.FileSystem> => | ||
| 873 | Effect.gen(function* () { | 132 | Effect.gen(function* () { |
| 874 | const fs = yield* FileSystem.FileSystem; | 133 | const material = yield* input.executorState.secretMaterials.getById( |
| 875 | const resolvedPath = resolveConfigRelativePath({ | 134 | input.ref.secretId, |
| 876 | path: input.provider.path, | ||
| 877 | scopeRoot: input.workspaceRoot, | ||
| 878 | }); | ||
| 879 | const raw = yield* fs.readFileString(resolvedPath, "utf8").pipe( | ||
| 880 | Effect.mapError(toError), | ||
| 881 | ); | 135 | ); |
| 882 | const mode = input.provider.mode ?? "singleValue"; | 136 | if (Option.isNone(material)) { |
| 883 | if (mode === "singleValue") { | ||
| 884 | return raw.trim(); | ||
| 885 | } | ||
| 886 | |||
| 887 | return yield* Effect.try({ | ||
| 888 | try: () => { | ||
| 889 | const parsed = JSON.parse(raw) as unknown; | ||
| 890 | if (input.ref.handle.startsWith("/")) { | ||
| 891 | const segments = input.ref.handle | ||
| 892 | .split("/") | ||
| 893 | .slice(1) | ||
| 894 | .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~")); | ||
| 895 | let current: unknown = parsed; | ||
| 896 | for (const segment of segments) { | ||
| 897 | if (typeof current !== "object" || current === null || !(segment in current)) { | ||
| 898 | throw new Error(`Secret path not found in ${resolvedPath}: ${input.ref.handle}`); | ||
| 899 | } | ||
| 900 | current = (current as Record<string, unknown>)[segment]; | ||
| 901 | } | ||
| 902 | if (typeof current !== "string" || current.trim().length === 0) { | ||
| 903 | throw new Error(`Secret path did not resolve to a string: ${input.ref.handle}`); | ||
| 904 | } | ||
| 905 | return current; | ||
| 906 | } | ||
| 907 | |||
| 908 | if (typeof parsed !== "object" || parsed === null) { | ||
| 909 | throw new Error(`JSON secret provider must resolve to an object: ${resolvedPath}`); | ||
| 910 | } | ||
| 911 | const value = (parsed as Record<string, unknown>)[input.ref.handle]; | ||
| 912 | if (typeof value !== "string" || value.trim().length === 0) { | ||
| 913 | throw new Error(`Secret key not found in ${resolvedPath}: ${input.ref.handle}`); | ||
| 914 | } | ||
| 915 | return value; | ||
| 916 | }, | ||
| 917 | catch: toError, | ||
| 918 | }); | ||
| 919 | }); | ||
| 920 | |||
| 921 | const resolveConfiguredSecretProvider = (input: { | ||
| 922 | ref: SecretRef; | ||
| 923 | runtime: SecretMaterialProviderRuntime; | ||
| 924 | }): Effect.Effect<string, Error, FileSystem.FileSystem> => | ||
| 925 | Effect.gen(function* () { | ||
| 926 | const providerAlias = fromConfigSecretProviderId(input.ref.providerId); | ||
| 927 | if (providerAlias === null) { | ||
| 928 | return yield* runtimeEffectError( | 137 | return yield* runtimeEffectError( |
| 929 | "local/secret-material-providers", | 138 | "local/secret-material-providers", |
| 930 | `Unsupported secret provider: ${input.ref.providerId}`, | 139 | `Secret not found: ${input.ref.secretId}`, |
| 931 | ); | 140 | ); |
| 932 | } | 141 | } |
| 933 | 142 | ||
| 934 | const provider = input.runtime.localConfig?.secrets?.providers?.[providerAlias]; | 143 | return material.value; |
| 935 | if (!provider) { | 144 | }); |
| 936 | return yield* runtimeEffectError( | ||
| 937 | "local/secret-material-providers", | ||
| 938 | `Config secret provider "${providerAlias}" is not configured`, | ||
| 939 | ); | ||
| 940 | } | ||
| 941 | if (input.runtime.workspaceRoot === null) { | ||
| 942 | return yield* runtimeEffectError( | ||
| 943 | "local/secret-material-providers", | ||
| 944 | `Config secret provider "${providerAlias}" requires a workspace root`, | ||
| 945 | ); | ||
| 946 | } | ||
| 947 | |||
| 948 | if (provider.source === "env") { | ||
| 949 | const value = ensureNonEmptyString(input.runtime.env[input.ref.handle]); | ||
| 950 | if (value === null) { | ||
| 951 | return yield* runtimeEffectError( | ||
| 952 | "local/secret-material-providers", | ||
| 953 | `Environment variable ${input.ref.handle} is not set`, | ||
| 954 | ); | ||
| 955 | } | ||
| 956 | return value; | ||
| 957 | } | ||
| 958 | |||
| 959 | if (provider.source === "file") { | ||
| 960 | return yield* readFileSecretValue({ | ||
| 961 | provider, | ||
| 962 | ref: input.ref, | ||
| 963 | workspaceRoot: input.runtime.workspaceRoot, | ||
| 964 | }); | ||
| 965 | } | ||
| 966 | 145 | ||
| 967 | const command = provider.command.trim(); | 146 | const loadStoreById = (input: { |
| 968 | if (!isAbsolute(command)) { | 147 | executorState: ExecutorStateStoreShape; |
| 969 | return yield* runtimeEffectError( | 148 | storeId: SecretStore["id"]; |
| 970 | "local/secret-material-providers", | 149 | }) => |
| 971 | `Exec secret provider command must be absolute: ${command}`, | 150 | Effect.gen(function* () { |
| 972 | ); | 151 | const store = yield* input.executorState.secretStores.getById(input.storeId); |
| 973 | } | 152 | if (Option.isNone(store)) { |
| 974 | const isRegularCommand = yield* isRegularFilePath( | ||
| 975 | command, | ||
| 976 | provider.allowSymlinkCommand ?? false, | ||
| 977 | ); | ||
| 978 | if (!isRegularCommand) { | ||
| 979 | return yield* runtimeEffectError( | ||
| 980 | "local/secret-material-providers", | ||
| 981 | `Exec secret provider command is not an allowed regular file: ${command}`, | ||
| 982 | ); | ||
| 983 | } | ||
| 984 | const isTrustedCommand = yield* ensureTrustedDir(command, provider.trustedDirs); | ||
| 985 | if (!isTrustedCommand) { | ||
| 986 | return yield* runtimeEffectError( | 153 | return yield* runtimeEffectError( |
| 987 | "local/secret-material-providers", | 154 | "local/secret-material-providers", |
| 988 | `Exec secret provider command is outside trustedDirs: ${command}`, | 155 | `Secret store not found: ${input.storeId}`, |
| 989 | ); | 156 | ); |
| 990 | } | 157 | } |
| 991 | 158 | ||
| 992 | return yield* runCommand({ | 159 | return store.value; |
| 993 | command, | ||
| 994 | args: [...(provider.args ?? []), input.ref.handle], | ||
| 995 | env: { | ||
| 996 | ...input.runtime.env, | ||
| 997 | ...provider.env, | ||
| 998 | }, | ||
| 999 | operation: `config-secret.get:${providerAlias}`, | ||
| 1000 | }).pipe( | ||
| 1001 | Effect.flatMap((result) => | ||
| 1002 | ensureCommandSuccess({ | ||
| 1003 | result, | ||
| 1004 | operation: `config-secret.get:${providerAlias}`, | ||
| 1005 | message: "Failed resolving configured exec secret", | ||
| 1006 | }), | ||
| 1007 | ), | ||
| 1008 | Effect.map((result) => result.stdout.trimEnd()), | ||
| 1009 | ); | ||
| 1010 | }); | 160 | }); |
| 1011 | 161 | ||
| 162 | const provideSecretResolver = <A, E>( | ||
| 163 | effect: Effect.Effect<A, E, any>, | ||
| 164 | resolveSecretMaterial: ResolveSecretMaterial, | ||
| 165 | ): Effect.Effect<A, E, never> => | ||
| 166 | effect.pipe( | ||
| 167 | Effect.provideService(SecretMaterialResolverService, resolveSecretMaterial), | ||
| 168 | ) as Effect.Effect<A, E, never>; | ||
| 169 | |||
| 1012 | export const createDefaultSecretMaterialResolver = (input: { | 170 | export const createDefaultSecretMaterialResolver = (input: { |
| 1013 | executorState: ExecutorStateStoreShape; | 171 | executorState: ExecutorStateStoreShape; |
| 1014 | dangerouslyAllowEnvSecrets?: boolean; | ||
| 1015 | keychainServiceName?: string; | 172 | keychainServiceName?: string; |
| 1016 | localConfig?: LocalExecutorConfig | null; | ||
| 1017 | workspaceRoot?: string | null; | ||
| 1018 | }): ResolveSecretMaterial => { | 173 | }): ResolveSecretMaterial => { |
| 1019 | const providers = createSecretMaterialProviderRegistry(); | 174 | let resolveSecretMaterial!: ResolveSecretMaterial; |
| 1020 | const runtime = createSecretMaterialProviderRuntime(input); | 175 | resolveSecretMaterial = ({ ref, context }) => |
| 1021 | |||
| 1022 | return ({ ref, context }) => | ||
| 1023 | Effect.gen(function* () { | 176 | Effect.gen(function* () { |
| 1024 | const provider = yield* getSecretMaterialProvider({ | 177 | const material = yield* loadMaterialByRef({ |
| 1025 | providers, | 178 | executorState: input.executorState, |
| 1026 | providerId: ref.providerId, | ||
| 1027 | }).pipe( | ||
| 1028 | Effect.catchAll(() => | ||
| 1029 | fromConfigSecretProviderId(ref.providerId) !== null | ||
| 1030 | ? Effect.succeed(null) | ||
| 1031 | : Effect.fail(runtimeEffectError("local/secret-material-providers", `Unsupported secret provider: ${ref.providerId}`)), | ||
| 1032 | ), | ||
| 1033 | ); | ||
| 1034 | |||
| 1035 | if (provider === null) { | ||
| 1036 | return yield* resolveConfiguredSecretProvider({ | ||
| 1037 | ref, | ||
| 1038 | runtime, | ||
| 1039 | }); | ||
| 1040 | } | ||
| 1041 | |||
| 1042 | return yield* provider.resolve({ | ||
| 1043 | ref, | 179 | ref, |
| 1044 | context: context ?? {}, | ||
| 1045 | runtime, | ||
| 1046 | }); | 180 | }); |
| 1047 | }).pipe(Effect.provide(NodeFileSystem.layer)); | 181 | const store = yield* loadStoreById({ |
| 182 | executorState: input.executorState, | ||
| 183 | storeId: material.storeId, | ||
| 184 | }); | ||
| 185 | const contribution = resolveStoreContribution(store); | ||
| 186 | return yield* provideSecretResolver( | ||
| 187 | contribution.resolveSecret({ | ||
| 188 | secret: material, | ||
| 189 | store, | ||
| 190 | context, | ||
| 191 | }), | ||
| 192 | resolveSecretMaterial, | ||
| 193 | ); | ||
| 194 | }); | ||
| 195 | |||
| 196 | return resolveSecretMaterial; | ||
| 1048 | }; | 197 | }; |
| 1049 | 198 | ||
| 1050 | export const createDefaultSecretMaterialStorer = (input: { | 199 | export const createDefaultSecretMaterialStorer = (input: { |
| 1051 | executorState: ExecutorStateStoreShape; | 200 | executorState: ExecutorStateStoreShape; |
| 1052 | dangerouslyAllowEnvSecrets?: boolean; | 201 | resolveSecretMaterial: ResolveSecretMaterial; |
| 1053 | keychainServiceName?: string; | 202 | keychainServiceName?: string; |
| 1054 | }): StoreSecretMaterial => { | 203 | }): StoreSecretMaterial => { |
| 1055 | const providers = createSecretMaterialProviderRegistry(); | 204 | return ({ purpose, value, name, storeId }) => |
| 1056 | const runtime = createSecretMaterialProviderRuntime(input); | ||
| 1057 | |||
| 1058 | return ({ purpose, value, name, providerId }) => | ||
| 1059 | Effect.gen(function* () { | 205 | Effect.gen(function* () { |
| 1060 | const defaultStoreProviderId = yield* resolveDefaultSecretStoreProviderId({ | 206 | const stores = yield* input.executorState.secretStores.listAll(); |
| 1061 | storeProviderId: parseSecretStoreProviderId(providerId) ?? undefined, | 207 | const resolvedStoreId = storeId ?? resolveDefaultStoreId(stores); |
| 1062 | env: runtime.env, | 208 | if (resolvedStoreId === null) { |
| 1063 | }); | 209 | return yield* runtimeEffectError( |
| 1064 | const provider = yield* getSecretMaterialProvider({ | 210 | "local/secret-material-providers", |
| 1065 | providers, | 211 | "No secret stores are registered in this runtime.", |
| 1066 | providerId: defaultStoreProviderId, | 212 | ); |
| 213 | } | ||
| 214 | |||
| 215 | const store = yield* loadStoreById({ | ||
| 216 | executorState: input.executorState, | ||
| 217 | storeId: resolvedStoreId as SecretStore["id"], | ||
| 1067 | }); | 218 | }); |
| 219 | const contribution = resolveStoreContribution(store); | ||
| 1068 | 220 | ||
| 1069 | if (!provider.store) { | 221 | const capabilities = yield* loadStoreCapabilities(store); |
| 1070 | return yield* runtimeEffectError("local/secret-material-providers", `Secret provider ${defaultStoreProviderId} does not support storing secret material`); | 222 | if (!capabilities.canCreateSecrets) { |
| 223 | return yield* runtimeEffectError( | ||
| 224 | "local/secret-material-providers", | ||
| 225 | `Secret store ${store.id} does not support creating secrets`, | ||
| 226 | ); | ||
| 1071 | } | 227 | } |
| 1072 | 228 | ||
| 1073 | return yield* provider.store({ | 229 | const created = yield* provideSecretResolver( |
| 230 | contribution.createSecret({ | ||
| 231 | store, | ||
| 232 | purpose, | ||
| 233 | value, | ||
| 234 | name, | ||
| 235 | }), | ||
| 236 | input.resolveSecretMaterial, | ||
| 237 | ); | ||
| 238 | |||
| 239 | const now = Date.now(); | ||
| 240 | const material: SecretMaterial = { | ||
| 241 | id: `sec_${randomUUID()}` as SecretMaterial["id"], | ||
| 242 | storeId: store.id, | ||
| 243 | handle: created.handle, | ||
| 244 | name: created.name, | ||
| 1074 | purpose, | 245 | purpose, |
| 1075 | value, | 246 | value: created.value ?? null, |
| 1076 | name, | 247 | createdAt: now, |
| 1077 | runtime, | 248 | updatedAt: now, |
| 1078 | }); | 249 | }; |
| 250 | |||
| 251 | yield* input.executorState.secretMaterials.upsert(material); | ||
| 252 | |||
| 253 | return { | ||
| 254 | secretId: material.id, | ||
| 255 | } satisfies SecretRef; | ||
| 1079 | }); | 256 | }); |
| 1080 | }; | 257 | }; |
| 1081 | 258 | ||
| 1082 | export const createDefaultSecretMaterialUpdater = (input: { | 259 | export const createDefaultSecretMaterialUpdater = (input: { |
| 1083 | executorState: ExecutorStateStoreShape; | 260 | executorState: ExecutorStateStoreShape; |
| 1084 | dangerouslyAllowEnvSecrets?: boolean; | 261 | resolveSecretMaterial: ResolveSecretMaterial; |
| 1085 | keychainServiceName?: string; | 262 | keychainServiceName?: string; |
| 1086 | }): UpdateSecretMaterial => { | 263 | }): UpdateSecretMaterial => { |
| 1087 | const providers = createSecretMaterialProviderRegistry(); | ||
| 1088 | const runtime = createSecretMaterialProviderRuntime(input); | ||
| 1089 | |||
| 1090 | return ({ ref, name, value }) => | 264 | return ({ ref, name, value }) => |
| 1091 | Effect.gen(function* () { | 265 | Effect.gen(function* () { |
| 1092 | const provider = yield* getSecretMaterialProvider({ | 266 | const material = yield* loadMaterialByRef({ |
| 1093 | providers, | 267 | executorState: input.executorState, |
| 1094 | providerId: ref.providerId, | 268 | ref, |
| 1095 | }); | 269 | }); |
| 270 | const store = yield* loadStoreById({ | ||
| 271 | executorState: input.executorState, | ||
| 272 | storeId: material.storeId, | ||
| 273 | }); | ||
| 274 | const contribution = resolveStoreContribution(store); | ||
| 1096 | 275 | ||
| 1097 | if (!provider.update) { | 276 | const capabilities = yield* loadStoreCapabilities(store); |
| 1098 | return yield* runtimeEffectError("local/secret-material-providers", `Secret provider ${ref.providerId} does not support updating secret material`); | 277 | if (!capabilities.canUpdateSecrets) { |
| 278 | return yield* runtimeEffectError( | ||
| 279 | "local/secret-material-providers", | ||
| 280 | `Secret store ${store.id} does not support updating secrets`, | ||
| 281 | ); | ||
| 1099 | } | 282 | } |
| 1100 | 283 | ||
| 1101 | return yield* provider.update({ | 284 | const updated = yield* provideSecretResolver( |
| 1102 | ref, | 285 | contribution.updateSecret({ |
| 1103 | name, | 286 | secret: material, |
| 1104 | value, | 287 | store, |
| 1105 | runtime, | 288 | name, |
| 1106 | }); | 289 | value, |
| 290 | }), | ||
| 291 | input.resolveSecretMaterial, | ||
| 292 | ); | ||
| 293 | |||
| 294 | const nextMaterial: SecretMaterial = { | ||
| 295 | ...material, | ||
| 296 | ...(updated.handle ? { handle: updated.handle } : {}), | ||
| 297 | name: updated.name, | ||
| 298 | value: updated.value ?? material.value, | ||
| 299 | updatedAt: Date.now(), | ||
| 300 | }; | ||
| 301 | yield* input.executorState.secretMaterials.upsert(nextMaterial); | ||
| 302 | |||
| 303 | return { | ||
| 304 | id: nextMaterial.id, | ||
| 305 | storeId: nextMaterial.storeId, | ||
| 306 | name: nextMaterial.name, | ||
| 307 | purpose: nextMaterial.purpose, | ||
| 308 | createdAt: nextMaterial.createdAt, | ||
| 309 | updatedAt: nextMaterial.updatedAt, | ||
| 310 | }; | ||
| 1107 | }); | 311 | }); |
| 1108 | }; | 312 | }; |
| 1109 | 313 | ||
| 1110 | export const createDefaultSecretMaterialDeleter = (input: { | 314 | export const createDefaultSecretMaterialDeleter = (input: { |
| 1111 | executorState: ExecutorStateStoreShape; | 315 | executorState: ExecutorStateStoreShape; |
| 1112 | dangerouslyAllowEnvSecrets?: boolean; | 316 | resolveSecretMaterial: ResolveSecretMaterial; |
| 1113 | keychainServiceName?: string; | 317 | keychainServiceName?: string; |
| 1114 | }): DeleteSecretMaterial => { | 318 | }): DeleteSecretMaterial => { |
| 1115 | const providers = createSecretMaterialProviderRegistry(); | ||
| 1116 | const runtime = createSecretMaterialProviderRuntime(input); | ||
| 1117 | |||
| 1118 | return (ref) => | 319 | return (ref) => |
| 1119 | Effect.gen(function* () { | 320 | Effect.gen(function* () { |
| 1120 | const provider = yield* getSecretMaterialProvider({ | 321 | const material = yield* loadMaterialByRef({ |
| 1121 | providers, | 322 | executorState: input.executorState, |
| 1122 | providerId: ref.providerId, | 323 | ref, |
| 1123 | }); | 324 | }); |
| 325 | const store = yield* loadStoreById({ | ||
| 326 | executorState: input.executorState, | ||
| 327 | storeId: material.storeId, | ||
| 328 | }); | ||
| 329 | const contribution = resolveStoreContribution(store); | ||
| 330 | const capabilities = yield* loadStoreCapabilities(store); | ||
| 331 | const deleted = capabilities.canDeleteSecrets | ||
| 332 | ? yield* provideSecretResolver( | ||
| 333 | contribution.deleteSecret({ | ||
| 334 | secret: material, | ||
| 335 | store, | ||
| 336 | }), | ||
| 337 | input.resolveSecretMaterial, | ||
| 338 | ) | ||
| 339 | : false; | ||
| 1124 | 340 | ||
| 1125 | if (!provider.remove) { | 341 | if (!deleted) { |
| 1126 | return false; | 342 | return false; |
| 1127 | } | 343 | } |
| 1128 | 344 | ||
| 1129 | return yield* provider.remove({ | 345 | return yield* input.executorState.secretMaterials.removeById(material.id); |
| 1130 | ref, | ||
| 1131 | runtime, | ||
| 1132 | }); | ||
| 1133 | }); | 346 | }); |
| 1134 | }; | 347 | }; |
| 1135 | 348 | ||
| 1136 | const resolveRuntimeSecretMaterialConfig = (input: { | 349 | const listAvailableSecretStorePlugins = (): SecretProvider[] => { |
| 1137 | localConfig?: LocalExecutorConfig | null; | 350 | return registeredSecretStoreContributions().map((entry) => ({ |
| 1138 | workspaceRoot?: string | null; | 351 | kind: entry.kind, |
| 1139 | }) => | 352 | displayName: entry.displayName, |
| 1140 | Effect.gen(function* () { | 353 | canCreate: entry.canCreate, |
| 1141 | const runtimeLocalScope = yield* getRuntimeLocalScopeOption(); | 354 | })); |
| 355 | }; | ||
| 1142 | 356 | ||
| 357 | export const createLocalInstanceConfigResolver = (input: { | ||
| 358 | executorState: ExecutorStateStoreShape; | ||
| 359 | }): ResolveInstanceConfig => () => | ||
| 360 | Effect.gen(function* () { | ||
| 361 | const stores = yield* input.executorState.secretStores.listAll(); | ||
| 1143 | return { | 362 | return { |
| 1144 | localConfig: input.localConfig | 363 | platform: process.platform, |
| 1145 | ?? runtimeLocalScope?.loadedConfig.config | 364 | secretStorePlugins: listAvailableSecretStorePlugins(), |
| 1146 | ?? null, | 365 | defaultSecretStoreId: resolveDefaultStoreId(stores), |
| 1147 | workspaceRoot: input.workspaceRoot | 366 | } satisfies InstanceConfig; |
| 1148 | ?? runtimeLocalScope?.scope.scopeRoot | ||
| 1149 | ?? null, | ||
| 1150 | }; | ||
| 1151 | }); | 367 | }); |
| 1152 | 368 | ||
| 1153 | export const SecretMaterialResolverLive = (input: { | 369 | export const SecretMaterialResolverLive = (input: { |
| 370 | executorState: ExecutorStateStoreShape; | ||
| 1154 | resolveSecretMaterial?: ResolveSecretMaterial; | 371 | resolveSecretMaterial?: ResolveSecretMaterial; |
| 1155 | dangerouslyAllowEnvSecrets?: boolean; | ||
| 1156 | keychainServiceName?: string; | 372 | keychainServiceName?: string; |
| 1157 | localConfig?: LocalExecutorConfig | null; | 373 | }) => |
| 1158 | workspaceRoot?: string | null; | ||
| 1159 | } = {}) => | ||
| 1160 | input.resolveSecretMaterial | 374 | input.resolveSecretMaterial |
| 1161 | ? Layer.succeed(SecretMaterialResolverService, input.resolveSecretMaterial) | 375 | ? Layer.succeed(SecretMaterialResolverService, input.resolveSecretMaterial) |
| 1162 | : Layer.effect( | 376 | : Layer.succeed( |
| 1163 | SecretMaterialResolverService, | 377 | SecretMaterialResolverService, |
| 1164 | Effect.gen(function* () { | 378 | createDefaultSecretMaterialResolver(input), |
| 1165 | const executorState = yield* ExecutorStateStore; | ||
| 1166 | const runtimeConfig = yield* resolveRuntimeSecretMaterialConfig(input); | ||
| 1167 | |||
| 1168 | return createDefaultSecretMaterialResolver({ | ||
| 1169 | executorState, | ||
| 1170 | dangerouslyAllowEnvSecrets: input.dangerouslyAllowEnvSecrets, | ||
| 1171 | keychainServiceName: input.keychainServiceName, | ||
| 1172 | localConfig: runtimeConfig.localConfig, | ||
| 1173 | workspaceRoot: runtimeConfig.workspaceRoot, | ||
| 1174 | }); | ||
| 1175 | }), | ||
| 1176 | ); | 379 | ); |
| 1177 | 380 | ||
| 1178 | export const SecretMaterialStorerLive = (input: { | 381 | export const SecretMaterialStorerLive = (input: { |
| 1179 | dangerouslyAllowEnvSecrets?: boolean; | 382 | executorState: ExecutorStateStoreShape; |
| 383 | resolveSecretMaterial: ResolveSecretMaterial; | ||
| 1180 | keychainServiceName?: string; | 384 | keychainServiceName?: string; |
| 1181 | } = {}) => | 385 | }) => |
| 1182 | Layer.effect( | 386 | Layer.succeed( |
| 1183 | SecretMaterialStorerService, | 387 | SecretMaterialStorerService, |
| 1184 | Effect.gen(function* () { | 388 | createDefaultSecretMaterialStorer(input), |
| 1185 | const executorState = yield* ExecutorStateStore; | ||
| 1186 | |||
| 1187 | return createDefaultSecretMaterialStorer({ | ||
| 1188 | executorState, | ||
| 1189 | dangerouslyAllowEnvSecrets: input.dangerouslyAllowEnvSecrets, | ||
| 1190 | keychainServiceName: input.keychainServiceName, | ||
| 1191 | }); | ||
| 1192 | }), | ||
| 1193 | ); | 389 | ); |
| 1194 | 390 | ||
| 1195 | export const SecretMaterialDeleterLive = (input: { | 391 | export const SecretMaterialDeleterLive = (input: { |
| 1196 | dangerouslyAllowEnvSecrets?: boolean; | 392 | executorState: ExecutorStateStoreShape; |
| 393 | resolveSecretMaterial: ResolveSecretMaterial; | ||
| 1197 | keychainServiceName?: string; | 394 | keychainServiceName?: string; |
| 1198 | } = {}) => | 395 | }) => |
| 1199 | Layer.effect( | 396 | Layer.succeed( |
| 1200 | SecretMaterialDeleterService, | 397 | SecretMaterialDeleterService, |
| 1201 | Effect.gen(function* () { | 398 | createDefaultSecretMaterialDeleter(input), |
| 1202 | const executorState = yield* ExecutorStateStore; | ||
| 1203 | |||
| 1204 | return createDefaultSecretMaterialDeleter({ | ||
| 1205 | executorState, | ||
| 1206 | dangerouslyAllowEnvSecrets: input.dangerouslyAllowEnvSecrets, | ||
| 1207 | keychainServiceName: input.keychainServiceName, | ||
| 1208 | }); | ||
| 1209 | }), | ||
| 1210 | ); | 399 | ); |
| 1211 | 400 | ||
| 1212 | export const SecretMaterialUpdaterLive = (input: { | 401 | export const SecretMaterialUpdaterLive = (input: { |
| 1213 | dangerouslyAllowEnvSecrets?: boolean; | 402 | executorState: ExecutorStateStoreShape; |
| 403 | resolveSecretMaterial: ResolveSecretMaterial; | ||
| 1214 | keychainServiceName?: string; | 404 | keychainServiceName?: string; |
| 1215 | } = {}) => | 405 | }) => |
| 1216 | Layer.effect( | 406 | Layer.succeed( |
| 1217 | SecretMaterialUpdaterService, | 407 | SecretMaterialUpdaterService, |
| 1218 | Effect.gen(function* () { | 408 | createDefaultSecretMaterialUpdater(input), |
| 1219 | const executorState = yield* ExecutorStateStore; | ||
| 1220 | |||
| 1221 | return createDefaultSecretMaterialUpdater({ | ||
| 1222 | executorState, | ||
| 1223 | dangerouslyAllowEnvSecrets: input.dangerouslyAllowEnvSecrets, | ||
| 1224 | keychainServiceName: input.keychainServiceName, | ||
| 1225 | }); | ||
| 1226 | }), | ||
| 1227 | ); | 409 | ); |
| 1228 | 410 | ||
| 1229 | export const SecretMaterialLive = (input: { | 411 | export const SecretMaterialLive = (input: { |
| 412 | executorState: ExecutorStateStoreShape; | ||
| 1230 | resolveSecretMaterial?: ResolveSecretMaterial; | 413 | resolveSecretMaterial?: ResolveSecretMaterial; |
| 1231 | dangerouslyAllowEnvSecrets?: boolean; | ||
| 1232 | keychainServiceName?: string; | 414 | keychainServiceName?: string; |
| 1233 | localConfig?: LocalExecutorConfig | null; | 415 | }) => |
| 1234 | workspaceRoot?: string | null; | ||
| 1235 | } = {}) => | ||
| 1236 | Layer.mergeAll( | 416 | Layer.mergeAll( |
| 1237 | SecretMaterialResolverLive(input), | 417 | SecretMaterialResolverLive(input), |
| 1238 | SecretMaterialStorerLive(input), | 418 | SecretMaterialStorerLive({ |
| 1239 | SecretMaterialDeleterLive(input), | 419 | executorState: input.executorState, |
| 1240 | SecretMaterialUpdaterLive(input), | 420 | resolveSecretMaterial: |
| 1241 | ); | 421 | input.resolveSecretMaterial |
| 1242 | 422 | ?? createDefaultSecretMaterialResolver({ | |
| 1243 | export const createLocalInstanceConfigResolver = (): ResolveInstanceConfig => () => { | 423 | executorState: input.executorState, |
| 1244 | const explicitDefaultStoreProvider = parseSecretStoreProviderId( | 424 | keychainServiceName: input.keychainServiceName, |
| 1245 | process.env[SECRET_STORE_PROVIDER_ENV], | 425 | }), |
| 426 | keychainServiceName: input.keychainServiceName, | ||
| 427 | }), | ||
| 428 | SecretMaterialDeleterLive({ | ||
| 429 | executorState: input.executorState, | ||
| 430 | resolveSecretMaterial: | ||
| 431 | input.resolveSecretMaterial | ||
| 432 | ?? createDefaultSecretMaterialResolver({ | ||
| 433 | executorState: input.executorState, | ||
| 434 | keychainServiceName: input.keychainServiceName, | ||
| 435 | }), | ||
| 436 | keychainServiceName: input.keychainServiceName, | ||
| 437 | }), | ||
| 438 | SecretMaterialUpdaterLive({ | ||
| 439 | executorState: input.executorState, | ||
| 440 | resolveSecretMaterial: | ||
| 441 | input.resolveSecretMaterial | ||
| 442 | ?? createDefaultSecretMaterialResolver({ | ||
| 443 | executorState: input.executorState, | ||
| 444 | keychainServiceName: input.keychainServiceName, | ||
| 445 | }), | ||
| 446 | keychainServiceName: input.keychainServiceName, | ||
| 447 | }), | ||
| 1246 | ); | 448 | ); |
| 1247 | const providers = [ | ||
| 1248 | { | ||
| 1249 | id: LOCAL_SECRET_PROVIDER_ID, | ||
| 1250 | name: "Local store", | ||
| 1251 | canStore: true, | ||
| 1252 | }, | ||
| 1253 | ]; | ||
| 1254 | 449 | ||
| 1255 | if (process.platform === "darwin" || process.platform === "linux") { | 450 | export const LocalInstanceConfigLive = (input: { |
| 1256 | providers.push({ | 451 | executorState: ExecutorStateStoreShape; |
| 1257 | id: KEYCHAIN_SECRET_PROVIDER_ID, | 452 | }) => |
| 1258 | name: | 453 | Layer.succeed( |
| 1259 | process.platform === "darwin" ? "macOS Keychain" : "Desktop Keyring", | 454 | LocalInstanceConfigService, |
| 1260 | canStore: | 455 | createLocalInstanceConfigResolver(input), |
| 1261 | process.platform === "darwin" || | ||
| 1262 | explicitDefaultStoreProvider === KEYCHAIN_SECRET_PROVIDER_ID, | ||
| 1263 | }); | ||
| 1264 | } | ||
| 1265 | |||
| 1266 | providers.push({ | ||
| 1267 | id: ENV_SECRET_PROVIDER_ID, | ||
| 1268 | name: "Environment variable", | ||
| 1269 | canStore: false, | ||
| 1270 | }); | ||
| 1271 | |||
| 1272 | return resolveDefaultSecretStoreProviderId({ | ||
| 1273 | storeProviderId: explicitDefaultStoreProvider ?? undefined, | ||
| 1274 | }).pipe( | ||
| 1275 | Effect.map((resolvedDefaultStoreProvider) => ({ | ||
| 1276 | platform: process.platform, | ||
| 1277 | secretProviders: providers, | ||
| 1278 | defaultSecretStoreProvider: resolvedDefaultStoreProvider, | ||
| 1279 | })), | ||
| 1280 | ); | 456 | ); |
| 1281 | }; | ||
| 1282 | |||
| 1283 | export const LocalInstanceConfigLive = () => | ||
| 1284 | Layer.succeed(LocalInstanceConfigService, createLocalInstanceConfigResolver()); | ||
| @@ -168,6 +168,14 @@ const toSourceTypeDeclarationsBackend = ( · 167 unmodified lines | |||
| 168 | const toSecretsBackend = ( | 168 | const toSecretsBackend = ( |
| 169 | input: ExecutorSecretRepository, | 169 | input: ExecutorSecretRepository, |
| 170 | ): RuntimeSecretsStorageServices => ({ | 170 | ): RuntimeSecretsStorageServices => ({ |
| 171 | secretStores: { | ||
| 172 | getById: (id) => toOptionEffect(input.secretStores.getById(id)), | ||
| 173 | listAll: () => toEffect(input.secretStores.listAll()), | ||
| 174 | upsert: (store) => toEffect(input.secretStores.upsert(store)), | ||
| 175 | updateById: (id, patch) => | ||
| 176 | toOptionEffect(input.secretStores.updateById(id, patch)), | ||
| 177 | removeById: (id) => toEffect(input.secretStores.removeById(id)), | ||
| 178 | }, | ||
| 171 | getById: (id) => toOptionEffect(input.getById(id)), | 179 | getById: (id) => toOptionEffect(input.getById(id)), |
| 172 | listAll: () => toEffect(input.listAll()), | 180 | listAll: () => toEffect(input.listAll()), |
| 173 | upsert: (material) => toEffect(input.upsert(material)), | 181 | upsert: (material) => toEffect(input.upsert(material)), |
| @@ -4,6 +4,7 @@ import type { · 3 unmodified lines | |||
| 4 | ScopeId, | 4 | ScopeId, |
| 5 | Execution, | 5 | Execution, |
| 6 | LocalInstallation, | 6 | LocalInstallation, |
| 7 | SecretStore, | ||
| 7 | Source, | 8 | Source, |
| 8 | } from "./schema"; | 9 | } from "./schema"; |
| 9 | import type { | 10 | import type { |
| @@ -11,13 +12,28 @@ import type { · 1 unmodified line | |||
| 11 | ResumeExecutionPayload, | 12 | ResumeExecutionPayload, |
| 12 | } from "./executions/contracts"; | 13 | } from "./executions/contracts"; |
| 13 | import type { | 14 | import type { |
| 15 | BrowseSecretStorePayload, | ||
| 16 | BrowseSecretStoreResult, | ||
| 14 | CreateSecretPayload, | 17 | CreateSecretPayload, |
| 18 | CreateSecretStorePayload, | ||
| 19 | CreateSecretResult, | ||
| 15 | DeleteSecretResult, | 20 | DeleteSecretResult, |
| 21 | DeleteSecretStoreResult, | ||
| 22 | ImportSecretFromStorePayload, | ||
| 16 | UpdateSecretPayload, | 23 | UpdateSecretPayload, |
| 24 | UpdateSecretStorePayload, | ||
| 17 | } from "./local/contracts"; | 25 | } from "./local/contracts"; |
| 18 | import { | 26 | import { |
| 19 | getLocalInstallation, | 27 | getLocalInstallation, |
| 20 | } from "./local/operations"; | 28 | } from "./local/operations"; |
| 29 | import { | ||
| 30 | createLocalSecretStore, | ||
| 31 | browseLocalSecretStore, | ||
| 32 | deleteLocalSecretStore, | ||
| 33 | importLocalSecretFromStore, | ||
| 34 | listLocalSecretStores, | ||
| 35 | updateLocalSecretStore, | ||
| 36 | } from "./local/secret-stores"; | ||
| 21 | import { | 37 | import { |
| 22 | createLocalSecret, | 38 | createLocalSecret, |
| 23 | deleteLocalSecret, | 39 | deleteLocalSecret, |
| @@ -49,6 +65,12 @@ import { · 25 unmodified lines | |||
| 49 | removeSource, | 65 | removeSource, |
| 50 | saveManagedSourceRecord, | 66 | saveManagedSourceRecord, |
| 51 | } from "./sources/operations"; | 67 | } from "./sources/operations"; |
| 68 | import { | ||
| 69 | createManagedSecretStoreRecord, | ||
| 70 | getManagedSecretStore, | ||
| 71 | removeManagedSecretStoreRecord, | ||
| 72 | saveManagedSecretStoreRecord, | ||
| 73 | } from "./runtime/secret-stores"; | ||
| 52 | import type { ExecutorBackend } from "./backend"; | 74 | import type { ExecutorBackend } from "./backend"; |
| 53 | import { | 75 | import { |
| 54 | provideExecutorRuntime, | 76 | provideExecutorRuntime, |
| @@ -93,6 +115,33 @@ export type ExecutorEffect = { · 38 unmodified lines | |||
| 93 | installation: () => ProvidedEffect<ReturnType<typeof getLocalInstallation>>; | 115 | installation: () => ProvidedEffect<ReturnType<typeof getLocalInstallation>>; |
| 94 | config: () => ProvidedEffect<ReturnType<typeof getLocalInstanceConfig>>; | 116 | config: () => ProvidedEffect<ReturnType<typeof getLocalInstanceConfig>>; |
| 95 | }; | 117 | }; |
| 118 | secretStores: { | ||
| 119 | list: () => ProvidedEffect<ReturnType<typeof listLocalSecretStores>>; | ||
| 120 | create: ( | ||
| 121 | payload: CreateSecretStorePayload, | ||
| 122 | ) => ProvidedEffect<ReturnType<typeof createLocalSecretStore>>; | ||
| 123 | update: (input: { | ||
| 124 | storeId: string; | ||
| 125 | payload: UpdateSecretStorePayload; | ||
| 126 | }) => ProvidedEffect<ReturnType<typeof updateLocalSecretStore>>; | ||
| 127 | browse: (input: { | ||
| 128 | storeId: string; | ||
| 129 | payload: BrowseSecretStorePayload; | ||
| 130 | }) => ProvidedEffect<ReturnType<typeof browseLocalSecretStore>>; | ||
| 131 | import: (input: { | ||
| 132 | storeId: string; | ||
| 133 | payload: ImportSecretFromStorePayload; | ||
| 134 | }) => MappedProvidedEffect< | ||
| 135 | ReturnType<typeof importLocalSecretFromStore>, | ||
| 136 | CreateSecretResult | ||
| 137 | >; | ||
| 138 | remove: ( | ||
| 139 | storeId: string, | ||
| 140 | ) => MappedProvidedEffect< | ||
| 141 | ReturnType<typeof deleteLocalSecretStore>, | ||
| 142 | DeleteSecretStoreResult | ||
| 143 | >; | ||
| 144 | }; | ||
| 96 | secrets: { | 145 | secrets: { |
| 97 | list: () => ProvidedEffect<ReturnType<typeof listLocalSecrets>>; | 146 | list: () => ProvidedEffect<ReturnType<typeof listLocalSecrets>>; |
| 98 | create: ( | 147 | create: ( |
| @@ -189,6 +238,14 @@ const fromRuntime = (runtime: ExecutorRuntime): ExecutorEffect => { · 90 unmodified lines | |||
| 189 | installation: () => provide(getLocalInstallation()), | 238 | installation: () => provide(getLocalInstallation()), |
| 190 | config: () => provide(getLocalInstanceConfig()), | 239 | config: () => provide(getLocalInstanceConfig()), |
| 191 | }, | 240 | }, |
| 241 | secretStores: { | ||
| 242 | list: () => provide(listLocalSecretStores()), | ||
| 243 | create: (payload) => provide(createLocalSecretStore(payload)), | ||
| 244 | update: (input) => provide(updateLocalSecretStore(input)), | ||
| 245 | browse: (input) => provide(browseLocalSecretStore(input)), | ||
| 246 | import: (input) => provide(importLocalSecretFromStore(input)), | ||
| 247 | remove: (storeId) => provide(deleteLocalSecretStore(storeId)), | ||
| 248 | }, | ||
| 192 | secrets: { | 249 | secrets: { |
| 193 | list: () => provide(listLocalSecrets()), | 250 | list: () => provide(listLocalSecrets()), |
| 194 | create: (payload) => provide(createLocalSecret(payload)), | 251 | create: (payload) => provide(createLocalSecret(payload)), |
| @@ -355,6 +412,34 @@ export const createExecutorEffect = < · 160 unmodified lines | |||
| 355 | }).pipe(Effect.map((result) => result.removed)), | 412 | }).pipe(Effect.map((result) => result.removed)), |
| 356 | ), | 413 | ), |
| 357 | }, | 414 | }, |
| 415 | secretStores: { | ||
| 416 | create: ({ | ||
| 417 | store, | ||
| 418 | }: { | ||
| 419 | store: Omit< | ||
| 420 | SecretStore, | ||
| 421 | "id" | "scopeId" | "createdAt" | "updatedAt" | ||
| 422 | >; | ||
| 423 | }) => | ||
| 424 | providePluginHostEffect( | ||
| 425 | createManagedSecretStoreRecord({ | ||
| 426 | scopeId: executor.scopeId, | ||
| 427 | store, | ||
| 428 | }), | ||
| 429 | ), | ||
| 430 | get: (storeId: SecretStore["id"]) => | ||
| 431 | providePluginHostEffect( | ||
| 432 | getManagedSecretStore(storeId), | ||
| 433 | ), | ||
| 434 | save: (store: SecretStore) => | ||
| 435 | providePluginHostEffect( | ||
| 436 | saveManagedSecretStoreRecord(store), | ||
| 437 | ), | ||
| 438 | remove: (storeId: SecretStore["id"]) => | ||
| 439 | providePluginHostEffect( | ||
| 440 | removeManagedSecretStoreRecord(storeId), | ||
| 441 | ), | ||
| 442 | }, | ||
| 358 | }; | 443 | }; |
| 359 | const host: ExecutorSdkPluginHost = sourceHost; | 444 | const host: ExecutorSdkPluginHost = sourceHost; |
| 360 | const extensions = Object.fromEntries( | 445 | const extensions = Object.fromEntries( |
| @@ -80,6 +80,15 @@ const toPromiseExecutor = < · 79 unmodified lines | |||
| 80 | installation: () => run(executor.local.installation()), | 80 | installation: () => run(executor.local.installation()), |
| 81 | config: () => run(executor.local.config()), | 81 | config: () => run(executor.local.config()), |
| 82 | }, | 82 | }, |
| 83 | secretStores: { | ||
| 84 | list: () => run(executor.secretStores.list()), | ||
| 85 | create: (payload: Parameters<typeof executor.secretStores.create>[0]) => | ||
| 86 | run(executor.secretStores.create(payload)), | ||
| 87 | update: (input: Parameters<typeof executor.secretStores.update>[0]) => | ||
| 88 | run(executor.secretStores.update(input)), | ||
| 89 | remove: (storeId: Parameters<typeof executor.secretStores.remove>[0]) => | ||
| 90 | run(executor.secretStores.remove(storeId)), | ||
| 91 | }, | ||
| 83 | secrets: { | 92 | secrets: { |
| 84 | list: () => run(executor.secrets.list()), | 93 | list: () => run(executor.secrets.list()), |
| 85 | create: (payload: Parameters<typeof executor.secrets.create>[0]) => | 94 | create: (payload: Parameters<typeof executor.secrets.create>[0]) => |
| @@ -142,6 +151,7 @@ const toPromiseExecutor = < · 56 unmodified lines | |||
| 142 | "resolutionScopeIds", | 151 | "resolutionScopeIds", |
| 143 | "close", | 152 | "close", |
| 144 | "local", | 153 | "local", |
| 154 | "secretStores", | ||
| 145 | "secrets", | 155 | "secrets", |
| 146 | "policies", | 156 | "policies", |
| 147 | "sources", | 157 | "sources", |
| @@ -4,15 +4,15 @@ import { · 3 unmodified lines | |||
| 4 | import * as Schema from "effect/Schema"; | 4 | import * as Schema from "effect/Schema"; |
| 5 | 5 | ||
| 6 | export const SecretProviderSchema = Schema.Struct({ | 6 | export const SecretProviderSchema = Schema.Struct({ |
| 7 | id: Schema.String, | 7 | kind: Schema.String, |
| 8 | name: Schema.String, | 8 | displayName: Schema.String, |
| 9 | canStore: Schema.Boolean, | 9 | canCreate: Schema.Boolean, |
| 10 | }); | 10 | }); |
| 11 | 11 | ||
| 12 | export const InstanceConfigSchema = Schema.Struct({ | 12 | export const InstanceConfigSchema = Schema.Struct({ |
| 13 | platform: Schema.String, | 13 | platform: Schema.String, |
| 14 | secretProviders: Schema.Array(SecretProviderSchema), | 14 | secretStorePlugins: Schema.Array(SecretProviderSchema), |
| 15 | defaultSecretStoreProvider: Schema.String, | 15 | defaultSecretStoreId: Schema.NullOr(Schema.String), |
| 16 | }); | 16 | }); |
| 17 | 17 | ||
| 18 | export type SecretProvider = typeof SecretProviderSchema.Type; | 18 | export type SecretProvider = typeof SecretProviderSchema.Type; |
| @@ -25,9 +25,21 @@ export const SecretLinkedSourceSchema = Schema.Struct({ · 6 unmodified lines | |||
| 25 | 25 | ||
| 26 | export type SecretLinkedSource = typeof SecretLinkedSourceSchema.Type; | 26 | export type SecretLinkedSource = typeof SecretLinkedSourceSchema.Type; |
| 27 | 27 | ||
| 28 | export const SecretStoreCapabilitiesSchema = Schema.Struct({ | ||
| 29 | canCreateSecrets: Schema.Boolean, | ||
| 30 | canUpdateSecrets: Schema.Boolean, | ||
| 31 | canDeleteSecrets: Schema.Boolean, | ||
| 32 | canBrowseSecrets: Schema.Boolean, | ||
| 33 | canImportSecrets: Schema.Boolean, | ||
| 34 | }); | ||
| 35 | |||
| 36 | export type SecretStoreCapabilities = typeof SecretStoreCapabilitiesSchema.Type; | ||
| 37 | |||
| 28 | export const SecretListItemSchema = Schema.Struct({ | 38 | export const SecretListItemSchema = Schema.Struct({ |
| 29 | id: Schema.String, | 39 | id: Schema.String, |
| 30 | providerId: Schema.String, | 40 | storeId: Schema.String, |
| 41 | storeName: Schema.String, | ||
| 42 | storeKind: Schema.String, | ||
| 31 | name: Schema.NullOr(Schema.String), | 43 | name: Schema.NullOr(Schema.String), |
| 32 | purpose: Schema.String, | 44 | purpose: Schema.String, |
| 33 | createdAt: Schema.Number, | 45 | createdAt: Schema.Number, |
| @@ -41,7 +53,7 @@ export const CreateSecretPayloadSchema = Schema.Struct({ · 7 unmodified lines | |||
| 41 | name: Schema.String, | 53 | name: Schema.String, |
| 42 | value: Schema.String, | 54 | value: Schema.String, |
| 43 | purpose: Schema.optional(SecretMaterialPurposeSchema), | 55 | purpose: Schema.optional(SecretMaterialPurposeSchema), |
| 44 | providerId: Schema.optional(Schema.String), | 56 | storeId: Schema.optional(Schema.String), |
| 45 | }); | 57 | }); |
| 46 | 58 | ||
| 47 | export type CreateSecretPayload = typeof CreateSecretPayloadSchema.Type; | 59 | export type CreateSecretPayload = typeof CreateSecretPayloadSchema.Type; |
| @@ -49,7 +61,7 @@ export type CreateSecretPayload = typeof CreateSecretPayloadSchema.Type; · 1 unmodified line | |||
| 49 | export const CreateSecretResultSchema = Schema.Struct({ | 61 | export const CreateSecretResultSchema = Schema.Struct({ |
| 50 | id: Schema.String, | 62 | id: Schema.String, |
| 51 | name: Schema.NullOr(Schema.String), | 63 | name: Schema.NullOr(Schema.String), |
| 52 | providerId: Schema.String, | 64 | storeId: Schema.String, |
| 53 | purpose: Schema.String, | 65 | purpose: Schema.String, |
| 54 | createdAt: Schema.Number, | 66 | createdAt: Schema.Number, |
| 55 | updatedAt: Schema.Number, | 67 | updatedAt: Schema.Number, |
| @@ -66,13 +78,78 @@ export type UpdateSecretPayload = typeof UpdateSecretPayloadSchema.Type; · 10 unmodified lines | |||
| 66 | 78 | ||
| 67 | export const UpdateSecretResultSchema = Schema.Struct({ | 79 | export const UpdateSecretResultSchema = Schema.Struct({ |
| 68 | id: Schema.String, | 80 | id: Schema.String, |
| 69 | providerId: Schema.String, | 81 | storeId: Schema.String, |
| 70 | name: Schema.NullOr(Schema.String), | 82 | name: Schema.NullOr(Schema.String), |
| 71 | purpose: Schema.String, | 83 | purpose: Schema.String, |
| 72 | createdAt: Schema.Number, | 84 | createdAt: Schema.Number, |
| 73 | updatedAt: Schema.Number, | 85 | updatedAt: Schema.Number, |
| 74 | }); | 86 | }); |
| 75 | 87 | ||
| 88 | export const SecretStoreSchema = Schema.Struct({ | ||
| 89 | id: Schema.String, | ||
| 90 | kind: Schema.String, | ||
| 91 | name: Schema.String, | ||
| 92 | status: Schema.String, | ||
| 93 | enabled: Schema.Boolean, | ||
| 94 | capabilities: SecretStoreCapabilitiesSchema, | ||
| 95 | createdAt: Schema.Number, | ||
| 96 | updatedAt: Schema.Number, | ||
| 97 | }); | ||
| 98 | |||
| 99 | export type SecretStore = typeof SecretStoreSchema.Type; | ||
| 100 | |||
| 101 | export const SecretStoreBrowseEntrySchema = Schema.Struct({ | ||
| 102 | key: Schema.String, | ||
| 103 | label: Schema.String, | ||
| 104 | description: Schema.NullOr(Schema.String), | ||
| 105 | kind: Schema.Literal("group", "secret"), | ||
| 106 | }); | ||
| 107 | |||
| 108 | export type SecretStoreBrowseEntry = typeof SecretStoreBrowseEntrySchema.Type; | ||
| 109 | |||
| 110 | export const BrowseSecretStorePayloadSchema = Schema.Struct({ | ||
| 111 | parentKey: Schema.optional(Schema.String), | ||
| 112 | query: Schema.optional(Schema.String), | ||
| 113 | }); | ||
| 114 | |||
| 115 | export type BrowseSecretStorePayload = typeof BrowseSecretStorePayloadSchema.Type; | ||
| 116 | |||
| 117 | export const BrowseSecretStoreResultSchema = Schema.Struct({ | ||
| 118 | entries: Schema.Array(SecretStoreBrowseEntrySchema), | ||
| 119 | }); | ||
| 120 | |||
| 121 | export type BrowseSecretStoreResult = typeof BrowseSecretStoreResultSchema.Type; | ||
| 122 | |||
| 123 | export const ImportSecretFromStorePayloadSchema = Schema.Struct({ | ||
| 124 | selectionKey: Schema.String, | ||
| 125 | name: Schema.optional(Schema.String), | ||
| 126 | purpose: Schema.optional(SecretMaterialPurposeSchema), | ||
| 127 | }); | ||
| 128 | |||
| 129 | export type ImportSecretFromStorePayload = | ||
| 130 | typeof ImportSecretFromStorePayloadSchema.Type; | ||
| 131 | |||
| 132 | export const CreateSecretStorePayloadSchema = Schema.Struct({ | ||
| 133 | kind: Schema.String, | ||
| 134 | name: Schema.String, | ||
| 135 | config: Schema.Unknown, | ||
| 136 | }); | ||
| 137 | |||
| 138 | export type CreateSecretStorePayload = typeof CreateSecretStorePayloadSchema.Type; | ||
| 139 | |||
| 140 | export const UpdateSecretStorePayloadSchema = Schema.Struct({ | ||
| 141 | name: Schema.optional(Schema.String), | ||
| 142 | config: Schema.optional(Schema.Unknown), | ||
| 143 | }); | ||
| 144 | |||
| 145 | export type UpdateSecretStorePayload = typeof UpdateSecretStorePayloadSchema.Type; | ||
| 146 | |||
| 147 | export const DeleteSecretStoreResultSchema = Schema.Struct({ | ||
| 148 | removed: Schema.Boolean, | ||
| 149 | }); | ||
| 150 | |||
| 151 | export type DeleteSecretStoreResult = typeof DeleteSecretStoreResultSchema.Type; | ||
| 152 | |||
| 76 | export type UpdateSecretResult = typeof UpdateSecretResultSchema.Type; | 153 | export type UpdateSecretResult = typeof UpdateSecretResultSchema.Type; |
| 77 | 154 | ||
| 78 | export const DeleteSecretResultSchema = Schema.Struct({ | 155 | export const DeleteSecretResultSchema = Schema.Struct({ |
| @@ -0,0 +1,441 @@ | |||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Option from "effect/Option"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | type BrowseSecretStorePayload, | ||
| 6 | type BrowseSecretStoreResult, | ||
| 7 | type CreateSecretResult, | ||
| 8 | type CreateSecretStorePayload, | ||
| 9 | type DeleteSecretStoreResult, | ||
| 10 | type ImportSecretFromStorePayload, | ||
| 11 | type SecretStoreCapabilities, | ||
| 12 | type SecretStore as SecretStoreContract, | ||
| 13 | type UpdateSecretStorePayload, | ||
| 14 | } from "./contracts"; | ||
| 15 | import { | ||
| 16 | ControlPlaneBadRequestError, | ||
| 17 | ControlPlaneNotFoundError, | ||
| 18 | ControlPlaneStorageError, | ||
| 19 | } from "../errors"; | ||
| 20 | import { | ||
| 21 | createManagedSecretStoreRecord, | ||
| 22 | getManagedSecretStore, | ||
| 23 | getSecretStoreContribution, | ||
| 24 | listManagedSecretStores, | ||
| 25 | removeManagedSecretStoreRecord, | ||
| 26 | saveManagedSecretStoreRecord, | ||
| 27 | } from "../runtime"; | ||
| 28 | import { | ||
| 29 | ExecutorStateStore, | ||
| 30 | } from "../runtime/executor-state-store"; | ||
| 31 | import { | ||
| 32 | requireRuntimeLocalScope, | ||
| 33 | } from "../runtime/scope/runtime-context"; | ||
| 34 | import type { | ||
| 35 | SecretMaterial, | ||
| 36 | SecretMaterialPurpose, | ||
| 37 | SecretStore, | ||
| 38 | } from "../schema"; | ||
| 39 | import { | ||
| 40 | SecretMaterialIdSchema, | ||
| 41 | } from "../schema"; | ||
| 42 | |||
| 43 | const secretStoreStorageError = (operation: string, message: string) => | ||
| 44 | new ControlPlaneStorageError({ | ||
| 45 | operation, | ||
| 46 | message, | ||
| 47 | details: message, | ||
| 48 | }); | ||
| 49 | |||
| 50 | const buildPluginHost = (scopeId: SecretStore["scopeId"]) => ({ | ||
| 51 | secretStores: { | ||
| 52 | create: ({ | ||
| 53 | store, | ||
| 54 | }: { | ||
| 55 | store: Omit< | ||
| 56 | SecretStore, | ||
| 57 | "id" | "scopeId" | "createdAt" | "updatedAt" | ||
| 58 | >; | ||
| 59 | }) => | ||
| 60 | createManagedSecretStoreRecord({ | ||
| 61 | scopeId, | ||
| 62 | store, | ||
| 63 | }), | ||
| 64 | get: (storeId: SecretStore["id"]) => | ||
| 65 | getManagedSecretStore(storeId), | ||
| 66 | save: (store: SecretStore) => | ||
| 67 | saveManagedSecretStoreRecord(store), | ||
| 68 | remove: (storeId: SecretStore["id"]) => | ||
| 69 | removeManagedSecretStoreRecord(storeId), | ||
| 70 | }, | ||
| 71 | }); | ||
| 72 | |||
| 73 | const asRecord = (value: unknown): Record<string, unknown> | null => | ||
| 74 | value !== null && typeof value === "object" && !Array.isArray(value) | ||
| 75 | ? value as Record<string, unknown> | ||
| 76 | : null; | ||
| 77 | |||
| 78 | const getSecretStoreContributionOption = (kind: string) => { | ||
| 79 | try { | ||
| 80 | return Option.some(getSecretStoreContribution(kind)); | ||
| 81 | } catch { | ||
| 82 | return Option.none<typeof getSecretStoreContribution extends (kind: string) => infer T ? T : never>(); | ||
| 83 | } | ||
| 84 | }; | ||
| 85 | |||
| 86 | const defaultCapabilities: SecretStoreCapabilities = { | ||
| 87 | canCreateSecrets: false, | ||
| 88 | canUpdateSecrets: false, | ||
| 89 | canDeleteSecrets: false, | ||
| 90 | canBrowseSecrets: false, | ||
| 91 | canImportSecrets: false, | ||
| 92 | }; | ||
| 93 | |||
| 94 | const loadStoreCapabilities = ( | ||
| 95 | store: SecretStore, | ||
| 96 | )=> | ||
| 97 | Effect.gen(function* () { | ||
| 98 | const contribution = getSecretStoreContributionOption(store.kind); | ||
| 99 | if (Option.isNone(contribution)) { | ||
| 100 | return defaultCapabilities; | ||
| 101 | } | ||
| 102 | |||
| 103 | return yield* contribution.value.getCapabilities({ | ||
| 104 | store, | ||
| 105 | }).pipe( | ||
| 106 | Effect.mapError(() => | ||
| 107 | secretStoreStorageError( | ||
| 108 | "secretStores.capabilities", | ||
| 109 | `Failed loading capabilities for secret store ${store.id}.`, | ||
| 110 | ), | ||
| 111 | ), | ||
| 112 | ); | ||
| 113 | }); | ||
| 114 | |||
| 115 | const toSecretStoreContract = ( | ||
| 116 | store: SecretStore, | ||
| 117 | )=> | ||
| 118 | Effect.map(loadStoreCapabilities(store), (capabilities) => ({ | ||
| 119 | ...store, | ||
| 120 | capabilities, | ||
| 121 | })); | ||
| 122 | |||
| 123 | const createImportedSecretRecord = (input: { | ||
| 124 | store: SecretStore; | ||
| 125 | handle: string; | ||
| 126 | name: string | null; | ||
| 127 | purpose: SecretMaterialPurpose; | ||
| 128 | value?: string | null; | ||
| 129 | }) => | ||
| 130 | Effect.gen(function* () { | ||
| 131 | const executorState = yield* ExecutorStateStore; | ||
| 132 | const now = Date.now(); | ||
| 133 | const material: SecretMaterial = { | ||
| 134 | id: SecretMaterialIdSchema.make(`sec_${crypto.randomUUID()}`), | ||
| 135 | storeId: input.store.id, | ||
| 136 | handle: input.handle, | ||
| 137 | name: input.name, | ||
| 138 | purpose: input.purpose, | ||
| 139 | value: input.value ?? null, | ||
| 140 | createdAt: now, | ||
| 141 | updatedAt: now, | ||
| 142 | }; | ||
| 143 | |||
| 144 | yield* executorState.secretMaterials.upsert(material); | ||
| 145 | |||
| 146 | return { | ||
| 147 | id: material.id, | ||
| 148 | name: material.name, | ||
| 149 | storeId: material.storeId, | ||
| 150 | purpose: material.purpose, | ||
| 151 | createdAt: material.createdAt, | ||
| 152 | updatedAt: material.updatedAt, | ||
| 153 | } satisfies CreateSecretResult; | ||
| 154 | }); | ||
| 155 | |||
| 156 | export const listLocalSecretStores = () => | ||
| 157 | Effect.gen(function* () { | ||
| 158 | const stores = yield* listManagedSecretStores().pipe( | ||
| 159 | Effect.mapError(() => | ||
| 160 | secretStoreStorageError( | ||
| 161 | "secretStores.list", | ||
| 162 | "Failed listing secret stores.", | ||
| 163 | ), | ||
| 164 | ), | ||
| 165 | ); | ||
| 166 | |||
| 167 | return yield* Effect.forEach( | ||
| 168 | stores, | ||
| 169 | (store) => toSecretStoreContract(store as SecretStore), | ||
| 170 | { | ||
| 171 | concurrency: 1, | ||
| 172 | }, | ||
| 173 | ); | ||
| 174 | }); | ||
| 175 | |||
| 176 | export const createLocalSecretStore = (payload: CreateSecretStorePayload) => | ||
| 177 | Effect.gen(function* () { | ||
| 178 | const runtimeLocalScope = yield* requireRuntimeLocalScope().pipe( | ||
| 179 | Effect.mapError(() => | ||
| 180 | secretStoreStorageError( | ||
| 181 | "secretStores.create", | ||
| 182 | "Failed resolving local scope.", | ||
| 183 | ), | ||
| 184 | ), | ||
| 185 | ); | ||
| 186 | const contribution = yield* Effect.try({ | ||
| 187 | try: () => getSecretStoreContribution(payload.kind), | ||
| 188 | catch: (cause) => | ||
| 189 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 190 | }).pipe( | ||
| 191 | Effect.mapError((cause) => | ||
| 192 | secretStoreStorageError( | ||
| 193 | "secretStores.create", | ||
| 194 | cause.message, | ||
| 195 | ), | ||
| 196 | ), | ||
| 197 | ); | ||
| 198 | if (!contribution.canCreate || !contribution.createStore) { | ||
| 199 | return yield* new ControlPlaneBadRequestError({ | ||
| 200 | operation: "secretStores.create", | ||
| 201 | message: `Secret store kind '${payload.kind}' cannot be created manually.`, | ||
| 202 | details: `Secret store kind '${payload.kind}' cannot be created manually.`, | ||
| 203 | }); | ||
| 204 | } | ||
| 205 | |||
| 206 | const configRecord = asRecord(payload.config); | ||
| 207 | if (configRecord === null) { | ||
| 208 | return yield* new ControlPlaneBadRequestError({ | ||
| 209 | operation: "secretStores.create", | ||
| 210 | message: "Secret store config must be an object.", | ||
| 211 | details: "Secret store config must be an object.", | ||
| 212 | }); | ||
| 213 | } | ||
| 214 | |||
| 215 | const created = yield* contribution.createStore!({ | ||
| 216 | args: { | ||
| 217 | kind: payload.kind, | ||
| 218 | name: payload.name, | ||
| 219 | ...configRecord, | ||
| 220 | }, | ||
| 221 | host: buildPluginHost(runtimeLocalScope.installation.scopeId), | ||
| 222 | }).pipe( | ||
| 223 | Effect.mapError((cause) => | ||
| 224 | secretStoreStorageError( | ||
| 225 | "secretStores.create", | ||
| 226 | cause instanceof Error ? cause.message : "Failed creating secret store.", | ||
| 227 | ), | ||
| 228 | ), | ||
| 229 | ); | ||
| 230 | |||
| 231 | return yield* toSecretStoreContract(created); | ||
| 232 | }); | ||
| 233 | |||
| 234 | export const updateLocalSecretStore = (input: { | ||
| 235 | storeId: string; | ||
| 236 | payload: UpdateSecretStorePayload; | ||
| 237 | }) => | ||
| 238 | Effect.gen(function* () { | ||
| 239 | const runtimeLocalScope = yield* requireRuntimeLocalScope().pipe( | ||
| 240 | Effect.mapError(() => | ||
| 241 | secretStoreStorageError( | ||
| 242 | "secretStores.update", | ||
| 243 | "Failed resolving local scope.", | ||
| 244 | ), | ||
| 245 | ), | ||
| 246 | ); | ||
| 247 | const store = yield* getManagedSecretStore(input.storeId as SecretStore["id"]).pipe( | ||
| 248 | Effect.mapError(() => | ||
| 249 | new ControlPlaneNotFoundError({ | ||
| 250 | operation: "secretStores.update", | ||
| 251 | message: `Secret store not found: ${input.storeId}`, | ||
| 252 | details: `Secret store not found: ${input.storeId}`, | ||
| 253 | }), | ||
| 254 | ), | ||
| 255 | ); | ||
| 256 | |||
| 257 | const contribution = getSecretStoreContributionOption(store.kind); | ||
| 258 | const configRecord = input.payload.config === undefined | ||
| 259 | ? null | ||
| 260 | : asRecord(input.payload.config); | ||
| 261 | |||
| 262 | let updated = store; | ||
| 263 | if (configRecord !== null) { | ||
| 264 | if (Option.isNone(contribution)) { | ||
| 265 | return yield* new ControlPlaneBadRequestError({ | ||
| 266 | operation: "secretStores.update", | ||
| 267 | message: `Secret store kind '${store.kind}' does not support config updates.`, | ||
| 268 | details: `Secret store kind '${store.kind}' does not support config updates.`, | ||
| 269 | }); | ||
| 270 | } | ||
| 271 | |||
| 272 | updated = yield* contribution.value.updateStore({ | ||
| 273 | store, | ||
| 274 | config: configRecord, | ||
| 275 | host: buildPluginHost(runtimeLocalScope.installation.scopeId), | ||
| 276 | }).pipe( | ||
| 277 | Effect.mapError((cause) => | ||
| 278 | secretStoreStorageError( | ||
| 279 | "secretStores.update", | ||
| 280 | cause instanceof Error ? cause.message : "Failed updating secret store config.", | ||
| 281 | ), | ||
| 282 | ), | ||
| 283 | ); | ||
| 284 | } else if (input.payload.config !== undefined) { | ||
| 285 | return yield* new ControlPlaneBadRequestError({ | ||
| 286 | operation: "secretStores.update", | ||
| 287 | message: "Secret store config must be an object.", | ||
| 288 | details: "Secret store config must be an object.", | ||
| 289 | }); | ||
| 290 | } | ||
| 291 | |||
| 292 | if (input.payload.name !== undefined) { | ||
| 293 | updated = yield* saveManagedSecretStoreRecord({ | ||
| 294 | ...updated, | ||
| 295 | name: input.payload.name.trim() || updated.name, | ||
| 296 | }).pipe( | ||
| 297 | Effect.mapError(() => | ||
| 298 | secretStoreStorageError( | ||
| 299 | "secretStores.update", | ||
| 300 | "Failed saving secret store name.", | ||
| 301 | ), | ||
| 302 | ), | ||
| 303 | ); | ||
| 304 | } | ||
| 305 | |||
| 306 | return yield* toSecretStoreContract(updated); | ||
| 307 | }); | ||
| 308 | |||
| 309 | export const deleteLocalSecretStore = (storeId: string) => | ||
| 310 | Effect.gen(function* () { | ||
| 311 | const executorState = yield* ExecutorStateStore; | ||
| 312 | const store = yield* executorState.secretStores.getById(storeId as SecretStore["id"]); | ||
| 313 | if (Option.isNone(store)) { | ||
| 314 | return yield* new ControlPlaneNotFoundError({ | ||
| 315 | operation: "secretStores.delete", | ||
| 316 | message: `Secret store not found: ${storeId}`, | ||
| 317 | details: `Secret store not found: ${storeId}`, | ||
| 318 | }); | ||
| 319 | } | ||
| 320 | |||
| 321 | const contribution = getSecretStoreContributionOption(store.value.kind); | ||
| 322 | if (Option.isNone(contribution)) { | ||
| 323 | return yield* new ControlPlaneBadRequestError({ | ||
| 324 | operation: "secretStores.delete", | ||
| 325 | message: `Secret store kind '${store.value.kind}' cannot be removed.`, | ||
| 326 | details: `Secret store kind '${store.value.kind}' cannot be removed.`, | ||
| 327 | }); | ||
| 328 | } | ||
| 329 | |||
| 330 | const removed = yield* contribution.value.removeStore({ | ||
| 331 | store: store.value, | ||
| 332 | host: buildPluginHost(store.value.scopeId), | ||
| 333 | }).pipe( | ||
| 334 | Effect.mapError((cause) => | ||
| 335 | secretStoreStorageError( | ||
| 336 | "secretStores.delete", | ||
| 337 | cause instanceof Error ? cause.message : "Failed removing secret store.", | ||
| 338 | ), | ||
| 339 | ), | ||
| 340 | ); | ||
| 341 | |||
| 342 | return { | ||
| 343 | removed, | ||
| 344 | } satisfies DeleteSecretStoreResult; | ||
| 345 | }); | ||
| 346 | |||
| 347 | export const browseLocalSecretStore = (input: { | ||
| 348 | storeId: string; | ||
| 349 | payload: BrowseSecretStorePayload; | ||
| 350 | }) => | ||
| 351 | Effect.gen(function* () { | ||
| 352 | const store = yield* getManagedSecretStore(input.storeId as SecretStore["id"]).pipe( | ||
| 353 | Effect.mapError(() => | ||
| 354 | new ControlPlaneNotFoundError({ | ||
| 355 | operation: "secretStores.browse", | ||
| 356 | message: `Secret store not found: ${input.storeId}`, | ||
| 357 | details: `Secret store not found: ${input.storeId}`, | ||
| 358 | }), | ||
| 359 | ), | ||
| 360 | ); | ||
| 361 | |||
| 362 | const contribution = getSecretStoreContributionOption(store.kind); | ||
| 363 | const capabilities = yield* loadStoreCapabilities(store); | ||
| 364 | if (Option.isNone(contribution) || !capabilities.canBrowseSecrets) { | ||
| 365 | return yield* new ControlPlaneBadRequestError({ | ||
| 366 | operation: "secretStores.browse", | ||
| 367 | message: `Secret store kind '${store.kind}' does not support browsing secrets.`, | ||
| 368 | details: `Secret store kind '${store.kind}' does not support browsing secrets.`, | ||
| 369 | }); | ||
| 370 | } | ||
| 371 | |||
| 372 | return yield* contribution.value.browseSecrets({ | ||
| 373 | store, | ||
| 374 | parentKey: input.payload.parentKey, | ||
| 375 | query: input.payload.query, | ||
| 376 | }).pipe( | ||
| 377 | Effect.mapError((cause) => | ||
| 378 | secretStoreStorageError( | ||
| 379 | "secretStores.browse", | ||
| 380 | cause instanceof Error ? cause.message : "Failed browsing secret store.", | ||
| 381 | ), | ||
| 382 | ), | ||
| 383 | Effect.map((result) => result satisfies BrowseSecretStoreResult), | ||
| 384 | ); | ||
| 385 | }); | ||
| 386 | |||
| 387 | export const importLocalSecretFromStore = (input: { | ||
| 388 | storeId: string; | ||
| 389 | payload: ImportSecretFromStorePayload; | ||
| 390 | }) => | ||
| 391 | Effect.gen(function* () { | ||
| 392 | const store = yield* getManagedSecretStore(input.storeId as SecretStore["id"]).pipe( | ||
| 393 | Effect.mapError(() => | ||
| 394 | new ControlPlaneNotFoundError({ | ||
| 395 | operation: "secretStores.import", | ||
| 396 | message: `Secret store not found: ${input.storeId}`, | ||
| 397 | details: `Secret store not found: ${input.storeId}`, | ||
| 398 | }), | ||
| 399 | ), | ||
| 400 | ); | ||
| 401 | |||
| 402 | const contribution = getSecretStoreContributionOption(store.kind); | ||
| 403 | if (Option.isNone(contribution)) { | ||
| 404 | return yield* new ControlPlaneBadRequestError({ | ||
| 405 | operation: "secretStores.import", | ||
| 406 | message: `Secret store kind '${store.kind}' does not support importing secrets.`, | ||
| 407 | details: `Secret store kind '${store.kind}' does not support importing secrets.`, | ||
| 408 | }); | ||
| 409 | } | ||
| 410 | |||
| 411 | const capabilities = yield* loadStoreCapabilities(store); | ||
| 412 | if (!capabilities.canImportSecrets) { | ||
| 413 | return yield* new ControlPlaneBadRequestError({ | ||
| 414 | operation: "secretStores.import", | ||
| 415 | message: `Secret store kind '${store.kind}' does not support importing secrets.`, | ||
| 416 | details: `Secret store kind '${store.kind}' does not support importing secrets.`, | ||
| 417 | }); | ||
| 418 | } | ||
| 419 | |||
| 420 | const created = yield* contribution.value.importSecret({ | ||
| 421 | store, | ||
| 422 | selectionKey: input.payload.selectionKey, | ||
| 423 | purpose: input.payload.purpose ?? "auth_material", | ||
| 424 | name: input.payload.name?.trim() || null, | ||
| 425 | }).pipe( | ||
| 426 | Effect.mapError((cause) => | ||
| 427 | secretStoreStorageError( | ||
| 428 | "secretStores.import", | ||
| 429 | cause instanceof Error ? cause.message : "Failed importing secret from store.", | ||
| 430 | ), | ||
| 431 | ), | ||
| 432 | ); | ||
| 433 | |||
| 434 | return yield* createImportedSecretRecord({ | ||
| 435 | store, | ||
| 436 | handle: created.handle, | ||
| 437 | name: created.name, | ||
| 438 | purpose: input.payload.purpose ?? "auth_material", | ||
| 439 | value: created.value, | ||
| 440 | }); | ||
| 441 | }); | ||
| @@ -3,6 +3,7 @@ import * as Option from "effect/Option"; · 2 unmodified lines | |||
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 5 | SecretMaterialIdSchema, | 5 | SecretMaterialIdSchema, |
| 6 | type SecretStore, | ||
| 6 | } from "../schema"; | 7 | } from "../schema"; |
| 7 | import { | 8 | import { |
| 8 | type CreateSecretPayload, | 9 | type CreateSecretPayload, |
| @@ -72,8 +73,21 @@ export const listLocalSecrets = () => · 63 unmodified lines | |||
| 72 | ), | 73 | ), |
| 73 | ); | 74 | ); |
| 74 | 75 | ||
| 76 | const secretStores = yield* store.secretStores | ||
| 77 | .listAll() | ||
| 78 | .pipe( | ||
| 79 | Effect.mapError(() => | ||
| 80 | secretStorageError("secrets.list", "Failed listing secret stores."), | ||
| 81 | ), | ||
| 82 | ); | ||
| 83 | const secretStoresById = new Map( | ||
| 84 | secretStores.map((secretStore) => [secretStore.id, secretStore]), | ||
| 85 | ); | ||
| 86 | |||
| 75 | return secretMaterials.map((row) => ({ | 87 | return secretMaterials.map((row) => ({ |
| 76 | ...row, | 88 | ...row, |
| 89 | storeName: secretStoresById.get(row.storeId)?.name ?? row.storeId, | ||
| 90 | storeKind: secretStoresById.get(row.storeId)?.kind ?? "unknown", | ||
| 77 | linkedSources: linkedSourcesMap.get(row.id) ?? [], | 91 | linkedSources: linkedSourcesMap.get(row.id) ?? [], |
| 78 | })); | 92 | })); |
| 79 | }); | 93 | }); |
| @@ -98,7 +112,7 @@ export const createLocalSecret = (payload: CreateSecretPayload) => · 18 unmodified lines | |||
| 98 | name, | 112 | name, |
| 99 | purpose, | 113 | purpose, |
| 100 | value, | 114 | value, |
| 101 | providerId: payload.providerId, | 115 | storeId: payload.storeId, |
| 102 | }).pipe( | 116 | }).pipe( |
| 103 | Effect.mapError((cause) => | 117 | Effect.mapError((cause) => |
| 104 | secretStorageError( | 118 | secretStorageError( |
| @@ -107,7 +121,7 @@ export const createLocalSecret = (payload: CreateSecretPayload) => · 2 unmodified lines | |||
| 107 | ), | 121 | ), |
| 108 | ), | 122 | ), |
| 109 | ); | 123 | ); |
| 110 | const secretId = SecretMaterialIdSchema.make(ref.handle); | 124 | const secretId = SecretMaterialIdSchema.make(ref.secretId); |
| 111 | const created = yield* store.secretMaterials | 125 | const created = yield* store.secretMaterials |
| 112 | .getById(secretId) | 126 | .getById(secretId) |
| 113 | .pipe( | 127 | .pipe( |
| @@ -122,14 +136,14 @@ export const createLocalSecret = (payload: CreateSecretPayload) => · 8 unmodified lines | |||
| 122 | if (Option.isNone(created)) { | 136 | if (Option.isNone(created)) { |
| 123 | return yield* secretStorageError( | 137 | return yield* secretStorageError( |
| 124 | "secrets.create", | 138 | "secrets.create", |
| 125 | `Created secret not found: ${ref.handle}`, | 139 | `Created secret not found: ${ref.secretId}`, |
| 126 | ); | 140 | ); |
| 127 | } | 141 | } |
| 128 | 142 | ||
| 129 | return { | 143 | return { |
| 130 | id: created.value.id, | 144 | id: created.value.id, |
| 131 | name: created.value.name, | 145 | name: created.value.name, |
| 132 | providerId: created.value.providerId, | 146 | storeId: created.value.storeId, |
| 133 | purpose: created.value.purpose, | 147 | purpose: created.value.purpose, |
| 134 | createdAt: created.value.createdAt, | 148 | createdAt: created.value.createdAt, |
| 135 | updatedAt: created.value.updatedAt, | 149 | updatedAt: created.value.updatedAt, |
| @@ -168,8 +182,7 @@ export const updateLocalSecret = (input: { · 32 unmodified lines | |||
| 168 | const updateSecretMaterial = yield* SecretMaterialUpdaterService; | 182 | const updateSecretMaterial = yield* SecretMaterialUpdaterService; |
| 169 | const updated = yield* updateSecretMaterial({ | 183 | const updated = yield* updateSecretMaterial({ |
| 170 | ref: { | 184 | ref: { |
| 171 | providerId: existing.value.providerId, | 185 | secretId: existing.value.id, |
| 172 | handle: existing.value.id, | ||
| 173 | }, | 186 | }, |
| 174 | ...update, | 187 | ...update, |
| 175 | }).pipe( | 188 | }).pipe( |
| @@ -180,7 +193,7 @@ export const updateLocalSecret = (input: { · 4 unmodified lines | |||
| 180 | 193 | ||
| 181 | return { | 194 | return { |
| 182 | id: updated.id, | 195 | id: updated.id, |
| 183 | providerId: updated.providerId, | 196 | storeId: updated.storeId, |
| 184 | name: updated.name, | 197 | name: updated.name, |
| 185 | purpose: updated.purpose, | 198 | purpose: updated.purpose, |
| 186 | createdAt: updated.createdAt, | 199 | createdAt: updated.createdAt, |
| @@ -211,8 +224,7 @@ export const deleteLocalSecret = (secretId: string) => · 24 unmodified lines | |||
| 211 | 224 | ||
| 212 | const deleteSecretMaterial = yield* SecretMaterialDeleterService; | 225 | const deleteSecretMaterial = yield* SecretMaterialDeleterService; |
| 213 | const removed = yield* deleteSecretMaterial({ | 226 | const removed = yield* deleteSecretMaterial({ |
| 214 | providerId: existing.value.providerId, | 227 | secretId: existing.value.id, |
| 215 | handle: existing.value.id, | ||
| 216 | }).pipe( | 228 | }).pipe( |
| 217 | Effect.mapError(() => | 229 | Effect.mapError(() => |
| 218 | secretStorageError("secrets.delete", "Failed removing secret."), | 230 | secretStorageError("secrets.delete", "Failed removing secret."), |
| @@ -6,7 +6,13 @@ import type { · 5 unmodified lines | |||
| 6 | } from "@executor/source-core"; | 6 | } from "@executor/source-core"; |
| 7 | import type { ExecutorEffect } from "./executor-effect"; | 7 | import type { ExecutorEffect } from "./executor-effect"; |
| 8 | import type { ExecutorScopeContext } from "./scope"; | 8 | import type { ExecutorScopeContext } from "./scope"; |
| 9 | import type { Source as ExecutorSource } from "./schema"; | 9 | import type { |
| 10 | Source as ExecutorSource, | ||
| 11 | SecretMaterial, | ||
| 12 | SecretMaterialPurpose, | ||
| 13 | SecretRef, | ||
| 14 | SecretStore, | ||
| 15 | } from "./schema"; | ||
| 10 | import type { | 16 | import type { |
| 11 | SourceInvokeInput, | 17 | SourceInvokeInput, |
| 12 | SourceInvokeResult, | 18 | SourceInvokeResult, |
| @@ -34,6 +40,7 @@ export type ExecutorSdkPluginStartContext< · 21 unmodified lines | |||
| 34 | 40 | ||
| 35 | type ExecutorSdkPluginInternals = { | 41 | type ExecutorSdkPluginInternals = { |
| 36 | sources?: readonly ExecutorSourceContribution<any>[]; | 42 | sources?: readonly ExecutorSourceContribution<any>[]; |
| 43 | secretStores?: readonly ExecutorSecretStoreContribution<any>[]; | ||
| 37 | }; | 44 | }; |
| 38 | 45 | ||
| 39 | const executorSdkPluginInternalsSymbol = Symbol.for( | 46 | const executorSdkPluginInternalsSymbol = Symbol.for( |
| @@ -78,6 +85,20 @@ type ExecutorSourcePluginInternalHost = { · 38 unmodified lines | |||
| 78 | }; | 85 | }; |
| 79 | }; | 86 | }; |
| 80 | 87 | ||
| 88 | type ExecutorSecretStorePluginInternalHost = { | ||
| 89 | secretStores: { | ||
| 90 | create: (input: { | ||
| 91 | store: Omit< | ||
| 92 | SecretStore, | ||
| 93 | "id" | "scopeId" | "createdAt" | "updatedAt" | ||
| 94 | >; | ||
| 95 | }) => Effect.Effect<SecretStore, Error, any>; | ||
| 96 | get: (storeId: SecretStore["id"]) => Effect.Effect<SecretStore, Error, any>; | ||
| 97 | save: (store: SecretStore) => Effect.Effect<SecretStore, Error, any>; | ||
| 98 | remove: (storeId: SecretStore["id"]) => Effect.Effect<boolean, Error, any>; | ||
| 99 | }; | ||
| 100 | }; | ||
| 101 | |||
| 81 | export type ExecutorSourcePluginStorage<TStored> = { | 102 | export type ExecutorSourcePluginStorage<TStored> = { |
| 82 | get: (input: { | 103 | get: (input: { |
| 83 | scopeId: ExecutorSource["scopeId"]; | 104 | scopeId: ExecutorSource["scopeId"]; |
| @@ -94,6 +115,22 @@ export type ExecutorSourcePluginStorage<TStored> = { · 10 unmodified lines | |||
| 94 | }) => Effect.Effect<void, Error, any>; | 115 | }) => Effect.Effect<void, Error, any>; |
| 95 | }; | 116 | }; |
| 96 | 117 | ||
| 118 | export type ExecutorSecretStorePluginStorage<TStored> = { | ||
| 119 | get: (input: { | ||
| 120 | scopeId: SecretStore["scopeId"]; | ||
| 121 | storeId: SecretStore["id"]; | ||
| 122 | }) => Effect.Effect<TStored | null, Error, any>; | ||
| 123 | put: (input: { | ||
| 124 | scopeId: SecretStore["scopeId"]; | ||
| 125 | storeId: SecretStore["id"]; | ||
| 126 | value: TStored; | ||
| 127 | }) => Effect.Effect<void, Error, any>; | ||
| 128 | remove?: (input: { | ||
| 129 | scopeId: SecretStore["scopeId"]; | ||
| 130 | storeId: SecretStore["id"]; | ||
| 131 | }) => Effect.Effect<void, Error, any>; | ||
| 132 | }; | ||
| 133 | |||
| 97 | export type ExecutorSourcePluginApi< | 134 | export type ExecutorSourcePluginApi< |
| 98 | TConnectInput, | 135 | TConnectInput, |
| 99 | TSourceConfig, | 136 | TSourceConfig, |
| @@ -122,6 +159,31 @@ export type ExecutorSourcePluginApi< · 22 unmodified lines | |||
| 122 | ) => Effect.Effect<boolean, Error, any>; | 159 | ) => Effect.Effect<boolean, Error, any>; |
| 123 | }; | 160 | }; |
| 124 | 161 | ||
| 162 | export type ExecutorSecretStorePluginApi< | ||
| 163 | TConnectInput, | ||
| 164 | TStoreConfig, | ||
| 165 | TUpdateInput extends { | ||
| 166 | storeId: string; | ||
| 167 | config: TStoreConfig; | ||
| 168 | }, | ||
| 169 | > = { | ||
| 170 | getStore: ( | ||
| 171 | storeId: SecretStore["id"], | ||
| 172 | ) => Effect.Effect<SecretStore, Error, any>; | ||
| 173 | getStoreConfig: ( | ||
| 174 | storeId: SecretStore["id"], | ||
| 175 | ) => Effect.Effect<TStoreConfig, Error, any>; | ||
| 176 | createStore: ( | ||
| 177 | input: TConnectInput, | ||
| 178 | ) => Effect.Effect<SecretStore, Error, any>; | ||
| 179 | updateStore: ( | ||
| 180 | input: TUpdateInput, | ||
| 181 | ) => Effect.Effect<SecretStore, Error, any>; | ||
| 182 | removeStore: ( | ||
| 183 | storeId: SecretStore["id"], | ||
| 184 | ) => Effect.Effect<boolean, Error, any>; | ||
| 185 | }; | ||
| 186 | |||
| 125 | export type ExecutorSourcePluginDefinition< | 187 | export type ExecutorSourcePluginDefinition< |
| 126 | TAddInput, | 188 | TAddInput, |
| 127 | TConnectInput, | 189 | TConnectInput, |
| @@ -183,6 +245,135 @@ export type ExecutorSourcePluginDefinition< · 55 unmodified lines | |||
| 183 | }; | 245 | }; |
| 184 | }; | 246 | }; |
| 185 | 247 | ||
| 248 | export type ExecutorSecretStorePluginCapabilities = { | ||
| 249 | canCreateSecrets: boolean; | ||
| 250 | canUpdateSecrets: boolean; | ||
| 251 | canDeleteSecrets: boolean; | ||
| 252 | canBrowseSecrets: boolean; | ||
| 253 | canImportSecrets: boolean; | ||
| 254 | }; | ||
| 255 | |||
| 256 | export type ExecutorSecretStoreBrowseEntry = { | ||
| 257 | key: string; | ||
| 258 | label: string; | ||
| 259 | description: string | null; | ||
| 260 | kind: "group" | "secret"; | ||
| 261 | }; | ||
| 262 | |||
| 263 | export type ExecutorSecretStoreBuiltinDefinition = { | ||
| 264 | storeId: string; | ||
| 265 | defaultPriority?: number; | ||
| 266 | enabled?: () => boolean; | ||
| 267 | createStore: () => Omit< | ||
| 268 | SecretStore, | ||
| 269 | "id" | "scopeId" | "createdAt" | "updatedAt" | ||
| 270 | >; | ||
| 271 | }; | ||
| 272 | |||
| 273 | export type ExecutorSecretStorePluginDefinition< | ||
| 274 | TAddInput, | ||
| 275 | TConnectInput, | ||
| 276 | TStoreConfig, | ||
| 277 | TStored, | ||
| 278 | _TUpdateInput extends { | ||
| 279 | storeId: string; | ||
| 280 | config: TStoreConfig; | ||
| 281 | }, | ||
| 282 | > = { | ||
| 283 | kind: string; | ||
| 284 | displayName: string; | ||
| 285 | builtin?: ExecutorSecretStoreBuiltinDefinition; | ||
| 286 | add?: { | ||
| 287 | inputSchema: Schema.Schema<TAddInput, any, never>; | ||
| 288 | inputSignatureWidth?: number; | ||
| 289 | helpText?: readonly string[]; | ||
| 290 | toConnectInput: (input: TAddInput) => TConnectInput; | ||
| 291 | }; | ||
| 292 | storage: ExecutorSecretStorePluginStorage<TStored>; | ||
| 293 | store: { | ||
| 294 | create: (input: TConnectInput) => { | ||
| 295 | store: Omit< | ||
| 296 | SecretStore, | ||
| 297 | "id" | "scopeId" | "createdAt" | "updatedAt" | ||
| 298 | >; | ||
| 299 | stored: TStored; | ||
| 300 | }; | ||
| 301 | update: (input: { | ||
| 302 | store: SecretStore; | ||
| 303 | config: TStoreConfig; | ||
| 304 | }) => { | ||
| 305 | store: SecretStore; | ||
| 306 | stored: TStored; | ||
| 307 | }; | ||
| 308 | toConfig: (input: { | ||
| 309 | store: SecretStore; | ||
| 310 | stored: TStored; | ||
| 311 | }) => TStoreConfig; | ||
| 312 | remove?: (input: { | ||
| 313 | store: SecretStore; | ||
| 314 | stored: TStored | null; | ||
| 315 | }) => Effect.Effect<void, Error, any>; | ||
| 316 | resolveSecret: (input: { | ||
| 317 | secret: SecretMaterial; | ||
| 318 | store: SecretStore; | ||
| 319 | stored: TStored | null; | ||
| 320 | context?: { | ||
| 321 | params?: Readonly<Record<string, string | undefined>>; | ||
| 322 | }; | ||
| 323 | }) => Effect.Effect<string, Error, any>; | ||
| 324 | createSecret?: (input: { | ||
| 325 | store: SecretStore; | ||
| 326 | stored: TStored | null; | ||
| 327 | purpose: SecretMaterialPurpose; | ||
| 328 | value: string; | ||
| 329 | name?: string | null; | ||
| 330 | }) => Effect.Effect<{ | ||
| 331 | handle: string; | ||
| 332 | name: string | null; | ||
| 333 | value?: string | null; | ||
| 334 | }, Error, any>; | ||
| 335 | updateSecret?: (input: { | ||
| 336 | secret: SecretMaterial; | ||
| 337 | store: SecretStore; | ||
| 338 | stored: TStored | null; | ||
| 339 | name?: string | null; | ||
| 340 | value?: string; | ||
| 341 | }) => Effect.Effect<{ | ||
| 342 | handle?: string; | ||
| 343 | name: string | null; | ||
| 344 | value?: string | null; | ||
| 345 | }, Error, any>; | ||
| 346 | deleteSecret?: (input: { | ||
| 347 | secret: SecretMaterial; | ||
| 348 | store: SecretStore; | ||
| 349 | stored: TStored | null; | ||
| 350 | }) => Effect.Effect<boolean, Error, any>; | ||
| 351 | browseSecrets?: (input: { | ||
| 352 | store: SecretStore; | ||
| 353 | stored: TStored | null; | ||
| 354 | parentKey?: string | null; | ||
| 355 | query?: string | null; | ||
| 356 | }) => Effect.Effect<{ | ||
| 357 | entries: ReadonlyArray<ExecutorSecretStoreBrowseEntry>; | ||
| 358 | }, Error, any>; | ||
| 359 | importSecret?: (input: { | ||
| 360 | store: SecretStore; | ||
| 361 | stored: TStored | null; | ||
| 362 | selectionKey: string; | ||
| 363 | purpose: SecretMaterialPurpose; | ||
| 364 | name?: string | null; | ||
| 365 | }) => Effect.Effect<{ | ||
| 366 | handle: string; | ||
| 367 | name: string | null; | ||
| 368 | value?: string | null; | ||
| 369 | }, Error, any>; | ||
| 370 | capabilities?: (input: { | ||
| 371 | store: SecretStore; | ||
| 372 | stored: TStored | null; | ||
| 373 | }) => ExecutorSecretStorePluginCapabilities; | ||
| 374 | }; | ||
| 375 | }; | ||
| 376 | |||
| 186 | export type ExecutorSourcePluginInput< | 377 | export type ExecutorSourcePluginInput< |
| 187 | TKey extends string = string, | 378 | TKey extends string = string, |
| 188 | TAddInput = unknown, | 379 | TAddInput = unknown, |
| @@ -220,6 +411,43 @@ export type ExecutorSourcePluginInput< · 31 unmodified lines | |||
| 220 | ) => Effect.Effect<PluginCleanup | void, Error, any>; | 411 | ) => Effect.Effect<PluginCleanup | void, Error, any>; |
| 221 | }; | 412 | }; |
| 222 | 413 | ||
| 414 | export type ExecutorSecretStorePluginInput< | ||
| 415 | TKey extends string = string, | ||
| 416 | TAddInput = unknown, | ||
| 417 | TConnectInput = unknown, | ||
| 418 | TStoreConfig = unknown, | ||
| 419 | TStored = unknown, | ||
| 420 | TUpdateInput extends { | ||
| 421 | storeId: string; | ||
| 422 | config: TStoreConfig; | ||
| 423 | } = { | ||
| 424 | storeId: string; | ||
| 425 | config: TStoreConfig; | ||
| 426 | }, | ||
| 427 | TExtension extends object = {}, | ||
| 428 | > = { | ||
| 429 | key: TKey; | ||
| 430 | secretStore: ExecutorSecretStorePluginDefinition< | ||
| 431 | TAddInput, | ||
| 432 | TConnectInput, | ||
| 433 | TStoreConfig, | ||
| 434 | TStored, | ||
| 435 | TUpdateInput | ||
| 436 | >; | ||
| 437 | extendExecutor?: (input: ExecutorSdkPluginContext & { | ||
| 438 | secretStore: ExecutorSecretStorePluginApi<TConnectInput, TStoreConfig, TUpdateInput>; | ||
| 439 | }) => TExtension; | ||
| 440 | start?: ( | ||
| 441 | input: ExecutorSdkPluginStartContext<TExtension> & { | ||
| 442 | secretStore: ExecutorSecretStorePluginApi< | ||
| 443 | TConnectInput, | ||
| 444 | TStoreConfig, | ||
| 445 | TUpdateInput | ||
| 446 | >; | ||
| 447 | }, | ||
| 448 | ) => Effect.Effect<PluginCleanup | void, Error, any>; | ||
| 449 | }; | ||
| 450 | |||
| 223 | const loadSourceOfKind = ( | 451 | const loadSourceOfKind = ( |
| 224 | sourceId: ExecutorSource["id"], | 452 | sourceId: ExecutorSource["id"], |
| 225 | input: { | 453 | input: { |
| @@ -358,6 +586,135 @@ const createExecutorSourcePluginApi = < · 132 unmodified lines | |||
| 358 | }), | 586 | }), |
| 359 | }); | 587 | }); |
| 360 | 588 | ||
| 589 | const loadStoreOfKind = ( | ||
| 590 | storeId: SecretStore["id"], | ||
| 591 | input: { | ||
| 592 | definition: ExecutorSecretStorePluginDefinition<any, any, any, any, any>; | ||
| 593 | host: ExecutorSecretStorePluginInternalHost; | ||
| 594 | }, | ||
| 595 | ): Effect.Effect<SecretStore, Error, any> => | ||
| 596 | Effect.gen(function* () { | ||
| 597 | const store = yield* input.host.secretStores.get(storeId); | ||
| 598 | if (store.kind !== input.definition.kind) { | ||
| 599 | return yield* runtimeEffectError( | ||
| 600 | "plugins", | ||
| 601 | `Secret store ${storeId} is not a ${input.definition.displayName} store.`, | ||
| 602 | ); | ||
| 603 | } | ||
| 604 | |||
| 605 | return store; | ||
| 606 | }); | ||
| 607 | |||
| 608 | const createExecutorSecretStorePluginApi = < | ||
| 609 | TAddInput, | ||
| 610 | TConnectInput, | ||
| 611 | TStoreConfig, | ||
| 612 | TStored, | ||
| 613 | _TUpdateInput extends { | ||
| 614 | storeId: string; | ||
| 615 | config: TStoreConfig; | ||
| 616 | }, | ||
| 617 | >( | ||
| 618 | definition: ExecutorSecretStorePluginDefinition< | ||
| 619 | TAddInput, | ||
| 620 | TConnectInput, | ||
| 621 | TStoreConfig, | ||
| 622 | TStored, | ||
| 623 | _TUpdateInput | ||
| 624 | >, | ||
| 625 | host: ExecutorSecretStorePluginInternalHost, | ||
| 626 | ): ExecutorSecretStorePluginApi<TConnectInput, TStoreConfig, _TUpdateInput> => ({ | ||
| 627 | getStore: (storeId) => | ||
| 628 | loadStoreOfKind(storeId, { | ||
| 629 | definition, | ||
| 630 | host, | ||
| 631 | }), | ||
| 632 | getStoreConfig: (storeId) => | ||
| 633 | Effect.gen(function* () { | ||
| 634 | const store = yield* loadStoreOfKind(storeId, { | ||
| 635 | definition, | ||
| 636 | host, | ||
| 637 | }); | ||
| 638 | const stored = yield* definition.storage.get({ | ||
| 639 | scopeId: store.scopeId, | ||
| 640 | storeId: store.id, | ||
| 641 | }); | ||
| 642 | if (stored === null) { | ||
| 643 | return yield* runtimeEffectError( | ||
| 644 | "plugins", | ||
| 645 | `${definition.displayName} store storage missing for ${store.id}`, | ||
| 646 | ); | ||
| 647 | } | ||
| 648 | |||
| 649 | return definition.store.toConfig({ | ||
| 650 | store, | ||
| 651 | stored, | ||
| 652 | }); | ||
| 653 | }), | ||
| 654 | createStore: (input) => | ||
| 655 | Effect.gen(function* () { | ||
| 656 | const created = definition.store.create(input); | ||
| 657 | const store = yield* host.secretStores.create({ | ||
| 658 | store: created.store, | ||
| 659 | }); | ||
| 660 | |||
| 661 | yield* definition.storage.put({ | ||
| 662 | scopeId: store.scopeId, | ||
| 663 | storeId: store.id, | ||
| 664 | value: created.stored, | ||
| 665 | }); | ||
| 666 | |||
| 667 | return store; | ||
| 668 | }), | ||
| 669 | updateStore: (input) => | ||
| 670 | Effect.gen(function* () { | ||
| 671 | const store = yield* loadStoreOfKind(input.storeId as SecretStore["id"], { | ||
| 672 | definition, | ||
| 673 | host, | ||
| 674 | }); | ||
| 675 | const updated = definition.store.update({ | ||
| 676 | store, | ||
| 677 | config: input.config, | ||
| 678 | }); | ||
| 679 | const saved = yield* host.secretStores.save(updated.store); | ||
| 680 | |||
| 681 | yield* definition.storage.put({ | ||
| 682 | scopeId: saved.scopeId, | ||
| 683 | storeId: saved.id, | ||
| 684 | value: updated.stored, | ||
| 685 | }); | ||
| 686 | |||
| 687 | return saved; | ||
| 688 | }), | ||
| 689 | removeStore: (storeId) => | ||
| 690 | Effect.gen(function* () { | ||
| 691 | const store = yield* loadStoreOfKind(storeId, { | ||
| 692 | definition, | ||
| 693 | host, | ||
| 694 | }); | ||
| 695 | const stored = yield* definition.storage.get({ | ||
| 696 | scopeId: store.scopeId, | ||
| 697 | storeId: store.id, | ||
| 698 | }); | ||
| 699 | |||
| 700 | if (definition.store.remove) { | ||
| 701 | yield* definition.store.remove({ | ||
| 702 | store, | ||
| 703 | stored, | ||
| 704 | }); | ||
| 705 | } | ||
| 706 | |||
| 707 | if (definition.storage.remove) { | ||
| 708 | yield* definition.storage.remove({ | ||
| 709 | scopeId: store.scopeId, | ||
| 710 | storeId: store.id, | ||
| 711 | }); | ||
| 712 | } | ||
| 713 | |||
| 714 | return yield* host.secretStores.remove(store.id); | ||
| 715 | }), | ||
| 716 | }); | ||
| 717 | |||
| 361 | type ExecutorSourceContribution<TInput = unknown> = { | 718 | type ExecutorSourceContribution<TInput = unknown> = { |
| 362 | kind: string; | 719 | kind: string; |
| 363 | displayName: string; | 720 | displayName: string; |
| @@ -382,6 +739,83 @@ type ExecutorSourceContribution<TInput = unknown> = { · 18 unmodified lines | |||
| 382 | ) => Effect.Effect<SourceInvokeResult, Error, any>; | 739 | ) => Effect.Effect<SourceInvokeResult, Error, any>; |
| 383 | }; | 740 | }; |
| 384 | 741 | ||
| 742 | type ExecutorSecretStoreContribution<TInput = unknown> = { | ||
| 743 | kind: string; | ||
| 744 | displayName: string; | ||
| 745 | builtin?: ExecutorSecretStoreBuiltinDefinition; | ||
| 746 | canCreate: boolean; | ||
| 747 | inputSchema?: Schema.Schema<TInput, any, never>; | ||
| 748 | inputSignatureWidth?: number; | ||
| 749 | helpText?: readonly string[]; | ||
| 750 | createStore?: (input: { | ||
| 751 | args: TInput; | ||
| 752 | host: ExecutorSecretStorePluginInternalHost; | ||
| 753 | }) => Effect.Effect<SecretStore, Error, any>; | ||
| 754 | getStoreConfig: (input: { | ||
| 755 | store: SecretStore; | ||
| 756 | }) => Effect.Effect<unknown, Error, any>; | ||
| 757 | updateStore: (input: { | ||
| 758 | store: SecretStore; | ||
| 759 | config: unknown; | ||
| 760 | host: ExecutorSecretStorePluginInternalHost; | ||
| 761 | }) => Effect.Effect<SecretStore, Error, any>; | ||
| 762 | removeStore: (input: { | ||
| 763 | store: SecretStore; | ||
| 764 | host: ExecutorSecretStorePluginInternalHost; | ||
| 765 | }) => Effect.Effect<boolean, Error, any>; | ||
| 766 | getCapabilities: (input: { | ||
| 767 | store: SecretStore; | ||
| 768 | }) => Effect.Effect<ExecutorSecretStorePluginCapabilities, Error, any>; | ||
| 769 | resolveSecret: (input: { | ||
| 770 | secret: SecretMaterial; | ||
| 771 | store: SecretStore; | ||
| 772 | context?: { | ||
| 773 | params?: Readonly<Record<string, string | undefined>>; | ||
| 774 | }; | ||
| 775 | }) => Effect.Effect<string, Error, any>; | ||
| 776 | createSecret: (input: { | ||
| 777 | store: SecretStore; | ||
| 778 | purpose: SecretMaterialPurpose; | ||
| 779 | value: string; | ||
| 780 | name?: string | null; | ||
| 781 | }) => Effect.Effect<{ | ||
| 782 | handle: string; | ||
| 783 | name: string | null; | ||
| 784 | value?: string | null; | ||
| 785 | }, Error, any>; | ||
| 786 | updateSecret: (input: { | ||
| 787 | secret: SecretMaterial; | ||
| 788 | store: SecretStore; | ||
| 789 | name?: string | null; | ||
| 790 | value?: string; | ||
| 791 | }) => Effect.Effect<{ | ||
| 792 | handle?: string; | ||
| 793 | name: string | null; | ||
| 794 | value?: string | null; | ||
| 795 | }, Error, any>; | ||
| 796 | deleteSecret: (input: { | ||
| 797 | secret: SecretMaterial; | ||
| 798 | store: SecretStore; | ||
| 799 | }) => Effect.Effect<boolean, Error, any>; | ||
| 800 | browseSecrets: (input: { | ||
| 801 | store: SecretStore; | ||
| 802 | parentKey?: string | null; | ||
| 803 | query?: string | null; | ||
| 804 | }) => Effect.Effect<{ | ||
| 805 | entries: ReadonlyArray<ExecutorSecretStoreBrowseEntry>; | ||
| 806 | }, Error, any>; | ||
| 807 | importSecret: (input: { | ||
| 808 | store: SecretStore; | ||
| 809 | selectionKey: string; | ||
| 810 | purpose: SecretMaterialPurpose; | ||
| 811 | name?: string | null; | ||
| 812 | }) => Effect.Effect<{ | ||
| 813 | handle: string; | ||
| 814 | name: string | null; | ||
| 815 | value?: string | null; | ||
| 816 | }, Error, any>; | ||
| 817 | }; | ||
| 818 | |||
| 385 | const createExecutorSourceContribution = < | 819 | const createExecutorSourceContribution = < |
| 386 | TAddInput, | 820 | TAddInput, |
| 387 | TConnectInput, | 821 | TConnectInput, |
| @@ -437,6 +871,199 @@ const createExecutorSourceContribution = < · 49 unmodified lines | |||
| 437 | ), | 871 | ), |
| 438 | }); | 872 | }); |
| 439 | 873 | ||
| 874 | const createExecutorSecretStoreContribution = < | ||
| 875 | TAddInput, | ||
| 876 | TConnectInput, | ||
| 877 | TStoreConfig, | ||
| 878 | TStored, | ||
| 879 | TUpdateInput extends { | ||
| 880 | storeId: string; | ||
| 881 | config: TStoreConfig; | ||
| 882 | }, | ||
| 883 | >( | ||
| 884 | definition: ExecutorSecretStorePluginDefinition< | ||
| 885 | TAddInput, | ||
| 886 | TConnectInput, | ||
| 887 | TStoreConfig, | ||
| 888 | TStored, | ||
| 889 | TUpdateInput | ||
| 890 | >, | ||
| 891 | ): ExecutorSecretStoreContribution<TAddInput> => ({ | ||
| 892 | kind: definition.kind, | ||
| 893 | displayName: definition.displayName, | ||
| 894 | builtin: definition.builtin, | ||
| 895 | canCreate: definition.add !== undefined, | ||
| 896 | inputSchema: definition.add?.inputSchema, | ||
| 897 | inputSignatureWidth: definition.add?.inputSignatureWidth, | ||
| 898 | helpText: definition.add?.helpText, | ||
| 899 | createStore: definition.add | ||
| 900 | ? ({ args, host }) => | ||
| 901 | createExecutorSecretStorePluginApi(definition, host).createStore( | ||
| 902 | definition.add!.toConnectInput(args), | ||
| 903 | ) | ||
| 904 | : undefined, | ||
| 905 | getStoreConfig: ({ store }) => | ||
| 906 | Effect.flatMap( | ||
| 907 | definition.storage.get({ | ||
| 908 | scopeId: store.scopeId, | ||
| 909 | storeId: store.id, | ||
| 910 | }), | ||
| 911 | (stored) => { | ||
| 912 | if (stored === null) { | ||
| 913 | return runtimeEffectError( | ||
| 914 | "plugins", | ||
| 915 | `${definition.displayName} store storage missing for ${store.id}`, | ||
| 916 | ); | ||
| 917 | } | ||
| 918 | |||
| 919 | return Effect.succeed( | ||
| 920 | definition.store.toConfig({ | ||
| 921 | store, | ||
| 922 | stored, | ||
| 923 | }), | ||
| 924 | ); | ||
| 925 | }, | ||
| 926 | ), | ||
| 927 | updateStore: ({ store, config, host }) => | ||
| 928 | createExecutorSecretStorePluginApi(definition, host).updateStore({ | ||
| 929 | storeId: store.id, | ||
| 930 | config: config as TStoreConfig, | ||
| 931 | } as unknown as TUpdateInput), | ||
| 932 | removeStore: ({ store, host }) => | ||
| 933 | createExecutorSecretStorePluginApi(definition, host).removeStore(store.id), | ||
| 934 | getCapabilities: ({ store }) => | ||
| 935 | Effect.flatMap( | ||
| 936 | definition.storage.get({ | ||
| 937 | scopeId: store.scopeId, | ||
| 938 | storeId: store.id, | ||
| 939 | }), | ||
| 940 | (stored) => | ||
| 941 | Effect.succeed( | ||
| 942 | definition.store.capabilities?.({ | ||
| 943 | store, | ||
| 944 | stored, | ||
| 945 | }) ?? { | ||
| 946 | canCreateSecrets: definition.store.createSecret !== undefined, | ||
| 947 | canUpdateSecrets: definition.store.updateSecret !== undefined, | ||
| 948 | canDeleteSecrets: definition.store.deleteSecret !== undefined, | ||
| 949 | canBrowseSecrets: definition.store.browseSecrets !== undefined, | ||
| 950 | canImportSecrets: definition.store.importSecret !== undefined, | ||
| 951 | }, | ||
| 952 | ), | ||
| 953 | ), | ||
| 954 | resolveSecret: ({ secret, store, context }) => | ||
| 955 | Effect.flatMap( | ||
| 956 | definition.storage.get({ | ||
| 957 | scopeId: store.scopeId, | ||
| 958 | storeId: store.id, | ||
| 959 | }), | ||
| 960 | (stored) => | ||
| 961 | definition.store.resolveSecret({ | ||
| 962 | secret, | ||
| 963 | store, | ||
| 964 | stored, | ||
| 965 | context, | ||
| 966 | }), | ||
| 967 | ), | ||
| 968 | createSecret: ({ store, purpose, value, name }) => | ||
| 969 | definition.store.createSecret | ||
| 970 | ? Effect.flatMap( | ||
| 971 | definition.storage.get({ | ||
| 972 | scopeId: store.scopeId, | ||
| 973 | storeId: store.id, | ||
| 974 | }), | ||
| 975 | (stored) => | ||
| 976 | definition.store.createSecret!({ | ||
| 977 | store, | ||
| 978 | stored, | ||
| 979 | purpose, | ||
| 980 | value, | ||
| 981 | name, | ||
| 982 | }), | ||
| 983 | ) | ||
| 984 | : runtimeEffectError( | ||
| 985 | "plugins", | ||
| 986 | `Secret store ${store.id} does not support creating secrets.`, | ||
| 987 | ), | ||
| 988 | updateSecret: ({ secret, store, name, value }) => | ||
| 989 | definition.store.updateSecret | ||
| 990 | ? Effect.flatMap( | ||
| 991 | definition.storage.get({ | ||
| 992 | scopeId: store.scopeId, | ||
| 993 | storeId: store.id, | ||
| 994 | }), | ||
| 995 | (stored) => | ||
| 996 | definition.store.updateSecret!({ | ||
| 997 | secret, | ||
| 998 | store, | ||
| 999 | stored, | ||
| 1000 | name, | ||
| 1001 | value, | ||
| 1002 | }), | ||
| 1003 | ) | ||
| 1004 | : runtimeEffectError( | ||
| 1005 | "plugins", | ||
| 1006 | `Secret store ${store.id} does not support updating secrets.`, | ||
| 1007 | ), | ||
| 1008 | deleteSecret: ({ secret, store }) => | ||
| 1009 | definition.store.deleteSecret | ||
| 1010 | ? Effect.flatMap( | ||
| 1011 | definition.storage.get({ | ||
| 1012 | scopeId: store.scopeId, | ||
| 1013 | storeId: store.id, | ||
| 1014 | }), | ||
| 1015 | (stored) => | ||
| 1016 | definition.store.deleteSecret!({ | ||
| 1017 | secret, | ||
| 1018 | store, | ||
| 1019 | stored, | ||
| 1020 | }), | ||
| 1021 | ) | ||
| 1022 | : runtimeEffectError( | ||
| 1023 | "plugins", | ||
| 1024 | `Secret store ${store.id} does not support deleting secrets.`, | ||
| 1025 | ), | ||
| 1026 | browseSecrets: ({ store, parentKey, query }) => | ||
| 1027 | definition.store.browseSecrets | ||
| 1028 | ? Effect.flatMap( | ||
| 1029 | definition.storage.get({ | ||
| 1030 | scopeId: store.scopeId, | ||
| 1031 | storeId: store.id, | ||
| 1032 | }), | ||
| 1033 | (stored) => | ||
| 1034 | definition.store.browseSecrets!({ | ||
| 1035 | store, | ||
| 1036 | stored, | ||
| 1037 | parentKey, | ||
| 1038 | query, | ||
| 1039 | }), | ||
| 1040 | ) | ||
| 1041 | : runtimeEffectError( | ||
| 1042 | "plugins", | ||
| 1043 | `Secret store ${store.id} does not support browsing secrets.`, | ||
| 1044 | ), | ||
| 1045 | importSecret: ({ store, selectionKey, purpose, name }) => | ||
| 1046 | definition.store.importSecret | ||
| 1047 | ? Effect.flatMap( | ||
| 1048 | definition.storage.get({ | ||
| 1049 | scopeId: store.scopeId, | ||
| 1050 | storeId: store.id, | ||
| 1051 | }), | ||
| 1052 | (stored) => | ||
| 1053 | definition.store.importSecret!({ | ||
| 1054 | store, | ||
| 1055 | stored, | ||
| 1056 | selectionKey, | ||
| 1057 | purpose, | ||
| 1058 | name, | ||
| 1059 | }), | ||
| 1060 | ) | ||
| 1061 | : runtimeEffectError( | ||
| 1062 | "plugins", | ||
| 1063 | `Secret store ${store.id} does not support importing secrets.`, | ||
| 1064 | ), | ||
| 1065 | }); | ||
| 1066 | |||
| 440 | export const defineExecutorSourcePlugin = < | 1067 | export const defineExecutorSourcePlugin = < |
| 441 | const TKey extends string, | 1068 | const TKey extends string, |
| 442 | TAddInput, | 1069 | TAddInput, |
| @@ -487,6 +1114,56 @@ export const defineExecutorSourcePlugin = < · 44 unmodified lines | |||
| 487 | }, | 1114 | }, |
| 488 | }))(input.extendExecutor, input.start); | 1115 | }))(input.extendExecutor, input.start); |
| 489 | 1116 | ||
| 1117 | export const defineExecutorSecretStorePlugin = < | ||
| 1118 | const TKey extends string, | ||
| 1119 | TAddInput, | ||
| 1120 | TConnectInput, | ||
| 1121 | TStoreConfig, | ||
| 1122 | TStored, | ||
| 1123 | TUpdateInput extends { | ||
| 1124 | storeId: string; | ||
| 1125 | config: TStoreConfig; | ||
| 1126 | }, | ||
| 1127 | TExtension extends object = {}, | ||
| 1128 | >( | ||
| 1129 | input: ExecutorSecretStorePluginInput< | ||
| 1130 | TKey, | ||
| 1131 | TAddInput, | ||
| 1132 | TConnectInput, | ||
| 1133 | TStoreConfig, | ||
| 1134 | TStored, | ||
| 1135 | TUpdateInput, | ||
| 1136 | TExtension | ||
| 1137 | >, | ||
| 1138 | ): ExecutorSdkPlugin<TKey, TExtension> => | ||
| 1139 | ((extendExecutor, start) => | ||
| 1140 | defineExecutorSdkPlugin({ | ||
| 1141 | key: input.key, | ||
| 1142 | extendExecutor: extendExecutor | ||
| 1143 | ? (pluginInput) => | ||
| 1144 | extendExecutor({ | ||
| 1145 | ...pluginInput, | ||
| 1146 | secretStore: createExecutorSecretStorePluginApi( | ||
| 1147 | input.secretStore, | ||
| 1148 | pluginInput.host as ExecutorSecretStorePluginInternalHost, | ||
| 1149 | ), | ||
| 1150 | }) | ||
| 1151 | : undefined, | ||
| 1152 | start: start | ||
| 1153 | ? (pluginInput) => | ||
| 1154 | start({ | ||
| 1155 | ...pluginInput, | ||
| 1156 | secretStore: createExecutorSecretStorePluginApi( | ||
| 1157 | input.secretStore, | ||
| 1158 | pluginInput.host as ExecutorSecretStorePluginInternalHost, | ||
| 1159 | ), | ||
| 1160 | }) | ||
| 1161 | : undefined, | ||
| 1162 | [executorSdkPluginInternalsSymbol]: { | ||
| 1163 | secretStores: [createExecutorSecretStoreContribution(input.secretStore)], | ||
| 1164 | }, | ||
| 1165 | }))(input.extendExecutor, input.start); | ||
| 1166 | |||
| 490 | export type ExecutorSdkPluginExtensions< | 1167 | export type ExecutorSdkPluginExtensions< |
| 491 | TPlugins extends readonly ExecutorSdkPlugin<any, any>[], | 1168 | TPlugins extends readonly ExecutorSdkPlugin<any, any>[], |
| 492 | > = { | 1169 | > = { |
| @@ -501,6 +1178,7 @@ export const registerExecutorSdkPlugins = ( · 8 unmodified lines | |||
| 501 | ) => { | 1178 | ) => { |
| 502 | const pluginKeys = new Set<string>(); | 1179 | const pluginKeys = new Set<string>(); |
| 503 | const sources = new Map<string, ExecutorSourceContribution<any>>(); | 1180 | const sources = new Map<string, ExecutorSourceContribution<any>>(); |
| 1181 | const secretStores = new Map<string, ExecutorSecretStoreContribution<any>>(); | ||
| 504 | 1182 | ||
| 505 | for (const plugin of plugins) { | 1183 | for (const plugin of plugins) { |
| 506 | if (pluginKeys.has(plugin.key)) { | 1184 | if (pluginKeys.has(plugin.key)) { |
| @@ -520,6 +1198,16 @@ export const registerExecutorSdkPlugins = ( · 13 unmodified lines | |||
| 520 | 1198 | ||
| 521 | sources.set(source.kind, source); | 1199 | sources.set(source.kind, source); |
| 522 | } | 1200 | } |
| 1201 | |||
| 1202 | for (const secretStore of internals?.secretStores ?? []) { | ||
| 1203 | if (secretStores.has(secretStore.kind)) { | ||
| 1204 | throw new Error( | ||
| 1205 | `Duplicate secret store registration: ${secretStore.kind}`, | ||
| 1206 | ); | ||
| 1207 | } | ||
| 1208 | |||
| 1209 | secretStores.set(secretStore.kind, secretStore); | ||
| 1210 | } | ||
| 523 | } | 1211 | } |
| 524 | 1212 | ||
| 525 | const getSourceContribution = (kind: string) => { | 1213 | const getSourceContribution = (kind: string) => { |
| @@ -534,10 +1222,26 @@ export const registerExecutorSdkPlugins = ( · 8 unmodified lines | |||
| 534 | const getSourceContributionForSource = (source: Pick<ExecutorSource, "kind">) => | 1222 | const getSourceContributionForSource = (source: Pick<ExecutorSource, "kind">) => |
| 535 | getSourceContribution(source.kind); | 1223 | getSourceContribution(source.kind); |
| 536 | 1224 | ||
| 1225 | const getSecretStoreContribution = (kind: string) => { | ||
| 1226 | const definition = secretStores.get(kind); | ||
| 1227 | if (!definition) { | ||
| 1228 | throw new Error(`Unsupported secret store kind: ${kind}`); | ||
| 1229 | } | ||
| 1230 | |||
| 1231 | return definition; | ||
| 1232 | }; | ||
| 1233 | |||
| 1234 | const getSecretStoreContributionForStore = ( | ||
| 1235 | store: Pick<SecretStore, "kind">, | ||
| 1236 | ) => getSecretStoreContribution(store.kind); | ||
| 1237 | |||
| 537 | return { | 1238 | return { |
| 538 | plugins, | 1239 | plugins, |
| 539 | sources: [...sources.values()], | 1240 | sources: [...sources.values()], |
| 1241 | secretStores: [...secretStores.values()], | ||
| 540 | getSourceContribution, | 1242 | getSourceContribution, |
| 541 | getSourceContributionForSource, | 1243 | getSourceContributionForSource, |
| 1244 | getSecretStoreContribution, | ||
| 1245 | getSecretStoreContributionForStore, | ||
| 542 | }; | 1246 | }; |
| 543 | }; | 1247 | }; |
| @@ -48,6 +48,9 @@ import { · 47 unmodified lines | |||
| 48 | import { | 48 | import { |
| 49 | createExecutorToolMap, | 49 | createExecutorToolMap, |
| 50 | } from "../../sources/executor-tools"; | 50 | } from "../../sources/executor-tools"; |
| 51 | import { | ||
| 52 | registeredSourceContributions, | ||
| 53 | } from "../../sources/source-plugins"; | ||
| 51 | import { | 54 | import { |
| 52 | RuntimeSourceCatalogSyncService, | 55 | RuntimeSourceCatalogSyncService, |
| 53 | } from "../../catalog/source/sync"; | 56 | } from "../../catalog/source/sync"; |
| @@ -147,10 +150,12 @@ export const createScopeToolInvoker = (input: { · 93 unmodified lines | |||
| 147 | return catalog; | 150 | return catalog; |
| 148 | }, | 151 | }, |
| 149 | }); | 152 | }); |
| 153 | const hasLocalToolsSourceContribution = registeredSourceContributions() | ||
| 154 | .some((source) => source.kind === "local-tools"); | ||
| 150 | const authoredTools = mergeToolMaps([ | 155 | const authoredTools = mergeToolMaps([ |
| 151 | systemTools, | 156 | systemTools, |
| 152 | executorTools, | 157 | executorTools, |
| 153 | input.localToolRuntime.tools, | 158 | ...(hasLocalToolsSourceContribution ? [] : [input.localToolRuntime.tools]), |
| 154 | ]); | 159 | ]); |
| 155 | const authoredCatalog = createToolCatalogFromTools({ | 160 | const authoredCatalog = createToolCatalogFromTools({ |
| 156 | tools: authoredTools, | 161 | tools: authoredTools, |
| @@ -6,18 +6,48 @@ import type { · 5 unmodified lines | |||
| 6 | ExecutionInteraction, | 6 | ExecutionInteraction, |
| 7 | ExecutionStep, | 7 | ExecutionStep, |
| 8 | SecretMaterial, | 8 | SecretMaterial, |
| 9 | SecretStore, | ||
| 9 | } from "#schema"; | 10 | } from "#schema"; |
| 10 | 11 | ||
| 11 | type SecretMaterialSummary = { | 12 | type SecretMaterialSummary = { |
| 12 | id: string; | 13 | id: string; |
| 13 | providerId: string; | 14 | storeId: string; |
| 14 | name: string | null; | 15 | name: string | null; |
| 15 | purpose: string; | 16 | purpose: string; |
| 16 | createdAt: number; | 17 | createdAt: number; |
| 17 | updatedAt: number; | 18 | updatedAt: number; |
| 18 | }; | 19 | }; |
| 19 | 20 | ||
| 21 | type SecretStoreSummary = { | ||
| 22 | id: string; | ||
| 23 | scopeId: string; | ||
| 24 | kind: string; | ||
| 25 | name: string; | ||
| 26 | status: string; | ||
| 27 | enabled: boolean; | ||
| 28 | createdAt: number; | ||
| 29 | updatedAt: number; | ||
| 30 | }; | ||
| 31 | |||
| 20 | export type ExecutorStateStoreShape = { | 32 | export type ExecutorStateStoreShape = { |
| 33 | secretStores: { | ||
| 34 | getById: ( | ||
| 35 | id: SecretStore["id"], | ||
| 36 | ) => Effect.Effect<import("effect/Option").Option<SecretStore>, Error, never>; | ||
| 37 | listAll: () => Effect.Effect<readonly SecretStoreSummary[], Error, never>; | ||
| 38 | upsert: (store: SecretStore) => Effect.Effect<void, Error, never>; | ||
| 39 | updateById: ( | ||
| 40 | id: SecretStore["id"], | ||
| 41 | update: Partial< | ||
| 42 | Pick<SecretStore, "name" | "status" | "enabled"> | ||
| 43 | >, | ||
| 44 | ) => Effect.Effect< | ||
| 45 | import("effect/Option").Option<SecretStoreSummary>, | ||
| 46 | Error, | ||
| 47 | never | ||
| 48 | >; | ||
| 49 | removeById: (id: SecretStore["id"]) => Effect.Effect<boolean, Error, never>; | ||
| 50 | }; | ||
| 21 | secretMaterials: { | 51 | secretMaterials: { |
| 22 | getById: ( | 52 | getById: ( |
| 23 | id: SecretMaterial["id"], | 53 | id: SecretMaterial["id"], |
| @@ -89,6 +89,7 @@ import { · 88 unmodified lines | |||
| 89 | } from "./sources/source-store"; | 89 | } from "./sources/source-store"; |
| 90 | 90 | ||
| 91 | export * from "./execution/state"; | 91 | export * from "./execution/state"; |
| 92 | export * from "./secret-stores"; | ||
| 92 | export * from "./sources/executor-tools"; | 93 | export * from "./sources/executor-tools"; |
| 93 | export * from "./execution/live"; | 94 | export * from "./execution/live"; |
| 94 | export * from "./catalog/schema-type-signature"; | 95 | export * from "./catalog/schema-type-signature"; |
| @@ -137,9 +138,12 @@ export type { · 42 unmodified lines | |||
| 137 | } from "./local-tool-runtime"; | 138 | } from "./local-tool-runtime"; |
| 138 | export { | 139 | export { |
| 139 | registeredSourceContributions, | 140 | registeredSourceContributions, |
| 141 | registeredSecretStoreContributions, | ||
| 140 | hasRegisteredExternalSourcePlugins, | 142 | hasRegisteredExternalSourcePlugins, |
| 141 | getSourceContribution, | 143 | getSourceContribution, |
| 142 | getSourceContributionForSource, | 144 | getSourceContributionForSource, |
| 145 | getSecretStoreContribution, | ||
| 146 | getSecretStoreContributionForStore, | ||
| 143 | } from "./sources/source-plugins"; | 147 | } from "./sources/source-plugins"; |
| 144 | 148 | ||
| 145 | export type ExecutorRuntimeOptions = { | 149 | export type ExecutorRuntimeOptions = { |
| @@ -219,6 +223,9 @@ export const prewarmWorkspaceSourceCatalogToolIndex = (input: { · 73 unmodified lines | |||
| 219 | }); | 223 | }); |
| 220 | 224 | ||
| 221 | export type RuntimeSecretsStorageServices = | 225 | export type RuntimeSecretsStorageServices = |
| 226 | & { | ||
| 227 | secretStores: ExecutorStateStoreShape["secretStores"]; | ||
| 228 | } | ||
| 222 | & ExecutorStateStoreShape["secretMaterials"] | 229 | & ExecutorStateStoreShape["secretMaterials"] |
| 223 | & RuntimeSecretMaterialServices; | 230 | & RuntimeSecretMaterialServices; |
| 224 | 231 | ||
| @@ -310,6 +317,7 @@ const makeInstanceConfigLayer = (input: RuntimeInstanceConfigService) => · 85 unmodified lines | |||
| 310 | const toExecutorStateStoreShape = ( | 317 | const toExecutorStateStoreShape = ( |
| 311 | input: RuntimeStorageServices, | 318 | input: RuntimeStorageServices, |
| 312 | ): ExecutorStateStoreShape => ({ | 319 | ): ExecutorStateStoreShape => ({ |
| 320 | secretStores: input.secrets.secretStores, | ||
| 313 | secretMaterials: input.secrets, | 321 | secretMaterials: input.secrets, |
| 314 | executions: input.executions.runs, | 322 | executions: input.executions.runs, |
| 315 | executionInteractions: input.executions.interactions, | 323 | executionInteractions: input.executions.interactions, |
| @@ -22,7 +22,7 @@ export type StoreSecretMaterial = (input: { · 21 unmodified lines | |||
| 22 | purpose: SecretMaterialPurpose; | 22 | purpose: SecretMaterialPurpose; |
| 23 | value: string; | 23 | value: string; |
| 24 | name?: string | null; | 24 | name?: string | null; |
| 25 | providerId?: string; | 25 | storeId?: string; |
| 26 | }) => Effect.Effect<SecretRef, Error, never>; | 26 | }) => Effect.Effect<SecretRef, Error, never>; |
| 27 | 27 | ||
| 28 | export type DeleteSecretMaterial = ( | 28 | export type DeleteSecretMaterial = ( |
| @@ -35,7 +35,7 @@ export type UpdateSecretMaterial = (input: { · 6 unmodified lines | |||
| 35 | value?: string; | 35 | value?: string; |
| 36 | }) => Effect.Effect<{ | 36 | }) => Effect.Effect<{ |
| 37 | id: string; | 37 | id: string; |
| 38 | providerId: string; | 38 | storeId: string; |
| 39 | name: string | null; | 39 | name: string | null; |
| 40 | purpose: string; | 40 | purpose: string; |
| 41 | createdAt: number; | 41 | createdAt: number; |
| @@ -0,0 +1,76 @@ | |||
| 1 | import { | ||
| 2 | type ScopeId, | ||
| 3 | SecretStoreIdSchema, | ||
| 4 | type SecretStore, | ||
| 5 | } from "#schema"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as Option from "effect/Option"; | ||
| 8 | |||
| 9 | import { | ||
| 10 | ExecutorStateStore, | ||
| 11 | } from "./executor-state-store"; | ||
| 12 | import { | ||
| 13 | runtimeEffectError, | ||
| 14 | } from "./effect-errors"; | ||
| 15 | |||
| 16 | export const listManagedSecretStores = () => | ||
| 17 | Effect.flatMap(ExecutorStateStore, (store) => store.secretStores.listAll()); | ||
| 18 | |||
| 19 | export const getManagedSecretStore = (storeId: SecretStore["id"]) => | ||
| 20 | Effect.gen(function* () { | ||
| 21 | const store = yield* ExecutorStateStore; | ||
| 22 | const existing = yield* store.secretStores.getById(storeId); | ||
| 23 | if (Option.isNone(existing)) { | ||
| 24 | return yield* runtimeEffectError( | ||
| 25 | "secret-stores", | ||
| 26 | `Secret store not found: ${storeId}`, | ||
| 27 | ); | ||
| 28 | } | ||
| 29 | |||
| 30 | return existing.value; | ||
| 31 | }); | ||
| 32 | |||
| 33 | export const createManagedSecretStoreRecord = (input: { | ||
| 34 | scopeId: ScopeId; | ||
| 35 | store: Omit< | ||
| 36 | SecretStore, | ||
| 37 | "id" | "scopeId" | "createdAt" | "updatedAt" | ||
| 38 | >; | ||
| 39 | storeId?: SecretStore["id"]; | ||
| 40 | }) => | ||
| 41 | Effect.gen(function* () { | ||
| 42 | const executorState = yield* ExecutorStateStore; | ||
| 43 | const now = Date.now(); | ||
| 44 | const store: SecretStore = { | ||
| 45 | id: | ||
| 46 | input.storeId | ||
| 47 | ?? SecretStoreIdSchema.make(`sts_${crypto.randomUUID()}`), | ||
| 48 | scopeId: input.scopeId, | ||
| 49 | createdAt: now, | ||
| 50 | updatedAt: now, | ||
| 51 | ...input.store, | ||
| 52 | }; | ||
| 53 | yield* executorState.secretStores.upsert(store); | ||
| 54 | return store; | ||
| 55 | }); | ||
| 56 | |||
| 57 | export const saveManagedSecretStoreRecord = ( | ||
| 58 | store: SecretStore, | ||
| 59 | ) => | ||
| 60 | Effect.gen(function* () { | ||
| 61 | const executorState = yield* ExecutorStateStore; | ||
| 62 | const nextStore: SecretStore = { | ||
| 63 | ...store, | ||
| 64 | updatedAt: Date.now(), | ||
| 65 | }; | ||
| 66 | yield* executorState.secretStores.upsert(nextStore); | ||
| 67 | return nextStore; | ||
| 68 | }); | ||
| 69 | |||
| 70 | export const removeManagedSecretStoreRecord = ( | ||
| 71 | storeId: SecretStore["id"], | ||
| 72 | ) => | ||
| 73 | Effect.gen(function* () { | ||
| 74 | const executorState = yield* ExecutorStateStore; | ||
| 75 | return yield* executorState.secretStores.removeById(storeId); | ||
| 76 | }); | ||
| @@ -18,12 +18,18 @@ export const configureExecutorSourcePlugins = ( · 17 unmodified lines | |||
| 18 | }; | 18 | }; |
| 19 | 19 | ||
| 20 | export const registeredSourceContributions = () => registry.sources; | 20 | export const registeredSourceContributions = () => registry.sources; |
| 21 | export const registeredSecretStoreContributions = () => registry.secretStores; | ||
| 21 | 22 | ||
| 22 | export const getSourceContribution = (kind: string) => | 23 | export const getSourceContribution = (kind: string) => |
| 23 | registry.getSourceContribution(kind); | 24 | registry.getSourceContribution(kind); |
| 24 | export const getSourceContributionForSource = ( | 25 | export const getSourceContributionForSource = ( |
| 25 | source: Parameters<typeof registry.getSourceContributionForSource>[0], | 26 | source: Parameters<typeof registry.getSourceContributionForSource>[0], |
| 26 | ) => registry.getSourceContributionForSource(source); | 27 | ) => registry.getSourceContributionForSource(source); |
| 28 | export const getSecretStoreContribution = (kind: string) => | ||
| 29 | registry.getSecretStoreContribution(kind); | ||
| 30 | export const getSecretStoreContributionForStore = ( | ||
| 31 | store: Parameters<typeof registry.getSecretStoreContributionForStore>[0], | ||
| 32 | ) => registry.getSecretStoreContributionForStore(store); | ||
| 27 | 33 | ||
| 28 | export const hasRegisteredExternalSourcePlugins = () => | 34 | export const hasRegisteredExternalSourcePlugins = () => |
| 29 | configuredSourcePlugins.length > 0; | 35 | configuredSourcePlugins.length > 0; |
| @@ -11,6 +11,9 @@ export const SourceCatalogRevisionIdSchema = Schema.String.pipe( · 10 unmodified lines | |||
| 11 | export const SecretMaterialIdSchema = Schema.String.pipe( | 11 | export const SecretMaterialIdSchema = Schema.String.pipe( |
| 12 | Schema.brand("SecretMaterialId"), | 12 | Schema.brand("SecretMaterialId"), |
| 13 | ); | 13 | ); |
| 14 | export const SecretStoreIdSchema = Schema.String.pipe( | ||
| 15 | Schema.brand("SecretStoreId"), | ||
| 16 | ); | ||
| 14 | export const PolicyIdSchema = Schema.String.pipe(Schema.brand("PolicyId")); | 17 | export const PolicyIdSchema = Schema.String.pipe(Schema.brand("PolicyId")); |
| 15 | export const ExecutionIdSchema = Schema.String.pipe(Schema.brand("ExecutionId")); | 18 | export const ExecutionIdSchema = Schema.String.pipe(Schema.brand("ExecutionId")); |
| 16 | export const ExecutionInteractionIdSchema = Schema.String.pipe( | 19 | export const ExecutionInteractionIdSchema = Schema.String.pipe( |
| @@ -25,6 +28,7 @@ export type SourceId = typeof SourceIdSchema.Type; · 8 unmodified lines | |||
| 25 | export type SourceCatalogId = typeof SourceCatalogIdSchema.Type; | 28 | export type SourceCatalogId = typeof SourceCatalogIdSchema.Type; |
| 26 | export type SourceCatalogRevisionId = typeof SourceCatalogRevisionIdSchema.Type; | 29 | export type SourceCatalogRevisionId = typeof SourceCatalogRevisionIdSchema.Type; |
| 27 | export type SecretMaterialId = typeof SecretMaterialIdSchema.Type; | 30 | export type SecretMaterialId = typeof SecretMaterialIdSchema.Type; |
| 31 | export type SecretStoreId = typeof SecretStoreIdSchema.Type; | ||
| 28 | export type PolicyId = typeof PolicyIdSchema.Type; | 32 | export type PolicyId = typeof PolicyIdSchema.Type; |
| 29 | export type ExecutionId = typeof ExecutionIdSchema.Type; | 33 | export type ExecutionId = typeof ExecutionIdSchema.Type; |
| 30 | export type ExecutionInteractionId = typeof ExecutionInteractionIdSchema.Type; | 34 | export type ExecutionInteractionId = typeof ExecutionInteractionIdSchema.Type; |
| @@ -6,6 +6,7 @@ export * from "./models/source"; · 5 unmodified lines | |||
| 6 | export * from "./models/source-catalog"; | 6 | export * from "./models/source-catalog"; |
| 7 | export * from "./models/secret-ref"; | 7 | export * from "./models/secret-ref"; |
| 8 | export * from "./models/secret-material"; | 8 | export * from "./models/secret-material"; |
| 9 | export * from "./models/secret-store"; | ||
| 9 | export * from "./models/policy"; | 10 | export * from "./models/policy"; |
| 10 | export * from "./models/local-installation"; | 11 | export * from "./models/local-installation"; |
| 11 | export * from "./models/local-config"; | 12 | export * from "./models/local-config"; |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | |||
| 7 | } from "../common"; | 7 | } from "../common"; |
| 8 | import { | 8 | import { |
| 9 | SecretMaterialIdSchema, | 9 | SecretMaterialIdSchema, |
| 10 | SecretStoreIdSchema, | ||
| 10 | } from "../ids"; | 11 | } from "../ids"; |
| 11 | 12 | ||
| 12 | export const SecretMaterialPurposeSchema = Schema.Literal( | 13 | export const SecretMaterialPurposeSchema = Schema.Literal( |
| @@ -20,7 +21,7 @@ export const SecretMaterialSchema = Schema.Struct({ · 7 unmodified lines | |||
| 20 | id: SecretMaterialIdSchema, | 21 | id: SecretMaterialIdSchema, |
| 21 | name: Schema.NullOr(Schema.String), | 22 | name: Schema.NullOr(Schema.String), |
| 22 | purpose: SecretMaterialPurposeSchema, | 23 | purpose: SecretMaterialPurposeSchema, |
| 23 | providerId: Schema.String, | 24 | storeId: SecretStoreIdSchema, |
| 24 | handle: Schema.String, | 25 | handle: Schema.String, |
| 25 | value: Schema.NullOr(Schema.String), | 26 | value: Schema.NullOr(Schema.String), |
| 26 | createdAt: TimestampMsSchema, | 27 | createdAt: TimestampMsSchema, |
| @@ -1,8 +1,10 @@ | |||
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | import { | ||
| 3 | SecretMaterialIdSchema, | ||
| 4 | } from "../ids"; | ||
| 2 | 5 | ||
| 3 | export const SecretRefSchema = Schema.Struct({ | 6 | export const SecretRefSchema = Schema.Struct({ |
| 4 | providerId: Schema.String, | 7 | secretId: SecretMaterialIdSchema, |
| 5 | handle: Schema.String, | ||
| 6 | }); | 8 | }); |
| 7 | 9 | ||
| 8 | export type SecretRef = typeof SecretRefSchema.Type; | 10 | export type SecretRef = typeof SecretRefSchema.Type; |
| @@ -0,0 +1,33 @@ | |||
| 1 | import { | ||
| 2 | Schema, | ||
| 3 | } from "effect"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | TimestampMsSchema, | ||
| 7 | } from "../common"; | ||
| 8 | import { | ||
| 9 | ScopeIdSchema, | ||
| 10 | SecretStoreIdSchema, | ||
| 11 | } from "../ids"; | ||
| 12 | |||
| 13 | export const SecretStoreKindSchema = Schema.String; | ||
| 14 | |||
| 15 | export const SecretStoreStatusSchema = Schema.Literal( | ||
| 16 | "connected", | ||
| 17 | "error", | ||
| 18 | ); | ||
| 19 | |||
| 20 | export const SecretStoreSchema = Schema.Struct({ | ||
| 21 | id: SecretStoreIdSchema, | ||
| 22 | scopeId: ScopeIdSchema, | ||
| 23 | name: Schema.String, | ||
| 24 | kind: SecretStoreKindSchema, | ||
| 25 | status: SecretStoreStatusSchema, | ||
| 26 | enabled: Schema.Boolean, | ||
| 27 | createdAt: TimestampMsSchema, | ||
| 28 | updatedAt: TimestampMsSchema, | ||
| 29 | }); | ||
| 30 | |||
| 31 | export type SecretStoreKind = typeof SecretStoreKindSchema.Type; | ||
| 32 | export type SecretStoreStatus = typeof SecretStoreStatusSchema.Type; | ||
| 33 | export type SecretStore = typeof SecretStoreSchema.Type; | ||
| @@ -0,0 +1,55 @@ | |||
| 1 | { | ||
| 2 | "endpoint": "http://127.0.0.1:53929/mcp", | ||
| 3 | "redirectUrl": "http://127.0.0.1:53930/v1/plugins/mcp/oauth/callback", | ||
| 4 | "codeVerifier": "UHBnonYD3tG2uUy9hF3NR-T24ViJUtWiJMfHldJaIEp", | ||
| 5 | "resourceMetadataUrl": null, | ||
| 6 | "authorizationServerUrl": "http://127.0.0.1:53929", | ||
| 7 | "resourceMetadata": { | ||
| 8 | "resource": "http://127.0.0.1:53929/mcp", | ||
| 9 | "authorization_servers": [ | ||
| 10 | "http://127.0.0.1:53929" | ||
| 11 | ], | ||
| 12 | "scopes_supported": [ | ||
| 13 | "openid", | ||
| 14 | "offline_access" | ||
| 15 | ], | ||
| 16 | "bearer_methods_supported": [ | ||
| 17 | "header" | ||
| 18 | ] | ||
| 19 | }, | ||
| 20 | "authorizationServerMetadata": { | ||
| 21 | "issuer": "http://127.0.0.1:53929", | ||
| 22 | "authorization_endpoint": "http://127.0.0.1:53929/authorize", | ||
| 23 | "token_endpoint": "http://127.0.0.1:53929/token", | ||
| 24 | "registration_endpoint": "http://127.0.0.1:53929/register", | ||
| 25 | "response_types_supported": [ | ||
| 26 | "code" | ||
| 27 | ], | ||
| 28 | "grant_types_supported": [ | ||
| 29 | "authorization_code", | ||
| 30 | "refresh_token" | ||
| 31 | ], | ||
| 32 | "token_endpoint_auth_methods_supported": [ | ||
| 33 | "none" | ||
| 34 | ], | ||
| 35 | "code_challenge_methods_supported": [ | ||
| 36 | "S256" | ||
| 37 | ] | ||
| 38 | }, | ||
| 39 | "clientInformation": { | ||
| 40 | "redirect_uris": [ | ||
| 41 | "http://127.0.0.1:53930/v1/plugins/mcp/oauth/callback" | ||
| 42 | ], | ||
| 43 | "token_endpoint_auth_method": "none", | ||
| 44 | "grant_types": [ | ||
| 45 | "authorization_code", | ||
| 46 | "refresh_token" | ||
| 47 | ], | ||
| 48 | "response_types": [ | ||
| 49 | "code" | ||
| 50 | ], | ||
| 51 | "client_name": "Executor Local", | ||
| 52 | "client_id": "client_47ba0105-db15-4301-ae15-26261f6af319", | ||
| 53 | "client_id_issued_at": 1774721141 | ||
| 54 | } | ||
| 55 | } | ||
| @@ -0,0 +1,55 @@ | |||
| 1 | { | ||
| 2 | "endpoint": "http://127.0.0.1:53947/mcp", | ||
| 3 | "redirectUrl": "http://127.0.0.1:53948/v1/plugins/mcp/oauth/callback", | ||
| 4 | "codeVerifier": "Pfnkn4QW_ekW1Ul0hE_prS_3znK32GFHAiKfdty3urA", | ||
| 5 | "resourceMetadataUrl": null, | ||
| 6 | "authorizationServerUrl": "http://127.0.0.1:53947", | ||
| 7 | "resourceMetadata": { | ||
| 8 | "resource": "http://127.0.0.1:53947/mcp", | ||
| 9 | "authorization_servers": [ | ||
| 10 | "http://127.0.0.1:53947" | ||
| 11 | ], | ||
| 12 | "scopes_supported": [ | ||
| 13 | "openid", | ||
| 14 | "offline_access" | ||
| 15 | ], | ||
| 16 | "bearer_methods_supported": [ | ||
| 17 | "header" | ||
| 18 | ] | ||
| 19 | }, | ||
| 20 | "authorizationServerMetadata": { | ||
| 21 | "issuer": "http://127.0.0.1:53947", | ||
| 22 | "authorization_endpoint": "http://127.0.0.1:53947/authorize", | ||
| 23 | "token_endpoint": "http://127.0.0.1:53947/token", | ||
| 24 | "registration_endpoint": "http://127.0.0.1:53947/register", | ||
| 25 | "response_types_supported": [ | ||
| 26 | "code" | ||
| 27 | ], | ||
| 28 | "grant_types_supported": [ | ||
| 29 | "authorization_code", | ||
| 30 | "refresh_token" | ||
| 31 | ], | ||
| 32 | "token_endpoint_auth_methods_supported": [ | ||
| 33 | "none" | ||
| 34 | ], | ||
| 35 | "code_challenge_methods_supported": [ | ||
| 36 | "S256" | ||
| 37 | ] | ||
| 38 | }, | ||
| 39 | "clientInformation": { | ||
| 40 | "redirect_uris": [ | ||
| 41 | "http://127.0.0.1:53948/v1/plugins/mcp/oauth/callback" | ||
| 42 | ], | ||
| 43 | "token_endpoint_auth_method": "none", | ||
| 44 | "grant_types": [ | ||
| 45 | "authorization_code", | ||
| 46 | "refresh_token" | ||
| 47 | ], | ||
| 48 | "response_types": [ | ||
| 49 | "code" | ||
| 50 | ], | ||
| 51 | "client_name": "Executor Local", | ||
| 52 | "client_id": "client_34c15a0b-c60d-491a-872c-ce590d1b1ef7", | ||
| 53 | "client_id_issued_at": 1774721141 | ||
| 54 | } | ||
| 55 | } | ||
| @@ -0,0 +1,13 @@ | |||
| 1 | { | ||
| 2 | "endpoint": "http://127.0.0.1:54054/mcp", | ||
| 3 | "transport": "streamable-http", | ||
| 4 | "queryParams": null, | ||
| 5 | "headers": null, | ||
| 6 | "command": null, | ||
| 7 | "args": null, | ||
| 8 | "env": null, | ||
| 9 | "cwd": null, | ||
| 10 | "auth": { | ||
| 11 | "kind": "none" | ||
| 12 | } | ||
| 13 | } | ||
| @@ -0,0 +1,73 @@ | |||
| 1 | { | ||
| 2 | "endpoint": "http://127.0.0.1:54092/mcp", | ||
| 3 | "transport": "streamable-http", | ||
| 4 | "queryParams": null, | ||
| 5 | "headers": null, | ||
| 6 | "command": null, | ||
| 7 | "args": null, | ||
| 8 | "env": null, | ||
| 9 | "cwd": null, | ||
| 10 | "auth": { | ||
| 11 | "kind": "oauth2", | ||
| 12 | "redirectUri": "http://127.0.0.1:54094/v1/plugins/mcp/oauth/callback", | ||
| 13 | "accessTokenRef": { | ||
| 14 | "secretId": "sec_471c240c-42f5-44c7-8436-21296ede39cd" | ||
| 15 | }, | ||
| 16 | "refreshTokenRef": { | ||
| 17 | "secretId": "sec_d1cc408e-d563-43b1-8a1c-7887dd054b46" | ||
| 18 | }, | ||
| 19 | "tokenType": "Bearer", | ||
| 20 | "expiresAt": 1774724782375, | ||
| 21 | "scope": "openid offline_access", | ||
| 22 | "resourceMetadataUrl": null, | ||
| 23 | "authorizationServerUrl": "http://127.0.0.1:54092", | ||
| 24 | "resourceMetadata": { | ||
| 25 | "resource": "http://127.0.0.1:54092/mcp", | ||
| 26 | "authorization_servers": [ | ||
| 27 | "http://127.0.0.1:54092" | ||
| 28 | ], | ||
| 29 | "scopes_supported": [ | ||
| 30 | "openid", | ||
| 31 | "offline_access" | ||
| 32 | ], | ||
| 33 | "bearer_methods_supported": [ | ||
| 34 | "header" | ||
| 35 | ] | ||
| 36 | }, | ||
| 37 | "authorizationServerMetadata": { | ||
| 38 | "issuer": "http://127.0.0.1:54092", | ||
| 39 | "authorization_endpoint": "http://127.0.0.1:54092/authorize", | ||
| 40 | "token_endpoint": "http://127.0.0.1:54092/token", | ||
| 41 | "registration_endpoint": "http://127.0.0.1:54092/register", | ||
| 42 | "response_types_supported": [ | ||
| 43 | "code" | ||
| 44 | ], | ||
| 45 | "grant_types_supported": [ | ||
| 46 | "authorization_code", | ||
| 47 | "refresh_token" | ||
| 48 | ], | ||
| 49 | "token_endpoint_auth_methods_supported": [ | ||
| 50 | "none" | ||
| 51 | ], | ||
| 52 | "code_challenge_methods_supported": [ | ||
| 53 | "S256" | ||
| 54 | ] | ||
| 55 | }, | ||
| 56 | "clientInformation": { | ||
| 57 | "redirect_uris": [ | ||
| 58 | "http://127.0.0.1:54094/v1/plugins/mcp/oauth/callback" | ||
| 59 | ], | ||
| 60 | "token_endpoint_auth_method": "none", | ||
| 61 | "grant_types": [ | ||
| 62 | "authorization_code", | ||
| 63 | "refresh_token" | ||
| 64 | ], | ||
| 65 | "response_types": [ | ||
| 66 | "code" | ||
| 67 | ], | ||
| 68 | "client_name": "Executor Local", | ||
| 69 | "client_id": "client_c1aac2f6-50d6-4d7c-9bbd-50b109465eba", | ||
| 70 | "client_id_issued_at": 1774721182 | ||
| 71 | } | ||
| 72 | } | ||
| 73 | } | ||
| @@ -0,0 +1,13 @@ | |||
| 1 | { | ||
| 2 | "endpoint": "http://127.0.0.1:54063/mcp", | ||
| 3 | "transport": "streamable-http", | ||
| 4 | "queryParams": null, | ||
| 5 | "headers": null, | ||
| 6 | "command": null, | ||
| 7 | "args": null, | ||
| 8 | "env": null, | ||
| 9 | "cwd": null, | ||
| 10 | "auth": { | ||
| 11 | "kind": "none" | ||
| 12 | } | ||
| 13 | } | ||
| @@ -0,0 +1,13 @@ | |||
| 1 | { | ||
| 2 | "endpoint": "http://127.0.0.1:53892/mcp", | ||
| 3 | "transport": "streamable-http", | ||
| 4 | "queryParams": null, | ||
| 5 | "headers": null, | ||
| 6 | "command": null, | ||
| 7 | "args": null, | ||
| 8 | "env": null, | ||
| 9 | "cwd": null, | ||
| 10 | "auth": { | ||
| 11 | "kind": "none" | ||
| 12 | } | ||
| 13 | } | ||
| @@ -0,0 +1,13 @@ | |||
| 1 | { | ||
| 2 | "endpoint": "http://127.0.0.1:54046/mcp", | ||
| 3 | "transport": "streamable-http", | ||
| 4 | "queryParams": null, | ||
| 5 | "headers": null, | ||
| 6 | "command": null, | ||
| 7 | "args": null, | ||
| 8 | "env": null, | ||
| 9 | "cwd": null, | ||
| 10 | "auth": { | ||
| 11 | "kind": "none" | ||
| 12 | } | ||
| 13 | } | ||
| @@ -0,0 +1,13 @@ | |||
| 1 | { | ||
| 2 | "endpoint": "http://127.0.0.1:53901/mcp", | ||
| 3 | "transport": "streamable-http", | ||
| 4 | "queryParams": null, | ||
| 5 | "headers": null, | ||
| 6 | "command": null, | ||
| 7 | "args": null, | ||
| 8 | "env": null, | ||
| 9 | "cwd": null, | ||
| 10 | "auth": { | ||
| 11 | "kind": "none" | ||
| 12 | } | ||
| 13 | } | ||
| @@ -0,0 +1,13 @@ | |||
| 1 | { | ||
| 2 | "endpoint": "http://127.0.0.1:53884/mcp", | ||
| 3 | "transport": "streamable-http", | ||
| 4 | "queryParams": null, | ||
| 5 | "headers": null, | ||
| 6 | "command": null, | ||
| 7 | "args": null, | ||
| 8 | "env": null, | ||
| 9 | "cwd": null, | ||
| 10 | "auth": { | ||
| 11 | "kind": "none" | ||
| 12 | } | ||
| 13 | } | ||
| @@ -0,0 +1,10 @@ | |||
| 1 | { | ||
| 2 | "specUrl": "http://127.0.0.1:54087/openapi.json", | ||
| 3 | "baseUrl": "http://127.0.0.1:54087", | ||
| 4 | "auth": { | ||
| 5 | "kind": "none" | ||
| 6 | }, | ||
| 7 | "defaultHeaders": null, | ||
| 8 | "etag": null, | ||
| 9 | "lastSyncAt": 1774721182318 | ||
| 10 | } | ||
| @@ -0,0 +1,10 @@ | |||
| 1 | { | ||
| 2 | "specUrl": "http://127.0.0.1:53937/openapi.json", | ||
| 3 | "baseUrl": "http://127.0.0.1:53937", | ||
| 4 | "auth": { | ||
| 5 | "kind": "none" | ||
| 6 | }, | ||
| 7 | "defaultHeaders": null, | ||
| 8 | "etag": null, | ||
| 9 | "lastSyncAt": 1774721141463 | ||
| 10 | } | ||
| @@ -0,0 +1,10 @@ | |||
| 1 | { | ||
| 2 | "specUrl": "http://127.0.0.1:54106/openapi.json", | ||
| 3 | "baseUrl": "http://127.0.0.1:54106", | ||
| 4 | "auth": { | ||
| 5 | "kind": "none" | ||
| 6 | }, | ||
| 7 | "defaultHeaders": null, | ||
| 8 | "etag": null, | ||
| 9 | "lastSyncAt": 1774721182506 | ||
| 10 | } | ||
| @@ -0,0 +1,10 @@ | |||
| 1 | { | ||
| 2 | "specUrl": "http://127.0.0.1:53942/openapi.json", | ||
| 3 | "baseUrl": "http://127.0.0.1:53942", | ||
| 4 | "auth": { | ||
| 5 | "kind": "none" | ||
| 6 | }, | ||
| 7 | "defaultHeaders": null, | ||
| 8 | "etag": null, | ||
| 9 | "lastSyncAt": 1774721141512 | ||
| 10 | } | ||
| @@ -0,0 +1,10 @@ | |||
| 1 | { | ||
| 2 | "specUrl": "http://127.0.0.1:54101/openapi.json", | ||
| 3 | "baseUrl": "http://127.0.0.1:54101", | ||
| 4 | "auth": { | ||
| 5 | "kind": "none" | ||
| 6 | }, | ||
| 7 | "defaultHeaders": null, | ||
| 8 | "etag": null, | ||
| 9 | "lastSyncAt": 1774721182459 | ||
| 10 | } | ||
| @@ -0,0 +1,15 @@ | |||
| 1 | { | ||
| 2 | "specUrl": "http://127.0.0.1:54082/openapi.json", | ||
| 3 | "baseUrl": "http://127.0.0.1:54082", | ||
| 4 | "auth": { | ||
| 5 | "kind": "bearer", | ||
| 6 | "tokenSecretRef": { | ||
| 7 | "secretId": "sec_16e24603-3db8-4fe8-827b-94d312cc2791" | ||
| 8 | }, | ||
| 9 | "headerName": "Authorization", | ||
| 10 | "prefix": "Bearer " | ||
| 11 | }, | ||
| 12 | "defaultHeaders": null, | ||
| 13 | "etag": null, | ||
| 14 | "lastSyncAt": 1774721182272 | ||
| 15 | } | ||
| @@ -0,0 +1,10 @@ | |||
| 1 | { | ||
| 2 | "specUrl": "http://127.0.0.1:53924/openapi.json", | ||
| 3 | "baseUrl": "http://127.0.0.1:53924", | ||
| 4 | "auth": { | ||
| 5 | "kind": "none" | ||
| 6 | }, | ||
| 7 | "defaultHeaders": null, | ||
| 8 | "etag": null, | ||
| 9 | "lastSyncAt": 1774721141358 | ||
| 10 | } | ||
| @@ -21,12 +21,18 @@ · 20 unmodified lines | |||
| 21 | "@executor/plugin-graphql-http": "workspace:*", | 21 | "@executor/plugin-graphql-http": "workspace:*", |
| 22 | "@executor/plugin-graphql-shared": "workspace:*", | 22 | "@executor/plugin-graphql-shared": "workspace:*", |
| 23 | "@executor/plugin-graphql-sdk": "workspace:*", | 23 | "@executor/plugin-graphql-sdk": "workspace:*", |
| 24 | "@executor/plugin-keychain-secret-store-sdk": "workspace:*", | ||
| 25 | "@executor/plugin-local-secret-store-sdk": "workspace:*", | ||
| 26 | "@executor/plugin-local-tools-sdk": "workspace:*", | ||
| 24 | "@executor/plugin-mcp-http": "workspace:*", | 27 | "@executor/plugin-mcp-http": "workspace:*", |
| 25 | "@executor/plugin-mcp-shared": "workspace:*", | 28 | "@executor/plugin-mcp-shared": "workspace:*", |
| 26 | "@executor/plugin-mcp-sdk": "workspace:*", | 29 | "@executor/plugin-mcp-sdk": "workspace:*", |
| 27 | "@executor/plugin-openapi-http": "workspace:*", | 30 | "@executor/plugin-openapi-http": "workspace:*", |
| 28 | "@executor/plugin-openapi-shared": "workspace:*", | 31 | "@executor/plugin-openapi-shared": "workspace:*", |
| 29 | "@executor/plugin-openapi-sdk": "workspace:*", | 32 | "@executor/plugin-openapi-sdk": "workspace:*", |
| 33 | "@executor/plugin-onepassword-http": "workspace:*", | ||
| 34 | "@executor/plugin-onepassword-sdk": "workspace:*", | ||
| 35 | "@executor/plugin-onepassword-shared": "workspace:*", | ||
| 30 | "@executor/platform-api": "workspace:*", | 36 | "@executor/platform-api": "workspace:*", |
| 31 | "@executor/platform-sdk": "workspace:*", | 37 | "@executor/platform-sdk": "workspace:*", |
| 32 | "@executor/platform-sdk-file": "workspace:*", | 38 | "@executor/platform-sdk-file": "workspace:*", |
| @@ -40,6 +40,7 @@ import { googleDiscoveryHttpPlugin } from "@executor/plugin-google-discovery-htt · 39 unmodified lines | |||
| 40 | import { graphqlHttpPlugin } from "@executor/plugin-graphql-http"; | 40 | import { graphqlHttpPlugin } from "@executor/plugin-graphql-http"; |
| 41 | import { mcpHttpPlugin } from "@executor/plugin-mcp-http"; | 41 | import { mcpHttpPlugin } from "@executor/plugin-mcp-http"; |
| 42 | import { openApiHttpPlugin } from "@executor/plugin-openapi-http"; | 42 | import { openApiHttpPlugin } from "@executor/plugin-openapi-http"; |
| 43 | import { onePasswordHttpPlugin } from "@executor/plugin-onepassword-http"; | ||
| 43 | import { makeSesExecutor } from "@executor/runtime-ses"; | 44 | import { makeSesExecutor } from "@executor/runtime-ses"; |
| 44 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | 45 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 45 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | 46 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
| @@ -62,6 +63,7 @@ const serverHttpPlugins = [ · 16 unmodified lines | |||
| 62 | graphqlHttpPlugin(), | 63 | graphqlHttpPlugin(), |
| 63 | googleDiscoveryHttpPlugin(), | 64 | googleDiscoveryHttpPlugin(), |
| 64 | mcpHttpPlugin(), | 65 | mcpHttpPlugin(), |
| 66 | onePasswordHttpPlugin(), | ||
| 65 | openApiHttpPlugin(), | 67 | openApiHttpPlugin(), |
| 66 | ] as const; | 68 | ] as const; |
| 67 | 69 | ||
| @@ -385,6 +387,26 @@ const seedGithubOpenApiSourceInWorkspace = (input: { · 317 unmodified lines | |||
| 385 | credentialEnvVar?: string; | 387 | credentialEnvVar?: string; |
| 386 | }) => | 388 | }) => |
| 387 | Effect.gen(function* () { | 389 | Effect.gen(function* () { |
| 390 | const tokenEnvVar = input.credentialEnvVar ?? "GITHUB_TOKEN"; | ||
| 391 | const tokenValue = process.env[tokenEnvVar]; | ||
| 392 | if (!tokenValue) { | ||
| 393 | return yield* Effect.fail( | ||
| 394 | new Error(`Missing token value in environment variable ${tokenEnvVar}`), | ||
| 395 | ); | ||
| 396 | } | ||
| 397 | |||
| 398 | const existingSecrets = yield* input.client.local.listSecrets({}); | ||
| 399 | const existingTokenSecret = existingSecrets.find((secret) => | ||
| 400 | secret.name === `${input.name} token` | ||
| 401 | ); | ||
| 402 | const tokenSecret = existingTokenSecret | ||
| 403 | ?? (yield* input.client.local.createSecret({ | ||
| 404 | payload: { | ||
| 405 | name: `${input.name} token`, | ||
| 406 | value: tokenValue, | ||
| 407 | }, | ||
| 408 | })); | ||
| 409 | |||
| 388 | const existing = yield* input.client.sources.list({ | 410 | const existing = yield* input.client.sources.list({ |
| 389 | path: { | 411 | path: { |
| 390 | workspaceId: input.workspaceId as never, | 412 | workspaceId: input.workspaceId as never, |
| @@ -400,8 +422,7 @@ const seedGithubOpenApiSourceInWorkspace = (input: { · 9 unmodified lines | |||
| 400 | headerName: "Authorization", | 422 | headerName: "Authorization", |
| 401 | prefix: "Bearer ", | 423 | prefix: "Bearer ", |
| 402 | tokenSecretRef: { | 424 | tokenSecretRef: { |
| 403 | providerId: "env", | 425 | secretId: tokenSecret.id as never, |
| 404 | handle: input.credentialEnvVar ?? "GITHUB_TOKEN", | ||
| 405 | }, | 426 | }, |
| 406 | }; | 427 | }; |
| 407 | 428 | ||
| @@ -1325,11 +1346,9 @@ describe("local-executor-server", () => { · 917 unmodified lines | |||
| 1325 | ); | 1346 | ); |
| 1326 | 1347 | ||
| 1327 | const previousGithubToken = process.env.GITHUB_TOKEN; | 1348 | const previousGithubToken = process.env.GITHUB_TOKEN; |
| 1328 | const previousAllowEnvSecrets = process.env.DANGEROUSLY_ALLOW_ENV_SECRETS; | ||
| 1329 | yield* Effect.acquireRelease( | 1349 | yield* Effect.acquireRelease( |
| 1330 | Effect.sync(() => { | 1350 | Effect.sync(() => { |
| 1331 | process.env.GITHUB_TOKEN = "ghp_test_executor"; | 1351 | process.env.GITHUB_TOKEN = "ghp_test_executor"; |
| 1332 | process.env.DANGEROUSLY_ALLOW_ENV_SECRETS = "true"; | ||
| 1333 | }), | 1352 | }), |
| 1334 | () => | 1353 | () => |
| 1335 | Effect.sync(() => { | 1354 | Effect.sync(() => { |
| @@ -1338,12 +1357,6 @@ describe("local-executor-server", () => { · 2 unmodified lines | |||
| 1338 | } else { | 1357 | } else { |
| 1339 | process.env.GITHUB_TOKEN = previousGithubToken; | 1358 | process.env.GITHUB_TOKEN = previousGithubToken; |
| 1340 | } | 1359 | } |
| 1341 | |||
| 1342 | if (previousAllowEnvSecrets === undefined) { | ||
| 1343 | delete process.env.DANGEROUSLY_ALLOW_ENV_SECRETS; | ||
| 1344 | } else { | ||
| 1345 | process.env.DANGEROUSLY_ALLOW_ENV_SECRETS = previousAllowEnvSecrets; | ||
| 1346 | } | ||
| 1347 | }), | 1360 | }), |
| 1348 | ); | 1361 | ); |
| 1349 | 1362 | ||
| @@ -12,12 +12,19 @@ import { googleDiscoveryHttpPlugin } from "@executor/plugin-google-discovery-htt · 11 unmodified lines | |||
| 12 | import { googleDiscoverySdkPlugin } from "@executor/plugin-google-discovery-sdk"; | 12 | import { googleDiscoverySdkPlugin } from "@executor/plugin-google-discovery-sdk"; |
| 13 | import { graphqlHttpPlugin } from "@executor/plugin-graphql-http"; | 13 | import { graphqlHttpPlugin } from "@executor/plugin-graphql-http"; |
| 14 | import { graphqlSdkPlugin } from "@executor/plugin-graphql-sdk"; | 14 | import { graphqlSdkPlugin } from "@executor/plugin-graphql-sdk"; |
| 15 | import { keychainSecretStoreSdkPlugin } from "@executor/plugin-keychain-secret-store-sdk"; | ||
| 16 | import { localSecretStoreSdkPlugin } from "@executor/plugin-local-secret-store-sdk"; | ||
| 17 | import { localToolsSdkPlugin } from "@executor/plugin-local-tools-sdk"; | ||
| 15 | import { mcpHttpPlugin } from "@executor/plugin-mcp-http"; | 18 | import { mcpHttpPlugin } from "@executor/plugin-mcp-http"; |
| 16 | import { mcpSdkPlugin } from "@executor/plugin-mcp-sdk"; | 19 | import { mcpSdkPlugin } from "@executor/plugin-mcp-sdk"; |
| 17 | import { openApiHttpPlugin } from "@executor/plugin-openapi-http"; | 20 | import { openApiHttpPlugin } from "@executor/plugin-openapi-http"; |
| 18 | import { | 21 | import { |
| 19 | openApiSdkPlugin, | 22 | openApiSdkPlugin, |
| 20 | } from "@executor/plugin-openapi-sdk"; | 23 | } from "@executor/plugin-openapi-sdk"; |
| 24 | import { onePasswordHttpPlugin } from "@executor/plugin-onepassword-http"; | ||
| 25 | import { | ||
| 26 | onePasswordSdkPlugin, | ||
| 27 | } from "@executor/plugin-onepassword-sdk"; | ||
| 21 | import { | 28 | import { |
| 22 | createExecutorApiLayer, | 29 | createExecutorApiLayer, |
| 23 | } from "@executor/platform-api/http"; | 30 | } from "@executor/platform-api/http"; |
| @@ -59,6 +66,7 @@ import { createFileGraphqlSourceStorage } from "./graphql-source-storage"; · 35 unmodified lines | |||
| 59 | import { createFileMcpOAuthSessionStorage } from "./mcp-oauth-session-storage"; | 66 | import { createFileMcpOAuthSessionStorage } from "./mcp-oauth-session-storage"; |
| 60 | import { createFileMcpSourceStorage } from "./mcp-source-storage"; | 67 | import { createFileMcpSourceStorage } from "./mcp-source-storage"; |
| 61 | import { createFileOpenApiSourceStorage } from "./openapi-source-storage"; | 68 | import { createFileOpenApiSourceStorage } from "./openapi-source-storage"; |
| 69 | import { createFileOnePasswordStoreStorage } from "./onepassword-store-storage"; | ||
| 62 | 70 | ||
| 63 | export { createFileGoogleDiscoveryOAuthSessionStorage } from "./google-discovery-oauth-session-storage"; | 71 | export { createFileGoogleDiscoveryOAuthSessionStorage } from "./google-discovery-oauth-session-storage"; |
| 64 | export { createFileGoogleDiscoverySourceStorage } from "./google-discovery-source-storage"; | 72 | export { createFileGoogleDiscoverySourceStorage } from "./google-discovery-source-storage"; |
| @@ -66,6 +74,7 @@ export { createFileGraphqlSourceStorage } from "./graphql-source-storage"; · 1 unmodified line | |||
| 66 | export { createFileMcpOAuthSessionStorage } from "./mcp-oauth-session-storage"; | 74 | export { createFileMcpOAuthSessionStorage } from "./mcp-oauth-session-storage"; |
| 67 | export { createFileMcpSourceStorage } from "./mcp-source-storage"; | 75 | export { createFileMcpSourceStorage } from "./mcp-source-storage"; |
| 68 | export { createFileOpenApiSourceStorage } from "./openapi-source-storage"; | 76 | export { createFileOpenApiSourceStorage } from "./openapi-source-storage"; |
| 77 | export { createFileOnePasswordStoreStorage } from "./onepassword-store-storage"; | ||
| 69 | 78 | ||
| 70 | export { | 79 | export { |
| 71 | DEFAULT_EXECUTOR_DATA_DIR, | 80 | DEFAULT_EXECUTOR_DATA_DIR, |
| @@ -133,6 +142,7 @@ const executorHttpPlugins = [ · 61 unmodified lines | |||
| 133 | graphqlHttpPlugin(), | 142 | graphqlHttpPlugin(), |
| 134 | googleDiscoveryHttpPlugin(), | 143 | googleDiscoveryHttpPlugin(), |
| 135 | mcpHttpPlugin(), | 144 | mcpHttpPlugin(), |
| 145 | onePasswordHttpPlugin(), | ||
| 136 | openApiHttpPlugin(), | 146 | openApiHttpPlugin(), |
| 137 | ] as const; | 147 | ] as const; |
| 138 | 148 | ||
| @@ -150,10 +160,14 @@ const createExecutorRuntime = ( · 11 unmodified lines | |||
| 150 | ) => | 160 | ) => |
| 151 | createExecutorEffect({ | 161 | createExecutorEffect({ |
| 152 | backend: createLocalExecutorBackend({ | 162 | backend: createLocalExecutorBackend({ |
| 163 | cwd: options.workspaceRoot ?? process.cwd(), | ||
| 153 | workspaceRoot: options.workspaceRoot, | 164 | workspaceRoot: options.workspaceRoot, |
| 154 | localDataDir, | 165 | localDataDir, |
| 155 | }), | 166 | }), |
| 156 | plugins: [ | 167 | plugins: [ |
| 168 | localSecretStoreSdkPlugin, | ||
| 169 | keychainSecretStoreSdkPlugin, | ||
| 170 | localToolsSdkPlugin(), | ||
| 157 | graphqlSdkPlugin({ | 171 | graphqlSdkPlugin({ |
| 158 | storage: createFileGraphqlSourceStorage({ | 172 | storage: createFileGraphqlSourceStorage({ |
| 159 | rootDir: resolve(localDataDir, "plugins", "graphql", "sources"), | 173 | rootDir: resolve(localDataDir, "plugins", "graphql", "sources"), |
| @@ -180,6 +194,11 @@ const createExecutorRuntime = ( · 20 unmodified lines | |||
| 180 | rootDir: resolve(localDataDir, "plugins", "openapi", "sources"), | 194 | rootDir: resolve(localDataDir, "plugins", "openapi", "sources"), |
| 181 | }), | 195 | }), |
| 182 | }), | 196 | }), |
| 197 | onePasswordSdkPlugin({ | ||
| 198 | storage: createFileOnePasswordStoreStorage({ | ||
| 199 | rootDir: resolve(localDataDir, "plugins", "onepassword", "stores"), | ||
| 200 | }), | ||
| 201 | }), | ||
| 183 | ] as const, | 202 | ] as const, |
| 184 | executionResolver: options.executionResolver, | 203 | executionResolver: options.executionResolver, |
| 185 | resolveSecretMaterial: options.resolveSecretMaterial, | 204 | resolveSecretMaterial: options.resolveSecretMaterial, |
| @@ -0,0 +1,43 @@ | |||
| 1 | import type { OnePasswordStoreStorage } from "@executor/plugin-onepassword-sdk"; | ||
| 2 | import { | ||
| 3 | OnePasswordStoredStoreDataSchema, | ||
| 4 | } from "@executor/plugin-onepassword-shared"; | ||
| 5 | |||
| 6 | import { | ||
| 7 | pluginSourceStoragePath, | ||
| 8 | readJsonFile, | ||
| 9 | removeJsonFile, | ||
| 10 | writeJsonFile, | ||
| 11 | } from "./json-file-storage"; | ||
| 12 | |||
| 13 | export const createFileOnePasswordStoreStorage = (input: { | ||
| 14 | rootDir: string; | ||
| 15 | }): OnePasswordStoreStorage => ({ | ||
| 16 | get: ({ scopeId, storeId }) => | ||
| 17 | readJsonFile({ | ||
| 18 | path: pluginSourceStoragePath({ | ||
| 19 | rootDir: input.rootDir, | ||
| 20 | scopeId, | ||
| 21 | sourceId: storeId, | ||
| 22 | }), | ||
| 23 | schema: OnePasswordStoredStoreDataSchema, | ||
| 24 | }), | ||
| 25 | put: ({ scopeId, storeId, value }) => | ||
| 26 | writeJsonFile({ | ||
| 27 | path: pluginSourceStoragePath({ | ||
| 28 | rootDir: input.rootDir, | ||
| 29 | scopeId, | ||
| 30 | sourceId: storeId, | ||
| 31 | }), | ||
| 32 | schema: OnePasswordStoredStoreDataSchema, | ||
| 33 | value, | ||
| 34 | }), | ||
| 35 | remove: ({ scopeId, storeId }) => | ||
| 36 | removeJsonFile( | ||
| 37 | pluginSourceStoragePath({ | ||
| 38 | rootDir: input.rootDir, | ||
| 39 | scopeId, | ||
| 40 | sourceId: storeId, | ||
| 41 | }), | ||
| 42 | ), | ||
| 43 | }); | ||
| @@ -1,14 +1,13 @@ | |||
| 1 | import { startTransition, useMemo, useState, type ReactNode } from "react"; | 1 | import { startTransition, useMemo, useState, type ReactNode } from "react"; |
| 2 | import type { Source } from "@executor/react"; | 2 | import type { Source } from "@executor/react"; |
| 3 | import { | 3 | import { |
| 4 | SecretReferenceField, | ||
| 4 | defineExecutorPluginHttpApiClient, | 5 | defineExecutorPluginHttpApiClient, |
| 5 | Result, | 6 | Result, |
| 6 | useAtomSet, | 7 | useAtomSet, |
| 7 | useAtomValue, | 8 | useAtomValue, |
| 8 | useCreateSecret, | ||
| 9 | useExecutorMutation, | 9 | useExecutorMutation, |
| 10 | useLocalInstallation, | 10 | useLocalInstallation, |
| 11 | useSecrets, | ||
| 12 | useSource, | 11 | useSource, |
| 13 | } from "@executor/react"; | 12 | } from "@executor/react"; |
| 14 | import { | 13 | import { |
| @@ -456,134 +455,6 @@ const openGoogleOauthPopup = (name: string): Window | null => { · 441 unmodified lines | |||
| 456 | return popup; | 455 | return popup; |
| 457 | }; | 456 | }; |
| 458 | 457 | ||
| 459 | const CREATE_SECRET_VALUE = "__create_google_secret__"; | ||
| 460 | |||
| 461 | function SecretSelectOrCreateField(props: { | ||
| 462 | label: string; | ||
| 463 | value: string; | ||
| 464 | emptyLabel: string; | ||
| 465 | onChange: (value: string) => void; | ||
| 466 | }) { | ||
| 467 | const secrets = useSecrets(); | ||
| 468 | const createSecret = useCreateSecret(); | ||
| 469 | const [draftName, setDraftName] = useState(""); | ||
| 470 | const [draftValue, setDraftValue] = useState(""); | ||
| 471 | const [createError, setCreateError] = useState<string | null>(null); | ||
| 472 | const [showCreate, setShowCreate] = useState(false); | ||
| 473 | |||
| 474 | const handleSelectChange = (nextValue: string) => { | ||
| 475 | if (nextValue === CREATE_SECRET_VALUE) { | ||
| 476 | setShowCreate(true); | ||
| 477 | props.onChange(""); | ||
| 478 | return; | ||
| 479 | } | ||
| 480 | |||
| 481 | setShowCreate(false); | ||
| 482 | setCreateError(null); | ||
| 483 | props.onChange(nextValue); | ||
| 484 | }; | ||
| 485 | |||
| 486 | const handleCreate = async () => { | ||
| 487 | const trimmedName = draftName.trim(); | ||
| 488 | if (!trimmedName) { | ||
| 489 | setCreateError("Secret name is required."); | ||
| 490 | return; | ||
| 491 | } | ||
| 492 | if (!draftValue.trim()) { | ||
| 493 | setCreateError("Secret value is required."); | ||
| 494 | return; | ||
| 495 | } | ||
| 496 | |||
| 497 | try { | ||
| 498 | setCreateError(null); | ||
| 499 | const created = await createSecret.mutateAsync({ | ||
| 500 | name: trimmedName, | ||
| 501 | value: draftValue, | ||
| 502 | }); | ||
| 503 | props.onChange(JSON.stringify({ | ||
| 504 | providerId: created.providerId, | ||
| 505 | handle: created.id, | ||
| 506 | })); | ||
| 507 | setDraftName(""); | ||
| 508 | setDraftValue(""); | ||
| 509 | setShowCreate(false); | ||
| 510 | } catch (cause) { | ||
| 511 | setCreateError(cause instanceof Error ? cause.message : "Failed creating secret."); | ||
| 512 | } | ||
| 513 | }; | ||
| 514 | |||
| 515 | return ( | ||
| 516 | <div className="grid gap-2"> | ||
| 517 | <span className="text-xs font-medium text-foreground">{props.label}</span> | ||
| 518 | <select | ||
| 519 | value={showCreate ? CREATE_SECRET_VALUE : props.value} | ||
| 520 | onChange={(event) => handleSelectChange(event.target.value)} | ||
| 521 | className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 522 | > | ||
| 523 | <option value="">{props.emptyLabel}</option> | ||
| 524 | {secrets.status === "ready" && | ||
| 525 | secrets.data.map((secret) => ( | ||
| 526 | <option | ||
| 527 | key={`${secret.providerId}:${secret.id}`} | ||
| 528 | value={JSON.stringify({ | ||
| 529 | providerId: secret.providerId, | ||
| 530 | handle: secret.id, | ||
| 531 | })} | ||
| 532 | > | ||
| 533 | {secret.name ?? secret.id} | ||
| 534 | </option> | ||
| 535 | ))} | ||
| 536 | <option value={CREATE_SECRET_VALUE}>Create new secret</option> | ||
| 537 | </select> | ||
| 538 | |||
| 539 | {showCreate && ( | ||
| 540 | <div className="space-y-3 border-l-2 border-border pl-4"> | ||
| 541 | <input | ||
| 542 | value={draftName} | ||
| 543 | onChange={(event) => setDraftName(event.target.value)} | ||
| 544 | placeholder="Google client secret" | ||
| 545 | className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 546 | /> | ||
| 547 | <textarea | ||
| 548 | value={draftValue} | ||
| 549 | onChange={(event) => setDraftValue(event.target.value)} | ||
| 550 | rows={3} | ||
| 551 | placeholder="Paste the secret value" | ||
| 552 | className="w-full rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 553 | /> | ||
| 554 | {createError && ( | ||
| 555 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-3 py-2 text-xs text-destructive"> | ||
| 556 | {createError} | ||
| 557 | </div> | ||
| 558 | )} | ||
| 559 | <div className="flex items-center gap-2"> | ||
| 560 | <button | ||
| 561 | type="button" | ||
| 562 | onClick={() => { | ||
| 563 | void handleCreate(); | ||
| 564 | }} | ||
| 565 | disabled={createSecret.status === "pending"} | ||
| 566 | className="inline-flex h-8 items-center justify-center rounded-lg bg-primary px-3 text-xs font-medium text-primary-foreground transition-opacity disabled:pointer-events-none disabled:opacity-50" | ||
| 567 | > | ||
| 568 | {createSecret.status === "pending" ? "Creating..." : "Create Secret"} | ||
| 569 | </button> | ||
| 570 | <button | ||
| 571 | type="button" | ||
| 572 | onClick={() => { | ||
| 573 | setShowCreate(false); | ||
| 574 | setCreateError(null); | ||
| 575 | }} | ||
| 576 | className="inline-flex h-8 items-center justify-center rounded-lg border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50" | ||
| 577 | > | ||
| 578 | Cancel | ||
| 579 | </button> | ||
| 580 | </div> | ||
| 581 | </div> | ||
| 582 | )} | ||
| 583 | </div> | ||
| 584 | ); | ||
| 585 | } | ||
| 586 | |||
| 587 | const batchSourceInputFromTemplate = ( | 458 | const batchSourceInputFromTemplate = ( |
| 588 | template: GoogleDiscoveryBatchTemplate, | 459 | template: GoogleDiscoveryBatchTemplate, |
| 589 | ): GoogleDiscoveryBatchSourceInput => ({ | 460 | ): GoogleDiscoveryBatchSourceInput => ({ |
| @@ -768,10 +639,12 @@ function GoogleDiscoverySourceForm(props: { · 178 unmodified lines | |||
| 768 | 639 | ||
| 769 | {authKind === "bearer" && ( | 640 | {authKind === "bearer" && ( |
| 770 | <div className="border-l-2 border-border pl-4"> | 641 | <div className="border-l-2 border-border pl-4"> |
| 771 | <SecretSelectOrCreateField | 642 | <SecretReferenceField |
| 772 | label="Secret" | 643 | label="Secret" |
| 773 | value={bearerSecretRef} | 644 | value={bearerSecretRef} |
| 774 | emptyLabel="Select a secret" | 645 | emptyLabel="Select a secret" |
| 646 | draftNamePlaceholder="Google bearer token" | ||
| 647 | draftValuePlaceholder="Paste the token value" | ||
| 775 | onChange={setBearerSecretRef} | 648 | onChange={setBearerSecretRef} |
| 776 | /> | 649 | /> |
| 777 | </div> | 650 | </div> |
| @@ -787,10 +660,12 @@ function GoogleDiscoverySourceForm(props: { · 9 unmodified lines | |||
| 787 | className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 660 | className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 788 | /> | 661 | /> |
| 789 | </label> | 662 | </label> |
| 790 | <SecretSelectOrCreateField | 663 | <SecretReferenceField |
| 791 | label="Client Secret" | 664 | label="Client Secret" |
| 792 | value={clientSecretRef} | 665 | value={clientSecretRef} |
| 793 | emptyLabel="Public client / no secret" | 666 | emptyLabel="Public client / no secret" |
| 667 | draftNamePlaceholder="Google client secret" | ||
| 668 | draftValuePlaceholder="Paste the secret value" | ||
| 794 | onChange={setClientSecretRef} | 669 | onChange={setClientSecretRef} |
| 795 | /> | 670 | /> |
| 796 | <div className="flex items-center gap-3"> | 671 | <div className="flex items-center gap-3"> |
| @@ -1095,10 +970,12 @@ function GoogleDiscoveryBatchConnectPanel(props: { · 298 unmodified lines | |||
| 1095 | /> | 970 | /> |
| 1096 | </label> | 971 | </label> |
| 1097 | <div className="md:col-span-2"> | 972 | <div className="md:col-span-2"> |
| 1098 | <SecretSelectOrCreateField | 973 | <SecretReferenceField |
| 1099 | label="Client Secret" | 974 | label="Client Secret" |
| 1100 | value={clientSecretRef} | 975 | value={clientSecretRef} |
| 1101 | emptyLabel="Public client / no secret" | 976 | emptyLabel="Public client / no secret" |
| 977 | draftNamePlaceholder="Google client secret" | ||
| 978 | draftValuePlaceholder="Paste the secret value" | ||
| 1102 | onChange={setClientSecretRef} | 979 | onChange={setClientSecretRef} |
| 1103 | /> | 980 | /> |
| 1104 | </div> | 981 | </div> |
| @@ -4,14 +4,13 @@ import type { · 3 unmodified lines | |||
| 4 | SourceInspectionToolDetail, | 4 | SourceInspectionToolDetail, |
| 5 | } from "@executor/react"; | 5 | } from "@executor/react"; |
| 6 | import { | 6 | import { |
| 7 | SecretReferenceField, | ||
| 7 | defineExecutorPluginHttpApiClient, | 8 | defineExecutorPluginHttpApiClient, |
| 8 | Result, | 9 | Result, |
| 9 | useAtomSet, | 10 | useAtomSet, |
| 10 | useAtomValue, | 11 | useAtomValue, |
| 11 | useCreateSecret, | ||
| 12 | useExecutorMutation, | 12 | useExecutorMutation, |
| 13 | useLocalInstallation, | 13 | useLocalInstallation, |
| 14 | useSecrets, | ||
| 15 | useSource, | 14 | useSource, |
| 16 | } from "@executor/react"; | 15 | } from "@executor/react"; |
| 17 | import { | 16 | import { |
| @@ -55,7 +54,6 @@ const defaultGraphqlInput = (): GraphqlConnectInput => ({ · 37 unmodified lines | |||
| 55 | 54 | ||
| 56 | const DEFAULT_BEARER_HEADER_NAME = "Authorization"; | 55 | const DEFAULT_BEARER_HEADER_NAME = "Authorization"; |
| 57 | const DEFAULT_BEARER_PREFIX = "Bearer "; | 56 | const DEFAULT_BEARER_PREFIX = "Bearer "; |
| 58 | const CREATE_SECRET_VALUE = "__create_graphql_secret__"; | ||
| 59 | 57 | ||
| 60 | const presetString = ( | 58 | const presetString = ( |
| 61 | search: Record<string, unknown>, | 59 | search: Record<string, unknown>, |
| @@ -170,134 +168,6 @@ const authFromSecretValue = ( · 108 unmodified lines | |||
| 170 | }; | 168 | }; |
| 171 | }; | 169 | }; |
| 172 | 170 | ||
| 173 | function SecretSelectOrCreateField(props: { | ||
| 174 | label: string; | ||
| 175 | value: string; | ||
| 176 | emptyLabel: string; | ||
| 177 | draftNamePlaceholder: string; | ||
| 178 | draftValuePlaceholder: string; | ||
| 179 | onChange: (value: string) => void; | ||
| 180 | }) { | ||
| 181 | const secrets = useSecrets(); | ||
| 182 | const createSecret = useCreateSecret(); | ||
| 183 | const [draftName, setDraftName] = useState(""); | ||
| 184 | const [draftValue, setDraftValue] = useState(""); | ||
| 185 | const [createError, setCreateError] = useState<string | null>(null); | ||
| 186 | const [showCreate, setShowCreate] = useState(false); | ||
| 187 | |||
| 188 | const handleSelectChange = (nextValue: string) => { | ||
| 189 | if (nextValue === CREATE_SECRET_VALUE) { | ||
| 190 | setShowCreate(true); | ||
| 191 | props.onChange(""); | ||
| 192 | return; | ||
| 193 | } | ||
| 194 | |||
| 195 | setShowCreate(false); | ||
| 196 | setCreateError(null); | ||
| 197 | props.onChange(nextValue); | ||
| 198 | }; | ||
| 199 | |||
| 200 | const handleCreate = async () => { | ||
| 201 | const trimmedName = draftName.trim(); | ||
| 202 | if (!trimmedName) { | ||
| 203 | setCreateError("Secret name is required."); | ||
| 204 | return; | ||
| 205 | } | ||
| 206 | if (!draftValue.trim()) { | ||
| 207 | setCreateError("Secret value is required."); | ||
| 208 | return; | ||
| 209 | } | ||
| 210 | |||
| 211 | try { | ||
| 212 | setCreateError(null); | ||
| 213 | const created = await createSecret.mutateAsync({ | ||
| 214 | name: trimmedName, | ||
| 215 | value: draftValue, | ||
| 216 | }); | ||
| 217 | props.onChange(JSON.stringify({ | ||
| 218 | providerId: created.providerId, | ||
| 219 | handle: created.id, | ||
| 220 | })); | ||
| 221 | setDraftName(""); | ||
| 222 | setDraftValue(""); | ||
| 223 | setShowCreate(false); | ||
| 224 | } catch (cause) { | ||
| 225 | setCreateError(cause instanceof Error ? cause.message : "Failed creating secret."); | ||
| 226 | } | ||
| 227 | }; | ||
| 228 | |||
| 229 | return ( | ||
| 230 | <div className="grid gap-2"> | ||
| 231 | <span className="text-xs font-medium text-foreground">{props.label}</span> | ||
| 232 | <select | ||
| 233 | value={showCreate ? CREATE_SECRET_VALUE : props.value} | ||
| 234 | onChange={(event) => handleSelectChange(event.target.value)} | ||
| 235 | className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 236 | > | ||
| 237 | <option value="">{props.emptyLabel}</option> | ||
| 238 | {secrets.status === "ready" && | ||
| 239 | secrets.data.map((secret) => ( | ||
| 240 | <option | ||
| 241 | key={`${secret.providerId}:${secret.id}`} | ||
| 242 | value={JSON.stringify({ | ||
| 243 | providerId: secret.providerId, | ||
| 244 | handle: secret.id, | ||
| 245 | })} | ||
| 246 | > | ||
| 247 | {secret.name ?? secret.id} | ||
| 248 | </option> | ||
| 249 | ))} | ||
| 250 | <option value={CREATE_SECRET_VALUE}>Create new secret</option> | ||
| 251 | </select> | ||
| 252 | |||
| 253 | {showCreate && ( | ||
| 254 | <div className="space-y-3 rounded-lg border border-border/70 bg-background/50 p-3"> | ||
| 255 | <input | ||
| 256 | value={draftName} | ||
| 257 | onChange={(event) => setDraftName(event.target.value)} | ||
| 258 | placeholder={props.draftNamePlaceholder} | ||
| 259 | className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 260 | /> | ||
| 261 | <textarea | ||
| 262 | value={draftValue} | ||
| 263 | onChange={(event) => setDraftValue(event.target.value)} | ||
| 264 | rows={3} | ||
| 265 | placeholder={props.draftValuePlaceholder} | ||
| 266 | className="w-full rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 267 | /> | ||
| 268 | {createError && ( | ||
| 269 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-3 py-2 text-xs text-destructive"> | ||
| 270 | {createError} | ||
| 271 | </div> | ||
| 272 | )} | ||
| 273 | <div className="flex items-center gap-2"> | ||
| 274 | <button | ||
| 275 | type="button" | ||
| 276 | onClick={() => { | ||
| 277 | void handleCreate(); | ||
| 278 | }} | ||
| 279 | disabled={createSecret.status === "pending"} | ||
| 280 | className="inline-flex h-8 items-center justify-center rounded-lg bg-primary px-3 text-xs font-medium text-primary-foreground transition-opacity disabled:pointer-events-none disabled:opacity-50" | ||
| 281 | > | ||
| 282 | {createSecret.status === "pending" ? "Creating..." : "Create Secret"} | ||
| 283 | </button> | ||
| 284 | <button | ||
| 285 | type="button" | ||
| 286 | onClick={() => { | ||
| 287 | setShowCreate(false); | ||
| 288 | setCreateError(null); | ||
| 289 | }} | ||
| 290 | className="inline-flex h-8 items-center justify-center rounded-lg border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50" | ||
| 291 | > | ||
| 292 | Cancel | ||
| 293 | </button> | ||
| 294 | </div> | ||
| 295 | </div> | ||
| 296 | )} | ||
| 297 | </div> | ||
| 298 | ); | ||
| 299 | } | ||
| 300 | |||
| 301 | const graphqlToolRoutePath = (toolPath: string): string => | 171 | const graphqlToolRoutePath = (toolPath: string): string => |
| 302 | `tool/${encodeURIComponent(toolPath)}`; | 172 | `tool/${encodeURIComponent(toolPath)}`; |
| 303 | 173 | ||
| @@ -383,7 +253,7 @@ function GraphqlSourceForm(props: { · 79 unmodified lines | |||
| 383 | 253 | ||
| 384 | {authKind === "bearer" && ( | 254 | {authKind === "bearer" && ( |
| 385 | <div className="space-y-4 border-l-2 border-border pl-4"> | 255 | <div className="space-y-4 border-l-2 border-border pl-4"> |
| 386 | <SecretSelectOrCreateField | 256 | <SecretReferenceField |
| 387 | label="Secret" | 257 | label="Secret" |
| 388 | value={secretRef} | 258 | value={secretRef} |
| 389 | emptyLabel="Select a secret" | 259 | emptyLabel="Select a secret" |
| @@ -0,0 +1,410 @@ | |||
| 1 | import { randomUUID } from "node:crypto"; | ||
| 2 | import { spawn } from "node:child_process"; | ||
| 3 | |||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | |||
| 6 | import { | ||
| 7 | defineExecutorSecretStorePlugin, | ||
| 8 | } from "@executor/platform-sdk/plugins"; | ||
| 9 | import { | ||
| 10 | runtimeEffectError, | ||
| 11 | } from "@executor/platform-sdk/runtime"; | ||
| 12 | |||
| 13 | export const KEYCHAIN_SECRET_STORE_KIND = "keychain"; | ||
| 14 | export const KEYCHAIN_SECRET_STORE_ID = "sts_builtin_keychain"; | ||
| 15 | |||
| 16 | const DEFAULT_KEYCHAIN_SERVICE_NAME = "executor"; | ||
| 17 | const KEYCHAIN_COMMAND_TIMEOUT_MS = 5_000; | ||
| 18 | const KEYCHAIN_SERVICE_NAME_ENV = "EXECUTOR_KEYCHAIN_SERVICE_NAME"; | ||
| 19 | |||
| 20 | type SpawnResult = { | ||
| 21 | exitCode: number; | ||
| 22 | stdout: string; | ||
| 23 | stderr: string; | ||
| 24 | }; | ||
| 25 | |||
| 26 | const toError = (cause: unknown): Error => | ||
| 27 | cause instanceof Error ? cause : new Error(String(cause)); | ||
| 28 | |||
| 29 | const trimOrNull = (value: string | null | undefined): string | null => { | ||
| 30 | if (value === null || value === undefined) { | ||
| 31 | return null; | ||
| 32 | } | ||
| 33 | |||
| 34 | const trimmed = value.trim(); | ||
| 35 | return trimmed.length > 0 ? trimmed : null; | ||
| 36 | }; | ||
| 37 | |||
| 38 | const ensureNonEmptyString = (value: string | undefined | null): string | null => { | ||
| 39 | const trimmed = value?.trim(); | ||
| 40 | return trimmed && trimmed.length > 0 ? trimmed : null; | ||
| 41 | }; | ||
| 42 | |||
| 43 | const resolveKeychainServiceName = (value: string | undefined): string => | ||
| 44 | trimOrNull(value) | ||
| 45 | ?? trimOrNull(process.env[KEYCHAIN_SERVICE_NAME_ENV]) | ||
| 46 | ?? DEFAULT_KEYCHAIN_SERVICE_NAME; | ||
| 47 | |||
| 48 | const runCommand = (input: { | ||
| 49 | command: string; | ||
| 50 | args: ReadonlyArray<string>; | ||
| 51 | stdin?: string; | ||
| 52 | operation: string; | ||
| 53 | timeoutMs?: number; | ||
| 54 | }): Effect.Effect<SpawnResult, Error, never> => | ||
| 55 | Effect.tryPromise({ | ||
| 56 | try: () => | ||
| 57 | new Promise<SpawnResult>((resolve, reject) => { | ||
| 58 | const child = spawn(input.command, [...input.args], { | ||
| 59 | stdio: "pipe", | ||
| 60 | env: process.env, | ||
| 61 | }); | ||
| 62 | |||
| 63 | let stdout = ""; | ||
| 64 | let stderr = ""; | ||
| 65 | let settled = false; | ||
| 66 | const timeout = input.timeoutMs === undefined | ||
| 67 | ? null | ||
| 68 | : setTimeout(() => { | ||
| 69 | if (settled) { | ||
| 70 | return; | ||
| 71 | } | ||
| 72 | |||
| 73 | settled = true; | ||
| 74 | child.kill("SIGKILL"); | ||
| 75 | reject( | ||
| 76 | new Error( | ||
| 77 | `${input.operation}: '${input.command}' timed out after ${input.timeoutMs}ms`, | ||
| 78 | ), | ||
| 79 | ); | ||
| 80 | }, input.timeoutMs); | ||
| 81 | |||
| 82 | child.stdout.on("data", (chunk) => { | ||
| 83 | stdout += chunk.toString("utf8"); | ||
| 84 | }); | ||
| 85 | |||
| 86 | child.stderr.on("data", (chunk) => { | ||
| 87 | stderr += chunk.toString("utf8"); | ||
| 88 | }); | ||
| 89 | |||
| 90 | child.on("error", (error) => { | ||
| 91 | if (settled) { | ||
| 92 | return; | ||
| 93 | } | ||
| 94 | settled = true; | ||
| 95 | if (timeout) { | ||
| 96 | clearTimeout(timeout); | ||
| 97 | } | ||
| 98 | reject(error); | ||
| 99 | }); | ||
| 100 | |||
| 101 | child.on("close", (code) => { | ||
| 102 | if (settled) { | ||
| 103 | return; | ||
| 104 | } | ||
| 105 | settled = true; | ||
| 106 | if (timeout) { | ||
| 107 | clearTimeout(timeout); | ||
| 108 | } | ||
| 109 | resolve({ | ||
| 110 | exitCode: code ?? 0, | ||
| 111 | stdout, | ||
| 112 | stderr, | ||
| 113 | }); | ||
| 114 | }); | ||
| 115 | |||
| 116 | if (input.stdin !== undefined) { | ||
| 117 | child.stdin.write(input.stdin); | ||
| 118 | } | ||
| 119 | |||
| 120 | child.stdin.end(); | ||
| 121 | }), | ||
| 122 | catch: toError, | ||
| 123 | }); | ||
| 124 | |||
| 125 | const ensureCommandSuccess = (input: { | ||
| 126 | result: SpawnResult; | ||
| 127 | operation: string; | ||
| 128 | message: string; | ||
| 129 | }): Effect.Effect<SpawnResult, Error, never> => { | ||
| 130 | if (input.result.exitCode === 0) { | ||
| 131 | return Effect.succeed(input.result); | ||
| 132 | } | ||
| 133 | |||
| 134 | const details = ensureNonEmptyString(input.result.stderr) | ||
| 135 | ?? ensureNonEmptyString(input.result.stdout) | ||
| 136 | ?? "command returned non-zero exit code"; | ||
| 137 | |||
| 138 | return Effect.fail( | ||
| 139 | runtimeEffectError( | ||
| 140 | "plugin-keychain-secret-store", | ||
| 141 | `${input.operation}: ${input.message}: ${details}`, | ||
| 142 | ), | ||
| 143 | ); | ||
| 144 | }; | ||
| 145 | |||
| 146 | const readKeychainSecretValue = (input: { | ||
| 147 | providerHandle: string; | ||
| 148 | keychainServiceName: string; | ||
| 149 | }) => { | ||
| 150 | switch (process.platform) { | ||
| 151 | case "darwin": | ||
| 152 | return runCommand({ | ||
| 153 | command: "security", | ||
| 154 | args: [ | ||
| 155 | "find-generic-password", | ||
| 156 | "-a", | ||
| 157 | input.providerHandle, | ||
| 158 | "-s", | ||
| 159 | input.keychainServiceName, | ||
| 160 | "-w", | ||
| 161 | ], | ||
| 162 | operation: "keychain.get", | ||
| 163 | timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS, | ||
| 164 | }).pipe( | ||
| 165 | Effect.flatMap((result) => | ||
| 166 | ensureCommandSuccess({ | ||
| 167 | result, | ||
| 168 | operation: "keychain.get", | ||
| 169 | message: "Failed loading secret from macOS keychain", | ||
| 170 | })), | ||
| 171 | Effect.map((result) => result.stdout.trimEnd()), | ||
| 172 | ); | ||
| 173 | case "linux": | ||
| 174 | return runCommand({ | ||
| 175 | command: "secret-tool", | ||
| 176 | args: [ | ||
| 177 | "lookup", | ||
| 178 | "service", | ||
| 179 | input.keychainServiceName, | ||
| 180 | "account", | ||
| 181 | input.providerHandle, | ||
| 182 | ], | ||
| 183 | operation: "keychain.get", | ||
| 184 | timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS, | ||
| 185 | }).pipe( | ||
| 186 | Effect.flatMap((result) => | ||
| 187 | ensureCommandSuccess({ | ||
| 188 | result, | ||
| 189 | operation: "keychain.get", | ||
| 190 | message: "Failed loading secret from desktop keyring", | ||
| 191 | })), | ||
| 192 | Effect.map((result) => result.stdout.trimEnd()), | ||
| 193 | ); | ||
| 194 | default: | ||
| 195 | return Effect.fail( | ||
| 196 | runtimeEffectError( | ||
| 197 | "plugin-keychain-secret-store", | ||
| 198 | `keychain.get: unsupported on platform '${process.platform}'`, | ||
| 199 | ), | ||
| 200 | ); | ||
| 201 | } | ||
| 202 | }; | ||
| 203 | |||
| 204 | const writeKeychainSecretValue = (input: { | ||
| 205 | providerHandle: string; | ||
| 206 | name?: string | null; | ||
| 207 | value: string; | ||
| 208 | keychainServiceName: string; | ||
| 209 | }) => { | ||
| 210 | const secretName = trimOrNull(input.name); | ||
| 211 | |||
| 212 | switch (process.platform) { | ||
| 213 | case "darwin": | ||
| 214 | return runCommand({ | ||
| 215 | command: "security", | ||
| 216 | args: [ | ||
| 217 | "add-generic-password", | ||
| 218 | "-a", | ||
| 219 | input.providerHandle, | ||
| 220 | "-s", | ||
| 221 | input.keychainServiceName, | ||
| 222 | "-w", | ||
| 223 | input.value, | ||
| 224 | "-U", | ||
| 225 | ...(secretName ? ["-l", secretName] : []), | ||
| 226 | ], | ||
| 227 | operation: "keychain.put", | ||
| 228 | timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS, | ||
| 229 | }).pipe( | ||
| 230 | Effect.flatMap((result) => | ||
| 231 | ensureCommandSuccess({ | ||
| 232 | result, | ||
| 233 | operation: "keychain.put", | ||
| 234 | message: "Failed storing secret in macOS keychain", | ||
| 235 | })), | ||
| 236 | Effect.asVoid, | ||
| 237 | ); | ||
| 238 | case "linux": | ||
| 239 | return runCommand({ | ||
| 240 | command: "secret-tool", | ||
| 241 | args: [ | ||
| 242 | "store", | ||
| 243 | "--label", | ||
| 244 | secretName ?? input.keychainServiceName, | ||
| 245 | "service", | ||
| 246 | input.keychainServiceName, | ||
| 247 | "account", | ||
| 248 | input.providerHandle, | ||
| 249 | ], | ||
| 250 | stdin: input.value, | ||
| 251 | operation: "keychain.put", | ||
| 252 | timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS, | ||
| 253 | }).pipe( | ||
| 254 | Effect.flatMap((result) => | ||
| 255 | ensureCommandSuccess({ | ||
| 256 | result, | ||
| 257 | operation: "keychain.put", | ||
| 258 | message: "Failed storing secret in desktop keyring", | ||
| 259 | })), | ||
| 260 | Effect.asVoid, | ||
| 261 | ); | ||
| 262 | default: | ||
| 263 | return Effect.fail( | ||
| 264 | runtimeEffectError( | ||
| 265 | "plugin-keychain-secret-store", | ||
| 266 | `keychain.put: unsupported on platform '${process.platform}'`, | ||
| 267 | ), | ||
| 268 | ); | ||
| 269 | } | ||
| 270 | }; | ||
| 271 | |||
| 272 | const deleteKeychainSecretValue = (input: { | ||
| 273 | providerHandle: string; | ||
| 274 | keychainServiceName: string; | ||
| 275 | }) => { | ||
| 276 | switch (process.platform) { | ||
| 277 | case "darwin": | ||
| 278 | return runCommand({ | ||
| 279 | command: "security", | ||
| 280 | args: [ | ||
| 281 | "delete-generic-password", | ||
| 282 | "-a", | ||
| 283 | input.providerHandle, | ||
| 284 | "-s", | ||
| 285 | input.keychainServiceName, | ||
| 286 | ], | ||
| 287 | operation: "keychain.delete", | ||
| 288 | timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS, | ||
| 289 | }).pipe(Effect.map((result) => result.exitCode === 0)); | ||
| 290 | case "linux": | ||
| 291 | return runCommand({ | ||
| 292 | command: "secret-tool", | ||
| 293 | args: [ | ||
| 294 | "clear", | ||
| 295 | "service", | ||
| 296 | input.keychainServiceName, | ||
| 297 | "account", | ||
| 298 | input.providerHandle, | ||
| 299 | ], | ||
| 300 | operation: "keychain.delete", | ||
| 301 | timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS, | ||
| 302 | }).pipe(Effect.map((result) => result.exitCode === 0)); | ||
| 303 | default: | ||
| 304 | return Effect.fail( | ||
| 305 | runtimeEffectError( | ||
| 306 | "plugin-keychain-secret-store", | ||
| 307 | `keychain.delete: unsupported on platform '${process.platform}'`, | ||
| 308 | ), | ||
| 309 | ); | ||
| 310 | } | ||
| 311 | }; | ||
| 312 | |||
| 313 | const builtinSecretStoreStorage = <TStored>(value: TStored) => ({ | ||
| 314 | get: () => Effect.succeed(value), | ||
| 315 | put: () => Effect.void, | ||
| 316 | remove: () => Effect.void, | ||
| 317 | }); | ||
| 318 | |||
| 319 | export const keychainSecretStoreSdkPlugin = defineExecutorSecretStorePlugin({ | ||
| 320 | key: KEYCHAIN_SECRET_STORE_KIND, | ||
| 321 | secretStore: { | ||
| 322 | kind: KEYCHAIN_SECRET_STORE_KIND, | ||
| 323 | displayName: | ||
| 324 | process.platform === "darwin" ? "macOS Keychain" : "Desktop Keyring", | ||
| 325 | builtin: { | ||
| 326 | storeId: KEYCHAIN_SECRET_STORE_ID, | ||
| 327 | defaultPriority: 10, | ||
| 328 | enabled: () => process.platform === "darwin" || process.platform === "linux", | ||
| 329 | createStore: () => ({ | ||
| 330 | kind: KEYCHAIN_SECRET_STORE_KIND, | ||
| 331 | name: process.platform === "darwin" ? "macOS Keychain" : "Desktop Keyring", | ||
| 332 | status: "connected", | ||
| 333 | enabled: true, | ||
| 334 | }), | ||
| 335 | }, | ||
| 336 | storage: builtinSecretStoreStorage({}), | ||
| 337 | store: { | ||
| 338 | create: (input: { name: string }) => ({ | ||
| 339 | store: { | ||
| 340 | kind: KEYCHAIN_SECRET_STORE_KIND, | ||
| 341 | name: input.name, | ||
| 342 | status: "connected", | ||
| 343 | enabled: true, | ||
| 344 | }, | ||
| 345 | stored: {}, | ||
| 346 | }), | ||
| 347 | update: ({ store }) => ({ | ||
| 348 | store, | ||
| 349 | stored: {}, | ||
| 350 | }), | ||
| 351 | toConfig: ({ store }) => ({ | ||
| 352 | kind: KEYCHAIN_SECRET_STORE_KIND, | ||
| 353 | name: store.name, | ||
| 354 | }), | ||
| 355 | resolveSecret: ({ secret }) => | ||
| 356 | readKeychainSecretValue({ | ||
| 357 | providerHandle: secret.handle, | ||
| 358 | keychainServiceName: resolveKeychainServiceName(undefined), | ||
| 359 | }), | ||
| 360 | createSecret: ({ value, name }) => | ||
| 361 | Effect.gen(function* () { | ||
| 362 | const providerHandle = randomUUID(); | ||
| 363 | yield* writeKeychainSecretValue({ | ||
| 364 | providerHandle, | ||
| 365 | name, | ||
| 366 | value, | ||
| 367 | keychainServiceName: resolveKeychainServiceName(undefined), | ||
| 368 | }); | ||
| 369 | return { | ||
| 370 | handle: providerHandle, | ||
| 371 | name: trimOrNull(name), | ||
| 372 | value: null, | ||
| 373 | }; | ||
| 374 | }), | ||
| 375 | updateSecret: ({ secret, name, value }) => | ||
| 376 | Effect.gen(function* () { | ||
| 377 | const keychainServiceName = resolveKeychainServiceName(undefined); | ||
| 378 | const nextName = trimOrNull(name ?? secret.name); | ||
| 379 | const nextValue = value | ||
| 380 | ?? (yield* readKeychainSecretValue({ | ||
| 381 | providerHandle: secret.handle, | ||
| 382 | keychainServiceName, | ||
| 383 | })); | ||
| 384 | yield* writeKeychainSecretValue({ | ||
| 385 | providerHandle: secret.handle, | ||
| 386 | name: nextName, | ||
| 387 | value: nextValue, | ||
| 388 | keychainServiceName, | ||
| 389 | }); | ||
| 390 | return { | ||
| 391 | handle: secret.handle, | ||
| 392 | name: nextName, | ||
| 393 | value: null, | ||
| 394 | }; | ||
| 395 | }), | ||
| 396 | deleteSecret: ({ secret }) => | ||
| 397 | deleteKeychainSecretValue({ | ||
| 398 | providerHandle: secret.handle, | ||
| 399 | keychainServiceName: resolveKeychainServiceName(undefined), | ||
| 400 | }), | ||
| 401 | capabilities: () => ({ | ||
| 402 | canCreateSecrets: true, | ||
| 403 | canUpdateSecrets: true, | ||
| 404 | canDeleteSecrets: true, | ||
| 405 | canBrowseSecrets: false, | ||
| 406 | canImportSecrets: false, | ||
| 407 | }), | ||
| 408 | }, | ||
| 409 | }, | ||
| 410 | }); | ||
| @@ -0,0 +1,23 @@ | |||
| 1 | { | ||
| 2 | "name": "@executor/plugin-keychain-secret-store-sdk", | ||
| 3 | "private": true, | ||
| 4 | "type": "module", | ||
| 5 | "exports": { | ||
| 6 | ".": "./index.ts" | ||
| 7 | }, | ||
| 8 | "files": [ | ||
| 9 | "*.ts" | ||
| 10 | ], | ||
| 11 | "scripts": { | ||
| 12 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | ||
| 13 | }, | ||
| 14 | "dependencies": { | ||
| 15 | "@executor/platform-sdk": "workspace:*", | ||
| 16 | "effect": "catalog:" | ||
| 17 | }, | ||
| 18 | "devDependencies": { | ||
| 19 | "@types/node": "catalog:", | ||
| 20 | "bun-types": "catalog:" | ||
| 21 | }, | ||
| 22 | "version": "0.0.0" | ||
| 23 | } | ||
| @@ -0,0 +1,15 @@ | |||
| 1 | { | ||
| 2 | "compilerOptions": { | ||
| 3 | "target": "ES2022", | ||
| 4 | "module": "ESNext", | ||
| 5 | "moduleResolution": "Bundler", | ||
| 6 | "strict": true, | ||
| 7 | "skipLibCheck": true, | ||
| 8 | "noEmit": true, | ||
| 9 | "lib": ["ES2022"], | ||
| 10 | "types": ["bun-types", "node"] | ||
| 11 | }, | ||
| 12 | "include": [ | ||
| 13 | "*.ts" | ||
| 14 | ] | ||
| 15 | } | ||
| @@ -0,0 +1,98 @@ | |||
| 1 | import { randomUUID } from "node:crypto"; | ||
| 2 | |||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | defineExecutorSecretStorePlugin, | ||
| 7 | } from "@executor/platform-sdk/plugins"; | ||
| 8 | import { | ||
| 9 | runtimeEffectError, | ||
| 10 | } from "@executor/platform-sdk/runtime"; | ||
| 11 | |||
| 12 | export const LOCAL_SECRET_STORE_KIND = "local"; | ||
| 13 | export const LOCAL_SECRET_STORE_ID = "sts_builtin_local"; | ||
| 14 | |||
| 15 | const builtinSecretStoreStorage = <TStored>(value: TStored) => ({ | ||
| 16 | get: () => Effect.succeed(value), | ||
| 17 | put: () => Effect.void, | ||
| 18 | remove: () => Effect.void, | ||
| 19 | }); | ||
| 20 | |||
| 21 | const trimOrNull = (value: string | null | undefined): string | null => { | ||
| 22 | if (value === null || value === undefined) { | ||
| 23 | return null; | ||
| 24 | } | ||
| 25 | |||
| 26 | const trimmed = value.trim(); | ||
| 27 | return trimmed.length > 0 ? trimmed : null; | ||
| 28 | }; | ||
| 29 | |||
| 30 | export const localSecretStoreSdkPlugin = defineExecutorSecretStorePlugin({ | ||
| 31 | key: LOCAL_SECRET_STORE_KIND, | ||
| 32 | secretStore: { | ||
| 33 | kind: LOCAL_SECRET_STORE_KIND, | ||
| 34 | displayName: "Local Store", | ||
| 35 | builtin: { | ||
| 36 | storeId: LOCAL_SECRET_STORE_ID, | ||
| 37 | defaultPriority: 100, | ||
| 38 | createStore: () => ({ | ||
| 39 | kind: LOCAL_SECRET_STORE_KIND, | ||
| 40 | name: "Local Store", | ||
| 41 | status: "connected", | ||
| 42 | enabled: true, | ||
| 43 | }), | ||
| 44 | }, | ||
| 45 | storage: builtinSecretStoreStorage({}), | ||
| 46 | store: { | ||
| 47 | create: (input: { name: string }) => ({ | ||
| 48 | store: { | ||
| 49 | kind: LOCAL_SECRET_STORE_KIND, | ||
| 50 | name: input.name, | ||
| 51 | status: "connected", | ||
| 52 | enabled: true, | ||
| 53 | }, | ||
| 54 | stored: {}, | ||
| 55 | }), | ||
| 56 | update: ({ store }) => ({ | ||
| 57 | store, | ||
| 58 | stored: {}, | ||
| 59 | }), | ||
| 60 | toConfig: ({ store }) => ({ | ||
| 61 | kind: LOCAL_SECRET_STORE_KIND, | ||
| 62 | name: store.name, | ||
| 63 | }), | ||
| 64 | resolveSecret: ({ secret }) => { | ||
| 65 | if (secret.value === null) { | ||
| 66 | return Effect.fail( | ||
| 67 | runtimeEffectError( | ||
| 68 | "plugin-local-secret-store", | ||
| 69 | `Local secret ${secret.id} does not have a stored value`, | ||
| 70 | ), | ||
| 71 | ); | ||
| 72 | } | ||
| 73 | |||
| 74 | return Effect.succeed(secret.value); | ||
| 75 | }, | ||
| 76 | createSecret: ({ value, name }) => | ||
| 77 | Effect.succeed({ | ||
| 78 | handle: `local:${randomUUID()}`, | ||
| 79 | name: trimOrNull(name), | ||
| 80 | value, | ||
| 81 | }), | ||
| 82 | updateSecret: ({ secret, name, value }) => | ||
| 83 | Effect.succeed({ | ||
| 84 | handle: secret.handle, | ||
| 85 | name: trimOrNull(name ?? secret.name), | ||
| 86 | ...(value !== undefined ? { value } : {}), | ||
| 87 | }), | ||
| 88 | deleteSecret: () => Effect.succeed(true), | ||
| 89 | capabilities: () => ({ | ||
| 90 | canCreateSecrets: true, | ||
| 91 | canUpdateSecrets: true, | ||
| 92 | canDeleteSecrets: true, | ||
| 93 | canBrowseSecrets: false, | ||
| 94 | canImportSecrets: false, | ||
| 95 | }), | ||
| 96 | }, | ||
| 97 | }, | ||
| 98 | }); | ||
| @@ -0,0 +1,23 @@ | |||
| 1 | { | ||
| 2 | "name": "@executor/plugin-local-secret-store-sdk", | ||
| 3 | "private": true, | ||
| 4 | "type": "module", | ||
| 5 | "exports": { | ||
| 6 | ".": "./index.ts" | ||
| 7 | }, | ||
| 8 | "files": [ | ||
| 9 | "*.ts" | ||
| 10 | ], | ||
| 11 | "scripts": { | ||
| 12 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | ||
| 13 | }, | ||
| 14 | "dependencies": { | ||
| 15 | "@executor/platform-sdk": "workspace:*", | ||
| 16 | "effect": "catalog:" | ||
| 17 | }, | ||
| 18 | "devDependencies": { | ||
| 19 | "@types/node": "catalog:", | ||
| 20 | "bun-types": "catalog:" | ||
| 21 | }, | ||
| 22 | "version": "0.0.0" | ||
| 23 | } | ||
| @@ -0,0 +1,15 @@ | |||
| 1 | { | ||
| 2 | "compilerOptions": { | ||
| 3 | "target": "ES2022", | ||
| 4 | "module": "ESNext", | ||
| 5 | "moduleResolution": "Bundler", | ||
| 6 | "strict": true, | ||
| 7 | "skipLibCheck": true, | ||
| 8 | "noEmit": true, | ||
| 9 | "lib": ["ES2022"], | ||
| 10 | "types": ["bun-types", "node"] | ||
| 11 | }, | ||
| 12 | "include": [ | ||
| 13 | "*.ts" | ||
| 14 | ] | ||
| 15 | } | ||
| @@ -0,0 +1,174 @@ | |||
| 1 | import { startTransition, type ReactNode } from "react"; | ||
| 2 | import type { | ||
| 3 | Source, | ||
| 4 | } from "@executor/react"; | ||
| 5 | import { | ||
| 6 | useSource, | ||
| 7 | useSources, | ||
| 8 | } from "@executor/react"; | ||
| 9 | import { | ||
| 10 | SourceToolExplorer, | ||
| 11 | parseSourceToolExplorerSearch, | ||
| 12 | useSourcePluginNavigation, | ||
| 13 | useSourcePluginRouteParams, | ||
| 14 | useSourcePluginSearch, | ||
| 15 | type SourceToolExplorerSearch, | ||
| 16 | } from "@executor/react/plugins"; | ||
| 17 | |||
| 18 | const LOCAL_TOOLS_SOURCE_KIND = "local-tools"; | ||
| 19 | |||
| 20 | const localToolsSummary = ( | ||
| 21 | <div className="text-xs text-muted-foreground"> | ||
| 22 | File-backed tools loaded from <code className="rounded bg-muted px-1 py-0.5 font-mono">.executor/tools</code>. | ||
| 23 | Restart the executor session after adding the first local tool so the source is provisioned. | ||
| 24 | </div> | ||
| 25 | ); | ||
| 26 | |||
| 27 | function LocalToolsSourceRoute(props: { | ||
| 28 | children: (source: Source) => ReactNode; | ||
| 29 | }) { | ||
| 30 | const params = useSourcePluginRouteParams<{ sourceId?: string }>(); | ||
| 31 | const sourceId = typeof params.sourceId === "string" ? params.sourceId : null; | ||
| 32 | const source = useSource(sourceId ?? ""); | ||
| 33 | |||
| 34 | if (sourceId === null || source.status === "error") { | ||
| 35 | return ( | ||
| 36 | <div className="px-6 py-8 text-sm text-destructive"> | ||
| 37 | This local tools source is unavailable. | ||
| 38 | </div> | ||
| 39 | ); | ||
| 40 | } | ||
| 41 | |||
| 42 | if (source.status === "loading") { | ||
| 43 | return ( | ||
| 44 | <div className="px-6 py-8 text-sm text-muted-foreground"> | ||
| 45 | Loading source... | ||
| 46 | </div> | ||
| 47 | ); | ||
| 48 | } | ||
| 49 | |||
| 50 | if (source.data.kind !== LOCAL_TOOLS_SOURCE_KIND) { | ||
| 51 | return ( | ||
| 52 | <div className="px-6 py-8 text-sm text-destructive"> | ||
| 53 | Expected a <code className="rounded bg-muted px-1 py-0.5 font-mono">local-tools</code> source, | ||
| 54 | but received <code className="rounded bg-muted px-1 py-0.5 font-mono">{source.data.kind}</code>. | ||
| 55 | </div> | ||
| 56 | ); | ||
| 57 | } | ||
| 58 | |||
| 59 | return props.children(source.data); | ||
| 60 | } | ||
| 61 | |||
| 62 | function LocalToolsSourceDetailPage(props: { | ||
| 63 | source: Source; | ||
| 64 | }) { | ||
| 65 | const navigation = useSourcePluginNavigation(); | ||
| 66 | const search = parseSourceToolExplorerSearch( | ||
| 67 | useSourcePluginSearch(), | ||
| 68 | ) satisfies SourceToolExplorerSearch; | ||
| 69 | const tab = search.tab === "discover" ? "discover" : "model"; | ||
| 70 | const query = search.query ?? ""; | ||
| 71 | |||
| 72 | return ( | ||
| 73 | <SourceToolExplorer | ||
| 74 | sourceId={props.source.id} | ||
| 75 | title={props.source.name} | ||
| 76 | kind={props.source.kind} | ||
| 77 | search={search} | ||
| 78 | navigate={(next) => | ||
| 79 | navigation.updateSearch({ | ||
| 80 | tab: next.tab ?? tab, | ||
| 81 | ...(next.tool !== undefined | ||
| 82 | ? { tool: next.tool || undefined } | ||
| 83 | : { tool: search.tool }), | ||
| 84 | ...(next.query !== undefined | ||
| 85 | ? { query: next.query || undefined } | ||
| 86 | : { query }), | ||
| 87 | })} | ||
| 88 | summary={localToolsSummary} | ||
| 89 | /> | ||
| 90 | ); | ||
| 91 | } | ||
| 92 | |||
| 93 | export function LocalToolsAddPage() { | ||
| 94 | const sources = useSources(); | ||
| 95 | const navigation = useSourcePluginNavigation(); | ||
| 96 | |||
| 97 | if (sources.status === "loading") { | ||
| 98 | return ( | ||
| 99 | <div className="px-6 py-8 text-sm text-muted-foreground"> | ||
| 100 | Loading sources... | ||
| 101 | </div> | ||
| 102 | ); | ||
| 103 | } | ||
| 104 | |||
| 105 | if (sources.status === "error") { | ||
| 106 | return ( | ||
| 107 | <div className="px-6 py-8 text-sm text-destructive"> | ||
| 108 | Failed loading sources. | ||
| 109 | </div> | ||
| 110 | ); | ||
| 111 | } | ||
| 112 | |||
| 113 | const localToolsSource = sources.data.find( | ||
| 114 | (source) => source.kind === LOCAL_TOOLS_SOURCE_KIND, | ||
| 115 | ) ?? null; | ||
| 116 | |||
| 117 | return ( | ||
| 118 | <div className="mx-auto w-full max-w-3xl px-6 py-10"> | ||
| 119 | <div className="rounded-2xl border border-border bg-card p-6"> | ||
| 120 | <h1 className="font-display text-2xl tracking-tight text-foreground"> | ||
| 121 | Local Tools | ||
| 122 | </h1> | ||
| 123 | <p className="mt-2 text-sm leading-6 text-muted-foreground"> | ||
| 124 | Promote file-backed tools in <code className="rounded bg-muted px-1 py-0.5 font-mono">.executor/tools</code> | ||
| 125 | {" "}into a first-class source with inspection and discovery. | ||
| 126 | </p> | ||
| 127 | |||
| 128 | <div className="mt-6 rounded-xl border border-border/70 bg-background/60 p-4 text-sm text-muted-foreground"> | ||
| 129 | <p> | ||
| 130 | The source is provisioned automatically when the executor session starts and finds | ||
| 131 | local tool files in <code className="rounded bg-muted px-1 py-0.5 font-mono">.executor/tools</code>. | ||
| 132 | </p> | ||
| 133 | <p className="mt-3"> | ||
| 134 | Existing tool path behavior is preserved, so a file like | ||
| 135 | {" "}<code className="rounded bg-muted px-1 py-0.5 font-mono">.executor/tools/demo.ts</code> | ||
| 136 | {" "}still appears as <code className="rounded bg-muted px-1 py-0.5 font-mono">tools.demo(...)</code>. | ||
| 137 | </p> | ||
| 138 | </div> | ||
| 139 | |||
| 140 | <div className="mt-6 flex flex-wrap items-center gap-3"> | ||
| 141 | {localToolsSource ? ( | ||
| 142 | <button | ||
| 143 | type="button" | ||
| 144 | onClick={() => { | ||
| 145 | startTransition(() => { | ||
| 146 | void navigation.detail(localToolsSource.id, { | ||
| 147 | tab: "model", | ||
| 148 | }); | ||
| 149 | }); | ||
| 150 | }} | ||
| 151 | className="inline-flex h-10 items-center justify-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90" | ||
| 152 | > | ||
| 153 | Open Local Tools Source | ||
| 154 | </button> | ||
| 155 | ) : ( | ||
| 156 | <div className="rounded-lg border border-amber-500/25 bg-amber-500/8 px-4 py-2.5 text-sm text-amber-700"> | ||
| 157 | No local tools source is active yet. Create a tool in | ||
| 158 | {" "}<code className="rounded bg-muted px-1 py-0.5 font-mono">.executor/tools</code> | ||
| 159 | {" "}and restart the executor session. | ||
| 160 | </div> | ||
| 161 | )} | ||
| 162 | </div> | ||
| 163 | </div> | ||
| 164 | </div> | ||
| 165 | ); | ||
| 166 | } | ||
| 167 | |||
| 168 | export function LocalToolsDetailRoute() { | ||
| 169 | return ( | ||
| 170 | <LocalToolsSourceRoute> | ||
| 171 | {(source) => <LocalToolsSourceDetailPage source={source} />} | ||
| 172 | </LocalToolsSourceRoute> | ||
| 173 | ); | ||
| 174 | } | ||
| @@ -0,0 +1,24 @@ | |||
| 1 | import { defineExecutorFrontendPlugin } from "@executor/react/plugins"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | LocalToolsAddPage, | ||
| 5 | LocalToolsDetailRoute, | ||
| 6 | } from "./components"; | ||
| 7 | |||
| 8 | export const LocalToolsReactPlugin = defineExecutorFrontendPlugin({ | ||
| 9 | key: "local-tools", | ||
| 10 | displayName: "Local Tools", | ||
| 11 | description: "Inspect file-backed tools from .executor/tools as a first-class source.", | ||
| 12 | routes: [ | ||
| 13 | { | ||
| 14 | key: "add", | ||
| 15 | path: "add", | ||
| 16 | component: LocalToolsAddPage, | ||
| 17 | }, | ||
| 18 | { | ||
| 19 | key: "detail", | ||
| 20 | path: "sources/$sourceId", | ||
| 21 | component: LocalToolsDetailRoute, | ||
| 22 | }, | ||
| 23 | ], | ||
| 24 | }); | ||
| @@ -0,0 +1,25 @@ | |||
| 1 | { | ||
| 2 | "name": "@executor/plugin-local-tools-react", | ||
| 3 | "private": true, | ||
| 4 | "type": "module", | ||
| 5 | "exports": { | ||
| 6 | ".": "./index.ts" | ||
| 7 | }, | ||
| 8 | "files": [ | ||
| 9 | "components.tsx", | ||
| 10 | "index.ts" | ||
| 11 | ], | ||
| 12 | "scripts": { | ||
| 13 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | ||
| 14 | }, | ||
| 15 | "dependencies": { | ||
| 16 | "@executor/react": "workspace:*", | ||
| 17 | "react": "^19.2.0" | ||
| 18 | }, | ||
| 19 | "devDependencies": { | ||
| 20 | "@types/node": "catalog:", | ||
| 21 | "@types/react": "^19.2.10", | ||
| 22 | "bun-types": "catalog:" | ||
| 23 | }, | ||
| 24 | "version": "0.0.0" | ||
| 25 | } | ||
| @@ -0,0 +1,17 @@ | |||
| 1 | { | ||
| 2 | "compilerOptions": { | ||
| 3 | "target": "ES2022", | ||
| 4 | "module": "ESNext", | ||
| 5 | "moduleResolution": "Bundler", | ||
| 6 | "strict": true, | ||
| 7 | "skipLibCheck": true, | ||
| 8 | "noEmit": true, | ||
| 9 | "lib": ["ES2022", "DOM", "DOM.Iterable"], | ||
| 10 | "jsx": "react-jsx", | ||
| 11 | "types": ["bun-types", "node"] | ||
| 12 | }, | ||
| 13 | "include": [ | ||
| 14 | "*.ts", | ||
| 15 | "*.tsx" | ||
| 16 | ] | ||
| 17 | } | ||
| @@ -0,0 +1,241 @@ | |||
| 1 | import { | ||
| 2 | CapabilityIdSchema, | ||
| 3 | DocumentIdSchema, | ||
| 4 | ExecutableIdSchema, | ||
| 5 | ResponseSymbolIdSchema, | ||
| 6 | ScopeIdSchema, | ||
| 7 | } from "@executor/ir/ids"; | ||
| 8 | import type { | ||
| 9 | Capability, | ||
| 10 | Executable, | ||
| 11 | ResponseSymbol, | ||
| 12 | } from "@executor/ir/model"; | ||
| 13 | import type { | ||
| 14 | ToolDescriptor, | ||
| 15 | } from "@executor/codemode-core"; | ||
| 16 | import { | ||
| 17 | EXECUTABLE_BINDING_VERSION, | ||
| 18 | buildCatalogFragment, | ||
| 19 | docsFrom, | ||
| 20 | interactionForEffect, | ||
| 21 | isObjectLikeJsonSchema, | ||
| 22 | mutableRecord, | ||
| 23 | provenanceFor, | ||
| 24 | responseSetFromSingleResponse, | ||
| 25 | schemaWithMergedDefs, | ||
| 26 | stableHash, | ||
| 27 | type CatalogFragmentBuilder, | ||
| 28 | type CatalogSourceDocumentInput, | ||
| 29 | type JsonSchemaImporter, | ||
| 30 | type Source, | ||
| 31 | } from "@executor/source-core"; | ||
| 32 | |||
| 33 | export type LocalToolsCatalogOperationInput = { | ||
| 34 | descriptor: ToolDescriptor; | ||
| 35 | inputSchema?: unknown; | ||
| 36 | outputSchema?: unknown; | ||
| 37 | }; | ||
| 38 | |||
| 39 | const leafFromPath = (path: string): string => | ||
| 40 | path.split(".").filter((segment) => segment.length > 0).at(-1) ?? path; | ||
| 41 | |||
| 42 | const toolPathSegmentsFromDescriptor = (descriptor: ToolDescriptor): string[] => | ||
| 43 | descriptor.path.split(".").filter((segment) => segment.length > 0); | ||
| 44 | |||
| 45 | const invocationInputMode = ( | ||
| 46 | schema: unknown, | ||
| 47 | ): "direct" | "wrapped" => | ||
| 48 | schema === undefined || isObjectLikeJsonSchema(schema) ? "direct" : "wrapped"; | ||
| 49 | |||
| 50 | const createLocalToolCapability = (input: { | ||
| 51 | catalog: CatalogFragmentBuilder; | ||
| 52 | source: Pick<Source, "id">; | ||
| 53 | documentId: ReturnType<typeof DocumentIdSchema.make>; | ||
| 54 | serviceScopeId: ReturnType<typeof ScopeIdSchema.make>; | ||
| 55 | operation: LocalToolsCatalogOperationInput; | ||
| 56 | importer: JsonSchemaImporter; | ||
| 57 | }) => { | ||
| 58 | const toolPath = toolPathSegmentsFromDescriptor(input.operation.descriptor); | ||
| 59 | const pathText = toolPath.join("."); | ||
| 60 | const capabilityId = CapabilityIdSchema.make( | ||
| 61 | `cap_${stableHash({ | ||
| 62 | sourceId: input.source.id, | ||
| 63 | toolPath: pathText, | ||
| 64 | })}`, | ||
| 65 | ); | ||
| 66 | const executableId = ExecutableIdSchema.make( | ||
| 67 | `exec_${stableHash({ | ||
| 68 | sourceId: input.source.id, | ||
| 69 | toolPath: pathText, | ||
| 70 | protocol: "local-tools", | ||
| 71 | })}`, | ||
| 72 | ); | ||
| 73 | const inputSchema = input.operation.inputSchema; | ||
| 74 | const callShapeId = | ||
| 75 | inputSchema === undefined | ||
| 76 | ? input.importer.importSchema( | ||
| 77 | { | ||
| 78 | type: "object", | ||
| 79 | properties: {}, | ||
| 80 | additionalProperties: false, | ||
| 81 | }, | ||
| 82 | `#/local-tools/${pathText}/call`, | ||
| 83 | ) | ||
| 84 | : invocationInputMode(inputSchema) === "direct" | ||
| 85 | ? input.importer.importSchema( | ||
| 86 | inputSchema, | ||
| 87 | `#/local-tools/${pathText}/call`, | ||
| 88 | inputSchema, | ||
| 89 | ) | ||
| 90 | : input.importer.importSchema( | ||
| 91 | schemaWithMergedDefs( | ||
| 92 | { | ||
| 93 | type: "object", | ||
| 94 | properties: { | ||
| 95 | input: inputSchema, | ||
| 96 | }, | ||
| 97 | required: ["input"], | ||
| 98 | additionalProperties: false, | ||
| 99 | }, | ||
| 100 | inputSchema, | ||
| 101 | ), | ||
| 102 | `#/local-tools/${pathText}/call`, | ||
| 103 | ); | ||
| 104 | const outputShapeId = | ||
| 105 | input.operation.outputSchema !== undefined | ||
| 106 | ? input.importer.importSchema( | ||
| 107 | input.operation.outputSchema, | ||
| 108 | `#/local-tools/${pathText}/output`, | ||
| 109 | ) | ||
| 110 | : undefined; | ||
| 111 | const resultStatusShapeId = input.importer.importSchema( | ||
| 112 | { type: "null" }, | ||
| 113 | `#/local-tools/${pathText}/status`, | ||
| 114 | ); | ||
| 115 | |||
| 116 | const responseId = ResponseSymbolIdSchema.make( | ||
| 117 | `response_${stableHash({ capabilityId })}`, | ||
| 118 | ); | ||
| 119 | mutableRecord(input.catalog.symbols)[responseId] = { | ||
| 120 | id: responseId, | ||
| 121 | kind: "response", | ||
| 122 | ...(docsFrom({ | ||
| 123 | description: input.operation.descriptor.description, | ||
| 124 | }) | ||
| 125 | ? { | ||
| 126 | docs: docsFrom({ | ||
| 127 | description: input.operation.descriptor.description, | ||
| 128 | })!, | ||
| 129 | } | ||
| 130 | : {}), | ||
| 131 | ...(outputShapeId | ||
| 132 | ? { | ||
| 133 | contents: [ | ||
| 134 | { | ||
| 135 | mediaType: "application/json", | ||
| 136 | shapeId: outputShapeId, | ||
| 137 | }, | ||
| 138 | ], | ||
| 139 | } | ||
| 140 | : {}), | ||
| 141 | synthetic: false, | ||
| 142 | provenance: provenanceFor( | ||
| 143 | input.documentId, | ||
| 144 | `#/local-tools/${pathText}/response`, | ||
| 145 | ), | ||
| 146 | } satisfies ResponseSymbol; | ||
| 147 | const responseSetId = responseSetFromSingleResponse({ | ||
| 148 | catalog: input.catalog, | ||
| 149 | responseId, | ||
| 150 | provenance: provenanceFor( | ||
| 151 | input.documentId, | ||
| 152 | `#/local-tools/${pathText}/responseSet`, | ||
| 153 | ), | ||
| 154 | }); | ||
| 155 | |||
| 156 | mutableRecord(input.catalog.executables)[executableId] = { | ||
| 157 | id: executableId, | ||
| 158 | capabilityId, | ||
| 159 | scopeId: input.serviceScopeId, | ||
| 160 | pluginKey: "local-tools", | ||
| 161 | bindingVersion: EXECUTABLE_BINDING_VERSION, | ||
| 162 | binding: { | ||
| 163 | toolPath: pathText, | ||
| 164 | invocationInput: invocationInputMode(inputSchema), | ||
| 165 | }, | ||
| 166 | projection: { | ||
| 167 | responseSetId, | ||
| 168 | callShapeId, | ||
| 169 | ...(outputShapeId ? { resultDataShapeId: outputShapeId } : {}), | ||
| 170 | resultStatusShapeId, | ||
| 171 | }, | ||
| 172 | display: { | ||
| 173 | protocol: "local-tools", | ||
| 174 | method: null, | ||
| 175 | pathTemplate: null, | ||
| 176 | operationId: pathText, | ||
| 177 | group: toolPath.length > 1 ? toolPath.slice(0, -1).join(".") : null, | ||
| 178 | leaf: leafFromPath(pathText), | ||
| 179 | rawToolId: pathText, | ||
| 180 | title: pathText, | ||
| 181 | summary: input.operation.descriptor.description ?? null, | ||
| 182 | }, | ||
| 183 | synthetic: false, | ||
| 184 | provenance: provenanceFor( | ||
| 185 | input.documentId, | ||
| 186 | `#/local-tools/${pathText}/executable`, | ||
| 187 | ), | ||
| 188 | } satisfies Executable; | ||
| 189 | |||
| 190 | const effect = | ||
| 191 | input.operation.descriptor.interaction === "required" | ||
| 192 | ? "action" | ||
| 193 | : "read"; | ||
| 194 | mutableRecord(input.catalog.capabilities)[capabilityId] = { | ||
| 195 | id: capabilityId, | ||
| 196 | serviceScopeId: input.serviceScopeId, | ||
| 197 | surface: { | ||
| 198 | toolPath, | ||
| 199 | title: pathText, | ||
| 200 | ...(input.operation.descriptor.description | ||
| 201 | ? { summary: input.operation.descriptor.description } | ||
| 202 | : {}), | ||
| 203 | }, | ||
| 204 | semantics: { | ||
| 205 | effect, | ||
| 206 | safe: effect === "read", | ||
| 207 | idempotent: effect === "read", | ||
| 208 | destructive: false, | ||
| 209 | }, | ||
| 210 | auth: { kind: "none" }, | ||
| 211 | interaction: interactionForEffect(effect), | ||
| 212 | executableIds: [executableId], | ||
| 213 | synthetic: false, | ||
| 214 | provenance: provenanceFor( | ||
| 215 | input.documentId, | ||
| 216 | `#/local-tools/${pathText}/capability`, | ||
| 217 | ), | ||
| 218 | } satisfies Capability; | ||
| 219 | }; | ||
| 220 | |||
| 221 | export const createLocalToolsCatalogFragment = (input: { | ||
| 222 | source: Source; | ||
| 223 | documents: readonly CatalogSourceDocumentInput[]; | ||
| 224 | operations: readonly LocalToolsCatalogOperationInput[]; | ||
| 225 | }) => | ||
| 226 | buildCatalogFragment({ | ||
| 227 | source: input.source, | ||
| 228 | documents: input.documents, | ||
| 229 | registerOperations: ({ catalog, documentId, serviceScopeId, importer }) => { | ||
| 230 | for (const operation of input.operations) { | ||
| 231 | createLocalToolCapability({ | ||
| 232 | catalog, | ||
| 233 | source: input.source, | ||
| 234 | documentId, | ||
| 235 | serviceScopeId, | ||
| 236 | operation, | ||
| 237 | importer, | ||
| 238 | }); | ||
| 239 | } | ||
| 240 | }, | ||
| 241 | }); | ||
| @@ -0,0 +1,277 @@ | |||
| 1 | import { | ||
| 2 | makeToolInvokerFromTools, | ||
| 3 | toolDescriptorsFromTools, | ||
| 4 | type ToolDescriptor, | ||
| 5 | type ToolInput, | ||
| 6 | type ToolMap, | ||
| 7 | } from "@executor/codemode-core"; | ||
| 8 | import { | ||
| 9 | createCatalogImportMetadata, | ||
| 10 | createSourceCatalogSyncResult, | ||
| 11 | stableHash, | ||
| 12 | } from "@executor/source-core"; | ||
| 13 | import { | ||
| 14 | defineExecutorSourcePlugin, | ||
| 15 | } from "@executor/platform-sdk/plugins"; | ||
| 16 | import { | ||
| 17 | LocalToolRuntimeLoaderService, | ||
| 18 | provideExecutorRuntime, | ||
| 19 | runtimeEffectError, | ||
| 20 | } from "@executor/platform-sdk/runtime"; | ||
| 21 | import type { | ||
| 22 | Source, | ||
| 23 | } from "@executor/platform-sdk/schema"; | ||
| 24 | import * as Effect from "effect/Effect"; | ||
| 25 | import * as Schema from "effect/Schema"; | ||
| 26 | |||
| 27 | import { | ||
| 28 | createLocalToolsCatalogFragment, | ||
| 29 | } from "./catalog"; | ||
| 30 | |||
| 31 | export const LOCAL_TOOLS_SOURCE_KIND = "local-tools"; | ||
| 32 | export const LOCAL_TOOLS_SOURCE_NAME = "Local Tools"; | ||
| 33 | |||
| 34 | const LocalToolsStoredSchema = Schema.Struct({ | ||
| 35 | kind: Schema.Literal(LOCAL_TOOLS_SOURCE_KIND), | ||
| 36 | }); | ||
| 37 | |||
| 38 | type LocalToolsStored = typeof LocalToolsStoredSchema.Type; | ||
| 39 | |||
| 40 | const LOCAL_TOOLS_STORED: LocalToolsStored = { | ||
| 41 | kind: LOCAL_TOOLS_SOURCE_KIND, | ||
| 42 | }; | ||
| 43 | |||
| 44 | const LocalToolsAddInputSchema = Schema.Struct({ | ||
| 45 | kind: Schema.Literal(LOCAL_TOOLS_SOURCE_KIND), | ||
| 46 | name: Schema.optional(Schema.String), | ||
| 47 | }); | ||
| 48 | |||
| 49 | type LocalToolsAddInput = typeof LocalToolsAddInputSchema.Type; | ||
| 50 | |||
| 51 | type LocalToolsSourceConfig = { | ||
| 52 | name: string; | ||
| 53 | }; | ||
| 54 | |||
| 55 | type LocalToolsUpdateSourceInput = { | ||
| 56 | sourceId: string; | ||
| 57 | config: LocalToolsSourceConfig; | ||
| 58 | }; | ||
| 59 | |||
| 60 | const asRecord = (value: unknown): Record<string, unknown> => | ||
| 61 | typeof value === "object" && value !== null && !Array.isArray(value) | ||
| 62 | ? (value as Record<string, unknown>) | ||
| 63 | : {}; | ||
| 64 | |||
| 65 | const loadLocalToolRuntime = () => | ||
| 66 | Effect.flatMap(LocalToolRuntimeLoaderService, (loader) => loader.load()); | ||
| 67 | |||
| 68 | const localToolDescriptors = (input: { | ||
| 69 | source: Source; | ||
| 70 | tools: ToolMap; | ||
| 71 | }): ToolDescriptor[] => | ||
| 72 | toolDescriptorsFromTools({ | ||
| 73 | tools: input.tools, | ||
| 74 | sourceKey: input.source.id, | ||
| 75 | }); | ||
| 76 | |||
| 77 | const localToolSourceHash = ( | ||
| 78 | descriptors: ReadonlyArray<ToolDescriptor>, | ||
| 79 | ): string => | ||
| 80 | stableHash( | ||
| 81 | descriptors.map((descriptor) => ({ | ||
| 82 | path: descriptor.path, | ||
| 83 | description: descriptor.description ?? null, | ||
| 84 | interaction: descriptor.interaction ?? "auto", | ||
| 85 | inputSchema: descriptor.contract?.inputSchema ?? null, | ||
| 86 | outputSchema: descriptor.contract?.outputSchema ?? null, | ||
| 87 | })), | ||
| 88 | ); | ||
| 89 | |||
| 90 | const createLocalToolsDocument = ( | ||
| 91 | source: Source, | ||
| 92 | descriptors: ReadonlyArray<ToolDescriptor>, | ||
| 93 | ) => ({ | ||
| 94 | documentKind: "local-tools", | ||
| 95 | documentKey: `.executor/tools:${source.id}`, | ||
| 96 | fetchedAt: Date.now(), | ||
| 97 | contentText: JSON.stringify( | ||
| 98 | { | ||
| 99 | tools: descriptors.map((descriptor) => ({ | ||
| 100 | path: descriptor.path, | ||
| 101 | description: descriptor.description ?? null, | ||
| 102 | interaction: descriptor.interaction ?? "auto", | ||
| 103 | inputSchema: descriptor.contract?.inputSchema ?? null, | ||
| 104 | outputSchema: descriptor.contract?.outputSchema ?? null, | ||
| 105 | })), | ||
| 106 | }, | ||
| 107 | null, | ||
| 108 | 2, | ||
| 109 | ), | ||
| 110 | }); | ||
| 111 | |||
| 112 | const extractExecutableTool = (entry: ToolInput | undefined) => { | ||
| 113 | if (!entry || typeof entry !== "object") { | ||
| 114 | return null; | ||
| 115 | } | ||
| 116 | |||
| 117 | return "tool" in entry ? entry.tool : entry; | ||
| 118 | }; | ||
| 119 | |||
| 120 | const wrappedInvocationPayload = ( | ||
| 121 | args: unknown, | ||
| 122 | ): unknown => asRecord(args).input; | ||
| 123 | |||
| 124 | export const localToolsSdkPlugin = () => | ||
| 125 | defineExecutorSourcePlugin< | ||
| 126 | "local-tools", | ||
| 127 | LocalToolsAddInput, | ||
| 128 | { name: string }, | ||
| 129 | LocalToolsSourceConfig, | ||
| 130 | LocalToolsStored, | ||
| 131 | LocalToolsUpdateSourceInput | ||
| 132 | >({ | ||
| 133 | key: LOCAL_TOOLS_SOURCE_KIND, | ||
| 134 | source: { | ||
| 135 | kind: LOCAL_TOOLS_SOURCE_KIND, | ||
| 136 | displayName: "Local Tools", | ||
| 137 | add: { | ||
| 138 | inputSchema: LocalToolsAddInputSchema, | ||
| 139 | helpText: [ | ||
| 140 | "Auto-discovers file-backed tools from .executor/tools in this workspace.", | ||
| 141 | ], | ||
| 142 | toConnectInput: (input) => ({ | ||
| 143 | name: input.name?.trim() || LOCAL_TOOLS_SOURCE_NAME, | ||
| 144 | }), | ||
| 145 | }, | ||
| 146 | storage: { | ||
| 147 | get: () => Effect.succeed(LOCAL_TOOLS_STORED), | ||
| 148 | put: () => Effect.void, | ||
| 149 | remove: () => Effect.void, | ||
| 150 | }, | ||
| 151 | source: { | ||
| 152 | create: (input) => ({ | ||
| 153 | source: { | ||
| 154 | name: input.name.trim() || LOCAL_TOOLS_SOURCE_NAME, | ||
| 155 | kind: LOCAL_TOOLS_SOURCE_KIND, | ||
| 156 | status: "connected", | ||
| 157 | enabled: true, | ||
| 158 | namespace: "", | ||
| 159 | }, | ||
| 160 | stored: LOCAL_TOOLS_STORED, | ||
| 161 | }), | ||
| 162 | update: (input) => ({ | ||
| 163 | source: { | ||
| 164 | ...input.source, | ||
| 165 | name: input.config.name.trim() || input.source.name, | ||
| 166 | }, | ||
| 167 | stored: LOCAL_TOOLS_STORED, | ||
| 168 | }), | ||
| 169 | toConfig: (input) => ({ | ||
| 170 | name: input.source.name, | ||
| 171 | }), | ||
| 172 | }, | ||
| 173 | catalog: { | ||
| 174 | kind: "imported", | ||
| 175 | sync: (input) => | ||
| 176 | Effect.gen(function* () { | ||
| 177 | const localToolRuntime = yield* loadLocalToolRuntime(); | ||
| 178 | const descriptors = localToolDescriptors({ | ||
| 179 | source: input.source, | ||
| 180 | tools: localToolRuntime.tools, | ||
| 181 | }); | ||
| 182 | |||
| 183 | return createSourceCatalogSyncResult({ | ||
| 184 | fragment: createLocalToolsCatalogFragment({ | ||
| 185 | source: input.source, | ||
| 186 | documents: [createLocalToolsDocument(input.source, descriptors)], | ||
| 187 | operations: descriptors.map((descriptor) => ({ | ||
| 188 | descriptor, | ||
| 189 | inputSchema: descriptor.contract?.inputSchema, | ||
| 190 | outputSchema: descriptor.contract?.outputSchema, | ||
| 191 | })), | ||
| 192 | }), | ||
| 193 | importMetadata: { | ||
| 194 | ...createCatalogImportMetadata({ | ||
| 195 | source: input.source, | ||
| 196 | pluginKey: LOCAL_TOOLS_SOURCE_KIND, | ||
| 197 | }), | ||
| 198 | importerVersion: "ir.v1.local-tools", | ||
| 199 | }, | ||
| 200 | sourceHash: localToolSourceHash(descriptors), | ||
| 201 | }); | ||
| 202 | }), | ||
| 203 | invoke: (input) => | ||
| 204 | Effect.gen(function* () { | ||
| 205 | const localToolRuntime = yield* loadLocalToolRuntime(); | ||
| 206 | const executable = extractExecutableTool( | ||
| 207 | localToolRuntime.tools[input.descriptor.path], | ||
| 208 | ); | ||
| 209 | if (!executable) { | ||
| 210 | return yield* runtimeEffectError( | ||
| 211 | "plugins/local-tools/sdk", | ||
| 212 | `Missing local tool definition for ${input.descriptor.path}`, | ||
| 213 | ); | ||
| 214 | } | ||
| 215 | |||
| 216 | const binding = asRecord(input.executable.binding); | ||
| 217 | const toolPath = | ||
| 218 | typeof binding.toolPath === "string" && binding.toolPath.length > 0 | ||
| 219 | ? binding.toolPath | ||
| 220 | : input.descriptor.path; | ||
| 221 | const payload = | ||
| 222 | binding.invocationInput === "wrapped" | ||
| 223 | ? wrappedInvocationPayload(input.args) | ||
| 224 | : input.args; | ||
| 225 | const toolInvoker = makeToolInvokerFromTools({ | ||
| 226 | tools: localToolRuntime.tools, | ||
| 227 | onElicitation: input.onElicitation, | ||
| 228 | }); | ||
| 229 | const result = yield* toolInvoker.invoke({ | ||
| 230 | path: toolPath as never, | ||
| 231 | args: payload, | ||
| 232 | context: input.context, | ||
| 233 | }).pipe( | ||
| 234 | Effect.mapError((cause) => | ||
| 235 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 236 | ), | ||
| 237 | ); | ||
| 238 | |||
| 239 | return { | ||
| 240 | data: result ?? null, | ||
| 241 | error: null, | ||
| 242 | headers: {}, | ||
| 243 | status: null, | ||
| 244 | }; | ||
| 245 | }), | ||
| 246 | }, | ||
| 247 | }, | ||
| 248 | start: ({ executor, source }) => { | ||
| 249 | const provideRuntime = <A>( | ||
| 250 | effect: Effect.Effect<A, Error, any>, | ||
| 251 | ): Effect.Effect<A, Error, never> => | ||
| 252 | provideExecutorRuntime(effect, executor.runtime); | ||
| 253 | |||
| 254 | return provideRuntime( | ||
| 255 | Effect.gen(function* () { | ||
| 256 | const localToolRuntime = yield* loadLocalToolRuntime(); | ||
| 257 | const existingSources = yield* executor.sources.list(); | ||
| 258 | const existingLocalToolsSource = existingSources.find( | ||
| 259 | (candidate) => candidate.kind === LOCAL_TOOLS_SOURCE_KIND, | ||
| 260 | ) ?? null; | ||
| 261 | |||
| 262 | if (existingLocalToolsSource) { | ||
| 263 | yield* source.refreshSource(existingLocalToolsSource.id); | ||
| 264 | return; | ||
| 265 | } | ||
| 266 | |||
| 267 | if (localToolRuntime.toolPaths.size === 0) { | ||
| 268 | return; | ||
| 269 | } | ||
| 270 | |||
| 271 | yield* source.createSource({ | ||
| 272 | name: LOCAL_TOOLS_SOURCE_NAME, | ||
| 273 | }); | ||
| 274 | }), | ||
| 275 | ); | ||
| 276 | }, | ||
| 277 | }); | ||
| @@ -0,0 +1,26 @@ | |||
| 1 | { | ||
| 2 | "name": "@executor/plugin-local-tools-sdk", | ||
| 3 | "private": true, | ||
| 4 | "type": "module", | ||
| 5 | "exports": { | ||
| 6 | ".": "./index.ts" | ||
| 7 | }, | ||
| 8 | "files": [ | ||
| 9 | "*.ts" | ||
| 10 | ], | ||
| 11 | "scripts": { | ||
| 12 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | ||
| 13 | }, | ||
| 14 | "dependencies": { | ||
| 15 | "@executor/codemode-core": "workspace:*", | ||
| 16 | "@executor/ir": "workspace:*", | ||
| 17 | "@executor/platform-sdk": "workspace:*", | ||
| 18 | "@executor/source-core": "workspace:*", | ||
| 19 | "effect": "catalog:" | ||
| 20 | }, | ||
| 21 | "devDependencies": { | ||
| 22 | "@types/node": "catalog:", | ||
| 23 | "bun-types": "catalog:" | ||
| 24 | }, | ||
| 25 | "version": "0.0.0" | ||
| 26 | } | ||
| @@ -0,0 +1,13 @@ | |||
| 1 | { | ||
| 2 | "compilerOptions": { | ||
| 3 | "target": "ES2022", | ||
| 4 | "module": "ESNext", | ||
| 5 | "moduleResolution": "Bundler", | ||
| 6 | "strict": true, | ||
| 7 | "skipLibCheck": true, | ||
| 8 | "noEmit": true, | ||
| 9 | "lib": ["ES2022", "DOM"], | ||
| 10 | "types": ["bun-types", "node"] | ||
| 11 | }, | ||
| 12 | "include": ["*.ts"] | ||
| 13 | } | ||
| @@ -0,0 +1,111 @@ | |||
| 1 | import { | ||
| 2 | HttpApi, | ||
| 3 | HttpApiBuilder, | ||
| 4 | } from "@effect/platform"; | ||
| 5 | import { | ||
| 6 | ControlPlaneBadRequestError, | ||
| 7 | ControlPlaneForbiddenError, | ||
| 8 | ControlPlaneStorageError, | ||
| 9 | type ExecutorHttpPlugin, | ||
| 10 | } from "@executor/platform-api"; | ||
| 11 | import { resolveRequestedLocalWorkspace } from "@executor/platform-api/local-context"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | |||
| 14 | import { | ||
| 15 | OnePasswordDiscoverStoreItemsInputSchema, | ||
| 16 | OnePasswordDiscoverItemFieldsInputSchema, | ||
| 17 | OnePasswordImportSecretInputSchema, | ||
| 18 | type OnePasswordDiscoverVaultsInput, | ||
| 19 | type OnePasswordDiscoverVaultsResult, | ||
| 20 | type OnePasswordDiscoverItemFieldsInput, | ||
| 21 | type OnePasswordDiscoverItemFieldsResult, | ||
| 22 | type OnePasswordDiscoverStoreItemsInput, | ||
| 23 | type OnePasswordDiscoverStoreItemsResult, | ||
| 24 | type OnePasswordImportSecretInput, | ||
| 25 | type OnePasswordImportSecretResult, | ||
| 26 | onePasswordHttpGroup, | ||
| 27 | } from "@executor/plugin-onepassword-shared"; | ||
| 28 | |||
| 29 | type OnePasswordExecutorExtension = { | ||
| 30 | onepassword: { | ||
| 31 | discoverVaults: ( | ||
| 32 | input: OnePasswordDiscoverVaultsInput, | ||
| 33 | ) => Effect.Effect<OnePasswordDiscoverVaultsResult, Error>; | ||
| 34 | discoverStoreItems: ( | ||
| 35 | input: OnePasswordDiscoverStoreItemsInput, | ||
| 36 | ) => Effect.Effect<OnePasswordDiscoverStoreItemsResult, Error>; | ||
| 37 | discoverItemFields: ( | ||
| 38 | input: OnePasswordDiscoverItemFieldsInput, | ||
| 39 | ) => Effect.Effect<OnePasswordDiscoverItemFieldsResult, Error>; | ||
| 40 | importSecret: ( | ||
| 41 | input: OnePasswordImportSecretInput, | ||
| 42 | ) => Effect.Effect<OnePasswordImportSecretResult, Error>; | ||
| 43 | }; | ||
| 44 | }; | ||
| 45 | |||
| 46 | const OnePasswordHttpApi = HttpApi.make("executor").add(onePasswordHttpGroup); | ||
| 47 | |||
| 48 | const toStorageError = (operation: string, cause: unknown) => | ||
| 49 | new ControlPlaneStorageError({ | ||
| 50 | operation, | ||
| 51 | message: cause instanceof Error ? cause.message : String(cause), | ||
| 52 | details: cause instanceof Error ? cause.stack ?? cause.message : String(cause), | ||
| 53 | }); | ||
| 54 | |||
| 55 | export const onePasswordHttpPlugin = (): ExecutorHttpPlugin< | ||
| 56 | typeof onePasswordHttpGroup, | ||
| 57 | OnePasswordExecutorExtension | ||
| 58 | > => ({ | ||
| 59 | key: "onepassword", | ||
| 60 | group: onePasswordHttpGroup, | ||
| 61 | build: ({ executor }) => | ||
| 62 | (HttpApiBuilder.group as any)(OnePasswordHttpApi, "onepassword", (handlers: any) => | ||
| 63 | handlers | ||
| 64 | .handle("discoverVaults", ({ path, payload }: any) => | ||
| 65 | resolveRequestedLocalWorkspace( | ||
| 66 | "onepassword.discoverVaults", | ||
| 67 | path.workspaceId, | ||
| 68 | ).pipe( | ||
| 69 | Effect.flatMap(() => executor.onepassword.discoverVaults(payload)), | ||
| 70 | Effect.mapError((cause) => | ||
| 71 | toStorageError("onepassword.discoverVaults", cause) | ||
| 72 | ), | ||
| 73 | )) | ||
| 74 | .handle("discoverStoreItems", ({ path, payload }: any) => | ||
| 75 | resolveRequestedLocalWorkspace( | ||
| 76 | "onepassword.discoverStoreItems", | ||
| 77 | path.workspaceId, | ||
| 78 | ).pipe( | ||
| 79 | Effect.flatMap(() => executor.onepassword.discoverStoreItems( | ||
| 80 | OnePasswordDiscoverStoreItemsInputSchema.make(payload), | ||
| 81 | )), | ||
| 82 | Effect.mapError((cause) => | ||
| 83 | toStorageError("onepassword.discoverStoreItems", cause) | ||
| 84 | ), | ||
| 85 | )) | ||
| 86 | .handle("discoverItemFields", ({ path, payload }: any) => | ||
| 87 | resolveRequestedLocalWorkspace( | ||
| 88 | "onepassword.discoverItemFields", | ||
| 89 | path.workspaceId, | ||
| 90 | ).pipe( | ||
| 91 | Effect.flatMap(() => executor.onepassword.discoverItemFields( | ||
| 92 | OnePasswordDiscoverItemFieldsInputSchema.make(payload), | ||
| 93 | )), | ||
| 94 | Effect.mapError((cause) => | ||
| 95 | toStorageError("onepassword.discoverItemFields", cause) | ||
| 96 | ), | ||
| 97 | )) | ||
| 98 | .handle("importSecret", ({ path, payload }: any) => | ||
| 99 | resolveRequestedLocalWorkspace( | ||
| 100 | "onepassword.importSecret", | ||
| 101 | path.workspaceId, | ||
| 102 | ).pipe( | ||
| 103 | Effect.flatMap(() => executor.onepassword.importSecret( | ||
| 104 | OnePasswordImportSecretInputSchema.make(payload), | ||
| 105 | )), | ||
| 106 | Effect.mapError((cause) => | ||
| 107 | toStorageError("onepassword.importSecret", cause) | ||
| 108 | ), | ||
| 109 | )) | ||
| 110 | ) as any, | ||
| 111 | }); | ||
| @@ -0,0 +1,26 @@ | |||
| 1 | { | ||
| 2 | "name": "@executor/plugin-onepassword-http", | ||
| 3 | "private": true, | ||
| 4 | "type": "module", | ||
| 5 | "exports": { | ||
| 6 | ".": "./index.ts" | ||
| 7 | }, | ||
| 8 | "files": [ | ||
| 9 | "index.ts" | ||
| 10 | ], | ||
| 11 | "scripts": { | ||
| 12 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | ||
| 13 | }, | ||
| 14 | "dependencies": { | ||
| 15 | "@effect/platform": "catalog:", | ||
| 16 | "@executor/platform-api": "workspace:*", | ||
| 17 | "@executor/platform-sdk": "workspace:*", | ||
| 18 | "@executor/plugin-onepassword-shared": "workspace:*", | ||
| 19 | "effect": "catalog:" | ||
| 20 | }, | ||
| 21 | "devDependencies": { | ||
| 22 | "@types/node": "catalog:", | ||
| 23 | "bun-types": "catalog:" | ||
| 24 | }, | ||
| 25 | "version": "0.0.0" | ||
| 26 | } | ||
| @@ -0,0 +1,15 @@ | |||
| 1 | { | ||
| 2 | "compilerOptions": { | ||
| 3 | "target": "ES2022", | ||
| 4 | "module": "ESNext", | ||
| 5 | "moduleResolution": "Bundler", | ||
| 6 | "strict": true, | ||
| 7 | "skipLibCheck": true, | ||
| 8 | "noEmit": true, | ||
| 9 | "lib": ["ES2022"], | ||
| 10 | "types": ["bun-types", "node"] | ||
| 11 | }, | ||
| 12 | "include": [ | ||
| 13 | "index.ts" | ||
| 14 | ] | ||
| 15 | } | ||
| @@ -0,0 +1,321 @@ | |||
| 1 | import { useEffect, useState, type ReactNode } from "react"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | getExecutorApiBaseUrl, | ||
| 5 | useExecutorMutation, | ||
| 6 | useWorkspaceRequestContext, | ||
| 7 | } from "@executor/react"; | ||
| 8 | import type { | ||
| 9 | SecretStoreCreateFormProps, | ||
| 10 | } from "@executor/react/plugins"; | ||
| 11 | import { | ||
| 12 | type OnePasswordDiscoverVaultsInput, | ||
| 13 | type OnePasswordDiscoverVaultsResult, | ||
| 14 | type OnePasswordStoreAuth, | ||
| 15 | type OnePasswordVault, | ||
| 16 | } from "@executor/plugin-onepassword-shared"; | ||
| 17 | |||
| 18 | const fieldClassName = | ||
| 19 | "h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"; | ||
| 20 | |||
| 21 | const buttonClassName = | ||
| 22 | "inline-flex h-9 items-center justify-center rounded-lg border border-input bg-card px-3 text-[13px] font-medium text-foreground transition-colors hover:bg-accent/50 disabled:pointer-events-none disabled:opacity-50"; | ||
| 23 | |||
| 24 | const primaryButtonClassName = | ||
| 25 | "inline-flex h-9 items-center justify-center rounded-lg bg-primary px-3 text-[13px] font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-50"; | ||
| 26 | |||
| 27 | function FieldLabel(props: { | ||
| 28 | label: string; | ||
| 29 | children: ReactNode; | ||
| 30 | }) { | ||
| 31 | return ( | ||
| 32 | <label className="grid gap-2"> | ||
| 33 | <span className="text-[12px] font-medium text-foreground">{props.label}</span> | ||
| 34 | {props.children} | ||
| 35 | </label> | ||
| 36 | ); | ||
| 37 | } | ||
| 38 | |||
| 39 | export function OnePasswordSecretStoreCreateForm( | ||
| 40 | props: SecretStoreCreateFormProps, | ||
| 41 | ) { | ||
| 42 | const workspace = useWorkspaceRequestContext(); | ||
| 43 | const [name, setName] = useState(""); | ||
| 44 | const [authKind, setAuthKind] = useState<"desktop-app" | "service-account">( | ||
| 45 | "desktop-app", | ||
| 46 | ); | ||
| 47 | const [accountName, setAccountName] = useState(""); | ||
| 48 | const [vaultId, setVaultId] = useState(""); | ||
| 49 | const [tokenSecretId, setTokenSecretId] = useState(""); | ||
| 50 | const [discoveredVaults, setDiscoveredVaults] = useState<ReadonlyArray<OnePasswordVault>>( | ||
| 51 | [], | ||
| 52 | ); | ||
| 53 | const [error, setError] = useState<string | null>(null); | ||
| 54 | const discoverVaultsMutation = useExecutorMutation< | ||
| 55 | OnePasswordDiscoverVaultsInput, | ||
| 56 | OnePasswordDiscoverVaultsResult | ||
| 57 | >(async (payload) => { | ||
| 58 | if (!workspace.enabled) { | ||
| 59 | throw new Error("Workspace is still loading."); | ||
| 60 | } | ||
| 61 | |||
| 62 | const response = await fetch( | ||
| 63 | `${getExecutorApiBaseUrl()}/v1/workspaces/${workspace.workspaceId}/plugins/onepassword/vaults/discover`, | ||
| 64 | { | ||
| 65 | method: "POST", | ||
| 66 | headers: { | ||
| 67 | "content-type": "application/json", | ||
| 68 | }, | ||
| 69 | body: JSON.stringify(payload), | ||
| 70 | }, | ||
| 71 | ); | ||
| 72 | |||
| 73 | if (!response.ok) { | ||
| 74 | let message = "Failed loading 1Password vaults."; | ||
| 75 | try { | ||
| 76 | const responseError = await response.json() as { | ||
| 77 | message?: string; | ||
| 78 | details?: string; | ||
| 79 | }; | ||
| 80 | message = responseError.message ?? responseError.details ?? message; | ||
| 81 | } catch { | ||
| 82 | // ignore response parsing errors | ||
| 83 | } | ||
| 84 | throw new Error(message); | ||
| 85 | } | ||
| 86 | |||
| 87 | return await response.json() as OnePasswordDiscoverVaultsResult; | ||
| 88 | }); | ||
| 89 | |||
| 90 | useEffect(() => { | ||
| 91 | setDiscoveredVaults([]); | ||
| 92 | discoverVaultsMutation.reset(); | ||
| 93 | }, [accountName, authKind, discoverVaultsMutation.reset, tokenSecretId]); | ||
| 94 | |||
| 95 | const onePasswordAuth = (): OnePasswordStoreAuth | null => { | ||
| 96 | if (authKind === "desktop-app") { | ||
| 97 | const trimmedAccountName = accountName.trim(); | ||
| 98 | if (!trimmedAccountName) { | ||
| 99 | return null; | ||
| 100 | } | ||
| 101 | |||
| 102 | return { | ||
| 103 | kind: "desktop-app", | ||
| 104 | accountName: trimmedAccountName, | ||
| 105 | }; | ||
| 106 | } | ||
| 107 | |||
| 108 | if (!tokenSecretId) { | ||
| 109 | return null; | ||
| 110 | } | ||
| 111 | |||
| 112 | return { | ||
| 113 | kind: "service-account", | ||
| 114 | tokenSecretRef: { | ||
| 115 | secretId: tokenSecretId as Extract< | ||
| 116 | OnePasswordStoreAuth, | ||
| 117 | { kind: "service-account" } | ||
| 118 | >["tokenSecretRef"]["secretId"], | ||
| 119 | }, | ||
| 120 | }; | ||
| 121 | }; | ||
| 122 | |||
| 123 | const handleDiscoverVaults = async () => { | ||
| 124 | setError(null); | ||
| 125 | |||
| 126 | const auth = onePasswordAuth(); | ||
| 127 | if (!auth) { | ||
| 128 | setError( | ||
| 129 | authKind === "desktop-app" | ||
| 130 | ? "Enter your 1Password account name before loading vaults." | ||
| 131 | : "Select a service-account token secret before loading vaults.", | ||
| 132 | ); | ||
| 133 | return; | ||
| 134 | } | ||
| 135 | |||
| 136 | try { | ||
| 137 | const result = await discoverVaultsMutation.mutateAsync({ auth }); | ||
| 138 | setDiscoveredVaults(result.vaults); | ||
| 139 | if (!vaultId.trim() && result.vaults.length > 0) { | ||
| 140 | setVaultId(result.vaults[0]!.id); | ||
| 141 | } | ||
| 142 | if (result.vaults.length === 0) { | ||
| 143 | setError("No accessible vaults were returned for this 1Password account."); | ||
| 144 | } | ||
| 145 | } catch (cause) { | ||
| 146 | setDiscoveredVaults([]); | ||
| 147 | setError( | ||
| 148 | cause instanceof Error ? cause.message : "Failed loading 1Password vaults.", | ||
| 149 | ); | ||
| 150 | } | ||
| 151 | }; | ||
| 152 | |||
| 153 | const handleSubmit = async () => { | ||
| 154 | setError(null); | ||
| 155 | |||
| 156 | const trimmedName = name.trim(); | ||
| 157 | if (!trimmedName) { | ||
| 158 | setError("Store name is required."); | ||
| 159 | return; | ||
| 160 | } | ||
| 161 | if (authKind === "desktop-app" && !accountName.trim()) { | ||
| 162 | setError("Account name is required for desktop app auth."); | ||
| 163 | return; | ||
| 164 | } | ||
| 165 | if (!vaultId.trim()) { | ||
| 166 | setError("Vault ID is required."); | ||
| 167 | return; | ||
| 168 | } | ||
| 169 | if (authKind === "service-account" && !tokenSecretId) { | ||
| 170 | setError("Select a secret that contains the 1Password service account token."); | ||
| 171 | return; | ||
| 172 | } | ||
| 173 | |||
| 174 | try { | ||
| 175 | await props.onSubmit({ | ||
| 176 | name: trimmedName, | ||
| 177 | config: { | ||
| 178 | vaultId: vaultId.trim(), | ||
| 179 | auth: onePasswordAuth()!, | ||
| 180 | }, | ||
| 181 | }); | ||
| 182 | } catch (cause) { | ||
| 183 | setError( | ||
| 184 | cause instanceof Error ? cause.message : "Failed creating secret store.", | ||
| 185 | ); | ||
| 186 | } | ||
| 187 | }; | ||
| 188 | |||
| 189 | return ( | ||
| 190 | <div className="space-y-4"> | ||
| 191 | {error && ( | ||
| 192 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-[13px] text-destructive"> | ||
| 193 | {error} | ||
| 194 | </div> | ||
| 195 | )} | ||
| 196 | |||
| 197 | <FieldLabel label="Name"> | ||
| 198 | <input | ||
| 199 | value={name} | ||
| 200 | onChange={(event) => setName(event.target.value)} | ||
| 201 | placeholder="Team 1Password" | ||
| 202 | className={fieldClassName} | ||
| 203 | autoFocus | ||
| 204 | /> | ||
| 205 | </FieldLabel> | ||
| 206 | |||
| 207 | <div className="grid gap-4 sm:grid-cols-2"> | ||
| 208 | <FieldLabel label="Auth method"> | ||
| 209 | <select | ||
| 210 | value={authKind} | ||
| 211 | onChange={(event) => | ||
| 212 | setAuthKind(event.target.value as "desktop-app" | "service-account")} | ||
| 213 | className={fieldClassName} | ||
| 214 | > | ||
| 215 | <option value="desktop-app">Desktop app</option> | ||
| 216 | <option value="service-account">Service account</option> | ||
| 217 | </select> | ||
| 218 | </FieldLabel> | ||
| 219 | <div className="flex items-end"> | ||
| 220 | <button | ||
| 221 | type="button" | ||
| 222 | onClick={() => { | ||
| 223 | void handleDiscoverVaults(); | ||
| 224 | }} | ||
| 225 | disabled={discoverVaultsMutation.status === "pending"} | ||
| 226 | className={buttonClassName} | ||
| 227 | > | ||
| 228 | {discoverVaultsMutation.status === "pending" ? "Loading..." : "Load vaults"} | ||
| 229 | </button> | ||
| 230 | </div> | ||
| 231 | </div> | ||
| 232 | |||
| 233 | {authKind === "desktop-app" ? ( | ||
| 234 | <FieldLabel label="Account name or UUID"> | ||
| 235 | <input | ||
| 236 | value={accountName} | ||
| 237 | onChange={(event) => setAccountName(event.target.value)} | ||
| 238 | placeholder="my.1password.com" | ||
| 239 | className={fieldClassName} | ||
| 240 | /> | ||
| 241 | <div className="text-[11px] text-muted-foreground"> | ||
| 242 | Use the account shown in the 1Password desktop app sidebar or the | ||
| 243 | account UUID from <code>op account list --format json</code>. | ||
| 244 | </div> | ||
| 245 | </FieldLabel> | ||
| 246 | ) : ( | ||
| 247 | <FieldLabel label="Service Account Token Secret"> | ||
| 248 | <select | ||
| 249 | value={tokenSecretId} | ||
| 250 | onChange={(event) => setTokenSecretId(event.target.value)} | ||
| 251 | className={fieldClassName} | ||
| 252 | > | ||
| 253 | <option value=""> | ||
| 254 | {props.secrets.status === "ready" | ||
| 255 | ? "Select a secret" | ||
| 256 | : "Secrets are loading"} | ||
| 257 | </option> | ||
| 258 | {props.secrets.status === "ready" && | ||
| 259 | props.secrets.data.map((secret) => ( | ||
| 260 | <option key={secret.id} value={secret.id}> | ||
| 261 | {secret.name ?? secret.id} | ||
| 262 | </option> | ||
| 263 | ))} | ||
| 264 | </select> | ||
| 265 | <div className="text-[11px] text-muted-foreground"> | ||
| 266 | Use this for remote or headless automation. Desktop app auth is better for | ||
| 267 | local Executor use. | ||
| 268 | </div> | ||
| 269 | </FieldLabel> | ||
| 270 | )} | ||
| 271 | |||
| 272 | {discoveredVaults.length > 0 && ( | ||
| 273 | <FieldLabel label="Discovered vaults"> | ||
| 274 | <select | ||
| 275 | value={vaultId} | ||
| 276 | onChange={(event) => setVaultId(event.target.value)} | ||
| 277 | className={fieldClassName} | ||
| 278 | > | ||
| 279 | {discoveredVaults.map((vault) => ( | ||
| 280 | <option key={vault.id} value={vault.id}> | ||
| 281 | {vault.name} | ||
| 282 | </option> | ||
| 283 | ))} | ||
| 284 | </select> | ||
| 285 | <div className="text-[11px] text-muted-foreground"> | ||
| 286 | Pick from the vaults visible to this account, or override the id below. | ||
| 287 | </div> | ||
| 288 | </FieldLabel> | ||
| 289 | )} | ||
| 290 | |||
| 291 | <FieldLabel label="Vault ID"> | ||
| 292 | <input | ||
| 293 | value={vaultId} | ||
| 294 | onChange={(event) => setVaultId(event.target.value)} | ||
| 295 | placeholder="vlt_..." | ||
| 296 | className={`${fieldClassName} font-mono text-[12px]`} | ||
| 297 | /> | ||
| 298 | </FieldLabel> | ||
| 299 | |||
| 300 | <div className="flex justify-end gap-2"> | ||
| 301 | <button | ||
| 302 | type="button" | ||
| 303 | onClick={props.onCancel} | ||
| 304 | className={buttonClassName} | ||
| 305 | > | ||
| 306 | Cancel | ||
| 307 | </button> | ||
| 308 | <button | ||
| 309 | type="button" | ||
| 310 | onClick={() => { | ||
| 311 | void handleSubmit(); | ||
| 312 | }} | ||
| 313 | disabled={props.isSubmitting} | ||
| 314 | className={primaryButtonClassName} | ||
| 315 | > | ||
| 316 | {props.isSubmitting ? "Creating..." : "Create store"} | ||
| 317 | </button> | ||
| 318 | </div> | ||
| 319 | </div> | ||
| 320 | ); | ||
| 321 | } | ||
| @@ -0,0 +1,13 @@ | |||
| 1 | import { defineExecutorFrontendPlugin } from "@executor/react/plugins"; | ||
| 2 | import { ONEPASSWORD_SECRET_STORE_KIND } from "@executor/plugin-onepassword-shared"; | ||
| 3 | |||
| 4 | import { OnePasswordSecretStoreCreateForm } from "./components"; | ||
| 5 | |||
| 6 | export const OnePasswordReactPlugin = defineExecutorFrontendPlugin({ | ||
| 7 | key: "onepassword", | ||
| 8 | displayName: "1Password", | ||
| 9 | secretStore: { | ||
| 10 | kind: ONEPASSWORD_SECRET_STORE_KIND, | ||
| 11 | CreateStoreForm: OnePasswordSecretStoreCreateForm, | ||
| 12 | }, | ||
| 13 | }); | ||
| @@ -0,0 +1,26 @@ | |||
| 1 | { | ||
| 2 | "name": "@executor/plugin-onepassword-react", | ||
| 3 | "private": true, | ||
| 4 | "type": "module", | ||
| 5 | "exports": { | ||
| 6 | ".": "./index.ts" | ||
| 7 | }, | ||
| 8 | "files": [ | ||
| 9 | "components.tsx", | ||
| 10 | "index.ts" | ||
| 11 | ], | ||
| 12 | "scripts": { | ||
| 13 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | ||
| 14 | }, | ||
| 15 | "dependencies": { | ||
| 16 | "@executor/plugin-onepassword-shared": "workspace:*", | ||
| 17 | "@executor/react": "workspace:*", | ||
| 18 | "react": "^19.2.0" | ||
| 19 | }, | ||
| 20 | "devDependencies": { | ||
| 21 | "@types/node": "catalog:", | ||
| 22 | "@types/react": "^19.2.10", | ||
| 23 | "bun-types": "catalog:" | ||
| 24 | }, | ||
| 25 | "version": "0.0.0" | ||
| 26 | } | ||
| @@ -0,0 +1,17 @@ | |||
| 1 | { | ||
| 2 | "compilerOptions": { | ||
| 3 | "target": "ES2022", | ||
| 4 | "module": "ESNext", | ||
| 5 | "moduleResolution": "Bundler", | ||
| 6 | "strict": true, | ||
| 7 | "skipLibCheck": true, | ||
| 8 | "noEmit": true, | ||
| 9 | "lib": ["ES2022", "DOM", "DOM.Iterable"], | ||
| 10 | "jsx": "react-jsx", | ||
| 11 | "types": ["bun-types", "node"] | ||
| 12 | }, | ||
| 13 | "include": [ | ||
| 14 | "*.ts", | ||
| 15 | "*.tsx" | ||
| 16 | ] | ||
| 17 | } | ||
| @@ -0,0 +1,675 @@ | |||
| 1 | import { | ||
| 2 | createClient, | ||
| 3 | DesktopAuth, | ||
| 4 | type ItemField, | ||
| 5 | ItemCategory, | ||
| 6 | ItemFieldType, | ||
| 7 | type Item, | ||
| 8 | } from "@1password/sdk"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | |||
| 11 | import { | ||
| 12 | defineExecutorSecretStorePlugin, | ||
| 13 | } from "@executor/platform-sdk/plugins"; | ||
| 14 | import { | ||
| 15 | SecretMaterialResolverService, | ||
| 16 | } from "@executor/platform-sdk/runtime"; | ||
| 17 | import type { | ||
| 18 | SecretMaterial, | ||
| 19 | SecretMaterialPurpose, | ||
| 20 | SecretStore, | ||
| 21 | } from "@executor/platform-sdk/schema"; | ||
| 22 | import { | ||
| 23 | SecretMaterialIdSchema, | ||
| 24 | } from "@executor/platform-sdk/schema"; | ||
| 25 | import { | ||
| 26 | ONEPASSWORD_SECRET_FIELD_ID, | ||
| 27 | ONEPASSWORD_SECRET_STORE_KIND, | ||
| 28 | OnePasswordConnectInputSchema, | ||
| 29 | type OnePasswordDiscoverVaultsInput, | ||
| 30 | type OnePasswordDiscoverVaultsResult, | ||
| 31 | type OnePasswordDiscoverStoreItemsInput, | ||
| 32 | type OnePasswordDiscoverStoreItemsResult, | ||
| 33 | type OnePasswordDiscoverItemFieldsInput, | ||
| 34 | type OnePasswordDiscoverItemFieldsResult, | ||
| 35 | type OnePasswordImportSecretInput, | ||
| 36 | type OnePasswordImportSecretResult, | ||
| 37 | type OnePasswordStoreAuth, | ||
| 38 | OnePasswordStoredStoreDataSchema, | ||
| 39 | type OnePasswordConnectInput, | ||
| 40 | type OnePasswordStoreConfigPayload, | ||
| 41 | type OnePasswordStoredStoreData, | ||
| 42 | type OnePasswordUpdateStoreInput, | ||
| 43 | } from "@executor/plugin-onepassword-shared"; | ||
| 44 | |||
| 45 | export type OnePasswordStoreStorage = { | ||
| 46 | get: (input: { | ||
| 47 | scopeId: string; | ||
| 48 | storeId: string; | ||
| 49 | }) => Effect.Effect<OnePasswordStoredStoreData | null, Error, never>; | ||
| 50 | put: (input: { | ||
| 51 | scopeId: string; | ||
| 52 | storeId: string; | ||
| 53 | value: OnePasswordStoredStoreData; | ||
| 54 | }) => Effect.Effect<void, Error, never>; | ||
| 55 | remove?: (input: { | ||
| 56 | scopeId: string; | ||
| 57 | storeId: string; | ||
| 58 | }) => Effect.Effect<void, Error, never>; | ||
| 59 | }; | ||
| 60 | |||
| 61 | const ONEPASSWORD_REQUEST_TIMEOUT_MS = 15_000; | ||
| 62 | |||
| 63 | const timedOutOnePasswordRequestError = (operation: string) => | ||
| 64 | new Error( | ||
| 65 | `1Password ${operation} timed out after ${Math.floor(ONEPASSWORD_REQUEST_TIMEOUT_MS / 1000)} seconds. Approve the request in the 1Password desktop app and try again.`, | ||
| 66 | ); | ||
| 67 | |||
| 68 | const runOnePasswordRequest = <T>( | ||
| 69 | operation: string, | ||
| 70 | effect: () => Promise<T>, | ||
| 71 | ) => | ||
| 72 | new Promise<T>((resolve, reject) => { | ||
| 73 | const timeoutId = setTimeout(() => { | ||
| 74 | reject(timedOutOnePasswordRequestError(operation)); | ||
| 75 | }, ONEPASSWORD_REQUEST_TIMEOUT_MS); | ||
| 76 | |||
| 77 | effect().then( | ||
| 78 | (value) => { | ||
| 79 | clearTimeout(timeoutId); | ||
| 80 | resolve(value); | ||
| 81 | }, | ||
| 82 | (cause) => { | ||
| 83 | clearTimeout(timeoutId); | ||
| 84 | reject(cause); | ||
| 85 | }, | ||
| 86 | ); | ||
| 87 | }); | ||
| 88 | |||
| 89 | const resolveServiceAccountToken = ( | ||
| 90 | auth: Extract<OnePasswordStoreAuth, { kind: "service-account" }>, | ||
| 91 | ) => | ||
| 92 | Effect.gen(function* () { | ||
| 93 | const resolveSecretMaterial = yield* SecretMaterialResolverService; | ||
| 94 | return yield* resolveSecretMaterial({ | ||
| 95 | ref: auth.tokenSecretRef, | ||
| 96 | }); | ||
| 97 | }); | ||
| 98 | |||
| 99 | const makeClientFromAuth = ( | ||
| 100 | auth: OnePasswordStoreAuth, | ||
| 101 | ) => | ||
| 102 | Effect.gen(function* () { | ||
| 103 | const resolvedAuth = auth.kind === "desktop-app" | ||
| 104 | ? new DesktopAuth(auth.accountName) | ||
| 105 | : yield* resolveServiceAccountToken(auth); | ||
| 106 | |||
| 107 | return yield* Effect.tryPromise({ | ||
| 108 | try: () => | ||
| 109 | runOnePasswordRequest("client setup", () => | ||
| 110 | createClient({ | ||
| 111 | auth: resolvedAuth, | ||
| 112 | integrationName: "Executor", | ||
| 113 | integrationVersion: "0.0.0", | ||
| 114 | }) | ||
| 115 | ), | ||
| 116 | catch: (cause) => | ||
| 117 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 118 | }); | ||
| 119 | }); | ||
| 120 | |||
| 121 | const makeClient = ( | ||
| 122 | stored: OnePasswordStoredStoreData | null, | ||
| 123 | ) => | ||
| 124 | Effect.gen(function* () { | ||
| 125 | if (!stored) { | ||
| 126 | throw new Error("1Password store configuration is missing."); | ||
| 127 | } | ||
| 128 | |||
| 129 | return yield* makeClientFromAuth(stored.auth); | ||
| 130 | }); | ||
| 131 | |||
| 132 | const importableFieldTypes = new Set<string>([ | ||
| 133 | ItemFieldType.Concealed, | ||
| 134 | ItemFieldType.Text, | ||
| 135 | ]); | ||
| 136 | const ONEPASSWORD_BROWSE_ITEM_LIMIT = 12; | ||
| 137 | |||
| 138 | const toImportableFields = ( | ||
| 139 | fields: ReadonlyArray<ItemField>, | ||
| 140 | ) => | ||
| 141 | fields | ||
| 142 | .filter((field) => | ||
| 143 | importableFieldTypes.has(field.fieldType) | ||
| 144 | && field.id.trim().length > 0 | ||
| 145 | && field.value.trim().length > 0 | ||
| 146 | ) | ||
| 147 | .map((field) => ({ | ||
| 148 | id: field.id, | ||
| 149 | title: field.title || field.id, | ||
| 150 | fieldType: field.fieldType, | ||
| 151 | ...(field.sectionId ? { sectionId: field.sectionId } : {}), | ||
| 152 | })); | ||
| 153 | |||
| 154 | const itemSelectionKey = (itemId: string) => `item:${itemId}`; | ||
| 155 | const secretSelectionKey = (itemId: string, fieldId: string) => | ||
| 156 | `secret:${itemId}:${fieldId}`; | ||
| 157 | |||
| 158 | const parseSelectionKey = (value: string): | ||
| 159 | | { | ||
| 160 | kind: "item"; | ||
| 161 | itemId: string; | ||
| 162 | } | ||
| 163 | | { | ||
| 164 | kind: "secret"; | ||
| 165 | itemId: string; | ||
| 166 | fieldId: string; | ||
| 167 | } => { | ||
| 168 | const itemMatch = /^item:([^:]+)$/.exec(value); | ||
| 169 | if (itemMatch) { | ||
| 170 | return { | ||
| 171 | kind: "item", | ||
| 172 | itemId: itemMatch[1]!, | ||
| 173 | }; | ||
| 174 | } | ||
| 175 | |||
| 176 | const secretMatch = /^secret:([^:]+):([^:]+)$/.exec(value); | ||
| 177 | if (secretMatch) { | ||
| 178 | return { | ||
| 179 | kind: "secret", | ||
| 180 | itemId: secretMatch[1]!, | ||
| 181 | fieldId: secretMatch[2]!, | ||
| 182 | }; | ||
| 183 | } | ||
| 184 | |||
| 185 | throw new Error(`Invalid 1Password selection key: ${value}`); | ||
| 186 | }; | ||
| 187 | |||
| 188 | const discoverVaults = ( | ||
| 189 | input: OnePasswordDiscoverVaultsInput, | ||
| 190 | ) => | ||
| 191 | Effect.flatMap(makeClientFromAuth(input.auth), (client) => | ||
| 192 | Effect.tryPromise({ | ||
| 193 | try: async () => { | ||
| 194 | const vaults = await runOnePasswordRequest("vault discovery", () => | ||
| 195 | client.vaults.list({ decryptDetails: true }) | ||
| 196 | ); | ||
| 197 | return { | ||
| 198 | vaults: vaults | ||
| 199 | .map((vault) => ({ | ||
| 200 | id: vault.id, | ||
| 201 | name: vault.title, | ||
| 202 | })) | ||
| 203 | .sort((left, right) => left.name.localeCompare(right.name)), | ||
| 204 | } satisfies OnePasswordDiscoverVaultsResult; | ||
| 205 | }, | ||
| 206 | catch: (cause) => | ||
| 207 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 208 | })); | ||
| 209 | |||
| 210 | const discoverStoreItems = (input: { | ||
| 211 | store: SecretStore; | ||
| 212 | stored: OnePasswordStoredStoreData | null; | ||
| 213 | }) => | ||
| 214 | Effect.flatMap(makeClient(input.stored), (client) => | ||
| 215 | Effect.tryPromise({ | ||
| 216 | try: async () => { | ||
| 217 | const overviews = await runOnePasswordRequest("item discovery", () => | ||
| 218 | client.items.list(input.stored!.vaultId) | ||
| 219 | ); | ||
| 220 | return { | ||
| 221 | items: overviews | ||
| 222 | .map((item) => ({ | ||
| 223 | id: item.id, | ||
| 224 | title: item.title, | ||
| 225 | category: item.category, | ||
| 226 | })) | ||
| 227 | .sort((left, right) => left.title.localeCompare(right.title)), | ||
| 228 | } satisfies OnePasswordDiscoverStoreItemsResult; | ||
| 229 | }, | ||
| 230 | catch: (cause) => | ||
| 231 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 232 | })); | ||
| 233 | |||
| 234 | const discoverItemFields = (input: { | ||
| 235 | store: SecretStore; | ||
| 236 | stored: OnePasswordStoredStoreData | null; | ||
| 237 | itemId: string; | ||
| 238 | }) => | ||
| 239 | Effect.flatMap(makeClient(input.stored), (client) => | ||
| 240 | Effect.tryPromise({ | ||
| 241 | try: async () => { | ||
| 242 | const item = await runOnePasswordRequest("field discovery", () => | ||
| 243 | client.items.get(input.stored!.vaultId, input.itemId) | ||
| 244 | ); | ||
| 245 | return { | ||
| 246 | itemId: item.id, | ||
| 247 | fields: toImportableFields(item.fields), | ||
| 248 | } satisfies OnePasswordDiscoverItemFieldsResult; | ||
| 249 | }, | ||
| 250 | catch: (cause) => | ||
| 251 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 252 | })); | ||
| 253 | |||
| 254 | const discoverImportableSecrets = (input: { | ||
| 255 | store: SecretStore; | ||
| 256 | stored: OnePasswordStoredStoreData | null; | ||
| 257 | query?: string | null; | ||
| 258 | }) => | ||
| 259 | Effect.flatMap(makeClient(input.stored), (client) => | ||
| 260 | Effect.tryPromise({ | ||
| 261 | try: async () => { | ||
| 262 | const normalizedQuery = input.query?.trim().toLowerCase() ?? ""; | ||
| 263 | const overviews = await runOnePasswordRequest("item discovery", () => | ||
| 264 | client.items.list(input.stored!.vaultId) | ||
| 265 | ); | ||
| 266 | const filteredItems = overviews | ||
| 267 | .filter((item) => | ||
| 268 | normalizedQuery.length === 0 | ||
| 269 | || item.title.toLowerCase().includes(normalizedQuery) | ||
| 270 | ) | ||
| 271 | .slice(0, ONEPASSWORD_BROWSE_ITEM_LIMIT); | ||
| 272 | |||
| 273 | const detailedItems = await Promise.all( | ||
| 274 | filteredItems.map((item) => | ||
| 275 | runOnePasswordRequest("field discovery", () => | ||
| 276 | client.items.get(input.stored!.vaultId, item.id) | ||
| 277 | )) | ||
| 278 | ); | ||
| 279 | |||
| 280 | return { | ||
| 281 | entries: detailedItems | ||
| 282 | .flatMap((item) => | ||
| 283 | toImportableFields(item.fields) | ||
| 284 | .filter((field) => | ||
| 285 | normalizedQuery.length === 0 | ||
| 286 | || item.title.toLowerCase().includes(normalizedQuery) | ||
| 287 | || field.title.toLowerCase().includes(normalizedQuery) | ||
| 288 | || field.id.toLowerCase().includes(normalizedQuery) | ||
| 289 | ) | ||
| 290 | .map((field) => ({ | ||
| 291 | key: secretSelectionKey(item.id, field.id), | ||
| 292 | label: `${item.title} · ${field.title}`, | ||
| 293 | description: item.category ?? field.fieldType ?? null, | ||
| 294 | kind: "secret" as const, | ||
| 295 | })) | ||
| 296 | ) | ||
| 297 | .sort((left, right) => left.label.localeCompare(right.label)), | ||
| 298 | }; | ||
| 299 | }, | ||
| 300 | catch: (cause) => | ||
| 301 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 302 | })); | ||
| 303 | |||
| 304 | const browseSecrets = (input: { | ||
| 305 | store: SecretStore; | ||
| 306 | stored: OnePasswordStoredStoreData | null; | ||
| 307 | parentKey?: string | null; | ||
| 308 | query?: string | null; | ||
| 309 | }) => | ||
| 310 | Effect.gen(function* () { | ||
| 311 | const normalizedQuery = input.query?.trim().toLowerCase() ?? ""; | ||
| 312 | |||
| 313 | if (!input.parentKey) { | ||
| 314 | return yield* discoverImportableSecrets({ | ||
| 315 | ...input, | ||
| 316 | query: normalizedQuery, | ||
| 317 | }); | ||
| 318 | } | ||
| 319 | |||
| 320 | const selection = parseSelectionKey(input.parentKey); | ||
| 321 | if (selection.kind !== "item") { | ||
| 322 | throw new Error(`Cannot browse children for selection: ${input.parentKey}`); | ||
| 323 | } | ||
| 324 | |||
| 325 | const fields = yield* discoverItemFields({ | ||
| 326 | ...input, | ||
| 327 | itemId: selection.itemId, | ||
| 328 | }); | ||
| 329 | |||
| 330 | return { | ||
| 331 | entries: fields.fields | ||
| 332 | .filter((field) => | ||
| 333 | normalizedQuery.length === 0 | ||
| 334 | || field.title.toLowerCase().includes(normalizedQuery) | ||
| 335 | || field.id.toLowerCase().includes(normalizedQuery) | ||
| 336 | ) | ||
| 337 | .map((field) => ({ | ||
| 338 | key: secretSelectionKey(selection.itemId, field.id), | ||
| 339 | label: field.title, | ||
| 340 | description: field.fieldType ?? null, | ||
| 341 | kind: "secret" as const, | ||
| 342 | })), | ||
| 343 | }; | ||
| 344 | }); | ||
| 345 | |||
| 346 | const importSecretFromSelection = (input: { | ||
| 347 | store: SecretStore; | ||
| 348 | stored: OnePasswordStoredStoreData | null; | ||
| 349 | selectionKey: string; | ||
| 350 | name?: string | null; | ||
| 351 | }) => | ||
| 352 | Effect.gen(function* () { | ||
| 353 | const selection = parseSelectionKey(input.selectionKey); | ||
| 354 | if (selection.kind !== "secret") { | ||
| 355 | throw new Error(`1Password selection is not a secret field: ${input.selectionKey}`); | ||
| 356 | } | ||
| 357 | |||
| 358 | const items = yield* discoverStoreItems(input); | ||
| 359 | const item = items.items.find((candidate) => candidate.id === selection.itemId); | ||
| 360 | if (!item) { | ||
| 361 | throw new Error(`1Password item not found: ${selection.itemId}`); | ||
| 362 | } | ||
| 363 | |||
| 364 | const fields = yield* discoverItemFields({ | ||
| 365 | ...input, | ||
| 366 | itemId: selection.itemId, | ||
| 367 | }); | ||
| 368 | const field = fields.fields.find((candidate) => candidate.id === selection.fieldId); | ||
| 369 | if (!field) { | ||
| 370 | throw new Error( | ||
| 371 | `1Password field not found: ${selection.itemId}/${selection.fieldId}`, | ||
| 372 | ); | ||
| 373 | } | ||
| 374 | |||
| 375 | return { | ||
| 376 | handle: `op://${input.stored!.vaultId}/${item.id}/${field.id}`, | ||
| 377 | name: input.name?.trim() || `${item.title} · ${field.title}`, | ||
| 378 | value: null, | ||
| 379 | }; | ||
| 380 | }); | ||
| 381 | |||
| 382 | const createImportedSecretRecord = (input: { | ||
| 383 | executor: { | ||
| 384 | runtime: { | ||
| 385 | storage: { | ||
| 386 | secrets: { | ||
| 387 | upsert: (value: SecretMaterial) => Effect.Effect<void, Error, never>; | ||
| 388 | }; | ||
| 389 | }; | ||
| 390 | }; | ||
| 391 | }; | ||
| 392 | store: SecretStore; | ||
| 393 | handle: string; | ||
| 394 | name: string | null; | ||
| 395 | purpose?: SecretMaterialPurpose; | ||
| 396 | }): Effect.Effect<OnePasswordImportSecretResult, Error, never> => | ||
| 397 | Effect.gen(function* () { | ||
| 398 | const now = Date.now(); | ||
| 399 | const secret: SecretMaterial = { | ||
| 400 | id: SecretMaterialIdSchema.make(`sec_${crypto.randomUUID()}`), | ||
| 401 | storeId: input.store.id, | ||
| 402 | handle: input.handle, | ||
| 403 | name: input.name, | ||
| 404 | purpose: input.purpose ?? "auth_material", | ||
| 405 | value: null, | ||
| 406 | createdAt: now, | ||
| 407 | updatedAt: now, | ||
| 408 | }; | ||
| 409 | |||
| 410 | yield* input.executor.runtime.storage.secrets.upsert(secret); | ||
| 411 | |||
| 412 | return { | ||
| 413 | id: secret.id, | ||
| 414 | name: secret.name, | ||
| 415 | storeId: secret.storeId, | ||
| 416 | purpose: secret.purpose, | ||
| 417 | createdAt: secret.createdAt, | ||
| 418 | updatedAt: secret.updatedAt, | ||
| 419 | }; | ||
| 420 | }); | ||
| 421 | |||
| 422 | const parseSecretReference = (handle: string): { | ||
| 423 | vaultId: string; | ||
| 424 | itemId: string; | ||
| 425 | fieldId: string; | ||
| 426 | } => { | ||
| 427 | const match = /^op:\/\/([^/]+)\/([^/]+)\/([^/]+)$/.exec(handle); | ||
| 428 | if (!match) { | ||
| 429 | throw new Error(`Invalid 1Password secret reference: ${handle}`); | ||
| 430 | } | ||
| 431 | |||
| 432 | return { | ||
| 433 | vaultId: match[1]!, | ||
| 434 | itemId: match[2]!, | ||
| 435 | fieldId: match[3]!, | ||
| 436 | }; | ||
| 437 | }; | ||
| 438 | |||
| 439 | const upsertCredentialField = (item: Item, value: string): Item => { | ||
| 440 | const existing = item.fields.find((field) => field.id === ONEPASSWORD_SECRET_FIELD_ID); | ||
| 441 | if (existing) { | ||
| 442 | existing.value = value; | ||
| 443 | return item; | ||
| 444 | } | ||
| 445 | |||
| 446 | item.fields.push({ | ||
| 447 | id: ONEPASSWORD_SECRET_FIELD_ID, | ||
| 448 | title: "Credential", | ||
| 449 | fieldType: ItemFieldType.Concealed, | ||
| 450 | value, | ||
| 451 | }); | ||
| 452 | return item; | ||
| 453 | }; | ||
| 454 | |||
| 455 | export const onePasswordSdkPlugin = (input: { | ||
| 456 | storage: OnePasswordStoreStorage; | ||
| 457 | }) => | ||
| 458 | defineExecutorSecretStorePlugin< | ||
| 459 | typeof ONEPASSWORD_SECRET_STORE_KIND, | ||
| 460 | OnePasswordConnectInput, | ||
| 461 | OnePasswordConnectInput, | ||
| 462 | OnePasswordStoreConfigPayload, | ||
| 463 | OnePasswordStoredStoreData, | ||
| 464 | OnePasswordUpdateStoreInput | ||
| 465 | >({ | ||
| 466 | key: ONEPASSWORD_SECRET_STORE_KIND, | ||
| 467 | secretStore: { | ||
| 468 | kind: ONEPASSWORD_SECRET_STORE_KIND, | ||
| 469 | displayName: "1Password", | ||
| 470 | add: { | ||
| 471 | inputSchema: OnePasswordConnectInputSchema, | ||
| 472 | toConnectInput: (value) => value, | ||
| 473 | }, | ||
| 474 | storage: input.storage, | ||
| 475 | store: { | ||
| 476 | create: (value) => ({ | ||
| 477 | store: { | ||
| 478 | kind: ONEPASSWORD_SECRET_STORE_KIND, | ||
| 479 | name: value.name, | ||
| 480 | status: "connected", | ||
| 481 | enabled: true, | ||
| 482 | }, | ||
| 483 | stored: { | ||
| 484 | vaultId: value.vaultId, | ||
| 485 | auth: value.auth, | ||
| 486 | }, | ||
| 487 | }), | ||
| 488 | update: ({ store, config }) => ({ | ||
| 489 | store: { | ||
| 490 | ...store, | ||
| 491 | name: config.name, | ||
| 492 | status: "connected", | ||
| 493 | }, | ||
| 494 | stored: { | ||
| 495 | vaultId: config.vaultId, | ||
| 496 | auth: config.auth, | ||
| 497 | }, | ||
| 498 | }), | ||
| 499 | toConfig: ({ store, stored }) => ({ | ||
| 500 | kind: ONEPASSWORD_SECRET_STORE_KIND, | ||
| 501 | name: store.name, | ||
| 502 | vaultId: stored.vaultId, | ||
| 503 | auth: stored.auth, | ||
| 504 | } satisfies OnePasswordStoreConfigPayload), | ||
| 505 | resolveSecret: ({ secret, stored }) => | ||
| 506 | Effect.flatMap(makeClient(stored), (client) => | ||
| 507 | Effect.tryPromise({ | ||
| 508 | try: () => | ||
| 509 | runOnePasswordRequest("secret resolution", () => | ||
| 510 | client.secrets.resolve(secret.handle) | ||
| 511 | ), | ||
| 512 | catch: (cause) => | ||
| 513 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 514 | })), | ||
| 515 | createSecret: ({ stored, value, name }) => | ||
| 516 | Effect.flatMap(makeClient(stored), (client) => | ||
| 517 | Effect.tryPromise({ | ||
| 518 | try: async () => { | ||
| 519 | const item = await runOnePasswordRequest("secret creation", () => | ||
| 520 | client.items.create({ | ||
| 521 | category: ItemCategory.Password, | ||
| 522 | vaultId: stored!.vaultId, | ||
| 523 | title: name?.trim() || "Executor Secret", | ||
| 524 | fields: [ | ||
| 525 | { | ||
| 526 | id: ONEPASSWORD_SECRET_FIELD_ID, | ||
| 527 | title: "Credential", | ||
| 528 | fieldType: ItemFieldType.Concealed, | ||
| 529 | value, | ||
| 530 | }, | ||
| 531 | ], | ||
| 532 | }) | ||
| 533 | ); | ||
| 534 | |||
| 535 | return { | ||
| 536 | handle: `op://${stored!.vaultId}/${item.id}/${ONEPASSWORD_SECRET_FIELD_ID}`, | ||
| 537 | name: item.title, | ||
| 538 | }; | ||
| 539 | }, | ||
| 540 | catch: (cause) => | ||
| 541 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 542 | })), | ||
| 543 | updateSecret: ({ secret, stored, name, value }) => | ||
| 544 | Effect.flatMap(makeClient(stored), (client) => | ||
| 545 | Effect.tryPromise({ | ||
| 546 | try: async () => { | ||
| 547 | const parsed = parseSecretReference(secret.handle); | ||
| 548 | const item = await runOnePasswordRequest("secret update", () => | ||
| 549 | client.items.get(parsed.vaultId, parsed.itemId) | ||
| 550 | ); | ||
| 551 | if (name !== undefined) { | ||
| 552 | item.title = name?.trim() || item.title; | ||
| 553 | } | ||
| 554 | if (value !== undefined) { | ||
| 555 | upsertCredentialField(item, value); | ||
| 556 | } | ||
| 557 | const updated = await runOnePasswordRequest("secret update", () => | ||
| 558 | client.items.put(item) | ||
| 559 | ); | ||
| 560 | return { | ||
| 561 | name: updated.title, | ||
| 562 | }; | ||
| 563 | }, | ||
| 564 | catch: (cause) => | ||
| 565 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 566 | })), | ||
| 567 | deleteSecret: ({ secret, stored }) => | ||
| 568 | Effect.flatMap(makeClient(stored), (client) => | ||
| 569 | Effect.tryPromise({ | ||
| 570 | try: async () => { | ||
| 571 | const parsed = parseSecretReference(secret.handle); | ||
| 572 | await runOnePasswordRequest("secret deletion", () => | ||
| 573 | client.items.delete(parsed.vaultId, parsed.itemId) | ||
| 574 | ); | ||
| 575 | return true; | ||
| 576 | }, | ||
| 577 | catch: (cause) => | ||
| 578 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 579 | })), | ||
| 580 | browseSecrets: ({ store, stored, parentKey, query }) => | ||
| 581 | browseSecrets({ | ||
| 582 | store, | ||
| 583 | stored, | ||
| 584 | parentKey, | ||
| 585 | query, | ||
| 586 | }), | ||
| 587 | importSecret: ({ store, stored, selectionKey, name }) => | ||
| 588 | importSecretFromSelection({ | ||
| 589 | store, | ||
| 590 | stored, | ||
| 591 | selectionKey, | ||
| 592 | name, | ||
| 593 | }), | ||
| 594 | capabilities: () => ({ | ||
| 595 | canCreateSecrets: true, | ||
| 596 | canUpdateSecrets: true, | ||
| 597 | canDeleteSecrets: true, | ||
| 598 | canBrowseSecrets: true, | ||
| 599 | canImportSecrets: true, | ||
| 600 | }), | ||
| 601 | }, | ||
| 602 | }, | ||
| 603 | extendExecutor: ({ executor, secretStore }) => ({ | ||
| 604 | getStoreConfig: ( | ||
| 605 | storeId: SecretStore["id"], | ||
| 606 | ) => secretStore.getStoreConfig(storeId), | ||
| 607 | createStore: (value: OnePasswordConnectInput) => | ||
| 608 | secretStore.createStore(value), | ||
| 609 | updateStore: (value: OnePasswordUpdateStoreInput) => | ||
| 610 | secretStore.updateStore(value), | ||
| 611 | removeStore: (storeId: SecretStore["id"]) => | ||
| 612 | secretStore.removeStore(storeId), | ||
| 613 | discoverVaults: (value: OnePasswordDiscoverVaultsInput) => | ||
| 614 | discoverVaults(value), | ||
| 615 | discoverStoreItems: (value: OnePasswordDiscoverStoreItemsInput) => | ||
| 616 | Effect.gen(function* () { | ||
| 617 | const store = yield* secretStore.getStore( | ||
| 618 | value.storeId as SecretStore["id"], | ||
| 619 | ); | ||
| 620 | const stored = yield* input.storage.get({ | ||
| 621 | scopeId: store.scopeId, | ||
| 622 | storeId: store.id, | ||
| 623 | }); | ||
| 624 | |||
| 625 | return yield* discoverStoreItems({ | ||
| 626 | store, | ||
| 627 | stored, | ||
| 628 | }); | ||
| 629 | }), | ||
| 630 | discoverItemFields: (value: OnePasswordDiscoverItemFieldsInput) => | ||
| 631 | Effect.gen(function* () { | ||
| 632 | const store = yield* secretStore.getStore( | ||
| 633 | value.storeId as SecretStore["id"], | ||
| 634 | ); | ||
| 635 | const stored = yield* input.storage.get({ | ||
| 636 | scopeId: store.scopeId, | ||
| 637 | storeId: store.id, | ||
| 638 | }); | ||
| 639 | |||
| 640 | return yield* discoverItemFields({ | ||
| 641 | store, | ||
| 642 | stored, | ||
| 643 | itemId: value.itemId, | ||
| 644 | }); | ||
| 645 | }), | ||
| 646 | importSecret: (value: OnePasswordImportSecretInput) => | ||
| 647 | Effect.gen(function* () { | ||
| 648 | const store = yield* secretStore.getStore( | ||
| 649 | value.storeId as SecretStore["id"], | ||
| 650 | ); | ||
| 651 | const stored = yield* input.storage.get({ | ||
| 652 | scopeId: store.scopeId, | ||
| 653 | storeId: store.id, | ||
| 654 | }); | ||
| 655 | const imported = yield* importSecretFromSelection({ | ||
| 656 | store, | ||
| 657 | stored, | ||
| 658 | selectionKey: secretSelectionKey(value.itemId, value.fieldId), | ||
| 659 | name: value.name?.trim() || null, | ||
| 660 | }); | ||
| 661 | |||
| 662 | return yield* createImportedSecretRecord({ | ||
| 663 | executor, | ||
| 664 | store, | ||
| 665 | handle: imported.handle, | ||
| 666 | name: imported.name, | ||
| 667 | }); | ||
| 668 | }), | ||
| 669 | }), | ||
| 670 | }); | ||
| 671 | |||
| 672 | export { | ||
| 673 | OnePasswordConnectInputSchema, | ||
| 674 | OnePasswordStoredStoreDataSchema, | ||
| 675 | }; | ||
| @@ -0,0 +1,25 @@ | |||
| 1 | { | ||
| 2 | "name": "@executor/plugin-onepassword-sdk", | ||
| 3 | "private": true, | ||
| 4 | "type": "module", | ||
| 5 | "exports": { | ||
| 6 | ".": "./index.ts" | ||
| 7 | }, | ||
| 8 | "files": [ | ||
| 9 | "*.ts" | ||
| 10 | ], | ||
| 11 | "scripts": { | ||
| 12 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | ||
| 13 | }, | ||
| 14 | "dependencies": { | ||
| 15 | "@1password/sdk": "0.4.1-beta.1", | ||
| 16 | "@executor/platform-sdk": "workspace:*", | ||
| 17 | "@executor/plugin-onepassword-shared": "workspace:*", | ||
| 18 | "effect": "catalog:" | ||
| 19 | }, | ||
| 20 | "devDependencies": { | ||
| 21 | "@types/node": "catalog:", | ||
| 22 | "bun-types": "catalog:" | ||
| 23 | }, | ||
| 24 | "version": "0.0.0" | ||
| 25 | } | ||
| @@ -0,0 +1,15 @@ | |||
| 1 | { | ||
| 2 | "compilerOptions": { | ||
| 3 | "target": "ES2022", | ||
| 4 | "module": "ESNext", | ||
| 5 | "moduleResolution": "Bundler", | ||
| 6 | "strict": true, | ||
| 7 | "skipLibCheck": true, | ||
| 8 | "noEmit": true, | ||
| 9 | "lib": ["ES2022"], | ||
| 10 | "types": ["bun-types", "node"] | ||
| 11 | }, | ||
| 12 | "include": [ | ||
| 13 | "*.ts" | ||
| 14 | ] | ||
| 15 | } | ||
| @@ -0,0 +1,163 @@ | |||
| 1 | import { | ||
| 2 | HttpApiEndpoint, | ||
| 3 | HttpApiGroup, | ||
| 4 | HttpApiSchema, | ||
| 5 | } from "@effect/platform"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | |||
| 8 | import { | ||
| 9 | ScopeIdSchema, | ||
| 10 | SecretRefSchema, | ||
| 11 | } from "@executor/platform-sdk/schema"; | ||
| 12 | |||
| 13 | export const ONEPASSWORD_SECRET_STORE_KIND = "onepassword"; | ||
| 14 | export const ONEPASSWORD_SECRET_FIELD_ID = "credential"; | ||
| 15 | |||
| 16 | export const OnePasswordStoreAuthSchema = Schema.Union( | ||
| 17 | Schema.Struct({ | ||
| 18 | kind: Schema.Literal("desktop-app"), | ||
| 19 | accountName: Schema.String, | ||
| 20 | }), | ||
| 21 | Schema.Struct({ | ||
| 22 | kind: Schema.Literal("service-account"), | ||
| 23 | tokenSecretRef: SecretRefSchema, | ||
| 24 | }), | ||
| 25 | ); | ||
| 26 | |||
| 27 | export const OnePasswordConnectInputSchema = Schema.Struct({ | ||
| 28 | kind: Schema.Literal(ONEPASSWORD_SECRET_STORE_KIND), | ||
| 29 | name: Schema.String, | ||
| 30 | vaultId: Schema.String, | ||
| 31 | auth: OnePasswordStoreAuthSchema, | ||
| 32 | }); | ||
| 33 | |||
| 34 | export const OnePasswordStoreConfigPayloadSchema = OnePasswordConnectInputSchema; | ||
| 35 | |||
| 36 | export const OnePasswordUpdateStoreInputSchema = Schema.Struct({ | ||
| 37 | storeId: Schema.String, | ||
| 38 | config: OnePasswordStoreConfigPayloadSchema, | ||
| 39 | }); | ||
| 40 | |||
| 41 | export const OnePasswordStoredStoreDataSchema = Schema.Struct({ | ||
| 42 | vaultId: Schema.String, | ||
| 43 | auth: OnePasswordStoreAuthSchema, | ||
| 44 | }); | ||
| 45 | |||
| 46 | export const OnePasswordDiscoverVaultsInputSchema = Schema.Struct({ | ||
| 47 | auth: OnePasswordStoreAuthSchema, | ||
| 48 | }); | ||
| 49 | |||
| 50 | export const OnePasswordVaultSchema = Schema.Struct({ | ||
| 51 | id: Schema.String, | ||
| 52 | name: Schema.String, | ||
| 53 | }); | ||
| 54 | |||
| 55 | export const OnePasswordDiscoverVaultsResultSchema = Schema.Struct({ | ||
| 56 | vaults: Schema.Array(OnePasswordVaultSchema), | ||
| 57 | }); | ||
| 58 | |||
| 59 | export const OnePasswordDiscoverStoreItemsInputSchema = Schema.Struct({ | ||
| 60 | storeId: Schema.String, | ||
| 61 | }); | ||
| 62 | |||
| 63 | export const OnePasswordItemFieldSchema = Schema.Struct({ | ||
| 64 | id: Schema.String, | ||
| 65 | title: Schema.String, | ||
| 66 | fieldType: Schema.String, | ||
| 67 | sectionId: Schema.optional(Schema.String), | ||
| 68 | }); | ||
| 69 | |||
| 70 | export const OnePasswordItemSchema = Schema.Struct({ | ||
| 71 | id: Schema.String, | ||
| 72 | title: Schema.String, | ||
| 73 | category: Schema.String, | ||
| 74 | fields: Schema.optional(Schema.Array(OnePasswordItemFieldSchema)), | ||
| 75 | }); | ||
| 76 | |||
| 77 | export const OnePasswordDiscoverStoreItemsResultSchema = Schema.Struct({ | ||
| 78 | items: Schema.Array(OnePasswordItemSchema), | ||
| 79 | }); | ||
| 80 | |||
| 81 | export const OnePasswordDiscoverItemFieldsInputSchema = Schema.Struct({ | ||
| 82 | storeId: Schema.String, | ||
| 83 | itemId: Schema.String, | ||
| 84 | }); | ||
| 85 | |||
| 86 | export const OnePasswordDiscoverItemFieldsResultSchema = Schema.Struct({ | ||
| 87 | itemId: Schema.String, | ||
| 88 | fields: Schema.Array(OnePasswordItemFieldSchema), | ||
| 89 | }); | ||
| 90 | |||
| 91 | export const OnePasswordImportSecretInputSchema = Schema.Struct({ | ||
| 92 | storeId: Schema.String, | ||
| 93 | itemId: Schema.String, | ||
| 94 | fieldId: Schema.String, | ||
| 95 | name: Schema.optional(Schema.String), | ||
| 96 | }); | ||
| 97 | |||
| 98 | export const OnePasswordImportSecretResultSchema = Schema.Struct({ | ||
| 99 | id: Schema.String, | ||
| 100 | name: Schema.NullOr(Schema.String), | ||
| 101 | storeId: Schema.String, | ||
| 102 | purpose: Schema.String, | ||
| 103 | createdAt: Schema.Number, | ||
| 104 | updatedAt: Schema.Number, | ||
| 105 | }); | ||
| 106 | |||
| 107 | const workspaceIdParam = HttpApiSchema.param("workspaceId", ScopeIdSchema); | ||
| 108 | |||
| 109 | export const onePasswordHttpGroup = HttpApiGroup.make("onepassword") | ||
| 110 | .add( | ||
| 111 | HttpApiEndpoint.post("discoverVaults")`/workspaces/${workspaceIdParam}/plugins/onepassword/vaults/discover` | ||
| 112 | .setPayload(OnePasswordDiscoverVaultsInputSchema) | ||
| 113 | .addSuccess(OnePasswordDiscoverVaultsResultSchema), | ||
| 114 | ) | ||
| 115 | .add( | ||
| 116 | HttpApiEndpoint.post("discoverStoreItems")`/workspaces/${workspaceIdParam}/plugins/onepassword/stores/discover-items` | ||
| 117 | .setPayload(OnePasswordDiscoverStoreItemsInputSchema) | ||
| 118 | .addSuccess(OnePasswordDiscoverStoreItemsResultSchema), | ||
| 119 | ) | ||
| 120 | .add( | ||
| 121 | HttpApiEndpoint.post("discoverItemFields")`/workspaces/${workspaceIdParam}/plugins/onepassword/items/discover-fields` | ||
| 122 | .setPayload(OnePasswordDiscoverItemFieldsInputSchema) | ||
| 123 | .addSuccess(OnePasswordDiscoverItemFieldsResultSchema), | ||
| 124 | ) | ||
| 125 | .add( | ||
| 126 | HttpApiEndpoint.post("importSecret")`/workspaces/${workspaceIdParam}/plugins/onepassword/secrets/import` | ||
| 127 | .setPayload(OnePasswordImportSecretInputSchema) | ||
| 128 | .addSuccess(OnePasswordImportSecretResultSchema), | ||
| 129 | ) | ||
| 130 | .prefix("/v1"); | ||
| 131 | |||
| 132 | export const onePasswordHttpApiExtension = { | ||
| 133 | key: "onepassword", | ||
| 134 | group: onePasswordHttpGroup, | ||
| 135 | } as const; | ||
| 136 | |||
| 137 | export type OnePasswordStoreAuth = typeof OnePasswordStoreAuthSchema.Type; | ||
| 138 | export type OnePasswordConnectInput = typeof OnePasswordConnectInputSchema.Type; | ||
| 139 | export type OnePasswordStoreConfigPayload = | ||
| 140 | typeof OnePasswordStoreConfigPayloadSchema.Type; | ||
| 141 | export type OnePasswordUpdateStoreInput = | ||
| 142 | typeof OnePasswordUpdateStoreInputSchema.Type; | ||
| 143 | export type OnePasswordStoredStoreData = | ||
| 144 | typeof OnePasswordStoredStoreDataSchema.Type; | ||
| 145 | export type OnePasswordDiscoverVaultsInput = | ||
| 146 | typeof OnePasswordDiscoverVaultsInputSchema.Type; | ||
| 147 | export type OnePasswordVault = typeof OnePasswordVaultSchema.Type; | ||
| 148 | export type OnePasswordDiscoverVaultsResult = | ||
| 149 | typeof OnePasswordDiscoverVaultsResultSchema.Type; | ||
| 150 | export type OnePasswordDiscoverStoreItemsInput = | ||
| 151 | typeof OnePasswordDiscoverStoreItemsInputSchema.Type; | ||
| 152 | export type OnePasswordItemField = typeof OnePasswordItemFieldSchema.Type; | ||
| 153 | export type OnePasswordItem = typeof OnePasswordItemSchema.Type; | ||
| 154 | export type OnePasswordDiscoverStoreItemsResult = | ||
| 155 | typeof OnePasswordDiscoverStoreItemsResultSchema.Type; | ||
| 156 | export type OnePasswordDiscoverItemFieldsInput = | ||
| 157 | typeof OnePasswordDiscoverItemFieldsInputSchema.Type; | ||
| 158 | export type OnePasswordDiscoverItemFieldsResult = | ||
| 159 | typeof OnePasswordDiscoverItemFieldsResultSchema.Type; | ||
| 160 | export type OnePasswordImportSecretInput = | ||
| 161 | typeof OnePasswordImportSecretInputSchema.Type; | ||
| 162 | export type OnePasswordImportSecretResult = | ||
| 163 | typeof OnePasswordImportSecretResultSchema.Type; | ||
| @@ -0,0 +1,24 @@ | |||
| 1 | { | ||
| 2 | "name": "@executor/plugin-onepassword-shared", | ||
| 3 | "private": true, | ||
| 4 | "type": "module", | ||
| 5 | "exports": { | ||
| 6 | ".": "./index.ts" | ||
| 7 | }, | ||
| 8 | "files": [ | ||
| 9 | "index.ts" | ||
| 10 | ], | ||
| 11 | "scripts": { | ||
| 12 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | ||
| 13 | }, | ||
| 14 | "dependencies": { | ||
| 15 | "@effect/platform": "catalog:", | ||
| 16 | "@executor/platform-sdk": "workspace:*", | ||
| 17 | "effect": "catalog:" | ||
| 18 | }, | ||
| 19 | "devDependencies": { | ||
| 20 | "@types/node": "catalog:", | ||
| 21 | "bun-types": "catalog:" | ||
| 22 | }, | ||
| 23 | "version": "0.0.0" | ||
| 24 | } | ||
| @@ -0,0 +1,15 @@ | |||
| 1 | { | ||
| 2 | "compilerOptions": { | ||
| 3 | "target": "ES2022", | ||
| 4 | "module": "ESNext", | ||
| 5 | "moduleResolution": "Bundler", | ||
| 6 | "strict": true, | ||
| 7 | "skipLibCheck": true, | ||
| 8 | "noEmit": true, | ||
| 9 | "lib": ["ES2022"], | ||
| 10 | "types": ["bun-types", "node"] | ||
| 11 | }, | ||
| 12 | "include": [ | ||
| 13 | "*.ts" | ||
| 14 | ] | ||
| 15 | } | ||
| @@ -4,14 +4,13 @@ import type { · 3 unmodified lines | |||
| 4 | } from "@executor/react"; | 4 | } from "@executor/react"; |
| 5 | import { | 5 | import { |
| 6 | Result, | 6 | Result, |
| 7 | SecretReferenceField, | ||
| 7 | defineExecutorPluginHttpApiClient, | 8 | defineExecutorPluginHttpApiClient, |
| 8 | useAtomValue, | 9 | useAtomValue, |
| 9 | useAtomSet, | 10 | useAtomSet, |
| 10 | useCreateSecret, | ||
| 11 | useExecutorMutation, | 11 | useExecutorMutation, |
| 12 | useSecrets, | ||
| 13 | useSource, | 12 | useSource, |
| 14 | useWorkspaceId, | 13 | useWorkspaceRequestContext, |
| 15 | } from "@executor/react"; | 14 | } from "@executor/react"; |
| 16 | import { | 15 | import { |
| 17 | DocumentPanel, | 16 | DocumentPanel, |
| @@ -49,7 +48,6 @@ const defaultOpenApiInput = (): OpenApiConnectInput => ({ · 31 unmodified lines | |||
| 49 | 48 | ||
| 50 | const DEFAULT_BEARER_HEADER_NAME = "Authorization"; | 49 | const DEFAULT_BEARER_HEADER_NAME = "Authorization"; |
| 51 | const DEFAULT_BEARER_PREFIX = "Bearer "; | 50 | const DEFAULT_BEARER_PREFIX = "Bearer "; |
| 52 | const CREATE_SECRET_VALUE = "__create_openapi_secret__"; | ||
| 53 | 51 | ||
| 54 | const presetString = ( | 52 | const presetString = ( |
| 55 | search: Record<string, unknown>, | 53 | search: Record<string, unknown>, |
| @@ -237,136 +235,9 @@ const authFromSecretValue = ( · 181 unmodified lines | |||
| 237 | }; | 235 | }; |
| 238 | }; | 236 | }; |
| 239 | 237 | ||
| 240 | function SecretSelectOrCreateField(props: { | ||
| 241 | label: string; | ||
| 242 | value: string; | ||
| 243 | emptyLabel: string; | ||
| 244 | draftNamePlaceholder: string; | ||
| 245 | draftValuePlaceholder: string; | ||
| 246 | onChange: (value: string) => void; | ||
| 247 | }) { | ||
| 248 | const secrets = useSecrets(); | ||
| 249 | const createSecret = useCreateSecret(); | ||
| 250 | const [draftName, setDraftName] = useState(""); | ||
| 251 | const [draftValue, setDraftValue] = useState(""); | ||
| 252 | const [createError, setCreateError] = useState<string | null>(null); | ||
| 253 | const [showCreate, setShowCreate] = useState(false); | ||
| 254 | |||
| 255 | const handleSelectChange = (nextValue: string) => { | ||
| 256 | if (nextValue === CREATE_SECRET_VALUE) { | ||
| 257 | setShowCreate(true); | ||
| 258 | props.onChange(""); | ||
| 259 | return; | ||
| 260 | } | ||
| 261 | |||
| 262 | setShowCreate(false); | ||
| 263 | setCreateError(null); | ||
| 264 | props.onChange(nextValue); | ||
| 265 | }; | ||
| 266 | |||
| 267 | const handleCreate = async () => { | ||
| 268 | const trimmedName = draftName.trim(); | ||
| 269 | if (!trimmedName) { | ||
| 270 | setCreateError("Secret name is required."); | ||
| 271 | return; | ||
| 272 | } | ||
| 273 | if (!draftValue.trim()) { | ||
| 274 | setCreateError("Secret value is required."); | ||
| 275 | return; | ||
| 276 | } | ||
| 277 | |||
| 278 | try { | ||
| 279 | setCreateError(null); | ||
| 280 | const created = await createSecret.mutateAsync({ | ||
| 281 | name: trimmedName, | ||
| 282 | value: draftValue, | ||
| 283 | }); | ||
| 284 | props.onChange(JSON.stringify({ | ||
| 285 | providerId: created.providerId, | ||
| 286 | handle: created.id, | ||
| 287 | })); | ||
| 288 | setDraftName(""); | ||
| 289 | setDraftValue(""); | ||
| 290 | setShowCreate(false); | ||
| 291 | } catch (cause) { | ||
| 292 | setCreateError(cause instanceof Error ? cause.message : "Failed creating secret."); | ||
| 293 | } | ||
| 294 | }; | ||
| 295 | |||
| 296 | return ( | ||
| 297 | <div className="grid gap-2"> | ||
| 298 | <span className="text-xs font-medium text-foreground">{props.label}</span> | ||
| 299 | <select | ||
| 300 | value={showCreate ? CREATE_SECRET_VALUE : props.value} | ||
| 301 | onChange={(event) => handleSelectChange(event.target.value)} | ||
| 302 | className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 303 | > | ||
| 304 | <option value="">{props.emptyLabel}</option> | ||
| 305 | {secrets.status === "ready" && | ||
| 306 | secrets.data.map((secret) => ( | ||
| 307 | <option | ||
| 308 | key={`${secret.providerId}:${secret.id}`} | ||
| 309 | value={JSON.stringify({ | ||
| 310 | providerId: secret.providerId, | ||
| 311 | handle: secret.id, | ||
| 312 | })} | ||
| 313 | > | ||
| 314 | {secret.name ?? secret.id} | ||
| 315 | </option> | ||
| 316 | ))} | ||
| 317 | <option value={CREATE_SECRET_VALUE}>Create new secret</option> | ||
| 318 | </select> | ||
| 319 | |||
| 320 | {showCreate && ( | ||
| 321 | <div className="space-y-3 border-l-2 border-border pl-4"> | ||
| 322 | <input | ||
| 323 | value={draftName} | ||
| 324 | onChange={(event) => setDraftName(event.target.value)} | ||
| 325 | placeholder={props.draftNamePlaceholder} | ||
| 326 | className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 327 | /> | ||
| 328 | <textarea | ||
| 329 | value={draftValue} | ||
| 330 | onChange={(event) => setDraftValue(event.target.value)} | ||
| 331 | rows={3} | ||
| 332 | placeholder={props.draftValuePlaceholder} | ||
| 333 | className="w-full rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 334 | /> | ||
| 335 | {createError && ( | ||
| 336 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-3 py-2 text-xs text-destructive"> | ||
| 337 | {createError} | ||
| 338 | </div> | ||
| 339 | )} | ||
| 340 | <div className="flex items-center gap-2"> | ||
| 341 | <button | ||
| 342 | type="button" | ||
| 343 | onClick={() => { | ||
| 344 | void handleCreate(); | ||
| 345 | }} | ||
| 346 | disabled={createSecret.status === "pending"} | ||
| 347 | className="inline-flex h-8 items-center justify-center rounded-lg bg-primary px-3 text-xs font-medium text-primary-foreground transition-opacity disabled:pointer-events-none disabled:opacity-50" | ||
| 348 | > | ||
| 349 | {createSecret.status === "pending" ? "Creating..." : "Create Secret"} | ||
| 350 | </button> | ||
| 351 | <button | ||
| 352 | type="button" | ||
| 353 | onClick={() => { | ||
| 354 | setShowCreate(false); | ||
| 355 | setCreateError(null); | ||
| 356 | }} | ||
| 357 | className="inline-flex h-8 items-center justify-center rounded-lg border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50" | ||
| 358 | > | ||
| 359 | Cancel | ||
| 360 | </button> | ||
| 361 | </div> | ||
| 362 | </div> | ||
| 363 | )} | ||
| 364 | </div> | ||
| 365 | ); | ||
| 366 | } | ||
| 367 | |||
| 368 | function OpenApiSourceForm(props: { | 238 | function OpenApiSourceForm(props: { |
| 369 | mode: "create" | "edit"; | 239 | mode: "create" | "edit"; |
| 240 | workspaceId: Source["scopeId"]; | ||
| 370 | initialValue: OpenApiConnectInput; | 241 | initialValue: OpenApiConnectInput; |
| 371 | submitLabel: string; | 242 | submitLabel: string; |
| 372 | busyLabel: string; | 243 | busyLabel: string; |
| @@ -377,7 +248,6 @@ function OpenApiSourceForm(props: { · 4 unmodified lines | |||
| 377 | openApiHttpClient.mutation("openapi", "previewDocument"), | 248 | openApiHttpClient.mutation("openapi", "previewDocument"), |
| 378 | { mode: "promise" }, | 249 | { mode: "promise" }, |
| 379 | ); | 250 | ); |
| 380 | const workspaceId = useWorkspaceId(); | ||
| 381 | const [name, setName] = useState(props.initialValue.name); | 251 | const [name, setName] = useState(props.initialValue.name); |
| 382 | const [specUrl, setSpecUrl] = useState(props.initialValue.specUrl); | 252 | const [specUrl, setSpecUrl] = useState(props.initialValue.specUrl); |
| 383 | const [baseUrl, setBaseUrl] = useState(props.initialValue.baseUrl ?? ""); | 253 | const [baseUrl, setBaseUrl] = useState(props.initialValue.baseUrl ?? ""); |
| @@ -403,7 +273,7 @@ function OpenApiSourceForm(props: { · 19 unmodified lines | |||
| 403 | OpenApiPreviewResponse | 273 | OpenApiPreviewResponse |
| 404 | >(async (payload) => | 274 | >(async (payload) => |
| 405 | previewDocument({ | 275 | previewDocument({ |
| 406 | path: { workspaceId }, | 276 | path: { workspaceId: props.workspaceId }, |
| 407 | payload, | 277 | payload, |
| 408 | }) | 278 | }) |
| 409 | ); | 279 | ); |
| @@ -568,7 +438,7 @@ function OpenApiSourceForm(props: { · 158 unmodified lines | |||
| 568 | 438 | ||
| 569 | {authKind === "bearer" && ( | 439 | {authKind === "bearer" && ( |
| 570 | <div className="space-y-4 border-l-2 border-border pl-4"> | 440 | <div className="space-y-4 border-l-2 border-border pl-4"> |
| 571 | <SecretSelectOrCreateField | 441 | <SecretReferenceField |
| 572 | label="Secret" | 442 | label="Secret" |
| 573 | value={tokenSecretRef} | 443 | value={tokenSecretRef} |
| 574 | emptyLabel="Select a secret" | 444 | emptyLabel="Select a secret" |
| @@ -683,24 +553,33 @@ export function OpenApiAddSourcePage() { · 108 unmodified lines | |||
| 683 | const navigation = useSourcePluginNavigation(); | 553 | const navigation = useSourcePluginNavigation(); |
| 684 | const initialValue = openApiInputFromSearch(useSourcePluginSearch()); | 554 | const initialValue = openApiInputFromSearch(useSourcePluginSearch()); |
| 685 | const openApiHttpClient = getOpenApiHttpClient(); | 555 | const openApiHttpClient = getOpenApiHttpClient(); |
| 556 | const workspace = useWorkspaceRequestContext(); | ||
| 686 | const createSource = useAtomSet( | 557 | const createSource = useAtomSet( |
| 687 | openApiHttpClient.mutation("openapi", "createSource"), | 558 | openApiHttpClient.mutation("openapi", "createSource"), |
| 688 | { mode: "promise" }, | 559 | { mode: "promise" }, |
| 689 | ); | 560 | ); |
| 690 | const workspaceId = useWorkspaceId(); | 561 | |
| 562 | if (!workspace.enabled) { | ||
| 563 | return ( | ||
| 564 | <Section title="Add Source"> | ||
| 565 | <div className="text-sm text-muted-foreground">Loading workspace...</div> | ||
| 566 | </Section> | ||
| 567 | ); | ||
| 568 | } | ||
| 691 | 569 | ||
| 692 | return ( | 570 | return ( |
| 693 | <OpenApiSourceForm | 571 | <OpenApiSourceForm |
| 694 | mode="create" | 572 | mode="create" |
| 573 | workspaceId={workspace.workspaceId} | ||
| 695 | initialValue={initialValue} | 574 | initialValue={initialValue} |
| 696 | submitLabel="Create Source" | 575 | submitLabel="Create Source" |
| 697 | busyLabel="Creating..." | 576 | busyLabel="Creating..." |
| 698 | onSubmit={async (payload) => { | 577 | onSubmit={async (payload) => { |
| 699 | const source = await createSource({ | 578 | const source = await createSource({ |
| 700 | path: { workspaceId }, | 579 | path: { workspaceId: workspace.workspaceId }, |
| 701 | payload, | 580 | payload, |
| 702 | reactivityKeys: { | 581 | reactivityKeys: { |
| 703 | sources: [workspaceId], | 582 | sources: [workspace.workspaceId], |
| 704 | }, | 583 | }, |
| 705 | }); | 584 | }); |
| 706 | 585 | ||
| @@ -716,18 +595,39 @@ export function OpenApiAddSourcePage() { · 9 unmodified lines | |||
| 716 | 595 | ||
| 717 | export function OpenApiEditSourcePage(props: { | 596 | export function OpenApiEditSourcePage(props: { |
| 718 | source: Source; | 597 | source: Source; |
| 598 | }) { | ||
| 599 | const workspace = useWorkspaceRequestContext(); | ||
| 600 | |||
| 601 | if (!workspace.enabled) { | ||
| 602 | return ( | ||
| 603 | <Section title="Edit Source"> | ||
| 604 | <div className="text-sm text-muted-foreground">Loading workspace...</div> | ||
| 605 | </Section> | ||
| 606 | ); | ||
| 607 | } | ||
| 608 | |||
| 609 | return ( | ||
| 610 | <OpenApiEditSourcePageReady | ||
| 611 | source={props.source} | ||
| 612 | workspaceId={workspace.workspaceId} | ||
| 613 | /> | ||
| 614 | ); | ||
| 615 | } | ||
| 616 | |||
| 617 | function OpenApiEditSourcePageReady(props: { | ||
| 618 | source: Source; | ||
| 619 | workspaceId: Source["scopeId"]; | ||
| 719 | }) { | 620 | }) { |
| 720 | const navigation = useSourcePluginNavigation(); | 621 | const navigation = useSourcePluginNavigation(); |
| 721 | const openApiHttpClient = getOpenApiHttpClient(); | 622 | const openApiHttpClient = getOpenApiHttpClient(); |
| 722 | const workspaceId = useWorkspaceId(); | ||
| 723 | const configResult = useAtomValue( | 623 | const configResult = useAtomValue( |
| 724 | openApiHttpClient.query("openapi", "getSourceConfig", { | 624 | openApiHttpClient.query("openapi", "getSourceConfig", { |
| 725 | path: { | 625 | path: { |
| 726 | workspaceId, | 626 | workspaceId: props.workspaceId, |
| 727 | sourceId: props.source.id, | 627 | sourceId: props.source.id, |
| 728 | }, | 628 | }, |
| 729 | reactivityKeys: { | 629 | reactivityKeys: { |
| 730 | source: [workspaceId, props.source.id], | 630 | source: [props.workspaceId, props.source.id], |
| 731 | }, | 631 | }, |
| 732 | timeToLive: "30 seconds", | 632 | timeToLive: "30 seconds", |
| 733 | }), | 633 | }), |
| @@ -758,22 +658,23 @@ export function OpenApiEditSourcePage(props: { · 24 unmodified lines | |||
| 758 | return ( | 658 | return ( |
| 759 | <OpenApiSourceForm | 659 | <OpenApiSourceForm |
| 760 | mode="edit" | 660 | mode="edit" |
| 661 | workspaceId={props.workspaceId} | ||
| 761 | initialValue={inputFromConfig(configResult.value)} | 662 | initialValue={inputFromConfig(configResult.value)} |
| 762 | submitLabel="Save Changes" | 663 | submitLabel="Save Changes" |
| 763 | busyLabel="Saving..." | 664 | busyLabel="Saving..." |
| 764 | onSubmit={async (config) => { | 665 | onSubmit={async (config) => { |
| 765 | const source = await updateSource({ | 666 | const source = await updateSource({ |
| 766 | path: { | 667 | path: { |
| 767 | workspaceId, | 668 | workspaceId: props.workspaceId, |
| 768 | sourceId: props.source.id, | 669 | sourceId: props.source.id, |
| 769 | }, | 670 | }, |
| 770 | payload: config, | 671 | payload: config, |
| 771 | reactivityKeys: { | 672 | reactivityKeys: { |
| 772 | sources: [workspaceId], | 673 | sources: [props.workspaceId], |
| 773 | source: [workspaceId, props.source.id], | 674 | source: [props.workspaceId, props.source.id], |
| 774 | sourceInspection: [workspaceId, props.source.id], | 675 | sourceInspection: [props.workspaceId, props.source.id], |
| 775 | sourceInspectionTool: [workspaceId, props.source.id], | 676 | sourceInspectionTool: [props.workspaceId, props.source.id], |
| 776 | sourceDiscovery: [workspaceId, props.source.id], | 677 | sourceDiscovery: [props.workspaceId, props.source.id], |
| 777 | }, | 678 | }, |
| 778 | }); | 679 | }); |
| 779 | 680 | ||
| @@ -789,10 +690,31 @@ export function OpenApiEditSourcePage(props: { · 9 unmodified lines | |||
| 789 | 690 | ||
| 790 | export function OpenApiSourceDetailPage(props: { | 691 | export function OpenApiSourceDetailPage(props: { |
| 791 | source: Source; | 692 | source: Source; |
| 693 | }) { | ||
| 694 | const workspace = useWorkspaceRequestContext(); | ||
| 695 | |||
| 696 | if (!workspace.enabled) { | ||
| 697 | return ( | ||
| 698 | <Section title="Source"> | ||
| 699 | <div className="text-sm text-muted-foreground">Loading workspace...</div> | ||
| 700 | </Section> | ||
| 701 | ); | ||
| 702 | } | ||
| 703 | |||
| 704 | return ( | ||
| 705 | <OpenApiSourceDetailPageReady | ||
| 706 | source={props.source} | ||
| 707 | workspaceId={workspace.workspaceId} | ||
| 708 | /> | ||
| 709 | ); | ||
| 710 | } | ||
| 711 | |||
| 712 | function OpenApiSourceDetailPageReady(props: { | ||
| 713 | source: Source; | ||
| 714 | workspaceId: Source["scopeId"]; | ||
| 792 | }) { | 715 | }) { |
| 793 | const navigation = useSourcePluginNavigation(); | 716 | const navigation = useSourcePluginNavigation(); |
| 794 | const openApiHttpClient = getOpenApiHttpClient(); | 717 | const openApiHttpClient = getOpenApiHttpClient(); |
| 795 | const workspaceId = useWorkspaceId(); | ||
| 796 | const removeSource = useAtomSet( | 718 | const removeSource = useAtomSet( |
| 797 | openApiHttpClient.mutation("openapi", "removeSource"), | 719 | openApiHttpClient.mutation("openapi", "removeSource"), |
| 798 | { mode: "promise" }, | 720 | { mode: "promise" }, |
| @@ -804,30 +726,30 @@ export function OpenApiSourceDetailPage(props: { · 5 unmodified lines | |||
| 804 | const refreshMutation = useExecutorMutation<Source["id"], Source>(async (sourceId) => | 726 | const refreshMutation = useExecutorMutation<Source["id"], Source>(async (sourceId) => |
| 805 | refreshSource({ | 727 | refreshSource({ |
| 806 | path: { | 728 | path: { |
| 807 | workspaceId, | 729 | workspaceId: props.workspaceId, |
| 808 | sourceId, | 730 | sourceId, |
| 809 | }, | 731 | }, |
| 810 | reactivityKeys: { | 732 | reactivityKeys: { |
| 811 | sources: [workspaceId], | 733 | sources: [props.workspaceId], |
| 812 | source: [workspaceId, sourceId], | 734 | source: [props.workspaceId, sourceId], |
| 813 | sourceInspection: [workspaceId, sourceId], | 735 | sourceInspection: [props.workspaceId, sourceId], |
| 814 | sourceInspectionTool: [workspaceId, sourceId], | 736 | sourceInspectionTool: [props.workspaceId, sourceId], |
| 815 | sourceDiscovery: [workspaceId, sourceId], | 737 | sourceDiscovery: [props.workspaceId, sourceId], |
| 816 | }, | 738 | }, |
| 817 | }) | 739 | }) |
| 818 | ); | 740 | ); |
| 819 | const removeMutation = useExecutorMutation<Source["id"], { removed: boolean }>(async (sourceId) => | 741 | const removeMutation = useExecutorMutation<Source["id"], { removed: boolean }>(async (sourceId) => |
| 820 | removeSource({ | 742 | removeSource({ |
| 821 | path: { | 743 | path: { |
| 822 | workspaceId, | 744 | workspaceId: props.workspaceId, |
| 823 | sourceId, | 745 | sourceId, |
| 824 | }, | 746 | }, |
| 825 | reactivityKeys: { | 747 | reactivityKeys: { |
| 826 | sources: [workspaceId], | 748 | sources: [props.workspaceId], |
| 827 | source: [workspaceId, sourceId], | 749 | source: [props.workspaceId, sourceId], |
| 828 | sourceInspection: [workspaceId, sourceId], | 750 | sourceInspection: [props.workspaceId, sourceId], |
| 829 | sourceInspectionTool: [workspaceId, sourceId], | 751 | sourceInspectionTool: [props.workspaceId, sourceId], |
| 830 | sourceDiscovery: [workspaceId, sourceId], | 752 | sourceDiscovery: [props.workspaceId, sourceId], |
| 831 | }, | 753 | }, |
| 832 | }) | 754 | }) |
| 833 | ); | 755 | ); |