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 | 19 | .executor/* |
| 20 | 20 | !.executor/executor.json |
| 21 | 21 | !.executor/executor.jsonc |
| 22 | +!.executor/tools/ | |
| 23 | +!.executor/tools/** | |
| 22 | 24 | .executor/artifacts/ |
| 23 | 25 | .executor/state/ |
| 24 | 26 | .executor/types/ |
| @@ -84,4 +86,4 @@ personal-notes/ · 59 unmodified lines | ||
| 84 | 86 | .tmp/ |
| 85 | 87 | executor/.env.executor-push |
| 86 | 88 | ignored/ |
| 87 | −agent-notes/ | |
| 89 | +agent-notes/ | |
| @@ -40,6 +40,9 @@ · 39 unmodified lines | ||
| 40 | 40 | "@executor/executor-mcp": "workspace:*", |
| 41 | 41 | "@executor/plugin-google-discovery-sdk": "workspace:*", |
| 42 | 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 | 46 | "@executor/plugin-mcp-sdk": "workspace:*", |
| 44 | 47 | "@executor/plugin-openapi-sdk": "workspace:*", |
| 45 | 48 | "@executor/platform-api": "workspace:*", |
| @@ -20,6 +20,15 @@ import { · 19 unmodified lines | ||
| 20 | 20 | import { |
| 21 | 21 | graphqlSdkPlugin, |
| 22 | 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 | 32 | import { |
| 24 | 33 | mcpSdkPlugin, |
| 25 | 34 | } from "@executor/plugin-mcp-sdk"; |
| @@ -34,7 +43,9 @@ import { · 8 unmodified lines | ||
| 34 | 43 | createExecutorEffect, |
| 35 | 44 | type ExecutorEffect as Executor, |
| 36 | 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 | 49 | import { |
| 39 | 50 | ExecutionIdSchema, |
| 40 | 51 | type ExecutionEnvelope, |
| @@ -300,9 +311,13 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 259 unmodified lines | ||
| 300 | 311 | Effect.acquireUseRelease( |
| 301 | 312 | createExecutorEffect({ |
| 302 | 313 | backend: createLocalExecutorBackend({ |
| 314 | + cwd: process.cwd(), | |
| 303 | 315 | localDataDir: CLI_LOCAL_DATA_DIR, |
| 304 | 316 | }), |
| 305 | 317 | plugins: [ |
| 318 | + localSecretStoreSdkPlugin, | |
| 319 | + keychainSecretStoreSdkPlugin, | |
| 320 | + localToolsSdkPlugin(), | |
| 306 | 321 | graphqlSdkPlugin({ |
| 307 | 322 | storage: createFileGraphqlSourceStorage({ |
| 308 | 323 | rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/graphql/sources`, |
| @@ -12,8 +12,10 @@ · 11 unmodified lines | ||
| 12 | 12 | "@executor/plugin-execution-history-react": "workspace:*", |
| 13 | 13 | "@executor/plugin-google-discovery-react": "workspace:*", |
| 14 | 14 | "@executor/plugin-graphql-react": "workspace:*", |
| 15 | + "@executor/plugin-local-tools-react": "workspace:*", | |
| 15 | 16 | "@executor/plugin-mcp-react": "workspace:*", |
| 16 | 17 | "@executor/plugin-openapi-react": "workspace:*", |
| 18 | + "@executor/plugin-onepassword-react": "workspace:*", | |
| 17 | 19 | "@executor/react": "workspace:*", |
| 18 | 20 | "@executor/server": "workspace:*", |
| 19 | 21 | "@shikijs/langs": "^4.0.1", |
| @@ -150,3 +150,11 @@ export function IconArrowLeft({ className, ...props }: IconProps) { · 149 unmodified lines | ||
| 150 | 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 | 3 | import { useSources, type Source } from "@executor/react"; |
| 4 | 4 | import { sourcePluginsIndexPath } from "@executor/react/plugins"; |
| 5 | 5 | import { cn } from "../lib/utils"; |
| 6 | −import { IconPlus, IconCopy, IconCheck } from "./icons"; | |
| 6 | +import { IconPlus, IconCopy, IconCheck, IconClose, IconMenu } from "./icons"; | |
| 7 | 7 | import { LoadableBlock } from "./loadable"; |
| 8 | 8 | import { SourceFavicon } from "./source-favicon"; |
| 9 | 9 | import { |
| @@ -224,6 +224,7 @@ export function AppShell() { · 214 unmodified lines | ||
| 224 | 224 | const sources = useSources(); |
| 225 | 225 | const location = useLocation(); |
| 226 | 226 | const matchRoute = useMatchRoute(); |
| 227 | + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); | |
| 227 | 228 | const isHome = matchRoute({ to: "/" }); |
| 228 | 229 | const isSecrets = matchRoute({ to: "/secrets" }); |
| 229 | 230 | const { latestVersion, updateAvailable, channel } = useLatestVersion(VITE_APP_VERSION); |
| @@ -234,11 +235,125 @@ export function AppShell() { · 4 unmodified lines | ||
| 234 | 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 | 254 | return ( |
| 238 | 255 | <div className="flex h-screen overflow-hidden"> |
| 239 | − {/* Sidebar */} | |
| 240 | − <aside className="flex w-52 shrink-0 flex-col border-r border-sidebar-border bg-sidebar lg:w-56"> | |
| 241 | − {/* Brand */} | |
| 256 | + <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56"> | |
| 257 | + <SidebarContent | |
| 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 | 357 | <div className="flex h-12 shrink-0 items-center border-b border-sidebar-border px-4"> |
| 243 | 358 | <Link to="/" className="flex items-center gap-1.5"> |
| 244 | 359 | <span className="font-display text-base tracking-tight text-foreground"> |
| @@ -249,99 +364,107 @@ export function AppShell() { · 4 unmodified lines | ||
| 249 | 364 | </span> |
| 250 | 365 | </Link> |
| 251 | 366 | </div> |
| 367 | + )} | |
| 252 | 368 | |
| 253 | − {/* Main nav */} | |
| 254 | − <nav className="flex flex-1 flex-col p-2 overflow-y-auto"> | |
| 255 | − <NavItem to="/" label="Dashboard" active={!!isHome} /> | |
| 256 | − <NavItem to="/secrets" label="Secrets" active={!!isSecrets} /> | |
| 257 | − {mainPluginNavItems.map(({ plugin, route, to }) => ( | |
| 258 | − <NavItem | |
| 259 | − key={`${plugin.key}:${route.key}`} | |
| 260 | − to={to} | |
| 261 | − label={route.nav?.label ?? route.key} | |
| 262 | − active={location.pathname === to || location.pathname.startsWith(`${to}/`)} | |
| 263 | − /> | |
| 264 | − ))} | |
| 265 | − | |
| 266 | − {/* Sources */} | |
| 267 | − <div className="mt-5 mb-1 px-2.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground/50"> | |
| 268 | − <div className="flex items-center justify-between gap-2"> | |
| 269 | − <span>Sources</span> | |
| 270 | − <Link | |
| 271 | − to={sourcePluginsIndexPath} | |
| 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" | |
| 273 | − > | |
| 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 | − ) | |
| 369 | + <nav className="flex flex-1 flex-col overflow-y-auto p-2"> | |
| 370 | + <NavItem | |
| 371 | + to="/" | |
| 372 | + label="Dashboard" | |
| 373 | + active={props.isHome} | |
| 374 | + onNavigate={props.onNavigate} | |
| 375 | + /> | |
| 376 | + <NavItem | |
| 377 | + to="/secrets" | |
| 378 | + label="Secrets" | |
| 379 | + active={props.isSecrets} | |
| 380 | + onNavigate={props.onNavigate} | |
| 381 | + /> | |
| 382 | + {props.mainPluginNavItems.map(({ plugin, route, to }) => ( | |
| 383 | + <NavItem | |
| 384 | + key={`${plugin.key}:${route.key}`} | |
| 385 | + to={to} | |
| 386 | + label={route.nav?.label ?? route.key} | |
| 387 | + active={ | |
| 388 | + props.locationPathname === to | |
| 389 | + || props.locationPathname.startsWith(`${to}/`) | |
| 315 | 390 | } |
| 316 | − </LoadableBlock> | |
| 317 | − </nav> | |
| 318 | − | |
| 319 | − {/* Update available */} | |
| 320 | − {updateAvailable && latestVersion && ( | |
| 321 | − <UpdateCard latestVersion={latestVersion} channel={channel} /> | |
| 322 | − )} | |
| 323 | − | |
| 324 | − {/* Footer */} | |
| 325 | − <div className="shrink-0 border-t border-sidebar-border px-4 py-2.5"> | |
| 326 | − <div className="flex items-center justify-between text-[10px] leading-none"> | |
| 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" | |
| 391 | + onNavigate={props.onNavigate} | |
| 392 | + /> | |
| 393 | + ))} | |
| 394 | + | |
| 395 | + <div className="mt-5 mb-1 px-2.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground/50"> | |
| 396 | + <div className="flex items-center justify-between gap-2"> | |
| 397 | + <span>Sources</span> | |
| 398 | + <Link | |
| 399 | + to={sourcePluginsIndexPath} | |
| 400 | + onClick={props.onNavigate} | |
| 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" | |
| 333 | 402 | > |
| 334 | − GitHub | |
| 335 | − </a> | |
| 403 | + <IconPlus className="size-3" /> | |
| 404 | + Add | |
| 405 | + </Link> | |
| 336 | 406 | </div> |
| 337 | 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 */} | |
| 341 | − <main className="flex min-h-0 flex-1 flex-col min-w-0 overflow-hidden"> | |
| 342 | − <Outlet /> | |
| 343 | − </main> | |
| 344 | − </div> | |
| 454 | + <div className="shrink-0 border-t border-sidebar-border px-4 py-2.5"> | |
| 455 | + <div className="flex items-center justify-between text-[10px] leading-none"> | |
| 456 | + <span className="text-muted-foreground/70 tabular-nums">v{VITE_APP_VERSION}</span> | |
| 457 | + <a | |
| 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 | 473 | function SourceItem(props: { |
| 351 | 474 | pathname: string; |
| 352 | 475 | source: Source; |
| 476 | + onNavigate?: () => void; | |
| 353 | 477 | }) { |
| 354 | 478 | const paths = getSourceFrontendPaths(props.source.kind); |
| 355 | 479 | |
| @@ -377,6 +501,7 @@ function SourceItem(props: { · 21 unmodified lines | ||
| 377 | 501 | <Link |
| 378 | 502 | to={detailPath} |
| 379 | 503 | search={{ tab: "model" }} |
| 504 | + onClick={props.onNavigate} | |
| 380 | 505 | className={cn( |
| 381 | 506 | "group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors", |
| 382 | 507 | active |
| @@ -398,10 +523,16 @@ function SourceItem(props: { · 15 unmodified lines | ||
| 398 | 523 | |
| 399 | 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 | 532 | return ( |
| 403 | 533 | <Link |
| 404 | 534 | to={props.to} |
| 535 | + onClick={props.onNavigate} | |
| 405 | 536 | className={cn( |
| 406 | 537 | "flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-[13px] transition-colors", |
| 407 | 538 | props.active |
| @@ -11,12 +11,18 @@ import { · 10 unmodified lines | ||
| 11 | 11 | import { |
| 12 | 12 | GraphqlReactPlugin, |
| 13 | 13 | } from "@executor/plugin-graphql-react"; |
| 14 | +import { | |
| 15 | + LocalToolsReactPlugin, | |
| 16 | +} from "@executor/plugin-local-tools-react"; | |
| 14 | 17 | import { |
| 15 | 18 | McpReactPlugin, |
| 16 | 19 | } from "@executor/plugin-mcp-react"; |
| 17 | 20 | import { |
| 18 | 21 | OpenApiReactPlugin, |
| 19 | 22 | } from "@executor/plugin-openapi-react"; |
| 23 | +import { | |
| 24 | + OnePasswordReactPlugin, | |
| 25 | +} from "@executor/plugin-onepassword-react"; | |
| 20 | 26 | import { |
| 21 | 27 | createExecutorPluginPaths, |
| 22 | 28 | createSourcePluginPaths, |
| @@ -26,10 +32,12 @@ import { · 3 unmodified lines | ||
| 26 | 32 | |
| 27 | 33 | const frontendPlugins = [ |
| 28 | 34 | ExecutionHistoryReactPlugin, |
| 35 | + LocalToolsReactPlugin, | |
| 29 | 36 | McpReactPlugin, |
| 30 | 37 | GraphqlReactPlugin, |
| 31 | 38 | GoogleDiscoveryReactPlugin, |
| 32 | 39 | OpenApiReactPlugin, |
| 40 | + OnePasswordReactPlugin, | |
| 33 | 41 | ] as const satisfies readonly ExecutorFrontendPlugin[]; |
| 34 | 42 | |
| 35 | 43 | const frontendPluginRegistry = registerExecutorFrontendPlugins(frontendPlugins); |
| @@ -81,3 +89,6 @@ export const getSourceFrontendIconUrl = (source: Source) => · 45 unmodified lines | ||
| 81 | 89 | source.kind === "google-discovery" |
| 82 | 90 | ? getGoogleDiscoveryIconUrl(source) |
| 83 | 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 | 2 | import { |
| 3 | + type BrowseSecretStoreResult, | |
| 4 | + getExecutorApiBaseUrl, | |
| 3 | 5 | type InstanceConfig, |
| 4 | − type SecretListItem, | |
| 5 | 6 | type Loadable, |
| 6 | − useSecrets, | |
| 7 | + type SecretListItem, | |
| 8 | + type SecretStore, | |
| 7 | 9 | useCreateSecret, |
| 8 | − useUpdateSecret, | |
| 10 | + useCreateSecretStore, | |
| 9 | 11 | useDeleteSecret, |
| 12 | + useDeleteSecretStore, | |
| 13 | + useExecutorMutation, | |
| 10 | 14 | useInstanceConfig, |
| 15 | + useRefreshSecrets, | |
| 16 | + useSecretStores, | |
| 17 | + useSecrets, | |
| 18 | + useUpdateSecret, | |
| 11 | 19 | } from "@executor/react"; |
| 20 | + | |
| 12 | 21 | import { LoadableBlock } from "../components/loadable"; |
| 13 | −import { Button } from "../components/ui/button"; | |
| 14 | 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 | 30 | import { cn } from "../lib/utils"; |
| 31 | +import { getSecretStoreFrontendPlugin } from "../plugins"; | |
| 17 | 32 | |
| 18 | −// --------------------------------------------------------------------------- | |
| 19 | −// Page | |
| 20 | −// --------------------------------------------------------------------------- | |
| 21 | − | |
| 22 | −const storableProvidersFromConfig = (config: Loadable<InstanceConfig>) => | |
| 33 | +const creatableStorePluginsFromConfig = (config: Loadable<InstanceConfig>) => | |
| 23 | 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 | 42 | config.status === "ready" |
| 29 | − ? config.data.secretProviders.find((provider) => provider.id === providerId)?.name ?? providerId | |
| 30 | − : providerId; | |
| 43 | + ? config.data.secretStorePlugins.some((plugin) => | |
| 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 | 75 | export function SecretsPage() { |
| 33 | 76 | const instanceConfig = useInstanceConfig(); |
| 77 | + const secretStores = useSecretStores(); | |
| 34 | 78 | const secrets = useSecrets(); |
| 35 | − const [showCreate, setShowCreate] = useState(false); | |
| 79 | + const [showCreateStore, setShowCreateStore] = useState(false); | |
| 80 | + const [showCreateSecret, setShowCreateSecret] = useState(false); | |
| 36 | 81 | const [editingId, setEditingId] = useState<string | null>(null); |
| 37 | − const storableProviders = storableProvidersFromConfig(instanceConfig); | |
| 38 | − const defaultProviderLabel = | |
| 39 | − instanceConfig.status === "ready" | |
| 40 | − ? providerLabelForId(instanceConfig, instanceConfig.data.defaultSecretStoreProvider) | |
| 41 | − : null; | |
| 82 | + | |
| 83 | + const creatableStorePlugins = creatableStorePluginsFromConfig(instanceConfig); | |
| 84 | + const defaultLabel = defaultStoreLabel(instanceConfig, secretStores); | |
| 42 | 85 | |
| 43 | 86 | return ( |
| 44 | 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"> | |
| 46 | − {/* Header */} | |
| 47 | − <div className="flex items-end justify-between mb-8"> | |
| 88 | + <div className="mx-auto flex max-w-4xl flex-col gap-8 px-6 py-10 lg:px-10 lg:py-14"> | |
| 89 | + <div className="flex flex-wrap items-end justify-between gap-4"> | |
| 48 | 90 | <div> |
| 49 | 91 | <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl"> |
| 50 | 92 | Secrets |
| 51 | 93 | </h1> |
| 52 | 94 | <p className="mt-1.5 text-[14px] text-muted-foreground"> |
| 53 | − Tokens and credentials stored by executor. | |
| 54 | − {defaultProviderLabel ? ` New secrets default to ${defaultProviderLabel}.` : ""} | |
| 95 | + Manage secret stores and the secrets linked into sources. | |
| 96 | + {defaultLabel ? ` New secrets default to ${defaultLabel}.` : ""} | |
| 55 | 97 | </p> |
| 56 | 98 | </div> |
| 57 | − <Button size="sm" onClick={() => { setShowCreate(true); setEditingId(null); }}> | |
| 58 | − <IconPlus className="size-3.5" /> | |
| 59 | − Add secret | |
| 60 | − </Button> | |
| 99 | + <div className="flex gap-2"> | |
| 100 | + <Button | |
| 101 | + size="sm" | |
| 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 | 122 | </div> |
| 62 | 123 | |
| 63 | − {/* Create form */} | |
| 64 | − {showCreate && ( | |
| 65 | − <CreateSecretForm | |
| 66 | − onClose={() => setShowCreate(false)} | |
| 67 | − className="mb-6" | |
| 68 | − providerOptions={storableProviders} | |
| 69 | − defaultProviderId={ | |
| 70 | − instanceConfig.status === "ready" | |
| 71 | − ? instanceConfig.data.defaultSecretStoreProvider | |
| 72 | − : null | |
| 124 | + <section className="space-y-4"> | |
| 125 | + <div> | |
| 126 | + <h2 className="text-sm font-semibold text-foreground">Stores</h2> | |
| 127 | + <p className="mt-1 text-[13px] text-muted-foreground"> | |
| 128 | + Local and external backends that hold managed secrets. | |
| 129 | + </p> | |
| 130 | + </div> | |
| 131 | + | |
| 132 | + {showCreateStore && ( | |
| 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 | − /> | |
| 75 | − )} | |
| 160 | + </LoadableBlock> | |
| 161 | + </section> | |
| 76 | 162 | |
| 77 | − {/* Secrets table */} | |
| 78 | − <LoadableBlock loadable={secrets} loading="Loading secrets..."> | |
| 79 | − {(items) => | |
| 80 | − items.length === 0 && !showCreate ? ( | |
| 81 | − <EmptyState onAdd={() => setShowCreate(true)} /> | |
| 82 | − ) : ( | |
| 83 | − <div className="rounded-xl border border-border bg-card/80 divide-y divide-border overflow-hidden"> | |
| 84 | − {items.map((secret) => ( | |
| 85 | − <SecretRow | |
| 86 | − key={secret.id} | |
| 87 | − secret={secret} | |
| 88 | − providerLabel={providerLabelForId(instanceConfig, secret.providerId)} | |
| 89 | − isEditing={editingId === secret.id} | |
| 90 | − onEdit={() => setEditingId(editingId === secret.id ? null : secret.id)} | |
| 91 | − onCancelEdit={() => setEditingId(null)} | |
| 92 | − /> | |
| 93 | − ))} | |
| 94 | − {items.length === 0 && ( | |
| 95 | − <div className="px-5 py-8 text-center text-[13px] text-muted-foreground/60"> | |
| 96 | − No secrets yet. Add one above. | |
| 97 | − </div> | |
| 98 | − )} | |
| 99 | − </div> | |
| 100 | − ) | |
| 101 | − } | |
| 102 | − </LoadableBlock> | |
| 163 | + <section className="space-y-4"> | |
| 164 | + <div> | |
| 165 | + <h2 className="text-sm font-semibold text-foreground">Managed Secrets</h2> | |
| 166 | + <p className="mt-1 text-[13px] text-muted-foreground"> | |
| 167 | + Secret records stay stable even if their underlying store handle changes. | |
| 168 | + </p> | |
| 169 | + </div> | |
| 170 | + | |
| 171 | + {showCreateSecret && ( | |
| 172 | + <CreateSecretForm | |
| 173 | + className="mb-2" | |
| 174 | + onClose={() => setShowCreateSecret(false)} | |
| 175 | + storeOptions={secretStores.status === "ready" ? secretStores.data : []} | |
| 176 | + defaultStoreId={ | |
| 177 | + instanceConfig.status === "ready" | |
| 178 | + ? instanceConfig.data.defaultSecretStoreId | |
| 179 | + : null | |
| 180 | + } | |
| 181 | + /> | |
| 182 | + )} | |
| 183 | + | |
| 184 | + <LoadableBlock loadable={secrets} loading="Loading secrets..."> | |
| 185 | + {(items) => | |
| 186 | + items.length === 0 && !showCreateSecret ? ( | |
| 187 | + <SectionEmptyState | |
| 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 | 220 | </div> |
| 104 | 221 | </div> |
| 105 | 222 | ); |
| 106 | 223 | } |
| 107 | 224 | |
| 108 | −// --------------------------------------------------------------------------- | |
| 109 | −// Empty state | |
| 110 | −// --------------------------------------------------------------------------- | |
| 225 | +function CreateSecretStoreForm(props: { | |
| 226 | + className?: string; | |
| 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 | 272 | return ( |
| 114 | − <div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-20"> | |
| 115 | − <div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground mb-4"> | |
| 116 | − <KeyIcon className="size-5" /> | |
| 117 | − </div> | |
| 118 | − <p className="text-[14px] font-medium text-foreground/70 mb-1"> | |
| 119 | − No secrets stored | |
| 120 | − </p> | |
| 121 | − <p className="text-[13px] text-muted-foreground/60 mb-5"> | |
| 122 | − Secrets let you securely provide tokens for source authentication without putting them in workspace config. | |
| 123 | − </p> | |
| 124 | − <Button size="sm" onClick={props.onAdd}> | |
| 125 | − <IconPlus className="size-3.5" /> | |
| 126 | − Add secret | |
| 127 | − </Button> | |
| 128 | − </div> | |
| 273 | + <FormCard | |
| 274 | + className={props.className} | |
| 275 | + title="New secret store" | |
| 276 | + onClose={props.onClose} | |
| 277 | + > | |
| 278 | + {error && <ErrorBanner>{error}</ErrorBanner>} | |
| 279 | + {props.pluginOptions.length === 0 ? ( | |
| 280 | + <div className="rounded-lg border border-border bg-background/40 px-4 py-3 text-[13px] text-muted-foreground"> | |
| 281 | + No external secret store plugins are available in this runtime. | |
| 282 | + </div> | |
| 283 | + ) : ( | |
| 284 | + <> | |
| 285 | + <div className="grid gap-4 sm:grid-cols-2"> | |
| 286 | + <FieldLabel label="Store type"> | |
| 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 | 346 | function CreateSecretForm(props: { |
| 137 | − onClose: () => void; | |
| 138 | 347 | className?: string; |
| 139 | − providerOptions: ReadonlyArray<{ id: string; name: string }>; | |
| 140 | − defaultProviderId: string | null; | |
| 348 | + onClose: () => void; | |
| 349 | + storeOptions: ReadonlyArray<SecretStore>; | |
| 350 | + defaultStoreId: string | null; | |
| 141 | 351 | }) { |
| 142 | 352 | const createSecret = useCreateSecret(); |
| 353 | + const refreshSecrets = useRefreshSecrets(); | |
| 354 | + const [mode, setMode] = useState<"create" | "import">("create"); | |
| 143 | 355 | const [name, setName] = useState(""); |
| 144 | 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 | 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 | 445 | useEffect(() => { |
| 149 | − if (providerId.length > 0) { | |
| 446 | + if (storeId.length > 0) { | |
| 150 | 447 | return; |
| 151 | 448 | } |
| 152 | − if (props.defaultProviderId) { | |
| 153 | − setProviderId(props.defaultProviderId); | |
| 449 | + if (props.defaultStoreId) { | |
| 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 () => { | |
| 158 | − setError(null); | |
| 159 | − const trimmedName = name.trim(); | |
| 160 | − if (!trimmedName) { | |
| 161 | − setError("Name is required."); | |
| 458 | + useEffect(() => { | |
| 459 | + setBrowserStack([]); | |
| 460 | + setBrowserQuery(""); | |
| 461 | + setBrowserEntries([]); | |
| 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 | 472 | return; |
| 163 | 473 | } |
| 164 | − if (!value) { | |
| 165 | − setError("Value is required."); | |
| 474 | + if (canImportSecrets) { | |
| 475 | + setMode("import"); | |
| 166 | 476 | return; |
| 167 | 477 | } |
| 478 | + setMode("create"); | |
| 479 | + }, [canCreateSecrets, canImportSecrets, selectedStore?.id]); | |
| 168 | 480 | |
| 169 | − try { | |
| 170 | − await createSecret.mutateAsync({ | |
| 171 | − name: trimmedName, | |
| 172 | − value, | |
| 173 | − ...(providerId ? { providerId } : {}), | |
| 481 | + useEffect(() => { | |
| 482 | + if (mode !== "import" || !canImportSecrets || !storeId) { | |
| 483 | + return; | |
| 484 | + } | |
| 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 | 571 | props.onClose(); |
| 176 | − } catch (err) { | |
| 177 | − setError(err instanceof Error ? err.message : "Failed creating secret."); | |
| 572 | + } catch (cause) { | |
| 573 | + setError(cause instanceof Error ? cause.message : "Failed creating secret."); | |
| 178 | 574 | } |
| 179 | 575 | }; |
| 180 | 576 | |
| 181 | 577 | return ( |
| 182 | − <div className={cn("rounded-xl border border-primary/20 bg-card/80", props.className)}> | |
| 183 | − <div className="border-b border-border px-5 py-3 flex items-center justify-between"> | |
| 184 | − <h2 className="text-sm font-semibold text-foreground">New secret</h2> | |
| 185 | − <button | |
| 186 | − type="button" | |
| 187 | − onClick={props.onClose} | |
| 188 | − className="text-[12px] text-muted-foreground hover:text-foreground transition-colors" | |
| 189 | − > | |
| 190 | − Cancel | |
| 191 | − </button> | |
| 192 | − </div> | |
| 193 | − <div className="p-5 space-y-4"> | |
| 194 | − {error && ( | |
| 195 | − <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-[13px] text-destructive"> | |
| 196 | − {error} | |
| 197 | − </div> | |
| 198 | − )} | |
| 199 | − <div className="grid gap-4 sm:grid-cols-2"> | |
| 200 | − <FieldLabel label="Name"> | |
| 201 | − <input | |
| 202 | − value={name} | |
| 203 | − onChange={(e) => setName(e.target.value)} | |
| 204 | − placeholder="GitHub PAT" | |
| 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" | |
| 206 | − autoFocus | |
| 207 | − /> | |
| 578 | + <FormCard | |
| 579 | + className={props.className} | |
| 580 | + title="New managed secret" | |
| 581 | + onClose={props.onClose} | |
| 582 | + > | |
| 583 | + {error && <ErrorBanner>{error}</ErrorBanner>} | |
| 584 | + <div className="grid gap-4 sm:grid-cols-2"> | |
| 585 | + <FieldLabel label="Store"> | |
| 586 | + <select | |
| 587 | + value={storeId} | |
| 588 | + onChange={(event) => setStoreId(event.target.value)} | |
| 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" | |
| 590 | + > | |
| 591 | + {props.storeOptions.map((store) => ( | |
| 592 | + <option key={store.id} value={store.id}> | |
| 593 | + {store.name} | |
| 594 | + </option> | |
| 595 | + ))} | |
| 596 | + </select> | |
| 597 | + </FieldLabel> | |
| 598 | + {canImportSecrets && canCreateSecrets && ( | |
| 599 | + <FieldLabel label="Mode"> | |
| 600 | + <select | |
| 601 | + value={mode} | |
| 602 | + onChange={(event) => setMode(event.target.value as "create" | "import")} | |
| 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 | 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 | 620 | <FieldLabel label="Value"> |
| 210 | 621 | <input |
| 211 | 622 | type="password" |
| 212 | 623 | value={value} |
| 213 | − onChange={(e) => setValue(e.target.value)} | |
| 624 | + onChange={(event) => setValue(event.target.value)} | |
| 214 | 625 | placeholder="ghp_..." |
| 215 | 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 | 628 | </FieldLabel> |
| 218 | − <FieldLabel label="Store in"> | |
| 219 | − <select | |
| 220 | − value={providerId} | |
| 221 | − onChange={(e) => setProviderId(e.target.value)} | |
| 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" | |
| 223 | − > | |
| 224 | − {props.providerOptions.map((provider) => ( | |
| 225 | − <option key={provider.id} value={provider.id}> | |
| 226 | − {provider.name} | |
| 227 | − </option> | |
| 629 | + )} | |
| 630 | + </div> | |
| 631 | + {mode === "import" && canImportSecrets && ( | |
| 632 | + <div className="mt-4 space-y-4"> | |
| 633 | + <div className="flex flex-wrap items-center gap-2"> | |
| 634 | + {browserStack.length > 0 && ( | |
| 635 | + <Button | |
| 636 | + size="sm" | |
| 637 | + variant="outline" | |
| 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> | |
| 230 | − </FieldLabel> | |
| 662 | + </div> | |
| 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 | 706 | </div> |
| 232 | − <div className="flex justify-end"> | |
| 233 | − <Button | |
| 234 | − size="sm" | |
| 235 | − onClick={handleSubmit} | |
| 236 | − disabled={createSecret.status === "pending"} | |
| 237 | − > | |
| 238 | − {createSecret.status === "pending" ? <IconSpinner className="size-3.5" /> : <IconPlus className="size-3.5" />} | |
| 239 | − Store secret | |
| 240 | − </Button> | |
| 707 | + )} | |
| 708 | + <div className="flex justify-end"> | |
| 709 | + <Button | |
| 710 | + size="sm" | |
| 711 | + onClick={handleSubmit} | |
| 712 | + disabled={createSecret.status === "pending" || importSecretMutation.status === "pending"} | |
| 713 | + > | |
| 714 | + {(createSecret.status === "pending" || importSecretMutation.status === "pending") | |
| 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 | 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 | 807 | </div> |
| 243 | 808 | </div> |
| 244 | 809 | ); |
| 245 | 810 | } |
| 246 | 811 | |
| 247 | −// --------------------------------------------------------------------------- | |
| 248 | −// Secret row | |
| 249 | −// --------------------------------------------------------------------------- | |
| 250 | − | |
| 251 | 812 | function SecretRow(props: { |
| 252 | 813 | secret: SecretListItem; |
| 253 | − providerLabel: string; | |
| 254 | 814 | isEditing: boolean; |
| 255 | 815 | onEdit: () => void; |
| 256 | 816 | onCancelEdit: () => void; |
| 257 | 817 | }) { |
| 258 | − const { secret, providerLabel, isEditing } = props; | |
| 259 | 818 | const deleteSecret = useDeleteSecret(); |
| 260 | 819 | const [isDeleting, setIsDeleting] = useState(false); |
| 261 | 820 | const [confirmDelete, setConfirmDelete] = useState(false); |
| @@ -263,72 +822,60 @@ function SecretRow(props: { · 1 unmodified line | ||
| 263 | 822 | const handleDelete = async () => { |
| 264 | 823 | setIsDeleting(true); |
| 265 | 824 | try { |
| 266 | − await deleteSecret.mutateAsync(secret.id); | |
| 825 | + await deleteSecret.mutateAsync(props.secret.id); | |
| 267 | 826 | } catch { |
| 268 | − // refresh will show the secret still there | |
| 827 | + // refresh state will keep the secret visible | |
| 269 | 828 | } finally { |
| 270 | − setConfirmDelete(false); | |
| 271 | 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 | 834 | return ( |
| 282 | − <div className="px-5 py-3.5"> | |
| 835 | + <> | |
| 283 | 836 | <div className="flex items-center justify-between gap-4"> |
| 284 | − <div className="flex items-center gap-3 min-w-0"> | |
| 285 | − <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-primary"> | |
| 286 | − <KeyIcon className="size-3.5" /> | |
| 837 | + <div className="min-w-0"> | |
| 838 | + <div className="flex flex-wrap items-center gap-2"> | |
| 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 | 851 | </div> |
| 288 | − <div className="min-w-0"> | |
| 289 | − <div className="flex items-center gap-2"> | |
| 290 | − <span className="text-[13px] font-medium text-foreground truncate"> | |
| 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 | − )} | |
| 852 | + <div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground/50"> | |
| 853 | + <span className="font-mono">{props.secret.id}</span> | |
| 854 | + <span>{formatDate(props.secret.createdAt)}</span> | |
| 321 | 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 | 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 | 871 | <button |
| 325 | 872 | type="button" |
| 326 | 873 | onClick={props.onEdit} |
| 327 | 874 | className={cn( |
| 328 | 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 | 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 | 881 | <IconPencil className="size-3" /> |
| @@ -336,14 +883,11 @@ function SecretRow(props: { · 1 unmodified line | ||
| 336 | 883 | </button> |
| 337 | 884 | {confirmDelete ? ( |
| 338 | 885 | <> |
| 339 | − <span className="text-[10px] font-medium text-destructive"> | |
| 340 | − Confirm delete? | |
| 341 | − </span> | |
| 342 | 886 | <button |
| 343 | 887 | type="button" |
| 344 | 888 | onClick={() => setConfirmDelete(false)} |
| 345 | 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 | 892 | Cancel |
| 349 | 893 | </button> |
| @@ -355,8 +899,10 @@ function SecretRow(props: { · 5 unmodified lines | ||
| 355 | 899 | disabled={isDeleting} |
| 356 | 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" />} | |
| 359 | − {isDeleting ? "Deleting..." : "Delete"} | |
| 902 | + {isDeleting | |
| 903 | + ? <IconSpinner className="size-3" /> | |
| 904 | + : <IconTrash className="size-3" />} | |
| 905 | + Delete | |
| 360 | 906 | </button> |
| 361 | 907 | </> |
| 362 | 908 | ) : ( |
| @@ -364,31 +910,28 @@ function SecretRow(props: { · 1 unmodified line | ||
| 364 | 910 | type="button" |
| 365 | 911 | onClick={() => setConfirmDelete(true)} |
| 366 | 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 | 918 | Delete |
| 371 | 919 | </button> |
| 372 | 920 | )} |
| 373 | 921 | </div> |
| 374 | 922 | </div> |
| 375 | 923 | |
| 376 | − {/* Inline edit */} | |
| 377 | − {isEditing && ( | |
| 378 | − <EditSecretForm | |
| 379 | − secret={secret} | |
| 380 | − onClose={props.onCancelEdit} | |
| 381 | − /> | |
| 924 | + {props.isEditing && ( | |
| 925 | + <EditSecretForm secret={props.secret} onClose={props.onCancelEdit} /> | |
| 382 | 926 | )} |
| 383 | − </div> | |
| 927 | + </> | |
| 384 | 928 | ); |
| 385 | 929 | } |
| 386 | 930 | |
| 387 | −// --------------------------------------------------------------------------- | |
| 388 | −// Edit form (inline) | |
| 389 | −// --------------------------------------------------------------------------- | |
| 390 | − | |
| 391 | −function EditSecretForm(props: { secret: SecretListItem; onClose: () => void }) { | |
| 931 | +function EditSecretForm(props: { | |
| 932 | + secret: SecretListItem; | |
| 933 | + onClose: () => void; | |
| 934 | +}) { | |
| 392 | 935 | const updateSecret = useUpdateSecret(); |
| 393 | 936 | const [name, setName] = useState(props.secret.name ?? ""); |
| 394 | 937 | const [value, setValue] = useState(""); |
| @@ -416,39 +959,35 @@ function EditSecretForm(props: { secret: SecretListItem; onClose: () => void }) · 21 unmodified lines | ||
| 416 | 959 | payload, |
| 417 | 960 | }); |
| 418 | 961 | props.onClose(); |
| 419 | − } catch (err) { | |
| 420 | − setError(err instanceof Error ? err.message : "Failed updating secret."); | |
| 962 | + } catch (cause) { | |
| 963 | + setError(cause instanceof Error ? cause.message : "Failed updating secret."); | |
| 421 | 964 | } |
| 422 | 965 | }; |
| 423 | 966 | |
| 424 | 967 | return ( |
| 425 | − <div className="mt-3 pt-3 border-t border-border/50"> | |
| 426 | − {error && ( | |
| 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 | − )} | |
| 968 | + <div className="mt-3 border-t border-border/50 pt-3"> | |
| 969 | + {error && <ErrorBanner className="mb-3">{error}</ErrorBanner>} | |
| 431 | 970 | <div className="grid gap-3 sm:grid-cols-2"> |
| 432 | 971 | <FieldLabel label="Name"> |
| 433 | 972 | <input |
| 434 | 973 | value={name} |
| 435 | − onChange={(e) => setName(e.target.value)} | |
| 974 | + onChange={(event) => setName(event.target.value)} | |
| 436 | 975 | placeholder="Secret name" |
| 437 | 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 | 977 | autoFocus |
| 439 | 978 | /> |
| 440 | 979 | </FieldLabel> |
| 441 | − <FieldLabel label="New value (leave empty to keep current)"> | |
| 980 | + <FieldLabel label="New value"> | |
| 442 | 981 | <input |
| 443 | 982 | type="password" |
| 444 | 983 | value={value} |
| 445 | − onChange={(e) => setValue(e.target.value)} | |
| 984 | + onChange={(event) => setValue(event.target.value)} | |
| 446 | 985 | placeholder="Leave empty to keep existing" |
| 447 | 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 | 988 | </FieldLabel> |
| 450 | 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 | 991 | <button |
| 453 | 992 | type="button" |
| 454 | 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 | −// --------------------------------------------------------------------------- | |
| 473 | −// Shared | |
| 474 | −// --------------------------------------------------------------------------- | |
| 1011 | +function FormCard(props: { | |
| 1012 | + title: string; | |
| 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 | 1038 | return ( |
| 478 | − <label className="block space-y-1"> | |
| 479 | − <span className="text-[11px] font-medium text-muted-foreground">{props.label}</span> | |
| 1039 | + <div | |
| 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 | 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 | 1076 | return ( |
| 487 | − <svg viewBox="0 0 16 16" fill="none" className={cn("size-4", props.className)}> | |
| 488 | − <path | |
| 489 | − d="M10 6a4 4 0 10-4.9 3.9L2 13v1.5h2V13h1.5v-1.5H7l.6-.6A4 4 0 0010 6z" | |
| 490 | − stroke="currentColor" | |
| 491 | − strokeWidth="1.3" | |
| 492 | − strokeLinejoin="round" | |
| 493 | − /> | |
| 494 | − <circle cx="10.5" cy="5.5" r="1" fill="currentColor" /> | |
| 495 | − </svg> | |
| 1077 | + <label className="block space-y-1"> | |
| 1078 | + <span className="text-[11px] font-medium text-muted-foreground"> | |
| 1079 | + {props.label} | |
| 1080 | + </span> | |
| 1081 | + {props.children} | |
| 1082 | + </label> | |
| 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 | 63 | server: { |
| 64 | 64 | port: 8788, |
| 65 | + allowedHosts: [ | |
| 66 | + "rhyss-laptop.tail5665af.ts.net", | |
| 67 | + ".tail5665af.ts.net", | |
| 68 | + ], | |
| 65 | 69 | watch: { |
| 66 | 70 | ignored: [ |
| 67 | 71 | "!**/node_modules/@executor/**", |
| @@ -33,6 +33,9 @@ · 32 unmodified lines | ||
| 33 | 33 | "@executor/platform-sdk-file": "workspace:*", |
| 34 | 34 | "@executor/plugin-google-discovery-sdk": "workspace:*", |
| 35 | 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 | 39 | "@executor/plugin-mcp-sdk": "workspace:*", |
| 37 | 40 | "@executor/plugin-openapi-sdk": "workspace:*", |
| 38 | 41 | "@executor/server": "workspace:*", |
| @@ -57,7 +60,9 @@ · 18 unmodified lines | ||
| 57 | 60 | "@executor/plugin-execution-history-react": "workspace:*", |
| 58 | 61 | "@executor/plugin-google-discovery-react": "workspace:*", |
| 59 | 62 | "@executor/plugin-graphql-react": "workspace:*", |
| 63 | + "@executor/plugin-local-tools-react": "workspace:*", | |
| 60 | 64 | "@executor/plugin-mcp-react": "workspace:*", |
| 65 | + "@executor/plugin-onepassword-react": "workspace:*", | |
| 61 | 66 | "@executor/plugin-openapi-react": "workspace:*", |
| 62 | 67 | "@executor/react": "workspace:*", |
| 63 | 68 | "@executor/server": "workspace:*", |
| @@ -373,9 +378,15 @@ · 309 unmodified lines | ||
| 373 | 378 | "@executor/plugin-graphql-http": "workspace:*", |
| 374 | 379 | "@executor/plugin-graphql-sdk": "workspace:*", |
| 375 | 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 | 384 | "@executor/plugin-mcp-http": "workspace:*", |
| 377 | 385 | "@executor/plugin-mcp-sdk": "workspace:*", |
| 378 | 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 | 390 | "@executor/plugin-openapi-http": "workspace:*", |
| 380 | 391 | "@executor/plugin-openapi-sdk": "workspace:*", |
| 381 | 392 | "@executor/plugin-openapi-shared": "workspace:*", |
| @@ -550,6 +561,58 @@ · 168 unmodified lines | ||
| 550 | 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 | 616 | "plugins/mcp/http": { |
| 554 | 617 | "name": "@executor/plugin-mcp-http", |
| 555 | 618 | "version": "0.0.0", |
| @@ -614,6 +677,62 @@ · 58 unmodified lines | ||
| 614 | 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 | 736 | "plugins/openapi/http": { |
| 618 | 737 | "name": "@executor/plugin-openapi-http", |
| 619 | 738 | "version": "0.0.0", |
| @@ -692,6 +811,10 @@ · 72 unmodified lines | ||
| 692 | 811 | "zod": "^4.3.6", |
| 693 | 812 | }, |
| 694 | 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 | 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 | 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 | 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 | 1056 | "@executor/plugin-mcp-http": ["@executor/plugin-mcp-http@workspace:plugins/mcp/http"], |
| 926 | 1057 | |
| 927 | 1058 | "@executor/plugin-mcp-react": ["@executor/plugin-mcp-react@workspace:plugins/mcp/react"], |
| @@ -930,6 +1061,14 @@ · 2 unmodified lines | ||
| 930 | 1061 | |
| 931 | 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 | 1072 | "@executor/plugin-openapi-http": ["@executor/plugin-openapi-http@workspace:plugins/openapi/http"], |
| 934 | 1073 | |
| 935 | 1074 | "@executor/plugin-openapi-react": ["@executor/plugin-openapi-react@workspace:plugins/openapi/react"], |
| @@ -27,6 +27,7 @@ import type { · 26 unmodified lines | ||
| 27 | 27 | LocalInstallation, |
| 28 | 28 | SecretMaterial, |
| 29 | 29 | SecretRef, |
| 30 | + SecretStore, | |
| 30 | 31 | } from "@executor/platform-sdk/schema"; |
| 31 | 32 | import { |
| 32 | 33 | ScopeIdSchema, |
| @@ -57,14 +58,15 @@ type SourceArtifactBuildInput = Parameters< · 24 unmodified lines | ||
| 57 | 58 | |
| 58 | 59 | type SecretMaterialSummary = { |
| 59 | 60 | id: string; |
| 60 | − providerId: string; | |
| 61 | + storeId: string; | |
| 61 | 62 | name: string | null; |
| 62 | 63 | purpose: string; |
| 63 | 64 | createdAt: number; |
| 64 | 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 | 71 | const installations = sqliteTable("installations", { |
| 70 | 72 | key: text("key").primaryKey(), |
| @@ -250,8 +252,39 @@ const openSqliteStore = (databasePath: string) => { · 179 unmodified lines | ||
| 250 | 252 | |
| 251 | 253 | type SqliteStore = ReturnType<typeof openSqliteStore>; |
| 252 | 254 | |
| 253 | −const createStorageDomains = (store: SqliteStore) => ({ | |
| 255 | +const createStorageDomains = ( | |
| 256 | + store: SqliteStore, | |
| 257 | + installation: LocalInstallation, | |
| 258 | +) => ({ | |
| 254 | 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 | 288 | getById: (id: SecretMaterial["id"]) => { |
| 256 | 289 | const row = store.db.select().from(secretMaterials).where(eq(secretMaterials.id, id)).get(); |
| 257 | 290 | return row ? parseJson<SecretMaterial>(row.json) : null; |
| @@ -259,7 +292,7 @@ const createStorageDomains = (store: SqliteStore) => ({ · 1 unmodified line | ||
| 259 | 292 | listAll: (): readonly SecretMaterialSummary[] => |
| 260 | 293 | store.db.select().from(secretMaterials).all().map((row) => ({ |
| 261 | 294 | id: row.id, |
| 262 | − providerId: row.providerId, | |
| 295 | + storeId: row.providerId, | |
| 263 | 296 | name: row.name, |
| 264 | 297 | purpose: row.purpose, |
| 265 | 298 | createdAt: row.createdAt, |
| @@ -268,7 +301,7 @@ const createStorageDomains = (store: SqliteStore) => ({ · 2 unmodified lines | ||
| 268 | 301 | upsert: (material: SecretMaterial) => |
| 269 | 302 | store.db.insert(secretMaterials).values({ |
| 270 | 303 | id: material.id, |
| 271 | − providerId: material.providerId, | |
| 304 | + providerId: material.storeId, | |
| 272 | 305 | name: material.name, |
| 273 | 306 | purpose: material.purpose, |
| 274 | 307 | createdAt: material.createdAt, |
| @@ -277,7 +310,7 @@ const createStorageDomains = (store: SqliteStore) => ({ · 2 unmodified lines | ||
| 277 | 310 | }).onConflictDoUpdate({ |
| 278 | 311 | target: secretMaterials.id, |
| 279 | 312 | set: { |
| 280 | − providerId: material.providerId, | |
| 313 | + providerId: material.storeId, | |
| 281 | 314 | name: material.name, |
| 282 | 315 | purpose: material.purpose, |
| 283 | 316 | createdAt: material.createdAt, |
| @@ -299,7 +332,7 @@ const createStorageDomains = (store: SqliteStore) => ({ · 15 unmodified lines | ||
| 299 | 332 | updatedAt: Date.now(), |
| 300 | 333 | }; |
| 301 | 334 | store.db.update(secretMaterials).set({ |
| 302 | − providerId: next.providerId, | |
| 335 | + providerId: next.storeId, | |
| 303 | 336 | name: next.name, |
| 304 | 337 | purpose: next.purpose, |
| 305 | 338 | createdAt: next.createdAt, |
| @@ -308,7 +341,7 @@ const createStorageDomains = (store: SqliteStore) => ({ · 2 unmodified lines | ||
| 308 | 341 | }).where(eq(secretMaterials.id, id)).run(); |
| 309 | 342 | return { |
| 310 | 343 | id: next.id, |
| 311 | − providerId: next.providerId, | |
| 344 | + storeId: next.storeId, | |
| 312 | 345 | name: next.name, |
| 313 | 346 | purpose: next.purpose, |
| 314 | 347 | createdAt: next.createdAt, |
| @@ -471,7 +504,19 @@ export const createSqliteExecutorBackend = ( · 156 unmodified lines | ||
| 471 | 504 | return createExecutorBackend({ |
| 472 | 505 | loadRepositories: () => { |
| 473 | 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 | 521 | return { |
| 477 | 522 | scope: { |
| @@ -484,30 +529,9 @@ export const createSqliteExecutorBackend = ( · 6 unmodified lines | ||
| 484 | 529 | }, |
| 485 | 530 | installation: { |
| 486 | 531 | load: () => { |
| 487 | − const row = store.db.select().from(installations).where(eq(installations.key, "active")).get(); | |
| 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); | |
| 532 | + return installation; | |
| 497 | 533 | }, |
| 498 | 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 | 535 | store.db.insert(installations).values({ |
| 512 | 536 | key: "active", |
| 513 | 537 | scopeId: installation.scopeId, |
| @@ -587,21 +611,22 @@ export const createSqliteExecutorBackend = ( · 73 unmodified lines | ||
| 587 | 611 | }, |
| 588 | 612 | secrets: { |
| 589 | 613 | ...secrets, |
| 614 | + secretStores: secrets.secretStores, | |
| 590 | 615 | resolve: ({ ref }) => { |
| 591 | 616 | const material = secrets.getById( |
| 592 | − ref.handle as SecretMaterial["id"], | |
| 617 | + ref.secretId as SecretMaterial["id"], | |
| 593 | 618 | ); |
| 594 | 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 | 622 | return material.value; |
| 598 | 623 | }, |
| 599 | − store: ({ purpose, value, name, providerId }) => { | |
| 624 | + store: ({ purpose, value, name, storeId }) => { | |
| 600 | 625 | const now = Date.now(); |
| 601 | 626 | const id = SecretMaterialIdSchema.make(`secret_${randomUUID()}`); |
| 602 | 627 | const material: SecretMaterial = { |
| 603 | 628 | id, |
| 604 | − providerId: providerId ?? SQLITE_SECRET_PROVIDER_ID, | |
| 629 | + storeId: (storeId ?? SQLITE_SECRET_STORE_ID) as SecretMaterial["storeId"], | |
| 605 | 630 | handle: id, |
| 606 | 631 | name: name ?? null, |
| 607 | 632 | purpose, |
| @@ -611,19 +636,18 @@ export const createSqliteExecutorBackend = ( · 3 unmodified lines | ||
| 611 | 636 | }; |
| 612 | 637 | secrets.upsert(material); |
| 613 | 638 | return { |
| 614 | − providerId: material.providerId, | |
| 615 | − handle: material.handle, | |
| 639 | + secretId: material.id, | |
| 616 | 640 | } satisfies SecretRef; |
| 617 | 641 | }, |
| 618 | 642 | delete: (ref) => |
| 619 | − secrets.removeById(ref.handle as SecretMaterial["id"]), | |
| 643 | + secrets.removeById(ref.secretId as SecretMaterial["id"]), | |
| 620 | 644 | update: ({ ref, name, value }) => { |
| 621 | 645 | const updated = secrets.updateById( |
| 622 | − ref.handle as SecretMaterial["id"], | |
| 646 | + ref.secretId as SecretMaterial["id"], | |
| 623 | 647 | { name, value }, |
| 624 | 648 | ); |
| 625 | 649 | if (!updated) { |
| 626 | − throw new Error(`Missing secret material ${ref.handle}`); | |
| 650 | + throw new Error(`Missing secret material ${ref.secretId}`); | |
| 627 | 651 | } |
| 628 | 652 | return updated; |
| 629 | 653 | }, |
| @@ -632,14 +656,14 @@ export const createSqliteExecutorBackend = ( · 2 unmodified lines | ||
| 632 | 656 | instanceConfig: { |
| 633 | 657 | resolve: () => ({ |
| 634 | 658 | platform: "sqlite-sdk-example", |
| 635 | − secretProviders: [ | |
| 659 | + secretStorePlugins: [ | |
| 636 | 660 | { |
| 637 | − id: SQLITE_SECRET_PROVIDER_ID, | |
| 638 | − name: "SQLite", | |
| 639 | − canStore: true, | |
| 661 | + kind: SQLITE_SECRET_STORE_KIND, | |
| 662 | + displayName: "SQLite", | |
| 663 | + canCreate: false, | |
| 640 | 664 | }, |
| 641 | 665 | ], |
| 642 | − defaultSecretStoreProvider: SQLITE_SECRET_PROVIDER_ID, | |
| 666 | + defaultSecretStoreId: SQLITE_SECRET_STORE_ID, | |
| 643 | 667 | }), |
| 644 | 668 | }, |
| 645 | 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 | 2 | Atom, |
| 3 | 3 | } from "@effect-atom/atom-react"; |
| 4 | 4 | import type { |
| 5 | + SecretStore, | |
| 5 | 6 | InstanceConfig, |
| 6 | 7 | LocalInstallation, |
| 7 | 8 | SecretListItem, |
| @@ -50,6 +51,12 @@ export const secretsAtom = (baseUrl: string = getExecutorApiBaseUrl()) => · 42 unmodified lines | ||
| 50 | 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 | 60 | export const executionsAtom = (workspaceId: Execution["scopeId"]) => |
| 54 | 61 | getExecutorApiHttpClient().query("executions", "list", { |
| 55 | 62 | path: { |
| @@ -168,6 +175,7 @@ export type ExecutorApiAtoms = { · 112 unmodified lines | ||
| 168 | 175 | localInstallationAtom: typeof localInstallationAtom; |
| 169 | 176 | instanceConfigAtom: typeof instanceConfigAtom; |
| 170 | 177 | secretsAtom: typeof secretsAtom; |
| 178 | + secretStoresAtom: typeof secretStoresAtom; | |
| 171 | 179 | executionsAtom: typeof executionsAtom; |
| 172 | 180 | executionAtom: typeof executionAtom; |
| 173 | 181 | sourcesAtom: typeof sourcesAtom; |
| @@ -181,6 +189,7 @@ export type ExecutorApiAtomValues = { · 7 unmodified lines | ||
| 181 | 189 | localInstallation: LocalInstallation; |
| 182 | 190 | instanceConfig: InstanceConfig; |
| 183 | 191 | secrets: ReadonlyArray<SecretListItem>; |
| 192 | + secretStores: ReadonlyArray<SecretStore>; | |
| 184 | 193 | executions: ReadonlyArray<Execution>; |
| 185 | 194 | execution: ExecutionEnvelope; |
| 186 | 195 | sources: ReadonlyArray<Source>; |
| @@ -7,10 +7,12 @@ import { · 6 unmodified lines | ||
| 7 | 7 | import type * as HttpApi from "@effect/platform/HttpApi"; |
| 8 | 8 | import type * as HttpApiGroup from "@effect/platform/HttpApiGroup"; |
| 9 | 9 | import { |
| 10 | − ExecutorApi, | |
| 11 | 10 | createExecutorApi, |
| 12 | − type ExecutorHttpApiExtension, | |
| 13 | −} from "@executor/platform-api"; | |
| 11 | + ExecutorApi, | |
| 12 | +} from "@executor/platform-api/api"; | |
| 13 | +import type { | |
| 14 | + ExecutorHttpApiExtension, | |
| 15 | +} from "@executor/platform-api/plugins"; | |
| 14 | 16 | |
| 15 | 17 | import { getExecutorApiBaseUrl } from "./base-url"; |
| 16 | 18 | |
| @@ -3,10 +3,14 @@ import { · 2 unmodified lines | ||
| 3 | 3 | useAtomSet, |
| 4 | 4 | } from "@effect-atom/atom-react"; |
| 5 | 5 | import type { |
| 6 | + CreateSecretStorePayload, | |
| 6 | 7 | CreateSecretPayload, |
| 8 | + DeleteSecretStoreResult, | |
| 7 | 9 | CreateSecretResult, |
| 8 | 10 | DeleteSecretResult, |
| 11 | + SecretStore, | |
| 9 | 12 | SecretListItem, |
| 13 | + UpdateSecretStorePayload, | |
| 10 | 14 | UpdateSecretPayload, |
| 11 | 15 | UpdateSecretResult, |
| 12 | 16 | } from "@executor/platform-api"; |
| @@ -14,7 +18,7 @@ import * as React from "react"; · 1 unmodified line | ||
| 14 | 18 | |
| 15 | 19 | import { getExecutorApiBaseUrl } from "../core/base-url"; |
| 16 | 20 | import { getExecutorApiHttpClient } from "../core/http-client"; |
| 17 | −import { secretsAtom } from "../core/api-atoms"; | |
| 21 | +import { secretStoresAtom, secretsAtom } from "../core/api-atoms"; | |
| 18 | 22 | import { useLoadableAtom } from "../core/loadable"; |
| 19 | 23 | import { secretsReactivityKey } from "../core/reactivity"; |
| 20 | 24 | import type { Loadable } from "../core/types"; |
| @@ -23,9 +27,73 @@ import { useExecutorMutation } from "./mutations"; · 2 unmodified lines | ||
| 23 | 27 | export const useSecrets = (): Loadable<ReadonlyArray<SecretListItem>> => |
| 24 | 28 | useLoadableAtom(secretsAtom(getExecutorApiBaseUrl())); |
| 25 | 29 | |
| 30 | +export const useSecretStores = (): Loadable<ReadonlyArray<SecretStore>> => | |
| 31 | + useLoadableAtom(secretStoresAtom(getExecutorApiBaseUrl())); | |
| 32 | + | |
| 26 | 33 | export const useRefreshSecrets = (): (() => void) => |
| 27 | 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 | 97 | export const useCreateSecret = () => { |
| 30 | 98 | const mutate = useAtomSet( |
| 31 | 99 | getExecutorApiHttpClient().mutation("local", "createSecret"), |
| @@ -10,12 +10,17 @@ export { · 9 unmodified lines | ||
| 10 | 10 | } from "@effect-atom/atom-react"; |
| 11 | 11 | |
| 12 | 12 | export type { |
| 13 | + BrowseSecretStoreResult, | |
| 14 | + CreateSecretStorePayload, | |
| 13 | 15 | CreateSecretPayload, |
| 14 | 16 | CreateSecretResult, |
| 17 | + DeleteSecretStoreResult, | |
| 15 | 18 | DeleteSecretResult, |
| 16 | 19 | InstanceConfig, |
| 17 | 20 | LocalInstallation, |
| 18 | 21 | SecretListItem, |
| 22 | + SecretStore, | |
| 23 | + UpdateSecretStorePayload, | |
| 19 | 24 | UpdateSecretPayload, |
| 20 | 25 | UpdateSecretResult, |
| 21 | 26 | } from "@executor/platform-api"; |
| @@ -51,6 +56,9 @@ export { · 29 unmodified lines | ||
| 51 | 56 | export { |
| 52 | 57 | ExecutorReactProvider, |
| 53 | 58 | } from "./provider"; |
| 59 | +export { | |
| 60 | + SecretReferenceField, | |
| 61 | +} from "./components/secret-reference-field"; | |
| 54 | 62 | export { |
| 55 | 63 | useExecutorMutation, |
| 56 | 64 | } from "./hooks/mutations"; |
| @@ -60,10 +68,15 @@ export { · 3 unmodified lines | ||
| 60 | 68 | useRefreshLocalInstallation, |
| 61 | 69 | } from "./hooks/local"; |
| 62 | 70 | export { |
| 71 | + useCreateSecretStore, | |
| 63 | 72 | useCreateSecret, |
| 73 | + useDeleteSecretStore, | |
| 64 | 74 | useDeleteSecret, |
| 75 | + useRefreshSecretStores, | |
| 65 | 76 | useRefreshSecrets, |
| 77 | + useSecretStores, | |
| 66 | 78 | useSecrets, |
| 79 | + useUpdateSecretStore, | |
| 67 | 80 | useUpdateSecret, |
| 68 | 81 | } from "./hooks/secrets"; |
| 69 | 82 | export { |
| @@ -5,6 +5,8 @@ export type { · 4 unmodified lines | ||
| 5 | 5 | FrontendPluginRouteDefinition, |
| 6 | 6 | FrontendPluginRouteParams, |
| 7 | 7 | FrontendPluginRouteSearch, |
| 8 | + SecretStoreCreateFormProps, | |
| 9 | + SecretStoreFrontendDefinition, | |
| 8 | 10 | SourcePluginNavigation, |
| 9 | 11 | SourcePluginRouteParams, |
| 10 | 12 | SourcePluginRouteSearch, |
| @@ -28,6 +28,7 @@ export const registerExecutorFrontendPlugins = ( · 27 unmodified lines | ||
| 28 | 28 | ) => { |
| 29 | 29 | const pluginsByKey = new Map<string, ExecutorFrontendPlugin>(); |
| 30 | 30 | const routesByPluginAndKey = new Map<string, RegisteredFrontendPluginRoute>(); |
| 31 | + const secretStoresByKind = new Map<string, ExecutorFrontendPlugin>(); | |
| 31 | 32 | |
| 32 | 33 | for (const plugin of plugins) { |
| 33 | 34 | if (pluginsByKey.has(plugin.key)) { |
| @@ -36,6 +37,16 @@ export const registerExecutorFrontendPlugins = ( · 2 unmodified lines | ||
| 36 | 37 | |
| 37 | 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 | 50 | const pluginRouteKeys = new Set<string>(); |
| 40 | 51 | const pluginRoutePaths = new Set<string>(); |
| 41 | 52 | |
| @@ -1,9 +1,11 @@ | ||
| 1 | 1 | import type { ComponentType } from "react"; |
| 2 | +import type { SecretListItem } from "@executor/platform-api"; | |
| 2 | 3 | |
| 3 | 4 | import type { |
| 4 | 5 | ExecutorPluginPaths, |
| 5 | 6 | SourcePluginPaths, |
| 6 | 7 | } from "./paths"; |
| 8 | +import type { Loadable } from "../core/types"; | |
| 7 | 9 | |
| 8 | 10 | export type FrontendPluginRouteSearch = Record<string, unknown>; |
| 9 | 11 | export type FrontendPluginRouteParams = Readonly<Record<string, string | undefined>>; |
| @@ -50,9 +52,25 @@ export type SourcePluginNavigation = { · 40 unmodified lines | ||
| 50 | 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 | 70 | export type ExecutorFrontendPlugin = { |
| 54 | 71 | key: string; |
| 55 | 72 | displayName?: string; |
| 56 | 73 | description?: string; |
| 57 | 74 | routes?: readonly FrontendPluginRouteDefinition[]; |
| 75 | + secretStore?: SecretStoreFrontendDefinition; | |
| 58 | 76 | }; |
| @@ -31,8 +31,16 @@ export { · 30 unmodified lines | ||
| 31 | 31 | |
| 32 | 32 | export { |
| 33 | 33 | LocalApi, |
| 34 | + type BrowseSecretStorePayload, | |
| 35 | + type BrowseSecretStoreResult, | |
| 34 | 36 | type SecretProvider, |
| 37 | + type SecretStore, | |
| 38 | + type SecretStoreBrowseEntry, | |
| 39 | + type ImportSecretFromStorePayload, | |
| 35 | 40 | type InstanceConfig, |
| 41 | + type CreateSecretStorePayload, | |
| 42 | + type UpdateSecretStorePayload, | |
| 43 | + type DeleteSecretStoreResult, | |
| 36 | 44 | type SecretListItem, |
| 37 | 45 | type CreateSecretPayload, |
| 38 | 46 | type CreateSecretResult, |
| @@ -1,12 +1,19 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | 2 | import { |
| 3 | + BrowseSecretStorePayloadSchema, | |
| 4 | + BrowseSecretStoreResultSchema, | |
| 3 | 5 | CreateSecretPayloadSchema, |
| 4 | 6 | CreateSecretResultSchema, |
| 7 | + CreateSecretStorePayloadSchema, | |
| 5 | 8 | DeleteSecretResultSchema, |
| 9 | + DeleteSecretStoreResultSchema, | |
| 10 | + ImportSecretFromStorePayloadSchema, | |
| 6 | 11 | InstanceConfigSchema, |
| 7 | 12 | SecretListItemSchema, |
| 13 | + SecretStoreSchema, | |
| 8 | 14 | UpdateSecretPayloadSchema, |
| 9 | 15 | UpdateSecretResultSchema, |
| 16 | + UpdateSecretStorePayloadSchema, | |
| 10 | 17 | } from "@executor/platform-sdk/contracts"; |
| 11 | 18 | import { |
| 12 | 19 | ControlPlaneBadRequestError, |
| @@ -17,15 +24,23 @@ import { LocalInstallationSchema } from "@executor/platform-sdk/schema"; · 4 unmodified lines | ||
| 17 | 24 | import * as Schema from "effect/Schema"; |
| 18 | 25 | |
| 19 | 26 | export type { |
| 27 | + BrowseSecretStorePayload, | |
| 28 | + BrowseSecretStoreResult, | |
| 20 | 29 | CreateSecretPayload, |
| 21 | 30 | CreateSecretResult, |
| 22 | 31 | DeleteSecretResult, |
| 32 | + DeleteSecretStoreResult, | |
| 33 | + CreateSecretStorePayload, | |
| 34 | + ImportSecretFromStorePayload, | |
| 23 | 35 | InstanceConfig, |
| 24 | 36 | SecretLinkedSource, |
| 25 | 37 | SecretListItem, |
| 26 | 38 | SecretProvider, |
| 39 | + SecretStoreBrowseEntry, | |
| 40 | + SecretStore, | |
| 27 | 41 | UpdateSecretPayload, |
| 28 | 42 | UpdateSecretResult, |
| 43 | + UpdateSecretStorePayload, | |
| 29 | 44 | } from "@executor/platform-sdk/contracts"; |
| 30 | 45 | |
| 31 | 46 | export class LocalApi extends HttpApiGroup.make("local") |
| @@ -41,6 +56,48 @@ export class LocalApi extends HttpApiGroup.make("local") · 9 unmodified lines | ||
| 41 | 56 | .addSuccess(InstanceConfigSchema) |
| 42 | 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 | 101 | .add( |
| 45 | 102 | HttpApiEndpoint.get("listSecrets")`/local/secrets` |
| 46 | 103 | .addSuccess(Schema.Array(SecretListItemSchema)) |
| @@ -1,5 +1,9 @@ | ||
| 1 | 1 | import { HttpApiBuilder } from "@effect/platform"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | +import { | |
| 4 | + LocalInstanceConfigService, | |
| 5 | + provideExecutorRuntime, | |
| 6 | +} from "@executor/platform-sdk/runtime"; | |
| 3 | 7 | |
| 4 | 8 | import { ExecutorApi } from "../api"; |
| 5 | 9 | import { ControlPlaneStorageError } from "../errors"; |
| @@ -19,18 +23,71 @@ export const ExecutorLocalLive = HttpApiBuilder.group( · 13 unmodified lines | ||
| 19 | 23 | handlers |
| 20 | 24 | .handle("installation", () => |
| 21 | 25 | Effect.flatMap(getControlPlaneExecutor(), (executor) => |
| 22 | − executor.local.installation().pipe( | |
| 23 | − Effect.mapError(toStorageError("local.installation")), | |
| 24 | − ) | |
| 26 | + Effect.succeed(executor.installation) | |
| 25 | 27 | ) |
| 26 | 28 | ) |
| 27 | 29 | .handle("config", () => |
| 28 | 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 | 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 | 91 | .handle("listSecrets", () => |
| 35 | 92 | Effect.flatMap(getControlPlaneExecutor(), (executor) => |
| 36 | 93 | executor.secrets.list().pipe( |
| @@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"; · 7 unmodified lines | ||
| 8 | 8 | import { |
| 9 | 9 | loadLocalExecutorConfig, |
| 10 | 10 | mergeLocalExecutorConfigs, |
| 11 | + migrateLegacyLocalExecutorConfigFile, | |
| 11 | 12 | resolveDefaultHomeConfigCandidates, |
| 12 | 13 | resolveDefaultHomeStateDirectory, |
| 13 | 14 | resolveLocalWorkspaceContext, |
| @@ -136,4 +137,54 @@ describe("local-config", () => { · 122 unmodified lines | ||
| 136 | 137 | |
| 137 | 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 | 33 | export const encodeLocalExecutorConfig = (config: LocalExecutorConfig): string => |
| 34 | 34 | `${JSON.stringify(config, null, 2)}\n`; |
| 35 | 35 | |
| 36 | +const encodeUnknownConfigDocument = (config: unknown): string => | |
| 37 | + `${JSON.stringify(config, null, 2)}\n`; | |
| 38 | + | |
| 36 | 39 | export const resolveConfigRelativePath = (input: { |
| 37 | 40 | path: string; |
| 38 | 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 | 353 | const mergeSourceMaps = ( |
| 227 | 354 | base: Record<string, LocalConfigSource> | undefined, |
| 228 | 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 | 601 | export const loadLocalExecutorConfig = ( |
| 430 | 602 | context: ResolvedLocalWorkspaceContext, |
| 431 | 603 | ): Effect.Effect< |
| @@ -12,6 +12,8 @@ import { · 11 unmodified lines | ||
| 12 | 12 | ExecutionStepSchema, |
| 13 | 13 | SecretMaterialSchema, |
| 14 | 14 | type SecretMaterial, |
| 15 | + SecretStoreSchema, | |
| 16 | + type SecretStore, | |
| 15 | 17 | } from "@executor/platform-sdk/schema"; |
| 16 | 18 | import * as Effect from "effect/Effect"; |
| 17 | 19 | import * as Option from "effect/Option"; |
| @@ -26,9 +28,12 @@ import { · 8 unmodified lines | ||
| 26 | 28 | |
| 27 | 29 | const LOCAL_EXECUTOR_STATE_VERSION = 1 as const; |
| 28 | 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 | 34 | const LocalExecutorStateSnapshotSchema = Schema.Struct({ |
| 31 | 35 | version: Schema.Literal(LOCAL_EXECUTOR_STATE_VERSION), |
| 36 | + secretStores: Schema.Array(SecretStoreSchema), | |
| 32 | 37 | secretMaterials: Schema.Array(SecretMaterialSchema), |
| 33 | 38 | executions: Schema.Array(ExecutionSchema), |
| 34 | 39 | executionInteractions: Schema.Array(ExecutionInteractionSchema), |
| @@ -48,6 +53,7 @@ const decodeLocalExecutorStateSnapshot = Schema.decodeUnknown( · 13 unmodified lines | ||
| 48 | 53 | |
| 49 | 54 | const defaultLocalExecutorStateSnapshot = (): LocalExecutorStateSnapshot => ({ |
| 50 | 55 | version: LOCAL_EXECUTOR_STATE_VERSION, |
| 56 | + secretStores: [], | |
| 51 | 57 | secretMaterials: [], |
| 52 | 58 | executions: [], |
| 53 | 59 | executionInteractions: [], |
| @@ -64,8 +70,31 @@ const asRecord = ( · 10 unmodified lines | ||
| 64 | 70 | ? (value as Record<string, unknown>) |
| 65 | 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 | 93 | const migrateLegacyExecutorStateValue = ( |
| 68 | 94 | value: unknown, |
| 95 | + input: { | |
| 96 | + scopeId: string; | |
| 97 | + }, | |
| 69 | 98 | ): { |
| 70 | 99 | value: unknown; |
| 71 | 100 | migrated: boolean; |
| @@ -73,7 +102,7 @@ const migrateLegacyExecutorStateValue = ( · 1 unmodified line | ||
| 73 | 102 | if (Array.isArray(value)) { |
| 74 | 103 | let migrated = false; |
| 75 | 104 | const next = value.map((item) => { |
| 76 | − const result = migrateLegacyExecutorStateValue(item); | |
| 105 | + const result = migrateLegacyExecutorStateValue(item, input); | |
| 77 | 106 | migrated = migrated || result.migrated; |
| 78 | 107 | return result.value; |
| 79 | 108 | }); |
| @@ -104,11 +133,72 @@ const migrateLegacyExecutorStateValue = ( · 24 unmodified lines | ||
| 104 | 133 | migrated = true; |
| 105 | 134 | } |
| 106 | 135 | |
| 107 | − const migratedEntry = migrateLegacyExecutorStateValue(entry); | |
| 136 | + const migratedEntry = migrateLegacyExecutorStateValue(entry, input); | |
| 108 | 137 | migrated = migrated || migratedEntry.migrated; |
| 109 | 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 | 202 | return { value: next, migrated }; |
| 113 | 203 | }; |
| 114 | 204 | |
| @@ -168,7 +258,9 @@ const readStateFromDisk = ( · 53 unmodified lines | ||
| 168 | 258 | try: () => JSON.parse(content) as unknown, |
| 169 | 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 | 264 | const decoded = yield* decodeLocalExecutorStateSnapshot(migrated.value).pipe( |
| 173 | 265 | Effect.mapError(mapFileSystemError(path, "decode executor state")), |
| 174 | 266 | ); |
| @@ -233,6 +325,10 @@ export const mergeImportedLocalExecutorStateSnapshot = (input: { · 58 unmodified lines | ||
| 233 | 325 | imported: Partial<Omit<LocalExecutorStateSnapshot, "version">>; |
| 234 | 326 | }): LocalExecutorStateSnapshot => ({ |
| 235 | 327 | version: LOCAL_EXECUTOR_STATE_VERSION, |
| 328 | + secretStores: mergeById( | |
| 329 | + input.current.secretStores, | |
| 330 | + input.imported.secretStores ?? [], | |
| 331 | + ), | |
| 236 | 332 | secretMaterials: mergeById( |
| 237 | 333 | input.current.secretMaterials, |
| 238 | 334 | input.imported.secretMaterials ?? [], |
| @@ -334,6 +430,81 @@ export const createLocalExecutorStateStore = ( · 95 unmodified lines | ||
| 334 | 430 | const stateManager = createStateManager(context, fileSystem); |
| 335 | 431 | |
| 336 | 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 | 508 | secretMaterials: { |
| 338 | 509 | getById: (id: SecretMaterial["id"]) => |
| 339 | 510 | stateManager.read((state) => { |
| @@ -31,6 +31,7 @@ import { · 30 unmodified lines | ||
| 31 | 31 | import * as Effect from "effect/Effect"; |
| 32 | 32 | import { |
| 33 | 33 | type LoadedLocalExecutorConfig, |
| 34 | + migrateLegacyLocalExecutorConfigs, | |
| 34 | 35 | type ResolvedLocalWorkspaceContext, |
| 35 | 36 | loadLocalExecutorConfig, |
| 36 | 37 | resolveConfigRelativePath, |
| @@ -44,6 +45,7 @@ import { · 7 unmodified lines | ||
| 44 | 45 | createDefaultSecretMaterialResolver, |
| 45 | 46 | createDefaultSecretMaterialStorer, |
| 46 | 47 | createDefaultSecretMaterialUpdater, |
| 48 | + provisionBuiltinSecretStores, | |
| 47 | 49 | } from "./secret-material-providers"; |
| 48 | 50 | import { |
| 49 | 51 | buildLocalSourceArtifact, |
| @@ -52,6 +54,7 @@ import { · 2 unmodified lines | ||
| 52 | 54 | writeLocalSourceArtifact, |
| 53 | 55 | } from "./source-artifacts"; |
| 54 | 56 | import { |
| 57 | + deriveLocalInstallation, | |
| 55 | 58 | getOrProvisionLocalInstallation, |
| 56 | 59 | loadLocalInstallation, |
| 57 | 60 | } from "./installation"; |
| @@ -235,6 +238,10 @@ export const createLocalExecutorRepositoriesEffect = ( · 177 unmodified lines | ||
| 235 | 238 | homeStateDirectory: options.homeStateDirectory, |
| 236 | 239 | }), |
| 237 | 240 | ).pipe(Effect.mapError(toError)); |
| 241 | + yield* bindFileSystem( | |
| 242 | + fileSystem, | |
| 243 | + migrateLegacyLocalExecutorConfigs(workspaceContext), | |
| 244 | + ).pipe(Effect.mapError(toError)); | |
| 238 | 245 | |
| 239 | 246 | const executorStateStorage = createLocalExecutorStatePersistence( |
| 240 | 247 | workspaceContext, |
| @@ -245,12 +252,14 @@ export const createLocalExecutorRepositoriesEffect = ( · 4 unmodified lines | ||
| 245 | 252 | fileSystem, |
| 246 | 253 | ); |
| 247 | 254 | const loadedConfig = yield* workspaceConfigStore.load(); |
| 255 | + yield* provisionBuiltinSecretStores({ | |
| 256 | + executorState: executorStateStorage.executorState, | |
| 257 | + scopeId: deriveLocalInstallation(workspaceContext).scopeId, | |
| 258 | + }); | |
| 248 | 259 | const resolveSecretMaterial: ResolveSecretMaterial = |
| 249 | 260 | runtimeOptions.resolveSecretMaterial |
| 250 | 261 | ?? createDefaultSecretMaterialResolver({ |
| 251 | 262 | executorState: executorStateStorage.executorState, |
| 252 | − localConfig: loadedConfig.config, | |
| 253 | − workspaceRoot: workspaceContext.workspaceRoot, | |
| 254 | 263 | }); |
| 255 | 264 | |
| 256 | 265 | return { |
| @@ -274,16 +283,20 @@ export const createLocalExecutorRepositoriesEffect = ( · 17 unmodified lines | ||
| 274 | 283 | createBoundSourceTypeDeclarationsRefresher(workspaceContext), |
| 275 | 284 | }, |
| 276 | 285 | secrets: { |
| 286 | + secretStores: executorStateStorage.executorState.secretStores, | |
| 277 | 287 | ...executorStateStorage.executorState.secretMaterials, |
| 278 | 288 | resolve: resolveSecretMaterial, |
| 279 | 289 | store: createDefaultSecretMaterialStorer({ |
| 280 | 290 | executorState: executorStateStorage.executorState, |
| 291 | + resolveSecretMaterial, | |
| 281 | 292 | }), |
| 282 | 293 | delete: createDefaultSecretMaterialDeleter({ |
| 283 | 294 | executorState: executorStateStorage.executorState, |
| 295 | + resolveSecretMaterial, | |
| 284 | 296 | }), |
| 285 | 297 | update: createDefaultSecretMaterialUpdater({ |
| 286 | 298 | executorState: executorStateStorage.executorState, |
| 299 | + resolveSecretMaterial, | |
| 287 | 300 | }), |
| 288 | 301 | }, |
| 289 | 302 | executions: { |
| @@ -293,7 +306,9 @@ export const createLocalExecutorRepositoriesEffect = ( · 3 unmodified lines | ||
| 293 | 306 | steps: executorStateStorage.executorState.executionSteps, |
| 294 | 307 | }, |
| 295 | 308 | instanceConfig: { |
| 296 | − resolve: createLocalInstanceConfigResolver(), | |
| 309 | + resolve: createLocalInstanceConfigResolver({ | |
| 310 | + executorState: executorStateStorage.executorState, | |
| 311 | + }), | |
| 297 | 312 | }, |
| 298 | 313 | close: executorStateStorage.close, |
| 299 | 314 | } satisfies ExecutorBackendRepositories; |
| @@ -329,27 +344,32 @@ export const createLocalExecutorEffect = < · 29 unmodified lines | ||
| 329 | 344 | ): Effect.Effect< |
| 330 | 345 | ExecutorEffect & ExecutorSdkPluginExtensions<TPlugins>, |
| 331 | 346 | Error |
| 332 | −> => | |
| 333 | − createExecutorEffect({ | |
| 347 | +> => { | |
| 348 | + return createExecutorEffect({ | |
| 334 | 349 | backend: createLocalExecutorBackend(options), |
| 335 | − plugins: options.plugins, | |
| 350 | + plugins: (options.plugins ?? []) as TPlugins, | |
| 336 | 351 | executionResolver: options.executionResolver, |
| 337 | 352 | resolveSecretMaterial: options.resolveSecretMaterial, |
| 338 | 353 | getLocalServerBaseUrl: options.getLocalServerBaseUrl, |
| 339 | − } satisfies CreateExecutorEffectOptions & { plugins?: TPlugins }); | |
| 354 | + }) as Effect.Effect< | |
| 355 | + ExecutorEffect & ExecutorSdkPluginExtensions<TPlugins>, | |
| 356 | + Error | |
| 357 | + >; | |
| 358 | +}; | |
| 340 | 359 | |
| 341 | 360 | export const createLocalExecutor = < |
| 342 | 361 | const TPlugins extends readonly ExecutorSdkPlugin<any, any>[] = [], |
| 343 | 362 | >( |
| 344 | 363 | options: CreateLocalExecutorOptions<TPlugins> = {}, |
| 345 | −): Promise<Executor<TPlugins>> => | |
| 346 | − createExecutor({ | |
| 364 | +) => { | |
| 365 | + return createExecutor({ | |
| 347 | 366 | backend: createLocalExecutorBackend(options), |
| 348 | − plugins: options.plugins, | |
| 367 | + plugins: (options.plugins ?? []) as TPlugins, | |
| 349 | 368 | executionResolver: options.executionResolver, |
| 350 | 369 | resolveSecretMaterial: options.resolveSecretMaterial, |
| 351 | 370 | getLocalServerBaseUrl: options.getLocalServerBaseUrl, |
| 352 | − }); | |
| 371 | + }) as Promise<Executor<TPlugins>>; | |
| 372 | +}; | |
| 353 | 373 | |
| 354 | 374 | export { |
| 355 | 375 | deriveLocalInstallation, |
| @@ -1,115 +1,38 @@ | ||
| 1 | 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 { | |
| 8 | − type LocalConfigSecretProvider, | |
| 9 | − type LocalExecutorConfig, | |
| 10 | − type SecretMaterialPurpose, | |
| 11 | − type SecretRef, | |
| 12 | − type SecretMaterial, | |
| 13 | − SecretMaterialIdSchema, | |
| 3 | +import type { | |
| 4 | + InstanceConfig, | |
| 5 | + SecretProvider, | |
| 6 | +} from "@executor/platform-sdk/contracts"; | |
| 7 | +import type { | |
| 8 | + SecretMaterial, | |
| 9 | + SecretMaterialPurpose, | |
| 10 | + SecretRef, | |
| 11 | + SecretStore, | |
| 12 | + SecretStoreId, | |
| 14 | 13 | } from "@executor/platform-sdk/schema"; |
| 15 | 14 | import * as Effect from "effect/Effect"; |
| 16 | 15 | import * as Layer from "effect/Layer"; |
| 17 | 16 | import * as Option from "effect/Option"; |
| 18 | 17 | |
| 19 | 18 | import { |
| 20 | − ExecutorStateStore, | |
| 21 | − runtimeEffectError, | |
| 19 | + getSecretStoreContribution, | |
| 20 | + registeredSecretStoreContributions, | |
| 22 | 21 | LocalInstanceConfigService, |
| 22 | + runtimeEffectError, | |
| 23 | 23 | SecretMaterialDeleterService, |
| 24 | 24 | SecretMaterialResolverService, |
| 25 | 25 | SecretMaterialStorerService, |
| 26 | 26 | SecretMaterialUpdaterService, |
| 27 | − fromConfigSecretProviderId, | |
| 28 | − getRuntimeLocalScopeOption, | |
| 27 | + type DeleteSecretMaterial, | |
| 29 | 28 | type ExecutorStateStoreShape, |
| 30 | − type ResolveSecretMaterial, | |
| 31 | 29 | type ResolveInstanceConfig, |
| 32 | − type SecretMaterialResolveContext, | |
| 30 | + type ResolveSecretMaterial, | |
| 33 | 31 | type StoreSecretMaterial, |
| 34 | − type DeleteSecretMaterial, | |
| 35 | 32 | type UpdateSecretMaterial, |
| 36 | 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>; | |
| 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 | −}; | |
| 35 | +const SECRET_STORE_KIND_ENV = "EXECUTOR_SECRET_STORE_PROVIDER"; | |
| 113 | 36 | |
| 114 | 37 | const trimOrNull = (value: string | null | undefined): string | null => { |
| 115 | 38 | if (value === null || value === undefined) { |
| @@ -120,1165 +43,414 @@ const trimOrNull = (value: string | null | undefined): string | null => { · 4 unmodified lines | ||
| 120 | 43 | return trimmed.length > 0 ? trimmed : null; |
| 121 | 44 | }; |
| 122 | 45 | |
| 123 | −const parseBooleanEnv = (value: string | undefined): boolean => { | |
| 124 | − const normalized = trimOrNull(value)?.toLowerCase(); | |
| 125 | − return normalized === "1" || normalized === "true" || normalized === "yes"; | |
| 126 | −}; | |
| 46 | +const builtinSecretStoreContributions = () => | |
| 47 | + registeredSecretStoreContributions() | |
| 48 | + .filter((entry) => entry.builtin !== undefined) | |
| 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 | 70 | const normalized = trimOrNull(value)?.toLowerCase(); |
| 130 | − if (normalized === KEYCHAIN_SECRET_PROVIDER_ID) { | |
| 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; | |
| 71 | + return normalized ?? null; | |
| 154 | 72 | }; |
| 155 | 73 | |
| 156 | −const toSecretMaterialSummary = ( | |
| 157 | − material: Pick<SecretMaterial, "id" | "providerId" | "name" | "purpose" | "createdAt" | "updatedAt">, | |
| 158 | −): SecretMaterialSummary => ({ | |
| 159 | − id: material.id, | |
| 160 | − providerId: material.providerId, | |
| 161 | − name: material.name, | |
| 162 | − purpose: material.purpose, | |
| 163 | − createdAt: material.createdAt, | |
| 164 | − updatedAt: material.updatedAt, | |
| 165 | −}); | |
| 166 | − | |
| 167 | −const keychainCommandForPlatform = ( | |
| 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); | |
| 74 | +const resolveDefaultStoreId = ( | |
| 75 | + stores: ReadonlyArray<{ | |
| 76 | + id: string; | |
| 77 | + kind: string; | |
| 78 | + }>, | |
| 79 | +): SecretStoreId | null => { | |
| 80 | + const explicitKind = parseDefaultStoreKind(process.env[SECRET_STORE_KIND_ENV]); | |
| 81 | + if (explicitKind) { | |
| 82 | + const explicitStore = stores.find((store) => store.kind === explicitKind); | |
| 83 | + if (explicitStore) { | |
| 84 | + return explicitStore.id as SecretStoreId; | |
| 85 | + } | |
| 269 | 86 | } |
| 270 | 87 | |
| 271 | − const details = ensureNonEmptyString(input.result.stderr) | |
| 272 | − ?? ensureNonEmptyString(input.result.stdout) | |
| 273 | − ?? "command returned non-zero exit code"; | |
| 274 | − | |
| 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 | − } | |
| 88 | + const prioritizedBuiltins = builtinSecretStoreContributions() | |
| 89 | + .sort((left, right) => | |
| 90 | + (right.builtin.defaultPriority ?? 0) - (left.builtin.defaultPriority ?? 0) | |
| 91 | + ); | |
| 339 | 92 | |
| 340 | − if ((input.platform ?? process.platform) !== "darwin") { | |
| 341 | − return Effect.succeed(localProviderId); | |
| 93 | + for (const entry of prioritizedBuiltins) { | |
| 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( | |
| 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>; | |
| 100 | + return (stores[0]?.id as SecretStoreId | undefined) ?? null; | |
| 349 | 101 | }; |
| 350 | 102 | |
| 351 | −const loadStoredSecretMaterial = (input: { | |
| 352 | − id: string; | |
| 353 | − runtime: SecretMaterialProviderRuntime; | |
| 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; | |
| 103 | +export const provisionBuiltinSecretStores = (input: { | |
| 104 | + executorState: ExecutorStateStoreShape; | |
| 105 | + scopeId: string; | |
| 373 | 106 | }) => |
| 374 | 107 | Effect.gen(function* () { |
| 375 | 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: { | |
| 475 | − providerHandle: string; | |
| 476 | − name?: string | null; | |
| 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}'`), | |
| 110 | + for (const entry of builtinSecretStoreContributions()) { | |
| 111 | + const existing = yield* input.executorState.secretStores.getById( | |
| 112 | + entry.builtin.storeId as SecretStore["id"], | |
| 535 | 113 | ); |
| 536 | − } | |
| 537 | −}; | |
| 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 | − ); | |
| 114 | + if (Option.isSome(existing)) { | |
| 115 | + continue; | |
| 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 }) => | |
| 772 | − Effect.gen(function* () { | |
| 773 | − const loaded = yield* loadManagedKeychainRef({ | |
| 774 | − ref, | |
| 775 | − runtime, | |
| 776 | − operation: "keychain.delete", | |
| 777 | − }); | |
| 778 | − const deleted = yield* deleteKeychainSecretValue({ | |
| 779 | − providerHandle: loaded.providerHandle, | |
| 780 | − runtime, | |
| 118 | + yield* input.executorState.secretStores.upsert({ | |
| 119 | + id: entry.builtin.storeId as SecretStoreId, | |
| 120 | + scopeId: input.scopeId as SecretStore["scopeId"], | |
| 121 | + ...entry.builtin.createStore(), | |
| 122 | + createdAt: now, | |
| 123 | + updatedAt: now, | |
| 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 = ( | |
| 847 | − path: string, | |
| 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" }>; | |
| 128 | +const loadMaterialByRef = (input: { | |
| 129 | + executorState: ExecutorStateStoreShape; | |
| 870 | 130 | ref: SecretRef; |
| 871 | − workspaceRoot: string; | |
| 872 | −}): Effect.Effect<string, Error, FileSystem.FileSystem> => | |
| 131 | +}) => | |
| 873 | 132 | Effect.gen(function* () { |
| 874 | − const fs = yield* FileSystem.FileSystem; | |
| 875 | − const resolvedPath = resolveConfigRelativePath({ | |
| 876 | − path: input.provider.path, | |
| 877 | − scopeRoot: input.workspaceRoot, | |
| 878 | − }); | |
| 879 | − const raw = yield* fs.readFileString(resolvedPath, "utf8").pipe( | |
| 880 | − Effect.mapError(toError), | |
| 133 | + const material = yield* input.executorState.secretMaterials.getById( | |
| 134 | + input.ref.secretId, | |
| 881 | 135 | ); |
| 882 | − const mode = input.provider.mode ?? "singleValue"; | |
| 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) { | |
| 136 | + if (Option.isNone(material)) { | |
| 928 | 137 | return yield* runtimeEffectError( |
| 929 | 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]; | |
| 935 | − if (!provider) { | |
| 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 | − } | |
| 143 | + return material.value; | |
| 144 | + }); | |
| 966 | 145 | |
| 967 | − const command = provider.command.trim(); | |
| 968 | − if (!isAbsolute(command)) { | |
| 969 | − return yield* runtimeEffectError( | |
| 970 | − "local/secret-material-providers", | |
| 971 | − `Exec secret provider command must be absolute: ${command}`, | |
| 972 | − ); | |
| 973 | − } | |
| 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) { | |
| 146 | +const loadStoreById = (input: { | |
| 147 | + executorState: ExecutorStateStoreShape; | |
| 148 | + storeId: SecretStore["id"]; | |
| 149 | +}) => | |
| 150 | + Effect.gen(function* () { | |
| 151 | + const store = yield* input.executorState.secretStores.getById(input.storeId); | |
| 152 | + if (Option.isNone(store)) { | |
| 986 | 153 | return yield* runtimeEffectError( |
| 987 | 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({ | |
| 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 | − ); | |
| 159 | + return store.value; | |
| 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 | 170 | export const createDefaultSecretMaterialResolver = (input: { |
| 1013 | 171 | executorState: ExecutorStateStoreShape; |
| 1014 | − dangerouslyAllowEnvSecrets?: boolean; | |
| 1015 | 172 | keychainServiceName?: string; |
| 1016 | − localConfig?: LocalExecutorConfig | null; | |
| 1017 | − workspaceRoot?: string | null; | |
| 1018 | 173 | }): ResolveSecretMaterial => { |
| 1019 | − const providers = createSecretMaterialProviderRegistry(); | |
| 1020 | − const runtime = createSecretMaterialProviderRuntime(input); | |
| 1021 | − | |
| 1022 | − return ({ ref, context }) => | |
| 174 | + let resolveSecretMaterial!: ResolveSecretMaterial; | |
| 175 | + resolveSecretMaterial = ({ ref, context }) => | |
| 1023 | 176 | Effect.gen(function* () { |
| 1024 | − const provider = yield* getSecretMaterialProvider({ | |
| 1025 | − providers, | |
| 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({ | |
| 177 | + const material = yield* loadMaterialByRef({ | |
| 178 | + executorState: input.executorState, | |
| 1043 | 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 | 199 | export const createDefaultSecretMaterialStorer = (input: { |
| 1051 | 200 | executorState: ExecutorStateStoreShape; |
| 1052 | − dangerouslyAllowEnvSecrets?: boolean; | |
| 201 | + resolveSecretMaterial: ResolveSecretMaterial; | |
| 1053 | 202 | keychainServiceName?: string; |
| 1054 | 203 | }): StoreSecretMaterial => { |
| 1055 | − const providers = createSecretMaterialProviderRegistry(); | |
| 1056 | − const runtime = createSecretMaterialProviderRuntime(input); | |
| 1057 | − | |
| 1058 | − return ({ purpose, value, name, providerId }) => | |
| 204 | + return ({ purpose, value, name, storeId }) => | |
| 1059 | 205 | Effect.gen(function* () { |
| 1060 | − const defaultStoreProviderId = yield* resolveDefaultSecretStoreProviderId({ | |
| 1061 | − storeProviderId: parseSecretStoreProviderId(providerId) ?? undefined, | |
| 1062 | − env: runtime.env, | |
| 1063 | − }); | |
| 1064 | − const provider = yield* getSecretMaterialProvider({ | |
| 1065 | − providers, | |
| 1066 | − providerId: defaultStoreProviderId, | |
| 206 | + const stores = yield* input.executorState.secretStores.listAll(); | |
| 207 | + const resolvedStoreId = storeId ?? resolveDefaultStoreId(stores); | |
| 208 | + if (resolvedStoreId === null) { | |
| 209 | + return yield* runtimeEffectError( | |
| 210 | + "local/secret-material-providers", | |
| 211 | + "No secret stores are registered in this runtime.", | |
| 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) { | |
| 1070 | − return yield* runtimeEffectError("local/secret-material-providers", `Secret provider ${defaultStoreProviderId} does not support storing secret material`); | |
| 221 | + const capabilities = yield* loadStoreCapabilities(store); | |
| 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 | 245 | purpose, |
| 1075 | − value, | |
| 1076 | − name, | |
| 1077 | − runtime, | |
| 1078 | − }); | |
| 246 | + value: created.value ?? null, | |
| 247 | + createdAt: now, | |
| 248 | + updatedAt: now, | |
| 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 | 259 | export const createDefaultSecretMaterialUpdater = (input: { |
| 1083 | 260 | executorState: ExecutorStateStoreShape; |
| 1084 | − dangerouslyAllowEnvSecrets?: boolean; | |
| 261 | + resolveSecretMaterial: ResolveSecretMaterial; | |
| 1085 | 262 | keychainServiceName?: string; |
| 1086 | 263 | }): UpdateSecretMaterial => { |
| 1087 | − const providers = createSecretMaterialProviderRegistry(); | |
| 1088 | − const runtime = createSecretMaterialProviderRuntime(input); | |
| 1089 | − | |
| 1090 | 264 | return ({ ref, name, value }) => |
| 1091 | 265 | Effect.gen(function* () { |
| 1092 | − const provider = yield* getSecretMaterialProvider({ | |
| 1093 | − providers, | |
| 1094 | − providerId: ref.providerId, | |
| 266 | + const material = yield* loadMaterialByRef({ | |
| 267 | + executorState: input.executorState, | |
| 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) { | |
| 1098 | − return yield* runtimeEffectError("local/secret-material-providers", `Secret provider ${ref.providerId} does not support updating secret material`); | |
| 276 | + const capabilities = yield* loadStoreCapabilities(store); | |
| 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({ | |
| 1102 | − ref, | |
| 1103 | − name, | |
| 1104 | − value, | |
| 1105 | − runtime, | |
| 1106 | − }); | |
| 284 | + const updated = yield* provideSecretResolver( | |
| 285 | + contribution.updateSecret({ | |
| 286 | + secret: material, | |
| 287 | + store, | |
| 288 | + name, | |
| 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 | 314 | export const createDefaultSecretMaterialDeleter = (input: { |
| 1111 | 315 | executorState: ExecutorStateStoreShape; |
| 1112 | − dangerouslyAllowEnvSecrets?: boolean; | |
| 316 | + resolveSecretMaterial: ResolveSecretMaterial; | |
| 1113 | 317 | keychainServiceName?: string; |
| 1114 | 318 | }): DeleteSecretMaterial => { |
| 1115 | − const providers = createSecretMaterialProviderRegistry(); | |
| 1116 | − const runtime = createSecretMaterialProviderRuntime(input); | |
| 1117 | − | |
| 1118 | 319 | return (ref) => |
| 1119 | 320 | Effect.gen(function* () { |
| 1120 | − const provider = yield* getSecretMaterialProvider({ | |
| 1121 | − providers, | |
| 1122 | − providerId: ref.providerId, | |
| 321 | + const material = yield* loadMaterialByRef({ | |
| 322 | + executorState: input.executorState, | |
| 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 | 342 | return false; |
| 1127 | 343 | } |
| 1128 | 344 | |
| 1129 | − return yield* provider.remove({ | |
| 1130 | − ref, | |
| 1131 | − runtime, | |
| 1132 | − }); | |
| 345 | + return yield* input.executorState.secretMaterials.removeById(material.id); | |
| 1133 | 346 | }); |
| 1134 | 347 | }; |
| 1135 | 348 | |
| 1136 | −const resolveRuntimeSecretMaterialConfig = (input: { | |
| 1137 | − localConfig?: LocalExecutorConfig | null; | |
| 1138 | − workspaceRoot?: string | null; | |
| 1139 | −}) => | |
| 1140 | − Effect.gen(function* () { | |
| 1141 | − const runtimeLocalScope = yield* getRuntimeLocalScopeOption(); | |
| 349 | +const listAvailableSecretStorePlugins = (): SecretProvider[] => { | |
| 350 | + return registeredSecretStoreContributions().map((entry) => ({ | |
| 351 | + kind: entry.kind, | |
| 352 | + displayName: entry.displayName, | |
| 353 | + canCreate: entry.canCreate, | |
| 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 | 362 | return { |
| 1144 | − localConfig: input.localConfig | |
| 1145 | − ?? runtimeLocalScope?.loadedConfig.config | |
| 1146 | − ?? null, | |
| 1147 | − workspaceRoot: input.workspaceRoot | |
| 1148 | − ?? runtimeLocalScope?.scope.scopeRoot | |
| 1149 | − ?? null, | |
| 1150 | − }; | |
| 363 | + platform: process.platform, | |
| 364 | + secretStorePlugins: listAvailableSecretStorePlugins(), | |
| 365 | + defaultSecretStoreId: resolveDefaultStoreId(stores), | |
| 366 | + } satisfies InstanceConfig; | |
| 1151 | 367 | }); |
| 1152 | 368 | |
| 1153 | 369 | export const SecretMaterialResolverLive = (input: { |
| 370 | + executorState: ExecutorStateStoreShape; | |
| 1154 | 371 | resolveSecretMaterial?: ResolveSecretMaterial; |
| 1155 | − dangerouslyAllowEnvSecrets?: boolean; | |
| 1156 | 372 | keychainServiceName?: string; |
| 1157 | − localConfig?: LocalExecutorConfig | null; | |
| 1158 | − workspaceRoot?: string | null; | |
| 1159 | −} = {}) => | |
| 373 | +}) => | |
| 1160 | 374 | input.resolveSecretMaterial |
| 1161 | 375 | ? Layer.succeed(SecretMaterialResolverService, input.resolveSecretMaterial) |
| 1162 | − : Layer.effect( | |
| 376 | + : Layer.succeed( | |
| 1163 | 377 | SecretMaterialResolverService, |
| 1164 | − Effect.gen(function* () { | |
| 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 | − }), | |
| 378 | + createDefaultSecretMaterialResolver(input), | |
| 1176 | 379 | ); |
| 1177 | 380 | |
| 1178 | 381 | export const SecretMaterialStorerLive = (input: { |
| 1179 | − dangerouslyAllowEnvSecrets?: boolean; | |
| 382 | + executorState: ExecutorStateStoreShape; | |
| 383 | + resolveSecretMaterial: ResolveSecretMaterial; | |
| 1180 | 384 | keychainServiceName?: string; |
| 1181 | −} = {}) => | |
| 1182 | − Layer.effect( | |
| 385 | +}) => | |
| 386 | + Layer.succeed( | |
| 1183 | 387 | SecretMaterialStorerService, |
| 1184 | − Effect.gen(function* () { | |
| 1185 | − const executorState = yield* ExecutorStateStore; | |
| 1186 | − | |
| 1187 | − return createDefaultSecretMaterialStorer({ | |
| 1188 | − executorState, | |
| 1189 | − dangerouslyAllowEnvSecrets: input.dangerouslyAllowEnvSecrets, | |
| 1190 | − keychainServiceName: input.keychainServiceName, | |
| 1191 | − }); | |
| 1192 | − }), | |
| 388 | + createDefaultSecretMaterialStorer(input), | |
| 1193 | 389 | ); |
| 1194 | 390 | |
| 1195 | 391 | export const SecretMaterialDeleterLive = (input: { |
| 1196 | − dangerouslyAllowEnvSecrets?: boolean; | |
| 392 | + executorState: ExecutorStateStoreShape; | |
| 393 | + resolveSecretMaterial: ResolveSecretMaterial; | |
| 1197 | 394 | keychainServiceName?: string; |
| 1198 | −} = {}) => | |
| 1199 | − Layer.effect( | |
| 395 | +}) => | |
| 396 | + Layer.succeed( | |
| 1200 | 397 | SecretMaterialDeleterService, |
| 1201 | − Effect.gen(function* () { | |
| 1202 | − const executorState = yield* ExecutorStateStore; | |
| 1203 | − | |
| 1204 | − return createDefaultSecretMaterialDeleter({ | |
| 1205 | − executorState, | |
| 1206 | − dangerouslyAllowEnvSecrets: input.dangerouslyAllowEnvSecrets, | |
| 1207 | − keychainServiceName: input.keychainServiceName, | |
| 1208 | − }); | |
| 1209 | − }), | |
| 398 | + createDefaultSecretMaterialDeleter(input), | |
| 1210 | 399 | ); |
| 1211 | 400 | |
| 1212 | 401 | export const SecretMaterialUpdaterLive = (input: { |
| 1213 | − dangerouslyAllowEnvSecrets?: boolean; | |
| 402 | + executorState: ExecutorStateStoreShape; | |
| 403 | + resolveSecretMaterial: ResolveSecretMaterial; | |
| 1214 | 404 | keychainServiceName?: string; |
| 1215 | −} = {}) => | |
| 1216 | − Layer.effect( | |
| 405 | +}) => | |
| 406 | + Layer.succeed( | |
| 1217 | 407 | SecretMaterialUpdaterService, |
| 1218 | − Effect.gen(function* () { | |
| 1219 | − const executorState = yield* ExecutorStateStore; | |
| 1220 | − | |
| 1221 | − return createDefaultSecretMaterialUpdater({ | |
| 1222 | − executorState, | |
| 1223 | − dangerouslyAllowEnvSecrets: input.dangerouslyAllowEnvSecrets, | |
| 1224 | − keychainServiceName: input.keychainServiceName, | |
| 1225 | − }); | |
| 1226 | − }), | |
| 408 | + createDefaultSecretMaterialUpdater(input), | |
| 1227 | 409 | ); |
| 1228 | 410 | |
| 1229 | 411 | export const SecretMaterialLive = (input: { |
| 412 | + executorState: ExecutorStateStoreShape; | |
| 1230 | 413 | resolveSecretMaterial?: ResolveSecretMaterial; |
| 1231 | − dangerouslyAllowEnvSecrets?: boolean; | |
| 1232 | 414 | keychainServiceName?: string; |
| 1233 | − localConfig?: LocalExecutorConfig | null; | |
| 1234 | − workspaceRoot?: string | null; | |
| 1235 | −} = {}) => | |
| 415 | +}) => | |
| 1236 | 416 | Layer.mergeAll( |
| 1237 | 417 | SecretMaterialResolverLive(input), |
| 1238 | − SecretMaterialStorerLive(input), | |
| 1239 | − SecretMaterialDeleterLive(input), | |
| 1240 | − SecretMaterialUpdaterLive(input), | |
| 1241 | − ); | |
| 1242 | − | |
| 1243 | −export const createLocalInstanceConfigResolver = (): ResolveInstanceConfig => () => { | |
| 1244 | − const explicitDefaultStoreProvider = parseSecretStoreProviderId( | |
| 1245 | − process.env[SECRET_STORE_PROVIDER_ENV], | |
| 418 | + SecretMaterialStorerLive({ | |
| 419 | + executorState: input.executorState, | |
| 420 | + resolveSecretMaterial: | |
| 421 | + input.resolveSecretMaterial | |
| 422 | + ?? createDefaultSecretMaterialResolver({ | |
| 423 | + executorState: input.executorState, | |
| 424 | + keychainServiceName: input.keychainServiceName, | |
| 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") { | |
| 1256 | − providers.push({ | |
| 1257 | − id: KEYCHAIN_SECRET_PROVIDER_ID, | |
| 1258 | − name: | |
| 1259 | − process.platform === "darwin" ? "macOS Keychain" : "Desktop Keyring", | |
| 1260 | − canStore: | |
| 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 | − })), | |
| 450 | +export const LocalInstanceConfigLive = (input: { | |
| 451 | + executorState: ExecutorStateStoreShape; | |
| 452 | +}) => | |
| 453 | + Layer.succeed( | |
| 454 | + LocalInstanceConfigService, | |
| 455 | + createLocalInstanceConfigResolver(input), | |
| 1280 | 456 | ); |
| 1281 | −}; | |
| 1282 | − | |
| 1283 | −export const LocalInstanceConfigLive = () => | |
| 1284 | − Layer.succeed(LocalInstanceConfigService, createLocalInstanceConfigResolver()); | |
| @@ -168,6 +168,14 @@ const toSourceTypeDeclarationsBackend = ( · 167 unmodified lines | ||
| 168 | 168 | const toSecretsBackend = ( |
| 169 | 169 | input: ExecutorSecretRepository, |
| 170 | 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 | 179 | getById: (id) => toOptionEffect(input.getById(id)), |
| 172 | 180 | listAll: () => toEffect(input.listAll()), |
| 173 | 181 | upsert: (material) => toEffect(input.upsert(material)), |
| @@ -4,6 +4,7 @@ import type { · 3 unmodified lines | ||
| 4 | 4 | ScopeId, |
| 5 | 5 | Execution, |
| 6 | 6 | LocalInstallation, |
| 7 | + SecretStore, | |
| 7 | 8 | Source, |
| 8 | 9 | } from "./schema"; |
| 9 | 10 | import type { |
| @@ -11,13 +12,28 @@ import type { · 1 unmodified line | ||
| 11 | 12 | ResumeExecutionPayload, |
| 12 | 13 | } from "./executions/contracts"; |
| 13 | 14 | import type { |
| 15 | + BrowseSecretStorePayload, | |
| 16 | + BrowseSecretStoreResult, | |
| 14 | 17 | CreateSecretPayload, |
| 18 | + CreateSecretStorePayload, | |
| 19 | + CreateSecretResult, | |
| 15 | 20 | DeleteSecretResult, |
| 21 | + DeleteSecretStoreResult, | |
| 22 | + ImportSecretFromStorePayload, | |
| 16 | 23 | UpdateSecretPayload, |
| 24 | + UpdateSecretStorePayload, | |
| 17 | 25 | } from "./local/contracts"; |
| 18 | 26 | import { |
| 19 | 27 | getLocalInstallation, |
| 20 | 28 | } from "./local/operations"; |
| 29 | +import { | |
| 30 | + createLocalSecretStore, | |
| 31 | + browseLocalSecretStore, | |
| 32 | + deleteLocalSecretStore, | |
| 33 | + importLocalSecretFromStore, | |
| 34 | + listLocalSecretStores, | |
| 35 | + updateLocalSecretStore, | |
| 36 | +} from "./local/secret-stores"; | |
| 21 | 37 | import { |
| 22 | 38 | createLocalSecret, |
| 23 | 39 | deleteLocalSecret, |
| @@ -49,6 +65,12 @@ import { · 25 unmodified lines | ||
| 49 | 65 | removeSource, |
| 50 | 66 | saveManagedSourceRecord, |
| 51 | 67 | } from "./sources/operations"; |
| 68 | +import { | |
| 69 | + createManagedSecretStoreRecord, | |
| 70 | + getManagedSecretStore, | |
| 71 | + removeManagedSecretStoreRecord, | |
| 72 | + saveManagedSecretStoreRecord, | |
| 73 | +} from "./runtime/secret-stores"; | |
| 52 | 74 | import type { ExecutorBackend } from "./backend"; |
| 53 | 75 | import { |
| 54 | 76 | provideExecutorRuntime, |
| @@ -93,6 +115,33 @@ export type ExecutorEffect = { · 38 unmodified lines | ||
| 93 | 115 | installation: () => ProvidedEffect<ReturnType<typeof getLocalInstallation>>; |
| 94 | 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 | 145 | secrets: { |
| 97 | 146 | list: () => ProvidedEffect<ReturnType<typeof listLocalSecrets>>; |
| 98 | 147 | create: ( |
| @@ -189,6 +238,14 @@ const fromRuntime = (runtime: ExecutorRuntime): ExecutorEffect => { · 90 unmodified lines | ||
| 189 | 238 | installation: () => provide(getLocalInstallation()), |
| 190 | 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 | 249 | secrets: { |
| 193 | 250 | list: () => provide(listLocalSecrets()), |
| 194 | 251 | create: (payload) => provide(createLocalSecret(payload)), |
| @@ -355,6 +412,34 @@ export const createExecutorEffect = < · 160 unmodified lines | ||
| 355 | 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 | 444 | const host: ExecutorSdkPluginHost = sourceHost; |
| 360 | 445 | const extensions = Object.fromEntries( |
| @@ -80,6 +80,15 @@ const toPromiseExecutor = < · 79 unmodified lines | ||
| 80 | 80 | installation: () => run(executor.local.installation()), |
| 81 | 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 | 92 | secrets: { |
| 84 | 93 | list: () => run(executor.secrets.list()), |
| 85 | 94 | create: (payload: Parameters<typeof executor.secrets.create>[0]) => |
| @@ -142,6 +151,7 @@ const toPromiseExecutor = < · 56 unmodified lines | ||
| 142 | 151 | "resolutionScopeIds", |
| 143 | 152 | "close", |
| 144 | 153 | "local", |
| 154 | + "secretStores", | |
| 145 | 155 | "secrets", |
| 146 | 156 | "policies", |
| 147 | 157 | "sources", |
| @@ -4,15 +4,15 @@ import { · 3 unmodified lines | ||
| 4 | 4 | import * as Schema from "effect/Schema"; |
| 5 | 5 | |
| 6 | 6 | export const SecretProviderSchema = Schema.Struct({ |
| 7 | − id: Schema.String, | |
| 8 | − name: Schema.String, | |
| 9 | − canStore: Schema.Boolean, | |
| 7 | + kind: Schema.String, | |
| 8 | + displayName: Schema.String, | |
| 9 | + canCreate: Schema.Boolean, | |
| 10 | 10 | }); |
| 11 | 11 | |
| 12 | 12 | export const InstanceConfigSchema = Schema.Struct({ |
| 13 | 13 | platform: Schema.String, |
| 14 | − secretProviders: Schema.Array(SecretProviderSchema), | |
| 15 | − defaultSecretStoreProvider: Schema.String, | |
| 14 | + secretStorePlugins: Schema.Array(SecretProviderSchema), | |
| 15 | + defaultSecretStoreId: Schema.NullOr(Schema.String), | |
| 16 | 16 | }); |
| 17 | 17 | |
| 18 | 18 | export type SecretProvider = typeof SecretProviderSchema.Type; |
| @@ -25,9 +25,21 @@ export const SecretLinkedSourceSchema = Schema.Struct({ · 6 unmodified lines | ||
| 25 | 25 | |
| 26 | 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 | 38 | export const SecretListItemSchema = Schema.Struct({ |
| 29 | 39 | id: Schema.String, |
| 30 | − providerId: Schema.String, | |
| 40 | + storeId: Schema.String, | |
| 41 | + storeName: Schema.String, | |
| 42 | + storeKind: Schema.String, | |
| 31 | 43 | name: Schema.NullOr(Schema.String), |
| 32 | 44 | purpose: Schema.String, |
| 33 | 45 | createdAt: Schema.Number, |
| @@ -41,7 +53,7 @@ export const CreateSecretPayloadSchema = Schema.Struct({ · 7 unmodified lines | ||
| 41 | 53 | name: Schema.String, |
| 42 | 54 | value: Schema.String, |
| 43 | 55 | purpose: Schema.optional(SecretMaterialPurposeSchema), |
| 44 | − providerId: Schema.optional(Schema.String), | |
| 56 | + storeId: Schema.optional(Schema.String), | |
| 45 | 57 | }); |
| 46 | 58 | |
| 47 | 59 | export type CreateSecretPayload = typeof CreateSecretPayloadSchema.Type; |
| @@ -49,7 +61,7 @@ export type CreateSecretPayload = typeof CreateSecretPayloadSchema.Type; · 1 unmodified line | ||
| 49 | 61 | export const CreateSecretResultSchema = Schema.Struct({ |
| 50 | 62 | id: Schema.String, |
| 51 | 63 | name: Schema.NullOr(Schema.String), |
| 52 | − providerId: Schema.String, | |
| 64 | + storeId: Schema.String, | |
| 53 | 65 | purpose: Schema.String, |
| 54 | 66 | createdAt: Schema.Number, |
| 55 | 67 | updatedAt: Schema.Number, |
| @@ -66,13 +78,78 @@ export type UpdateSecretPayload = typeof UpdateSecretPayloadSchema.Type; · 10 unmodified lines | ||
| 66 | 78 | |
| 67 | 79 | export const UpdateSecretResultSchema = Schema.Struct({ |
| 68 | 80 | id: Schema.String, |
| 69 | − providerId: Schema.String, | |
| 81 | + storeId: Schema.String, | |
| 70 | 82 | name: Schema.NullOr(Schema.String), |
| 71 | 83 | purpose: Schema.String, |
| 72 | 84 | createdAt: Schema.Number, |
| 73 | 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 | 153 | export type UpdateSecretResult = typeof UpdateSecretResultSchema.Type; |
| 77 | 154 | |
| 78 | 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 | 4 | import { |
| 5 | 5 | SecretMaterialIdSchema, |
| 6 | + type SecretStore, | |
| 6 | 7 | } from "../schema"; |
| 7 | 8 | import { |
| 8 | 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 | 87 | return secretMaterials.map((row) => ({ |
| 76 | 88 | ...row, |
| 89 | + storeName: secretStoresById.get(row.storeId)?.name ?? row.storeId, | |
| 90 | + storeKind: secretStoresById.get(row.storeId)?.kind ?? "unknown", | |
| 77 | 91 | linkedSources: linkedSourcesMap.get(row.id) ?? [], |
| 78 | 92 | })); |
| 79 | 93 | }); |
| @@ -98,7 +112,7 @@ export const createLocalSecret = (payload: CreateSecretPayload) => · 18 unmodified lines | ||
| 98 | 112 | name, |
| 99 | 113 | purpose, |
| 100 | 114 | value, |
| 101 | − providerId: payload.providerId, | |
| 115 | + storeId: payload.storeId, | |
| 102 | 116 | }).pipe( |
| 103 | 117 | Effect.mapError((cause) => |
| 104 | 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 | 125 | const created = yield* store.secretMaterials |
| 112 | 126 | .getById(secretId) |
| 113 | 127 | .pipe( |
| @@ -122,14 +136,14 @@ export const createLocalSecret = (payload: CreateSecretPayload) => · 8 unmodified lines | ||
| 122 | 136 | if (Option.isNone(created)) { |
| 123 | 137 | return yield* secretStorageError( |
| 124 | 138 | "secrets.create", |
| 125 | − `Created secret not found: ${ref.handle}`, | |
| 139 | + `Created secret not found: ${ref.secretId}`, | |
| 126 | 140 | ); |
| 127 | 141 | } |
| 128 | 142 | |
| 129 | 143 | return { |
| 130 | 144 | id: created.value.id, |
| 131 | 145 | name: created.value.name, |
| 132 | − providerId: created.value.providerId, | |
| 146 | + storeId: created.value.storeId, | |
| 133 | 147 | purpose: created.value.purpose, |
| 134 | 148 | createdAt: created.value.createdAt, |
| 135 | 149 | updatedAt: created.value.updatedAt, |
| @@ -168,8 +182,7 @@ export const updateLocalSecret = (input: { · 32 unmodified lines | ||
| 168 | 182 | const updateSecretMaterial = yield* SecretMaterialUpdaterService; |
| 169 | 183 | const updated = yield* updateSecretMaterial({ |
| 170 | 184 | ref: { |
| 171 | − providerId: existing.value.providerId, | |
| 172 | − handle: existing.value.id, | |
| 185 | + secretId: existing.value.id, | |
| 173 | 186 | }, |
| 174 | 187 | ...update, |
| 175 | 188 | }).pipe( |
| @@ -180,7 +193,7 @@ export const updateLocalSecret = (input: { · 4 unmodified lines | ||
| 180 | 193 | |
| 181 | 194 | return { |
| 182 | 195 | id: updated.id, |
| 183 | − providerId: updated.providerId, | |
| 196 | + storeId: updated.storeId, | |
| 184 | 197 | name: updated.name, |
| 185 | 198 | purpose: updated.purpose, |
| 186 | 199 | createdAt: updated.createdAt, |
| @@ -211,8 +224,7 @@ export const deleteLocalSecret = (secretId: string) => · 24 unmodified lines | ||
| 211 | 224 | |
| 212 | 225 | const deleteSecretMaterial = yield* SecretMaterialDeleterService; |
| 213 | 226 | const removed = yield* deleteSecretMaterial({ |
| 214 | − providerId: existing.value.providerId, | |
| 215 | − handle: existing.value.id, | |
| 227 | + secretId: existing.value.id, | |
| 216 | 228 | }).pipe( |
| 217 | 229 | Effect.mapError(() => |
| 218 | 230 | secretStorageError("secrets.delete", "Failed removing secret."), |
| @@ -6,7 +6,13 @@ import type { · 5 unmodified lines | ||
| 6 | 6 | } from "@executor/source-core"; |
| 7 | 7 | import type { ExecutorEffect } from "./executor-effect"; |
| 8 | 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 | 16 | import type { |
| 11 | 17 | SourceInvokeInput, |
| 12 | 18 | SourceInvokeResult, |
| @@ -34,6 +40,7 @@ export type ExecutorSdkPluginStartContext< · 21 unmodified lines | ||
| 34 | 40 | |
| 35 | 41 | type ExecutorSdkPluginInternals = { |
| 36 | 42 | sources?: readonly ExecutorSourceContribution<any>[]; |
| 43 | + secretStores?: readonly ExecutorSecretStoreContribution<any>[]; | |
| 37 | 44 | }; |
| 38 | 45 | |
| 39 | 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 | 102 | export type ExecutorSourcePluginStorage<TStored> = { |
| 82 | 103 | get: (input: { |
| 83 | 104 | scopeId: ExecutorSource["scopeId"]; |
| @@ -94,6 +115,22 @@ export type ExecutorSourcePluginStorage<TStored> = { · 10 unmodified lines | ||
| 94 | 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 | 134 | export type ExecutorSourcePluginApi< |
| 98 | 135 | TConnectInput, |
| 99 | 136 | TSourceConfig, |
| @@ -122,6 +159,31 @@ export type ExecutorSourcePluginApi< · 22 unmodified lines | ||
| 122 | 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 | 187 | export type ExecutorSourcePluginDefinition< |
| 126 | 188 | TAddInput, |
| 127 | 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 | 377 | export type ExecutorSourcePluginInput< |
| 187 | 378 | TKey extends string = string, |
| 188 | 379 | TAddInput = unknown, |
| @@ -220,6 +411,43 @@ export type ExecutorSourcePluginInput< · 31 unmodified lines | ||
| 220 | 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 | 451 | const loadSourceOfKind = ( |
| 224 | 452 | sourceId: ExecutorSource["id"], |
| 225 | 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 | 718 | type ExecutorSourceContribution<TInput = unknown> = { |
| 362 | 719 | kind: string; |
| 363 | 720 | displayName: string; |
| @@ -382,6 +739,83 @@ type ExecutorSourceContribution<TInput = unknown> = { · 18 unmodified lines | ||
| 382 | 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 | 819 | const createExecutorSourceContribution = < |
| 386 | 820 | TAddInput, |
| 387 | 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 | 1067 | export const defineExecutorSourcePlugin = < |
| 441 | 1068 | const TKey extends string, |
| 442 | 1069 | TAddInput, |
| @@ -487,6 +1114,56 @@ export const defineExecutorSourcePlugin = < · 44 unmodified lines | ||
| 487 | 1114 | }, |
| 488 | 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 | 1167 | export type ExecutorSdkPluginExtensions< |
| 491 | 1168 | TPlugins extends readonly ExecutorSdkPlugin<any, any>[], |
| 492 | 1169 | > = { |
| @@ -501,6 +1178,7 @@ export const registerExecutorSdkPlugins = ( · 8 unmodified lines | ||
| 501 | 1178 | ) => { |
| 502 | 1179 | const pluginKeys = new Set<string>(); |
| 503 | 1180 | const sources = new Map<string, ExecutorSourceContribution<any>>(); |
| 1181 | + const secretStores = new Map<string, ExecutorSecretStoreContribution<any>>(); | |
| 504 | 1182 | |
| 505 | 1183 | for (const plugin of plugins) { |
| 506 | 1184 | if (pluginKeys.has(plugin.key)) { |
| @@ -520,6 +1198,16 @@ export const registerExecutorSdkPlugins = ( · 13 unmodified lines | ||
| 520 | 1198 | |
| 521 | 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 | 1213 | const getSourceContribution = (kind: string) => { |
| @@ -534,10 +1222,26 @@ export const registerExecutorSdkPlugins = ( · 8 unmodified lines | ||
| 534 | 1222 | const getSourceContributionForSource = (source: Pick<ExecutorSource, "kind">) => |
| 535 | 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 | 1238 | return { |
| 538 | 1239 | plugins, |
| 539 | 1240 | sources: [...sources.values()], |
| 1241 | + secretStores: [...secretStores.values()], | |
| 540 | 1242 | getSourceContribution, |
| 541 | 1243 | getSourceContributionForSource, |
| 1244 | + getSecretStoreContribution, | |
| 1245 | + getSecretStoreContributionForStore, | |
| 542 | 1246 | }; |
| 543 | 1247 | }; |
| @@ -48,6 +48,9 @@ import { · 47 unmodified lines | ||
| 48 | 48 | import { |
| 49 | 49 | createExecutorToolMap, |
| 50 | 50 | } from "../../sources/executor-tools"; |
| 51 | +import { | |
| 52 | + registeredSourceContributions, | |
| 53 | +} from "../../sources/source-plugins"; | |
| 51 | 54 | import { |
| 52 | 55 | RuntimeSourceCatalogSyncService, |
| 53 | 56 | } from "../../catalog/source/sync"; |
| @@ -147,10 +150,12 @@ export const createScopeToolInvoker = (input: { · 93 unmodified lines | ||
| 147 | 150 | return catalog; |
| 148 | 151 | }, |
| 149 | 152 | }); |
| 153 | + const hasLocalToolsSourceContribution = registeredSourceContributions() | |
| 154 | + .some((source) => source.kind === "local-tools"); | |
| 150 | 155 | const authoredTools = mergeToolMaps([ |
| 151 | 156 | systemTools, |
| 152 | 157 | executorTools, |
| 153 | − input.localToolRuntime.tools, | |
| 158 | + ...(hasLocalToolsSourceContribution ? [] : [input.localToolRuntime.tools]), | |
| 154 | 159 | ]); |
| 155 | 160 | const authoredCatalog = createToolCatalogFromTools({ |
| 156 | 161 | tools: authoredTools, |
| @@ -6,18 +6,48 @@ import type { · 5 unmodified lines | ||
| 6 | 6 | ExecutionInteraction, |
| 7 | 7 | ExecutionStep, |
| 8 | 8 | SecretMaterial, |
| 9 | + SecretStore, | |
| 9 | 10 | } from "#schema"; |
| 10 | 11 | |
| 11 | 12 | type SecretMaterialSummary = { |
| 12 | 13 | id: string; |
| 13 | − providerId: string; | |
| 14 | + storeId: string; | |
| 14 | 15 | name: string | null; |
| 15 | 16 | purpose: string; |
| 16 | 17 | createdAt: number; |
| 17 | 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 | 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 | 51 | secretMaterials: { |
| 22 | 52 | getById: ( |
| 23 | 53 | id: SecretMaterial["id"], |
| @@ -89,6 +89,7 @@ import { · 88 unmodified lines | ||
| 89 | 89 | } from "./sources/source-store"; |
| 90 | 90 | |
| 91 | 91 | export * from "./execution/state"; |
| 92 | +export * from "./secret-stores"; | |
| 92 | 93 | export * from "./sources/executor-tools"; |
| 93 | 94 | export * from "./execution/live"; |
| 94 | 95 | export * from "./catalog/schema-type-signature"; |
| @@ -137,9 +138,12 @@ export type { · 42 unmodified lines | ||
| 137 | 138 | } from "./local-tool-runtime"; |
| 138 | 139 | export { |
| 139 | 140 | registeredSourceContributions, |
| 141 | + registeredSecretStoreContributions, | |
| 140 | 142 | hasRegisteredExternalSourcePlugins, |
| 141 | 143 | getSourceContribution, |
| 142 | 144 | getSourceContributionForSource, |
| 145 | + getSecretStoreContribution, | |
| 146 | + getSecretStoreContributionForStore, | |
| 143 | 147 | } from "./sources/source-plugins"; |
| 144 | 148 | |
| 145 | 149 | export type ExecutorRuntimeOptions = { |
| @@ -219,6 +223,9 @@ export const prewarmWorkspaceSourceCatalogToolIndex = (input: { · 73 unmodified lines | ||
| 219 | 223 | }); |
| 220 | 224 | |
| 221 | 225 | export type RuntimeSecretsStorageServices = |
| 226 | + & { | |
| 227 | + secretStores: ExecutorStateStoreShape["secretStores"]; | |
| 228 | + } | |
| 222 | 229 | & ExecutorStateStoreShape["secretMaterials"] |
| 223 | 230 | & RuntimeSecretMaterialServices; |
| 224 | 231 | |
| @@ -310,6 +317,7 @@ const makeInstanceConfigLayer = (input: RuntimeInstanceConfigService) => · 85 unmodified lines | ||
| 310 | 317 | const toExecutorStateStoreShape = ( |
| 311 | 318 | input: RuntimeStorageServices, |
| 312 | 319 | ): ExecutorStateStoreShape => ({ |
| 320 | + secretStores: input.secrets.secretStores, | |
| 313 | 321 | secretMaterials: input.secrets, |
| 314 | 322 | executions: input.executions.runs, |
| 315 | 323 | executionInteractions: input.executions.interactions, |
| @@ -22,7 +22,7 @@ export type StoreSecretMaterial = (input: { · 21 unmodified lines | ||
| 22 | 22 | purpose: SecretMaterialPurpose; |
| 23 | 23 | value: string; |
| 24 | 24 | name?: string | null; |
| 25 | − providerId?: string; | |
| 25 | + storeId?: string; | |
| 26 | 26 | }) => Effect.Effect<SecretRef, Error, never>; |
| 27 | 27 | |
| 28 | 28 | export type DeleteSecretMaterial = ( |
| @@ -35,7 +35,7 @@ export type UpdateSecretMaterial = (input: { · 6 unmodified lines | ||
| 35 | 35 | value?: string; |
| 36 | 36 | }) => Effect.Effect<{ |
| 37 | 37 | id: string; |
| 38 | − providerId: string; | |
| 38 | + storeId: string; | |
| 39 | 39 | name: string | null; |
| 40 | 40 | purpose: string; |
| 41 | 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 | 20 | export const registeredSourceContributions = () => registry.sources; |
| 21 | +export const registeredSecretStoreContributions = () => registry.secretStores; | |
| 21 | 22 | |
| 22 | 23 | export const getSourceContribution = (kind: string) => |
| 23 | 24 | registry.getSourceContribution(kind); |
| 24 | 25 | export const getSourceContributionForSource = ( |
| 25 | 26 | source: Parameters<typeof registry.getSourceContributionForSource>[0], |
| 26 | 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 | 34 | export const hasRegisteredExternalSourcePlugins = () => |
| 29 | 35 | configuredSourcePlugins.length > 0; |
| @@ -11,6 +11,9 @@ export const SourceCatalogRevisionIdSchema = Schema.String.pipe( · 10 unmodified lines | ||
| 11 | 11 | export const SecretMaterialIdSchema = Schema.String.pipe( |
| 12 | 12 | Schema.brand("SecretMaterialId"), |
| 13 | 13 | ); |
| 14 | +export const SecretStoreIdSchema = Schema.String.pipe( | |
| 15 | + Schema.brand("SecretStoreId"), | |
| 16 | +); | |
| 14 | 17 | export const PolicyIdSchema = Schema.String.pipe(Schema.brand("PolicyId")); |
| 15 | 18 | export const ExecutionIdSchema = Schema.String.pipe(Schema.brand("ExecutionId")); |
| 16 | 19 | export const ExecutionInteractionIdSchema = Schema.String.pipe( |
| @@ -25,6 +28,7 @@ export type SourceId = typeof SourceIdSchema.Type; · 8 unmodified lines | ||
| 25 | 28 | export type SourceCatalogId = typeof SourceCatalogIdSchema.Type; |
| 26 | 29 | export type SourceCatalogRevisionId = typeof SourceCatalogRevisionIdSchema.Type; |
| 27 | 30 | export type SecretMaterialId = typeof SecretMaterialIdSchema.Type; |
| 31 | +export type SecretStoreId = typeof SecretStoreIdSchema.Type; | |
| 28 | 32 | export type PolicyId = typeof PolicyIdSchema.Type; |
| 29 | 33 | export type ExecutionId = typeof ExecutionIdSchema.Type; |
| 30 | 34 | export type ExecutionInteractionId = typeof ExecutionInteractionIdSchema.Type; |
| @@ -6,6 +6,7 @@ export * from "./models/source"; · 5 unmodified lines | ||
| 6 | 6 | export * from "./models/source-catalog"; |
| 7 | 7 | export * from "./models/secret-ref"; |
| 8 | 8 | export * from "./models/secret-material"; |
| 9 | +export * from "./models/secret-store"; | |
| 9 | 10 | export * from "./models/policy"; |
| 10 | 11 | export * from "./models/local-installation"; |
| 11 | 12 | export * from "./models/local-config"; |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | ||
| 7 | 7 | } from "../common"; |
| 8 | 8 | import { |
| 9 | 9 | SecretMaterialIdSchema, |
| 10 | + SecretStoreIdSchema, | |
| 10 | 11 | } from "../ids"; |
| 11 | 12 | |
| 12 | 13 | export const SecretMaterialPurposeSchema = Schema.Literal( |
| @@ -20,7 +21,7 @@ export const SecretMaterialSchema = Schema.Struct({ · 7 unmodified lines | ||
| 20 | 21 | id: SecretMaterialIdSchema, |
| 21 | 22 | name: Schema.NullOr(Schema.String), |
| 22 | 23 | purpose: SecretMaterialPurposeSchema, |
| 23 | − providerId: Schema.String, | |
| 24 | + storeId: SecretStoreIdSchema, | |
| 24 | 25 | handle: Schema.String, |
| 25 | 26 | value: Schema.NullOr(Schema.String), |
| 26 | 27 | createdAt: TimestampMsSchema, |
| @@ -1,8 +1,10 @@ | ||
| 1 | 1 | import * as Schema from "effect/Schema"; |
| 2 | +import { | |
| 3 | + SecretMaterialIdSchema, | |
| 4 | +} from "../ids"; | |
| 2 | 5 | |
| 3 | 6 | export const SecretRefSchema = Schema.Struct({ |
| 4 | − providerId: Schema.String, | |
| 5 | − handle: Schema.String, | |
| 7 | + secretId: SecretMaterialIdSchema, | |
| 6 | 8 | }); |
| 7 | 9 | |
| 8 | 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 | 21 | "@executor/plugin-graphql-http": "workspace:*", |
| 22 | 22 | "@executor/plugin-graphql-shared": "workspace:*", |
| 23 | 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 | 27 | "@executor/plugin-mcp-http": "workspace:*", |
| 25 | 28 | "@executor/plugin-mcp-shared": "workspace:*", |
| 26 | 29 | "@executor/plugin-mcp-sdk": "workspace:*", |
| 27 | 30 | "@executor/plugin-openapi-http": "workspace:*", |
| 28 | 31 | "@executor/plugin-openapi-shared": "workspace:*", |
| 29 | 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 | 36 | "@executor/platform-api": "workspace:*", |
| 31 | 37 | "@executor/platform-sdk": "workspace:*", |
| 32 | 38 | "@executor/platform-sdk-file": "workspace:*", |
| @@ -40,6 +40,7 @@ import { googleDiscoveryHttpPlugin } from "@executor/plugin-google-discovery-htt · 39 unmodified lines | ||
| 40 | 40 | import { graphqlHttpPlugin } from "@executor/plugin-graphql-http"; |
| 41 | 41 | import { mcpHttpPlugin } from "@executor/plugin-mcp-http"; |
| 42 | 42 | import { openApiHttpPlugin } from "@executor/plugin-openapi-http"; |
| 43 | +import { onePasswordHttpPlugin } from "@executor/plugin-onepassword-http"; | |
| 43 | 44 | import { makeSesExecutor } from "@executor/runtime-ses"; |
| 44 | 45 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 45 | 46 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
| @@ -62,6 +63,7 @@ const serverHttpPlugins = [ · 16 unmodified lines | ||
| 62 | 63 | graphqlHttpPlugin(), |
| 63 | 64 | googleDiscoveryHttpPlugin(), |
| 64 | 65 | mcpHttpPlugin(), |
| 66 | + onePasswordHttpPlugin(), | |
| 65 | 67 | openApiHttpPlugin(), |
| 66 | 68 | ] as const; |
| 67 | 69 | |
| @@ -385,6 +387,26 @@ const seedGithubOpenApiSourceInWorkspace = (input: { · 317 unmodified lines | ||
| 385 | 387 | credentialEnvVar?: string; |
| 386 | 388 | }) => |
| 387 | 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 | 410 | const existing = yield* input.client.sources.list({ |
| 389 | 411 | path: { |
| 390 | 412 | workspaceId: input.workspaceId as never, |
| @@ -400,8 +422,7 @@ const seedGithubOpenApiSourceInWorkspace = (input: { · 9 unmodified lines | ||
| 400 | 422 | headerName: "Authorization", |
| 401 | 423 | prefix: "Bearer ", |
| 402 | 424 | tokenSecretRef: { |
| 403 | − providerId: "env", | |
| 404 | − handle: input.credentialEnvVar ?? "GITHUB_TOKEN", | |
| 425 | + secretId: tokenSecret.id as never, | |
| 405 | 426 | }, |
| 406 | 427 | }; |
| 407 | 428 | |
| @@ -1325,11 +1346,9 @@ describe("local-executor-server", () => { · 917 unmodified lines | ||
| 1325 | 1346 | ); |
| 1326 | 1347 | |
| 1327 | 1348 | const previousGithubToken = process.env.GITHUB_TOKEN; |
| 1328 | − const previousAllowEnvSecrets = process.env.DANGEROUSLY_ALLOW_ENV_SECRETS; | |
| 1329 | 1349 | yield* Effect.acquireRelease( |
| 1330 | 1350 | Effect.sync(() => { |
| 1331 | 1351 | process.env.GITHUB_TOKEN = "ghp_test_executor"; |
| 1332 | − process.env.DANGEROUSLY_ALLOW_ENV_SECRETS = "true"; | |
| 1333 | 1352 | }), |
| 1334 | 1353 | () => |
| 1335 | 1354 | Effect.sync(() => { |
| @@ -1338,12 +1357,6 @@ describe("local-executor-server", () => { · 2 unmodified lines | ||
| 1338 | 1357 | } else { |
| 1339 | 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 | 12 | import { googleDiscoverySdkPlugin } from "@executor/plugin-google-discovery-sdk"; |
| 13 | 13 | import { graphqlHttpPlugin } from "@executor/plugin-graphql-http"; |
| 14 | 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 | 18 | import { mcpHttpPlugin } from "@executor/plugin-mcp-http"; |
| 16 | 19 | import { mcpSdkPlugin } from "@executor/plugin-mcp-sdk"; |
| 17 | 20 | import { openApiHttpPlugin } from "@executor/plugin-openapi-http"; |
| 18 | 21 | import { |
| 19 | 22 | openApiSdkPlugin, |
| 20 | 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 | 28 | import { |
| 22 | 29 | createExecutorApiLayer, |
| 23 | 30 | } from "@executor/platform-api/http"; |
| @@ -59,6 +66,7 @@ import { createFileGraphqlSourceStorage } from "./graphql-source-storage"; · 35 unmodified lines | ||
| 59 | 66 | import { createFileMcpOAuthSessionStorage } from "./mcp-oauth-session-storage"; |
| 60 | 67 | import { createFileMcpSourceStorage } from "./mcp-source-storage"; |
| 61 | 68 | import { createFileOpenApiSourceStorage } from "./openapi-source-storage"; |
| 69 | +import { createFileOnePasswordStoreStorage } from "./onepassword-store-storage"; | |
| 62 | 70 | |
| 63 | 71 | export { createFileGoogleDiscoveryOAuthSessionStorage } from "./google-discovery-oauth-session-storage"; |
| 64 | 72 | export { createFileGoogleDiscoverySourceStorage } from "./google-discovery-source-storage"; |
| @@ -66,6 +74,7 @@ export { createFileGraphqlSourceStorage } from "./graphql-source-storage"; · 1 unmodified line | ||
| 66 | 74 | export { createFileMcpOAuthSessionStorage } from "./mcp-oauth-session-storage"; |
| 67 | 75 | export { createFileMcpSourceStorage } from "./mcp-source-storage"; |
| 68 | 76 | export { createFileOpenApiSourceStorage } from "./openapi-source-storage"; |
| 77 | +export { createFileOnePasswordStoreStorage } from "./onepassword-store-storage"; | |
| 69 | 78 | |
| 70 | 79 | export { |
| 71 | 80 | DEFAULT_EXECUTOR_DATA_DIR, |
| @@ -133,6 +142,7 @@ const executorHttpPlugins = [ · 61 unmodified lines | ||
| 133 | 142 | graphqlHttpPlugin(), |
| 134 | 143 | googleDiscoveryHttpPlugin(), |
| 135 | 144 | mcpHttpPlugin(), |
| 145 | + onePasswordHttpPlugin(), | |
| 136 | 146 | openApiHttpPlugin(), |
| 137 | 147 | ] as const; |
| 138 | 148 | |
| @@ -150,10 +160,14 @@ const createExecutorRuntime = ( · 11 unmodified lines | ||
| 150 | 160 | ) => |
| 151 | 161 | createExecutorEffect({ |
| 152 | 162 | backend: createLocalExecutorBackend({ |
| 163 | + cwd: options.workspaceRoot ?? process.cwd(), | |
| 153 | 164 | workspaceRoot: options.workspaceRoot, |
| 154 | 165 | localDataDir, |
| 155 | 166 | }), |
| 156 | 167 | plugins: [ |
| 168 | + localSecretStoreSdkPlugin, | |
| 169 | + keychainSecretStoreSdkPlugin, | |
| 170 | + localToolsSdkPlugin(), | |
| 157 | 171 | graphqlSdkPlugin({ |
| 158 | 172 | storage: createFileGraphqlSourceStorage({ |
| 159 | 173 | rootDir: resolve(localDataDir, "plugins", "graphql", "sources"), |
| @@ -180,6 +194,11 @@ const createExecutorRuntime = ( · 20 unmodified lines | ||
| 180 | 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 | 202 | ] as const, |
| 184 | 203 | executionResolver: options.executionResolver, |
| 185 | 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 | 1 | import { startTransition, useMemo, useState, type ReactNode } from "react"; |
| 2 | 2 | import type { Source } from "@executor/react"; |
| 3 | 3 | import { |
| 4 | + SecretReferenceField, | |
| 4 | 5 | defineExecutorPluginHttpApiClient, |
| 5 | 6 | Result, |
| 6 | 7 | useAtomSet, |
| 7 | 8 | useAtomValue, |
| 8 | − useCreateSecret, | |
| 9 | 9 | useExecutorMutation, |
| 10 | 10 | useLocalInstallation, |
| 11 | − useSecrets, | |
| 12 | 11 | useSource, |
| 13 | 12 | } from "@executor/react"; |
| 14 | 13 | import { |
| @@ -456,134 +455,6 @@ const openGoogleOauthPopup = (name: string): Window | null => { · 441 unmodified lines | ||
| 456 | 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 | 458 | const batchSourceInputFromTemplate = ( |
| 588 | 459 | template: GoogleDiscoveryBatchTemplate, |
| 589 | 460 | ): GoogleDiscoveryBatchSourceInput => ({ |
| @@ -768,10 +639,12 @@ function GoogleDiscoverySourceForm(props: { · 178 unmodified lines | ||
| 768 | 639 | |
| 769 | 640 | {authKind === "bearer" && ( |
| 770 | 641 | <div className="border-l-2 border-border pl-4"> |
| 771 | − <SecretSelectOrCreateField | |
| 642 | + <SecretReferenceField | |
| 772 | 643 | label="Secret" |
| 773 | 644 | value={bearerSecretRef} |
| 774 | 645 | emptyLabel="Select a secret" |
| 646 | + draftNamePlaceholder="Google bearer token" | |
| 647 | + draftValuePlaceholder="Paste the token value" | |
| 775 | 648 | onChange={setBearerSecretRef} |
| 776 | 649 | /> |
| 777 | 650 | </div> |
| @@ -787,10 +660,12 @@ function GoogleDiscoverySourceForm(props: { · 9 unmodified lines | ||
| 787 | 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 | 662 | </label> |
| 790 | − <SecretSelectOrCreateField | |
| 663 | + <SecretReferenceField | |
| 791 | 664 | label="Client Secret" |
| 792 | 665 | value={clientSecretRef} |
| 793 | 666 | emptyLabel="Public client / no secret" |
| 667 | + draftNamePlaceholder="Google client secret" | |
| 668 | + draftValuePlaceholder="Paste the secret value" | |
| 794 | 669 | onChange={setClientSecretRef} |
| 795 | 670 | /> |
| 796 | 671 | <div className="flex items-center gap-3"> |
| @@ -1095,10 +970,12 @@ function GoogleDiscoveryBatchConnectPanel(props: { · 298 unmodified lines | ||
| 1095 | 970 | /> |
| 1096 | 971 | </label> |
| 1097 | 972 | <div className="md:col-span-2"> |
| 1098 | − <SecretSelectOrCreateField | |
| 973 | + <SecretReferenceField | |
| 1099 | 974 | label="Client Secret" |
| 1100 | 975 | value={clientSecretRef} |
| 1101 | 976 | emptyLabel="Public client / no secret" |
| 977 | + draftNamePlaceholder="Google client secret" | |
| 978 | + draftValuePlaceholder="Paste the secret value" | |
| 1102 | 979 | onChange={setClientSecretRef} |
| 1103 | 980 | /> |
| 1104 | 981 | </div> |
| @@ -4,14 +4,13 @@ import type { · 3 unmodified lines | ||
| 4 | 4 | SourceInspectionToolDetail, |
| 5 | 5 | } from "@executor/react"; |
| 6 | 6 | import { |
| 7 | + SecretReferenceField, | |
| 7 | 8 | defineExecutorPluginHttpApiClient, |
| 8 | 9 | Result, |
| 9 | 10 | useAtomSet, |
| 10 | 11 | useAtomValue, |
| 11 | − useCreateSecret, | |
| 12 | 12 | useExecutorMutation, |
| 13 | 13 | useLocalInstallation, |
| 14 | − useSecrets, | |
| 15 | 14 | useSource, |
| 16 | 15 | } from "@executor/react"; |
| 17 | 16 | import { |
| @@ -55,7 +54,6 @@ const defaultGraphqlInput = (): GraphqlConnectInput => ({ · 37 unmodified lines | ||
| 55 | 54 | |
| 56 | 55 | const DEFAULT_BEARER_HEADER_NAME = "Authorization"; |
| 57 | 56 | const DEFAULT_BEARER_PREFIX = "Bearer "; |
| 58 | −const CREATE_SECRET_VALUE = "__create_graphql_secret__"; | |
| 59 | 57 | |
| 60 | 58 | const presetString = ( |
| 61 | 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 | 171 | const graphqlToolRoutePath = (toolPath: string): string => |
| 302 | 172 | `tool/${encodeURIComponent(toolPath)}`; |
| 303 | 173 | |
| @@ -383,7 +253,7 @@ function GraphqlSourceForm(props: { · 79 unmodified lines | ||
| 383 | 253 | |
| 384 | 254 | {authKind === "bearer" && ( |
| 385 | 255 | <div className="space-y-4 border-l-2 border-border pl-4"> |
| 386 | − <SecretSelectOrCreateField | |
| 256 | + <SecretReferenceField | |
| 387 | 257 | label="Secret" |
| 388 | 258 | value={secretRef} |
| 389 | 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 | 4 | } from "@executor/react"; |
| 5 | 5 | import { |
| 6 | 6 | Result, |
| 7 | + SecretReferenceField, | |
| 7 | 8 | defineExecutorPluginHttpApiClient, |
| 8 | 9 | useAtomValue, |
| 9 | 10 | useAtomSet, |
| 10 | − useCreateSecret, | |
| 11 | 11 | useExecutorMutation, |
| 12 | − useSecrets, | |
| 13 | 12 | useSource, |
| 14 | − useWorkspaceId, | |
| 13 | + useWorkspaceRequestContext, | |
| 15 | 14 | } from "@executor/react"; |
| 16 | 15 | import { |
| 17 | 16 | DocumentPanel, |
| @@ -49,7 +48,6 @@ const defaultOpenApiInput = (): OpenApiConnectInput => ({ · 31 unmodified lines | ||
| 49 | 48 | |
| 50 | 49 | const DEFAULT_BEARER_HEADER_NAME = "Authorization"; |
| 51 | 50 | const DEFAULT_BEARER_PREFIX = "Bearer "; |
| 52 | −const CREATE_SECRET_VALUE = "__create_openapi_secret__"; | |
| 53 | 51 | |
| 54 | 52 | const presetString = ( |
| 55 | 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 | 238 | function OpenApiSourceForm(props: { |
| 369 | 239 | mode: "create" | "edit"; |
| 240 | + workspaceId: Source["scopeId"]; | |
| 370 | 241 | initialValue: OpenApiConnectInput; |
| 371 | 242 | submitLabel: string; |
| 372 | 243 | busyLabel: string; |
| @@ -377,7 +248,6 @@ function OpenApiSourceForm(props: { · 4 unmodified lines | ||
| 377 | 248 | openApiHttpClient.mutation("openapi", "previewDocument"), |
| 378 | 249 | { mode: "promise" }, |
| 379 | 250 | ); |
| 380 | − const workspaceId = useWorkspaceId(); | |
| 381 | 251 | const [name, setName] = useState(props.initialValue.name); |
| 382 | 252 | const [specUrl, setSpecUrl] = useState(props.initialValue.specUrl); |
| 383 | 253 | const [baseUrl, setBaseUrl] = useState(props.initialValue.baseUrl ?? ""); |
| @@ -403,7 +273,7 @@ function OpenApiSourceForm(props: { · 19 unmodified lines | ||
| 403 | 273 | OpenApiPreviewResponse |
| 404 | 274 | >(async (payload) => |
| 405 | 275 | previewDocument({ |
| 406 | − path: { workspaceId }, | |
| 276 | + path: { workspaceId: props.workspaceId }, | |
| 407 | 277 | payload, |
| 408 | 278 | }) |
| 409 | 279 | ); |
| @@ -568,7 +438,7 @@ function OpenApiSourceForm(props: { · 158 unmodified lines | ||
| 568 | 438 | |
| 569 | 439 | {authKind === "bearer" && ( |
| 570 | 440 | <div className="space-y-4 border-l-2 border-border pl-4"> |
| 571 | − <SecretSelectOrCreateField | |
| 441 | + <SecretReferenceField | |
| 572 | 442 | label="Secret" |
| 573 | 443 | value={tokenSecretRef} |
| 574 | 444 | emptyLabel="Select a secret" |
| @@ -683,24 +553,33 @@ export function OpenApiAddSourcePage() { · 108 unmodified lines | ||
| 683 | 553 | const navigation = useSourcePluginNavigation(); |
| 684 | 554 | const initialValue = openApiInputFromSearch(useSourcePluginSearch()); |
| 685 | 555 | const openApiHttpClient = getOpenApiHttpClient(); |
| 556 | + const workspace = useWorkspaceRequestContext(); | |
| 686 | 557 | const createSource = useAtomSet( |
| 687 | 558 | openApiHttpClient.mutation("openapi", "createSource"), |
| 688 | 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 | 570 | return ( |
| 693 | 571 | <OpenApiSourceForm |
| 694 | 572 | mode="create" |
| 573 | + workspaceId={workspace.workspaceId} | |
| 695 | 574 | initialValue={initialValue} |
| 696 | 575 | submitLabel="Create Source" |
| 697 | 576 | busyLabel="Creating..." |
| 698 | 577 | onSubmit={async (payload) => { |
| 699 | 578 | const source = await createSource({ |
| 700 | − path: { workspaceId }, | |
| 579 | + path: { workspaceId: workspace.workspaceId }, | |
| 701 | 580 | payload, |
| 702 | 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 | 596 | export function OpenApiEditSourcePage(props: { |
| 718 | 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 | 621 | const navigation = useSourcePluginNavigation(); |
| 721 | 622 | const openApiHttpClient = getOpenApiHttpClient(); |
| 722 | − const workspaceId = useWorkspaceId(); | |
| 723 | 623 | const configResult = useAtomValue( |
| 724 | 624 | openApiHttpClient.query("openapi", "getSourceConfig", { |
| 725 | 625 | path: { |
| 726 | − workspaceId, | |
| 626 | + workspaceId: props.workspaceId, | |
| 727 | 627 | sourceId: props.source.id, |
| 728 | 628 | }, |
| 729 | 629 | reactivityKeys: { |
| 730 | − source: [workspaceId, props.source.id], | |
| 630 | + source: [props.workspaceId, props.source.id], | |
| 731 | 631 | }, |
| 732 | 632 | timeToLive: "30 seconds", |
| 733 | 633 | }), |
| @@ -758,22 +658,23 @@ export function OpenApiEditSourcePage(props: { · 24 unmodified lines | ||
| 758 | 658 | return ( |
| 759 | 659 | <OpenApiSourceForm |
| 760 | 660 | mode="edit" |
| 661 | + workspaceId={props.workspaceId} | |
| 761 | 662 | initialValue={inputFromConfig(configResult.value)} |
| 762 | 663 | submitLabel="Save Changes" |
| 763 | 664 | busyLabel="Saving..." |
| 764 | 665 | onSubmit={async (config) => { |
| 765 | 666 | const source = await updateSource({ |
| 766 | 667 | path: { |
| 767 | − workspaceId, | |
| 668 | + workspaceId: props.workspaceId, | |
| 768 | 669 | sourceId: props.source.id, |
| 769 | 670 | }, |
| 770 | 671 | payload: config, |
| 771 | 672 | reactivityKeys: { |
| 772 | − sources: [workspaceId], | |
| 773 | − source: [workspaceId, props.source.id], | |
| 774 | − sourceInspection: [workspaceId, props.source.id], | |
| 775 | − sourceInspectionTool: [workspaceId, props.source.id], | |
| 776 | − sourceDiscovery: [workspaceId, props.source.id], | |
| 673 | + sources: [props.workspaceId], | |
| 674 | + source: [props.workspaceId, props.source.id], | |
| 675 | + sourceInspection: [props.workspaceId, props.source.id], | |
| 676 | + sourceInspectionTool: [props.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 | 691 | export function OpenApiSourceDetailPage(props: { |
| 791 | 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 | 716 | const navigation = useSourcePluginNavigation(); |
| 794 | 717 | const openApiHttpClient = getOpenApiHttpClient(); |
| 795 | − const workspaceId = useWorkspaceId(); | |
| 796 | 718 | const removeSource = useAtomSet( |
| 797 | 719 | openApiHttpClient.mutation("openapi", "removeSource"), |
| 798 | 720 | { mode: "promise" }, |
| @@ -804,30 +726,30 @@ export function OpenApiSourceDetailPage(props: { · 5 unmodified lines | ||
| 804 | 726 | const refreshMutation = useExecutorMutation<Source["id"], Source>(async (sourceId) => |
| 805 | 727 | refreshSource({ |
| 806 | 728 | path: { |
| 807 | − workspaceId, | |
| 729 | + workspaceId: props.workspaceId, | |
| 808 | 730 | sourceId, |
| 809 | 731 | }, |
| 810 | 732 | reactivityKeys: { |
| 811 | − sources: [workspaceId], | |
| 812 | − source: [workspaceId, sourceId], | |
| 813 | − sourceInspection: [workspaceId, sourceId], | |
| 814 | − sourceInspectionTool: [workspaceId, sourceId], | |
| 815 | − sourceDiscovery: [workspaceId, sourceId], | |
| 733 | + sources: [props.workspaceId], | |
| 734 | + source: [props.workspaceId, sourceId], | |
| 735 | + sourceInspection: [props.workspaceId, sourceId], | |
| 736 | + sourceInspectionTool: [props.workspaceId, sourceId], | |
| 737 | + sourceDiscovery: [props.workspaceId, sourceId], | |
| 816 | 738 | }, |
| 817 | 739 | }) |
| 818 | 740 | ); |
| 819 | 741 | const removeMutation = useExecutorMutation<Source["id"], { removed: boolean }>(async (sourceId) => |
| 820 | 742 | removeSource({ |
| 821 | 743 | path: { |
| 822 | − workspaceId, | |
| 744 | + workspaceId: props.workspaceId, | |
| 823 | 745 | sourceId, |
| 824 | 746 | }, |
| 825 | 747 | reactivityKeys: { |
| 826 | − sources: [workspaceId], | |
| 827 | − source: [workspaceId, sourceId], | |
| 828 | − sourceInspection: [workspaceId, sourceId], | |
| 829 | − sourceInspectionTool: [workspaceId, sourceId], | |
| 830 | − sourceDiscovery: [workspaceId, sourceId], | |
| 748 | + sources: [props.workspaceId], | |
| 749 | + source: [props.workspaceId, sourceId], | |
| 750 | + sourceInspection: [props.workspaceId, sourceId], | |
| 751 | + sourceInspectionTool: [props.workspaceId, sourceId], | |
| 752 | + sourceDiscovery: [props.workspaceId, sourceId], | |
| 831 | 753 | }, |
| 832 | 754 | }) |
| 833 | 755 | ); |