History

Refactor secrets into plugins and unify secret selection

Rhys Sullivan committed 7 months ago

Commit d42dabe1 parent 0a5d72e97 files · +7698 −2033Browse files

Unified · Split

97 files
  1. .executor/executor.jsonc +0 −13
  2. .executor/tools/demo.ts +45 −0
  3. .gitignore +3 −1
  4. apps/executor/package.json +3 −0
  5. apps/executor/src/cli/main.ts +16 −1
  6. apps/web/package.json +2 −0
  7. apps/web/src/components/icons.tsx +8 −0
  8. apps/web/src/components/shell.tsx +223 −92
  9. apps/web/src/plugins/index.ts +11 −0
  10. apps/web/src/views/secrets.tsx +859 −265
  11. apps/web/vite.config.ts +4 −0
  12. bun.lock +139 −0
  13. examples/sqlite-sdk-consumer/src/sqlite-backend.ts +69 −45
  14. packages/clients/react/src/components/secret-reference-field.tsx +453 −0
  15. packages/clients/react/src/core/api-atoms.ts +9 −0
  16. packages/clients/react/src/core/http-client.ts +5 −3
  17. packages/clients/react/src/hooks/secrets.ts +69 −1
  18. packages/clients/react/src/index.ts +13 −0
  19. packages/clients/react/src/plugins/index.ts +2 −0
  20. packages/clients/react/src/plugins/registry.ts +11 −0
  21. packages/clients/react/src/plugins/types.ts +18 −0
  22. packages/platform/api/src/index.ts +8 −0
  23. packages/platform/api/src/local/api.ts +57 −0
  24. packages/platform/api/src/local/http.ts +61 −4
  25. packages/platform/sdk-file/src/config.test.ts +51 −0
  26. packages/platform/sdk-file/src/config.ts +172 −0
  27. packages/platform/sdk-file/src/executor-state-store.ts +174 −3
  28. packages/platform/sdk-file/src/index.ts +31 −11
  29. packages/platform/sdk-file/src/secret-material-providers.ts +305 −1133
  30. packages/platform/sdk/src/backend.ts +8 −0
  31. packages/platform/sdk/src/executor-effect.ts +85 −0
  32. packages/platform/sdk/src/executor.ts +10 −0
  33. packages/platform/sdk/src/local/contracts.ts +86 −9
  34. packages/platform/sdk/src/local/secret-stores.ts +441 −0
  35. packages/platform/sdk/src/local/secrets.ts +21 −9
  36. packages/platform/sdk/src/plugins.ts +705 −1
  37. packages/platform/sdk/src/runtime/execution/scope/tool-invoker.ts +6 −1
  38. packages/platform/sdk/src/runtime/executor-state-store.ts +31 −1
  39. packages/platform/sdk/src/runtime/index.ts +8 −0
  40. packages/platform/sdk/src/runtime/scope/secret-material-providers.ts +2 −2
  41. packages/platform/sdk/src/runtime/secret-stores.ts +76 −0
  42. packages/platform/sdk/src/runtime/sources/source-plugins/index.ts +6 −0
  43. packages/platform/sdk/src/schema/ids.ts +4 −0
  44. packages/platform/sdk/src/schema/index.ts +1 −0
  45. packages/platform/sdk/src/schema/models/secret-material.ts +2 −1
  46. packages/platform/sdk/src/schema/models/secret-ref.ts +4 −2
  47. packages/platform/sdk/src/schema/models/secret-store.ts +33 −0
  48. packages/platform/server/:memory:/plugins/mcp/oauth-sessions/mcp_oauth_100f1f4b-14d2-48c5-a54b-493b3207c03b.json +55 −0
  49. packages/platform/server/:memory:/plugins/mcp/oauth-sessions/mcp_oauth_b0e04146-de7e-42e3-bd49-cab494c7f228.json +55 −0
  50. packages/platform/server/:memory:/plugins/mcp/sources/ws_local_23e7b12d3729f586/demo.json +13 −0
  51. packages/platform/server/:memory:/plugins/mcp/sources/ws_local_362335ffda3967d1/axiom.json +73 −0
  52. packages/platform/server/:memory:/plugins/mcp/sources/ws_local_49e62a1379b1ac86/demo.json +13 −0
  53. packages/platform/server/:memory:/plugins/mcp/sources/ws_local_6a36adfbe2442b43/demo.json +13 −0
  54. packages/platform/server/:memory:/plugins/mcp/sources/ws_local_8c0d810ef89e0233/demo.json +13 −0
  55. packages/platform/server/:memory:/plugins/mcp/sources/ws_local_cff5a2d1595b24ad/demo.json +13 −0
  56. packages/platform/server/:memory:/plugins/mcp/sources/ws_local_f6c616f206aeb799/demo.json +13 −0
  57. packages/platform/server/:memory:/plugins/openapi/sources/ws_local_0d19f5b81c734d12/github.json +10 −0
  58. packages/platform/server/:memory:/plugins/openapi/sources/ws_local_4bee124a76436ffd/github.json +10 −0
  59. packages/platform/server/:memory:/plugins/openapi/sources/ws_local_8d1d3aa6ceef50ab/dns.json +10 −0
  60. packages/platform/server/:memory:/plugins/openapi/sources/ws_local_9cca5d99347c43c1/dns.json +10 −0
  61. packages/platform/server/:memory:/plugins/openapi/sources/ws_local_a521c893e78de546/github.json +10 −0
  62. packages/platform/server/:memory:/plugins/openapi/sources/ws_local_b7e75e6d303b1e69/github.json +15 −0
  63. packages/platform/server/:memory:/plugins/openapi/sources/ws_local_c02b02b1312bd625/github.json +10 −0
  64. packages/platform/server/package.json +6 −0
  65. packages/platform/server/src/index.test.ts +23 −10
  66. packages/platform/server/src/index.ts +19 −0
  67. packages/platform/server/src/onepassword-store-storage.ts +43 −0
  68. plugins/google-discovery/react/components.tsx +10 −133
  69. plugins/graphql/react/components.tsx +2 −132
  70. plugins/keychain-secret-store/sdk/index.ts +410 −0
  71. plugins/keychain-secret-store/sdk/package.json +23 −0
  72. plugins/keychain-secret-store/sdk/tsconfig.json +15 −0
  73. plugins/local-secret-store/sdk/index.ts +98 −0
  74. plugins/local-secret-store/sdk/package.json +23 −0
  75. plugins/local-secret-store/sdk/tsconfig.json +15 −0
  76. plugins/local-tools/react/components.tsx +174 −0
  77. plugins/local-tools/react/index.ts +24 −0
  78. plugins/local-tools/react/package.json +25 −0
  79. plugins/local-tools/react/tsconfig.json +17 −0
  80. plugins/local-tools/sdk/catalog.ts +241 −0
  81. plugins/local-tools/sdk/index.ts +277 −0
  82. plugins/local-tools/sdk/package.json +26 −0
  83. plugins/local-tools/sdk/tsconfig.json +13 −0
  84. plugins/onepassword/http/index.ts +111 −0
  85. plugins/onepassword/http/package.json +26 −0
  86. plugins/onepassword/http/tsconfig.json +15 −0
  87. plugins/onepassword/react/components.tsx +321 −0
  88. plugins/onepassword/react/index.ts +13 −0
  89. plugins/onepassword/react/package.json +26 −0
  90. plugins/onepassword/react/tsconfig.json +17 −0
  91. plugins/onepassword/sdk/index.ts +675 −0
  92. plugins/onepassword/sdk/package.json +25 −0
  93. plugins/onepassword/sdk/tsconfig.json +15 −0
  94. plugins/onepassword/shared/index.ts +163 −0
  95. plugins/onepassword/shared/package.json +24 −0
  96. plugins/onepassword/shared/tsconfig.json +15 −0
  97. plugins/openapi/react/components.tsx +82 −160
.executor/executor.jsonc+0−13
@@ -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−}
.executor/tools/demo.ts+45−0
@@ -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+};
.gitignore+3−1
@@ -19,6 +19,8 @@ · 18 unmodified lines
1919 .executor/*
2020 !.executor/executor.json
2121 !.executor/executor.jsonc
22+!.executor/tools/
23+!.executor/tools/**
2224 .executor/artifacts/
2325 .executor/state/
2426 .executor/types/
@@ -84,4 +86,4 @@ personal-notes/ · 59 unmodified lines
8486 .tmp/
8587 executor/.env.executor-push
8688 ignored/
87−agent-notes/
89+agent-notes/
apps/executor/package.json+3−0
@@ -40,6 +40,9 @@ · 39 unmodified lines
4040 "@executor/executor-mcp": "workspace:*",
4141 "@executor/plugin-google-discovery-sdk": "workspace:*",
4242 "@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:*",
4346 "@executor/plugin-mcp-sdk": "workspace:*",
4447 "@executor/plugin-openapi-sdk": "workspace:*",
4548 "@executor/platform-api": "workspace:*",
apps/executor/src/cli/main.ts+16−1
@@ -20,6 +20,15 @@ import { · 19 unmodified lines
2020 import {
2121 graphqlSdkPlugin,
2222 } 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";
2332 import {
2433 mcpSdkPlugin,
2534 } from "@executor/plugin-mcp-sdk";
@@ -34,7 +43,9 @@ import { · 8 unmodified lines
3443 createExecutorEffect,
3544 type ExecutorEffect as Executor,
3645 } from "@executor/platform-sdk/effect";
37−import { createLocalExecutorBackend } from "@executor/platform-sdk-file";
46+import {
47+ createLocalExecutorBackend,
48+} from "@executor/platform-sdk-file";
3849 import {
3950 ExecutionIdSchema,
4051 type ExecutionEnvelope,
@@ -300,9 +311,13 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 259 unmodified lines
300311 Effect.acquireUseRelease(
301312 createExecutorEffect({
302313 backend: createLocalExecutorBackend({
314+ cwd: process.cwd(),
303315 localDataDir: CLI_LOCAL_DATA_DIR,
304316 }),
305317 plugins: [
318+ localSecretStoreSdkPlugin,
319+ keychainSecretStoreSdkPlugin,
320+ localToolsSdkPlugin(),
306321 graphqlSdkPlugin({
307322 storage: createFileGraphqlSourceStorage({
308323 rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/graphql/sources`,
apps/web/package.json+2−0
@@ -12,8 +12,10 @@ · 11 unmodified lines
1212 "@executor/plugin-execution-history-react": "workspace:*",
1313 "@executor/plugin-google-discovery-react": "workspace:*",
1414 "@executor/plugin-graphql-react": "workspace:*",
15+ "@executor/plugin-local-tools-react": "workspace:*",
1516 "@executor/plugin-mcp-react": "workspace:*",
1617 "@executor/plugin-openapi-react": "workspace:*",
18+ "@executor/plugin-onepassword-react": "workspace:*",
1719 "@executor/react": "workspace:*",
1820 "@executor/server": "workspace:*",
1921 "@shikijs/langs": "^4.0.1",
apps/web/src/components/icons.tsx+8−0
@@ -150,3 +150,11 @@ export function IconArrowLeft({ className, ...props }: IconProps) { · 149 unmodified lines
150150 </svg>
151151 );
152152 }
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+}
apps/web/src/components/shell.tsx+223−92
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from "react"; · 2 unmodified lines
33 import { useSources, type Source } from "@executor/react";
44 import { sourcePluginsIndexPath } from "@executor/react/plugins";
55 import { cn } from "../lib/utils";
6−import { IconPlus, IconCopy, IconCheck } from "./icons";
6+import { IconPlus, IconCopy, IconCheck, IconClose, IconMenu } from "./icons";
77 import { LoadableBlock } from "./loadable";
88 import { SourceFavicon } from "./source-favicon";
99 import {
@@ -224,6 +224,7 @@ export function AppShell() { · 214 unmodified lines
224224 const sources = useSources();
225225 const location = useLocation();
226226 const matchRoute = useMatchRoute();
227+ const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
227228 const isHome = matchRoute({ to: "/" });
228229 const isSecrets = matchRoute({ to: "/secrets" });
229230 const { latestVersion, updateAvailable, channel } = useLatestVersion(VITE_APP_VERSION);
@@ -234,11 +235,125 @@ export function AppShell() { · 4 unmodified lines
234235 ({ route }) => route.nav?.section === "sources",
235236 );
236237
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+
237254 return (
238255 <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 && (
242357 <div className="flex h-12 shrink-0 items-center border-b border-sidebar-border px-4">
243358 <Link to="/" className="flex items-center gap-1.5">
244359 <span className="font-display text-base tracking-tight text-foreground">
@@ -249,99 +364,107 @@ export function AppShell() { · 4 unmodified lines
249364 </span>
250365 </Link>
251366 </div>
367+ )}
252368
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}/`)
315390 }
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"
333402 >
334− GitHub
335− </a>
403+ <IconPlus className="size-3" />
404+ Add
405+ </Link>
336406 </div>
337407 </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+ )}
339453
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+ </>
345468 );
346469 }
347470
@@ -350,6 +473,7 @@ export function AppShell() { · 2 unmodified lines
350473 function SourceItem(props: {
351474 pathname: string;
352475 source: Source;
476+ onNavigate?: () => void;
353477 }) {
354478 const paths = getSourceFrontendPaths(props.source.kind);
355479
@@ -377,6 +501,7 @@ function SourceItem(props: { · 21 unmodified lines
377501 <Link
378502 to={detailPath}
379503 search={{ tab: "model" }}
504+ onClick={props.onNavigate}
380505 className={cn(
381506 "group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors",
382507 active
@@ -398,10 +523,16 @@ function SourceItem(props: { · 15 unmodified lines
398523
399524 // ── NavItem ──────────────────────────────────────────────────────────────
400525
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+}) {
402532 return (
403533 <Link
404534 to={props.to}
535+ onClick={props.onNavigate}
405536 className={cn(
406537 "flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-[13px] transition-colors",
407538 props.active
apps/web/src/plugins/index.ts+11−0
@@ -11,12 +11,18 @@ import { · 10 unmodified lines
1111 import {
1212 GraphqlReactPlugin,
1313 } from "@executor/plugin-graphql-react";
14+import {
15+ LocalToolsReactPlugin,
16+} from "@executor/plugin-local-tools-react";
1417 import {
1518 McpReactPlugin,
1619 } from "@executor/plugin-mcp-react";
1720 import {
1821 OpenApiReactPlugin,
1922 } from "@executor/plugin-openapi-react";
23+import {
24+ OnePasswordReactPlugin,
25+} from "@executor/plugin-onepassword-react";
2026 import {
2127 createExecutorPluginPaths,
2228 createSourcePluginPaths,
@@ -26,10 +32,12 @@ import { · 3 unmodified lines
2632
2733 const frontendPlugins = [
2834 ExecutionHistoryReactPlugin,
35+ LocalToolsReactPlugin,
2936 McpReactPlugin,
3037 GraphqlReactPlugin,
3138 GoogleDiscoveryReactPlugin,
3239 OpenApiReactPlugin,
40+ OnePasswordReactPlugin,
3341 ] as const satisfies readonly ExecutorFrontendPlugin[];
3442
3543 const frontendPluginRegistry = registerExecutorFrontendPlugins(frontendPlugins);
@@ -81,3 +89,6 @@ export const getSourceFrontendIconUrl = (source: Source) => · 45 unmodified lines
8189 source.kind === "google-discovery"
8290 ? getGoogleDiscoveryIconUrl(source)
8391 : null;
92+
93+export const getSecretStoreFrontendPlugin = (kind: string) =>
94+ registeredFrontendPlugins.find((plugin) => plugin.secretStore?.kind === kind) ?? null;
apps/web/src/views/secrets.tsx+859−265
@@ -1,261 +1,820 @@
1−import { useEffect, useState, type ReactNode } from "react";
1+import { useCallback, useEffect, useState, type ReactNode } from "react";
22 import {
3+ type BrowseSecretStoreResult,
4+ getExecutorApiBaseUrl,
35 type InstanceConfig,
4− type SecretListItem,
56 type Loadable,
6− useSecrets,
7+ type SecretListItem,
8+ type SecretStore,
79 useCreateSecret,
8− useUpdateSecret,
10+ useCreateSecretStore,
911 useDeleteSecret,
12+ useDeleteSecretStore,
13+ useExecutorMutation,
1014 useInstanceConfig,
15+ useRefreshSecrets,
16+ useSecretStores,
17+ useSecrets,
18+ useUpdateSecret,
1119 } from "@executor/react";
20+
1221 import { LoadableBlock } from "../components/loadable";
13−import { Button } from "../components/ui/button";
1422 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";
1630 import { cn } from "../lib/utils";
31+import { getSecretStoreFrontendPlugin } from "../plugins";
1732
18−// ---------------------------------------------------------------------------
19−// Page
20−// ---------------------------------------------------------------------------
21−
22−const storableProvidersFromConfig = (config: Loadable<InstanceConfig>) =>
33+const creatableStorePluginsFromConfig = (config: Loadable<InstanceConfig>) =>
2334 config.status === "ready"
24− ? config.data.secretProviders.filter((provider) => provider.canStore)
35+ ? config.data.secretStorePlugins.filter((plugin) => plugin.canCreate)
2536 : [];
2637
27−const providerLabelForId = (config: Loadable<InstanceConfig>, providerId: string): string =>
38+const canManageStoreKind = (
39+ config: Loadable<InstanceConfig>,
40+ kind: string,
41+): boolean =>
2842 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;
3174
3275 export function SecretsPage() {
3376 const instanceConfig = useInstanceConfig();
77+ const secretStores = useSecretStores();
3478 const secrets = useSecrets();
35− const [showCreate, setShowCreate] = useState(false);
79+ const [showCreateStore, setShowCreateStore] = useState(false);
80+ const [showCreateSecret, setShowCreateSecret] = useState(false);
3681 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);
4285
4386 return (
4487 <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">
4890 <div>
4991 <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl">
5092 Secrets
5193 </h1>
5294 <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}.` : ""}
5597 </p>
5698 </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>
61122 </div>
62123
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+ )
73159 }
74− />
75− )}
160+ </LoadableBlock>
161+ </section>
76162
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>
103220 </div>
104221 </div>
105222 );
106223 }
107224
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+ };
111271
112−function EmptyState(props: { onAdd: () => void }) {
113272 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>
129343 );
130344 }
131345
132−// ---------------------------------------------------------------------------
133−// Create form
134−// ---------------------------------------------------------------------------
135−
136346 function CreateSecretForm(props: {
137− onClose: () => void;
138347 className?: string;
139− providerOptions: ReadonlyArray<{ id: string; name: string }>;
140− defaultProviderId: string | null;
348+ onClose: () => void;
349+ storeOptions: ReadonlyArray<SecretStore>;
350+ defaultStoreId: string | null;
141351 }) {
142352 const createSecret = useCreateSecret();
353+ const refreshSecrets = useRefreshSecrets();
354+ const [mode, setMode] = useState<"create" | "import">("create");
143355 const [name, setName] = useState("");
144356 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("");
146369 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+ });
147444
148445 useEffect(() => {
149− if (providerId.length > 0) {
446+ if (storeId.length > 0) {
150447 return;
151448 }
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);
154455 }
155− }, [props.defaultProviderId, providerId]);
456+ }, [props.defaultStoreId, props.storeOptions, storeId]);
156457
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");
162472 return;
163473 }
164− if (!value) {
165− setError("Value is required.");
474+ if (canImportSecrets) {
475+ setMode("import");
166476 return;
167477 }
478+ setMode("create");
479+ }, [canCreateSecrets, canImportSecrets, selectedStore?.id]);
168480
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");
174510 });
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+
175571 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.");
178574 }
179575 };
180576
181577 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>
208608 </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" && (
209620 <FieldLabel label="Value">
210621 <input
211622 type="password"
212623 value={value}
213− onChange={(e) => setValue(e.target.value)}
624+ onChange={(event) => setValue(event.target.value)}
214625 placeholder="ghp_..."
215626 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"
216627 />
217628 </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>
228661 ))}
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+ )}
231706 </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>
241763 </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+ )}
242807 </div>
243808 </div>
244809 );
245810 }
246811
247−// ---------------------------------------------------------------------------
248−// Secret row
249−// ---------------------------------------------------------------------------
250−
251812 function SecretRow(props: {
252813 secret: SecretListItem;
253− providerLabel: string;
254814 isEditing: boolean;
255815 onEdit: () => void;
256816 onCancelEdit: () => void;
257817 }) {
258− const { secret, providerLabel, isEditing } = props;
259818 const deleteSecret = useDeleteSecret();
260819 const [isDeleting, setIsDeleting] = useState(false);
261820 const [confirmDelete, setConfirmDelete] = useState(false);
@@ -263,72 +822,60 @@ function SecretRow(props: { · 1 unmodified line
263822 const handleDelete = async () => {
264823 setIsDeleting(true);
265824 try {
266− await deleteSecret.mutateAsync(secret.id);
825+ await deleteSecret.mutateAsync(props.secret.id);
267826 } catch {
268− // refresh will show the secret still there
827+ // refresh state will keep the secret visible
269828 } finally {
270− setConfirmDelete(false);
271829 setIsDeleting(false);
830+ setConfirmDelete(false);
272831 }
273832 };
274833
275− const createdDate = new Date(secret.createdAt).toLocaleDateString(undefined, {
276− month: "short",
277− day: "numeric",
278− year: "numeric",
279− });
280−
281834 return (
282− <div className="px-5 py-3.5">
835+ <>
283836 <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>
287851 </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>
321855 </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+ )}
322869 </div>
323− <div className="flex items-center gap-1.5 shrink-0">
870+ <div className="flex shrink-0 items-center gap-1.5">
324871 <button
325872 type="button"
326873 onClick={props.onEdit}
327874 className={cn(
328875 "inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium transition-colors",
329− isEditing
876+ props.isEditing
330877 ? "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",
332879 )}
333880 >
334881 <IconPencil className="size-3" />
@@ -336,14 +883,11 @@ function SecretRow(props: { · 1 unmodified line
336883 </button>
337884 {confirmDelete ? (
338885 <>
339− <span className="text-[10px] font-medium text-destructive">
340− Confirm delete?
341− </span>
342886 <button
343887 type="button"
344888 onClick={() => setConfirmDelete(false)}
345889 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"
347891 >
348892 Cancel
349893 </button>
@@ -355,8 +899,10 @@ function SecretRow(props: { · 5 unmodified lines
355899 disabled={isDeleting}
356900 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"
357901 >
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
360906 </button>
361907 </>
362908 ) : (
@@ -364,31 +910,28 @@ function SecretRow(props: { · 1 unmodified line
364910 type="button"
365911 onClick={() => setConfirmDelete(true)}
366912 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"
368914 >
369− {isDeleting ? <IconSpinner className="size-3" /> : <IconTrash className="size-3" />}
915+ {isDeleting
916+ ? <IconSpinner className="size-3" />
917+ : <IconTrash className="size-3" />}
370918 Delete
371919 </button>
372920 )}
373921 </div>
374922 </div>
375923
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} />
382926 )}
383− </div>
927+ </>
384928 );
385929 }
386930
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+}) {
392935 const updateSecret = useUpdateSecret();
393936 const [name, setName] = useState(props.secret.name ?? "");
394937 const [value, setValue] = useState("");
@@ -416,39 +959,35 @@ function EditSecretForm(props: { secret: SecretListItem; onClose: () => void }) · 21 unmodified lines
416959 payload,
417960 });
418961 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.");
421964 }
422965 };
423966
424967 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>}
431970 <div className="grid gap-3 sm:grid-cols-2">
432971 <FieldLabel label="Name">
433972 <input
434973 value={name}
435− onChange={(e) => setName(e.target.value)}
974+ onChange={(event) => setName(event.target.value)}
436975 placeholder="Secret name"
437976 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"
438977 autoFocus
439978 />
440979 </FieldLabel>
441− <FieldLabel label="New value (leave empty to keep current)">
980+ <FieldLabel label="New value">
442981 <input
443982 type="password"
444983 value={value}
445− onChange={(e) => setValue(e.target.value)}
984+ onChange={(event) => setValue(event.target.value)}
446985 placeholder="Leave empty to keep existing"
447986 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"
448987 />
449988 </FieldLabel>
450989 </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">
452991 <button
453992 type="button"
454993 onClick={props.onClose}
@@ -469,29 +1008,84 @@ function EditSecretForm(props: { secret: SecretListItem; onClose: () => void }) · 14 unmodified lines
4691008 );
4701009 }
4711010
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+}
4751033
476−function FieldLabel(props: { label: string; children: ReactNode }) {
1034+function ErrorBanner(props: {
1035+ children: ReactNode;
1036+ className?: string;
1037+}) {
4771038 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+ >
4801045 {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>
4821069 );
4831070 }
4841071
485−function KeyIcon(props: { className?: string }) {
1072+function FieldLabel(props: {
1073+ label: string;
1074+ children: ReactNode;
1075+}) {
4861076 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>
4961083 );
4971084 }
1085+
1086+const formatDate = (value: number): string =>
1087+ new Date(value).toLocaleDateString(undefined, {
1088+ month: "short",
1089+ day: "numeric",
1090+ year: "numeric",
1091+ });
apps/web/vite.config.ts+4−0
@@ -62,6 +62,10 @@ export default defineConfig({ · 61 unmodified lines
6262 },
6363 server: {
6464 port: 8788,
65+ allowedHosts: [
66+ "rhyss-laptop.tail5665af.ts.net",
67+ ".tail5665af.ts.net",
68+ ],
6569 watch: {
6670 ignored: [
6771 "!**/node_modules/@executor/**",
bun.lock+139−0
@@ -33,6 +33,9 @@ · 32 unmodified lines
3333 "@executor/platform-sdk-file": "workspace:*",
3434 "@executor/plugin-google-discovery-sdk": "workspace:*",
3535 "@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:*",
3639 "@executor/plugin-mcp-sdk": "workspace:*",
3740 "@executor/plugin-openapi-sdk": "workspace:*",
3841 "@executor/server": "workspace:*",
@@ -57,7 +60,9 @@ · 18 unmodified lines
5760 "@executor/plugin-execution-history-react": "workspace:*",
5861 "@executor/plugin-google-discovery-react": "workspace:*",
5962 "@executor/plugin-graphql-react": "workspace:*",
63+ "@executor/plugin-local-tools-react": "workspace:*",
6064 "@executor/plugin-mcp-react": "workspace:*",
65+ "@executor/plugin-onepassword-react": "workspace:*",
6166 "@executor/plugin-openapi-react": "workspace:*",
6267 "@executor/react": "workspace:*",
6368 "@executor/server": "workspace:*",
@@ -373,9 +378,15 @@ · 309 unmodified lines
373378 "@executor/plugin-graphql-http": "workspace:*",
374379 "@executor/plugin-graphql-sdk": "workspace:*",
375380 "@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:*",
376384 "@executor/plugin-mcp-http": "workspace:*",
377385 "@executor/plugin-mcp-sdk": "workspace:*",
378386 "@executor/plugin-mcp-shared": "workspace:*",
387+ "@executor/plugin-onepassword-http": "workspace:*",
388+ "@executor/plugin-onepassword-sdk": "workspace:*",
389+ "@executor/plugin-onepassword-shared": "workspace:*",
379390 "@executor/plugin-openapi-http": "workspace:*",
380391 "@executor/plugin-openapi-sdk": "workspace:*",
381392 "@executor/plugin-openapi-shared": "workspace:*",
@@ -550,6 +561,58 @@ · 168 unmodified lines
550561 "bun-types": "catalog:",
551562 },
552563 },
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+ },
553616 "plugins/mcp/http": {
554617 "name": "@executor/plugin-mcp-http",
555618 "version": "0.0.0",
@@ -614,6 +677,62 @@ · 58 unmodified lines
614677 "bun-types": "catalog:",
615678 },
616679 },
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+ },
617736 "plugins/openapi/http": {
618737 "name": "@executor/plugin-openapi-http",
619738 "version": "0.0.0",
@@ -692,6 +811,10 @@ · 72 unmodified lines
692811 "zod": "^4.3.6",
693812 },
694813 "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+
695818 "@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=="],
696819
697820 "@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
9221045
9231046 "@executor/plugin-graphql-shared": ["@executor/plugin-graphql-shared@workspace:plugins/graphql/shared"],
9241047
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+
9251056 "@executor/plugin-mcp-http": ["@executor/plugin-mcp-http@workspace:plugins/mcp/http"],
9261057
9271058 "@executor/plugin-mcp-react": ["@executor/plugin-mcp-react@workspace:plugins/mcp/react"],
@@ -930,6 +1061,14 @@ · 2 unmodified lines
9301061
9311062 "@executor/plugin-mcp-shared": ["@executor/plugin-mcp-shared@workspace:plugins/mcp/shared"],
9321063
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+
9331072 "@executor/plugin-openapi-http": ["@executor/plugin-openapi-http@workspace:plugins/openapi/http"],
9341073
9351074 "@executor/plugin-openapi-react": ["@executor/plugin-openapi-react@workspace:plugins/openapi/react"],
examples/sqlite-sdk-consumer/src/sqlite-backend.ts+69−45
@@ -27,6 +27,7 @@ import type { · 26 unmodified lines
2727 LocalInstallation,
2828 SecretMaterial,
2929 SecretRef,
30+ SecretStore,
3031 } from "@executor/platform-sdk/schema";
3132 import {
3233 ScopeIdSchema,
@@ -57,14 +58,15 @@ type SourceArtifactBuildInput = Parameters< · 24 unmodified lines
5758
5859 type SecretMaterialSummary = {
5960 id: string;
60− providerId: string;
61+ storeId: string;
6162 name: string | null;
6263 purpose: string;
6364 createdAt: number;
6465 updatedAt: number;
6566 };
6667
67−const SQLITE_SECRET_PROVIDER_ID = "sqlite";
68+const SQLITE_SECRET_STORE_KIND = "sqlite";
69+const SQLITE_SECRET_STORE_ID = "sts_sqlite";
6870
6971 const installations = sqliteTable("installations", {
7072 key: text("key").primaryKey(),
@@ -250,8 +252,39 @@ const openSqliteStore = (databasePath: string) => { · 179 unmodified lines
250252
251253 type SqliteStore = ReturnType<typeof openSqliteStore>;
252254
253−const createStorageDomains = (store: SqliteStore) => ({
255+const createStorageDomains = (
256+ store: SqliteStore,
257+ installation: LocalInstallation,
258+) => ({
254259 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+ },
255288 getById: (id: SecretMaterial["id"]) => {
256289 const row = store.db.select().from(secretMaterials).where(eq(secretMaterials.id, id)).get();
257290 return row ? parseJson<SecretMaterial>(row.json) : null;
@@ -259,7 +292,7 @@ const createStorageDomains = (store: SqliteStore) => ({ · 1 unmodified line
259292 listAll: (): readonly SecretMaterialSummary[] =>
260293 store.db.select().from(secretMaterials).all().map((row) => ({
261294 id: row.id,
262− providerId: row.providerId,
295+ storeId: row.providerId,
263296 name: row.name,
264297 purpose: row.purpose,
265298 createdAt: row.createdAt,
@@ -268,7 +301,7 @@ const createStorageDomains = (store: SqliteStore) => ({ · 2 unmodified lines
268301 upsert: (material: SecretMaterial) =>
269302 store.db.insert(secretMaterials).values({
270303 id: material.id,
271− providerId: material.providerId,
304+ providerId: material.storeId,
272305 name: material.name,
273306 purpose: material.purpose,
274307 createdAt: material.createdAt,
@@ -277,7 +310,7 @@ const createStorageDomains = (store: SqliteStore) => ({ · 2 unmodified lines
277310 }).onConflictDoUpdate({
278311 target: secretMaterials.id,
279312 set: {
280− providerId: material.providerId,
313+ providerId: material.storeId,
281314 name: material.name,
282315 purpose: material.purpose,
283316 createdAt: material.createdAt,
@@ -299,7 +332,7 @@ const createStorageDomains = (store: SqliteStore) => ({ · 15 unmodified lines
299332 updatedAt: Date.now(),
300333 };
301334 store.db.update(secretMaterials).set({
302− providerId: next.providerId,
335+ providerId: next.storeId,
303336 name: next.name,
304337 purpose: next.purpose,
305338 createdAt: next.createdAt,
@@ -308,7 +341,7 @@ const createStorageDomains = (store: SqliteStore) => ({ · 2 unmodified lines
308341 }).where(eq(secretMaterials.id, id)).run();
309342 return {
310343 id: next.id,
311− providerId: next.providerId,
344+ storeId: next.storeId,
312345 name: next.name,
313346 purpose: next.purpose,
314347 createdAt: next.createdAt,
@@ -471,7 +504,19 @@ export const createSqliteExecutorBackend = ( · 156 unmodified lines
471504 return createExecutorBackend({
472505 loadRepositories: () => {
473506 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);
475520
476521 return {
477522 scope: {
@@ -484,30 +529,9 @@ export const createSqliteExecutorBackend = ( · 6 unmodified lines
484529 },
485530 installation: {
486531 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;
497533 },
498534 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− })();
511535 store.db.insert(installations).values({
512536 key: "active",
513537 scopeId: installation.scopeId,
@@ -587,21 +611,22 @@ export const createSqliteExecutorBackend = ( · 73 unmodified lines
587611 },
588612 secrets: {
589613 ...secrets,
614+ secretStores: secrets.secretStores,
590615 resolve: ({ ref }) => {
591616 const material = secrets.getById(
592− ref.handle as SecretMaterial["id"],
617+ ref.secretId as SecretMaterial["id"],
593618 );
594619 if (!material || material.value === null) {
595− throw new Error(`Missing secret material ${ref.handle}`);
620+ throw new Error(`Missing secret material ${ref.secretId}`);
596621 }
597622 return material.value;
598623 },
599− store: ({ purpose, value, name, providerId }) => {
624+ store: ({ purpose, value, name, storeId }) => {
600625 const now = Date.now();
601626 const id = SecretMaterialIdSchema.make(`secret_${randomUUID()}`);
602627 const material: SecretMaterial = {
603628 id,
604− providerId: providerId ?? SQLITE_SECRET_PROVIDER_ID,
629+ storeId: (storeId ?? SQLITE_SECRET_STORE_ID) as SecretMaterial["storeId"],
605630 handle: id,
606631 name: name ?? null,
607632 purpose,
@@ -611,19 +636,18 @@ export const createSqliteExecutorBackend = ( · 3 unmodified lines
611636 };
612637 secrets.upsert(material);
613638 return {
614− providerId: material.providerId,
615− handle: material.handle,
639+ secretId: material.id,
616640 } satisfies SecretRef;
617641 },
618642 delete: (ref) =>
619− secrets.removeById(ref.handle as SecretMaterial["id"]),
643+ secrets.removeById(ref.secretId as SecretMaterial["id"]),
620644 update: ({ ref, name, value }) => {
621645 const updated = secrets.updateById(
622− ref.handle as SecretMaterial["id"],
646+ ref.secretId as SecretMaterial["id"],
623647 { name, value },
624648 );
625649 if (!updated) {
626− throw new Error(`Missing secret material ${ref.handle}`);
650+ throw new Error(`Missing secret material ${ref.secretId}`);
627651 }
628652 return updated;
629653 },
@@ -632,14 +656,14 @@ export const createSqliteExecutorBackend = ( · 2 unmodified lines
632656 instanceConfig: {
633657 resolve: () => ({
634658 platform: "sqlite-sdk-example",
635− secretProviders: [
659+ secretStorePlugins: [
636660 {
637− id: SQLITE_SECRET_PROVIDER_ID,
638− name: "SQLite",
639− canStore: true,
661+ kind: SQLITE_SECRET_STORE_KIND,
662+ displayName: "SQLite",
663+ canCreate: false,
640664 },
641665 ],
642− defaultSecretStoreProvider: SQLITE_SECRET_PROVIDER_ID,
666+ defaultSecretStoreId: SQLITE_SECRET_STORE_ID,
643667 }),
644668 },
645669 close: async () => {
packages/clients/react/src/components/secret-reference-field.tsx+453−0
@@ -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+}
packages/clients/react/src/core/api-atoms.ts+9−0
@@ -2,6 +2,7 @@ import { · 1 unmodified line
22 Atom,
33 } from "@effect-atom/atom-react";
44 import type {
5+ SecretStore,
56 InstanceConfig,
67 LocalInstallation,
78 SecretListItem,
@@ -50,6 +51,12 @@ export const secretsAtom = (baseUrl: string = getExecutorApiBaseUrl()) => · 42 unmodified lines
5051 timeToLive: "1 minute",
5152 });
5253
54+export const secretStoresAtom = (baseUrl: string = getExecutorApiBaseUrl()) =>
55+ getExecutorApiHttpClient(baseUrl).query("local", "listSecretStores", {
56+ reactivityKeys: secretsReactivityKey(),
57+ timeToLive: "1 minute",
58+ });
59+
5360 export const executionsAtom = (workspaceId: Execution["scopeId"]) =>
5461 getExecutorApiHttpClient().query("executions", "list", {
5562 path: {
@@ -168,6 +175,7 @@ export type ExecutorApiAtoms = { · 112 unmodified lines
168175 localInstallationAtom: typeof localInstallationAtom;
169176 instanceConfigAtom: typeof instanceConfigAtom;
170177 secretsAtom: typeof secretsAtom;
178+ secretStoresAtom: typeof secretStoresAtom;
171179 executionsAtom: typeof executionsAtom;
172180 executionAtom: typeof executionAtom;
173181 sourcesAtom: typeof sourcesAtom;
@@ -181,6 +189,7 @@ export type ExecutorApiAtomValues = { · 7 unmodified lines
181189 localInstallation: LocalInstallation;
182190 instanceConfig: InstanceConfig;
183191 secrets: ReadonlyArray<SecretListItem>;
192+ secretStores: ReadonlyArray<SecretStore>;
184193 executions: ReadonlyArray<Execution>;
185194 execution: ExecutionEnvelope;
186195 sources: ReadonlyArray<Source>;
packages/clients/react/src/core/http-client.ts+5−3
@@ -7,10 +7,12 @@ import { · 6 unmodified lines
77 import type * as HttpApi from "@effect/platform/HttpApi";
88 import type * as HttpApiGroup from "@effect/platform/HttpApiGroup";
99 import {
10− ExecutorApi,
1110 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";
1416
1517 import { getExecutorApiBaseUrl } from "./base-url";
1618
packages/clients/react/src/hooks/secrets.ts+69−1
@@ -3,10 +3,14 @@ import { · 2 unmodified lines
33 useAtomSet,
44 } from "@effect-atom/atom-react";
55 import type {
6+ CreateSecretStorePayload,
67 CreateSecretPayload,
8+ DeleteSecretStoreResult,
79 CreateSecretResult,
810 DeleteSecretResult,
11+ SecretStore,
912 SecretListItem,
13+ UpdateSecretStorePayload,
1014 UpdateSecretPayload,
1115 UpdateSecretResult,
1216 } from "@executor/platform-api";
@@ -14,7 +18,7 @@ import * as React from "react"; · 1 unmodified line
1418
1519 import { getExecutorApiBaseUrl } from "../core/base-url";
1620 import { getExecutorApiHttpClient } from "../core/http-client";
17−import { secretsAtom } from "../core/api-atoms";
21+import { secretStoresAtom, secretsAtom } from "../core/api-atoms";
1822 import { useLoadableAtom } from "../core/loadable";
1923 import { secretsReactivityKey } from "../core/reactivity";
2024 import type { Loadable } from "../core/types";
@@ -23,9 +27,73 @@ import { useExecutorMutation } from "./mutations"; · 2 unmodified lines
2327 export const useSecrets = (): Loadable<ReadonlyArray<SecretListItem>> =>
2428 useLoadableAtom(secretsAtom(getExecutorApiBaseUrl()));
2529
30+export const useSecretStores = (): Loadable<ReadonlyArray<SecretStore>> =>
31+ useLoadableAtom(secretStoresAtom(getExecutorApiBaseUrl()));
32+
2633 export const useRefreshSecrets = (): (() => void) =>
2734 useAtomRefresh(secretsAtom(getExecutorApiBaseUrl()));
2835
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+
2997 export const useCreateSecret = () => {
3098 const mutate = useAtomSet(
3199 getExecutorApiHttpClient().mutation("local", "createSecret"),
packages/clients/react/src/index.ts+13−0
@@ -10,12 +10,17 @@ export { · 9 unmodified lines
1010 } from "@effect-atom/atom-react";
1111
1212 export type {
13+ BrowseSecretStoreResult,
14+ CreateSecretStorePayload,
1315 CreateSecretPayload,
1416 CreateSecretResult,
17+ DeleteSecretStoreResult,
1518 DeleteSecretResult,
1619 InstanceConfig,
1720 LocalInstallation,
1821 SecretListItem,
22+ SecretStore,
23+ UpdateSecretStorePayload,
1924 UpdateSecretPayload,
2025 UpdateSecretResult,
2126 } from "@executor/platform-api";
@@ -51,6 +56,9 @@ export { · 29 unmodified lines
5156 export {
5257 ExecutorReactProvider,
5358 } from "./provider";
59+export {
60+ SecretReferenceField,
61+} from "./components/secret-reference-field";
5462 export {
5563 useExecutorMutation,
5664 } from "./hooks/mutations";
@@ -60,10 +68,15 @@ export { · 3 unmodified lines
6068 useRefreshLocalInstallation,
6169 } from "./hooks/local";
6270 export {
71+ useCreateSecretStore,
6372 useCreateSecret,
73+ useDeleteSecretStore,
6474 useDeleteSecret,
75+ useRefreshSecretStores,
6576 useRefreshSecrets,
77+ useSecretStores,
6678 useSecrets,
79+ useUpdateSecretStore,
6780 useUpdateSecret,
6881 } from "./hooks/secrets";
6982 export {
packages/clients/react/src/plugins/index.ts+2−0
@@ -5,6 +5,8 @@ export type { · 4 unmodified lines
55 FrontendPluginRouteDefinition,
66 FrontendPluginRouteParams,
77 FrontendPluginRouteSearch,
8+ SecretStoreCreateFormProps,
9+ SecretStoreFrontendDefinition,
810 SourcePluginNavigation,
911 SourcePluginRouteParams,
1012 SourcePluginRouteSearch,
packages/clients/react/src/plugins/registry.ts+11−0
@@ -28,6 +28,7 @@ export const registerExecutorFrontendPlugins = ( · 27 unmodified lines
2828 ) => {
2929 const pluginsByKey = new Map<string, ExecutorFrontendPlugin>();
3030 const routesByPluginAndKey = new Map<string, RegisteredFrontendPluginRoute>();
31+ const secretStoresByKind = new Map<string, ExecutorFrontendPlugin>();
3132
3233 for (const plugin of plugins) {
3334 if (pluginsByKey.has(plugin.key)) {
@@ -36,6 +37,16 @@ export const registerExecutorFrontendPlugins = ( · 2 unmodified lines
3637
3738 pluginsByKey.set(plugin.key, plugin);
3839
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+
3950 const pluginRouteKeys = new Set<string>();
4051 const pluginRoutePaths = new Set<string>();
4152
packages/clients/react/src/plugins/types.ts+18−0
@@ -1,9 +1,11 @@
11 import type { ComponentType } from "react";
2+import type { SecretListItem } from "@executor/platform-api";
23
34 import type {
45 ExecutorPluginPaths,
56 SourcePluginPaths,
67 } from "./paths";
8+import type { Loadable } from "../core/types";
79
810 export type FrontendPluginRouteSearch = Record<string, unknown>;
911 export type FrontendPluginRouteParams = Readonly<Record<string, string | undefined>>;
@@ -50,9 +52,25 @@ export type SourcePluginNavigation = { · 40 unmodified lines
5052 updateSearch: (search: SourcePluginRouteSearch) => void | Promise<void>;
5153 };
5254
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+
5370 export type ExecutorFrontendPlugin = {
5471 key: string;
5572 displayName?: string;
5673 description?: string;
5774 routes?: readonly FrontendPluginRouteDefinition[];
75+ secretStore?: SecretStoreFrontendDefinition;
5876 };
packages/platform/api/src/index.ts+8−0
@@ -31,8 +31,16 @@ export { · 30 unmodified lines
3131
3232 export {
3333 LocalApi,
34+ type BrowseSecretStorePayload,
35+ type BrowseSecretStoreResult,
3436 type SecretProvider,
37+ type SecretStore,
38+ type SecretStoreBrowseEntry,
39+ type ImportSecretFromStorePayload,
3540 type InstanceConfig,
41+ type CreateSecretStorePayload,
42+ type UpdateSecretStorePayload,
43+ type DeleteSecretStoreResult,
3644 type SecretListItem,
3745 type CreateSecretPayload,
3846 type CreateSecretResult,
packages/platform/api/src/local/api.ts+57−0
@@ -1,12 +1,19 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
22 import {
3+ BrowseSecretStorePayloadSchema,
4+ BrowseSecretStoreResultSchema,
35 CreateSecretPayloadSchema,
46 CreateSecretResultSchema,
7+ CreateSecretStorePayloadSchema,
58 DeleteSecretResultSchema,
9+ DeleteSecretStoreResultSchema,
10+ ImportSecretFromStorePayloadSchema,
611 InstanceConfigSchema,
712 SecretListItemSchema,
13+ SecretStoreSchema,
814 UpdateSecretPayloadSchema,
915 UpdateSecretResultSchema,
16+ UpdateSecretStorePayloadSchema,
1017 } from "@executor/platform-sdk/contracts";
1118 import {
1219 ControlPlaneBadRequestError,
@@ -17,15 +24,23 @@ import { LocalInstallationSchema } from "@executor/platform-sdk/schema"; · 4 unmodified lines
1724 import * as Schema from "effect/Schema";
1825
1926 export type {
27+ BrowseSecretStorePayload,
28+ BrowseSecretStoreResult,
2029 CreateSecretPayload,
2130 CreateSecretResult,
2231 DeleteSecretResult,
32+ DeleteSecretStoreResult,
33+ CreateSecretStorePayload,
34+ ImportSecretFromStorePayload,
2335 InstanceConfig,
2436 SecretLinkedSource,
2537 SecretListItem,
2638 SecretProvider,
39+ SecretStoreBrowseEntry,
40+ SecretStore,
2741 UpdateSecretPayload,
2842 UpdateSecretResult,
43+ UpdateSecretStorePayload,
2944 } from "@executor/platform-sdk/contracts";
3045
3146 export class LocalApi extends HttpApiGroup.make("local")
@@ -41,6 +56,48 @@ export class LocalApi extends HttpApiGroup.make("local") · 9 unmodified lines
4156 .addSuccess(InstanceConfigSchema)
4257 .addError(ControlPlaneStorageError),
4358 )
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+ )
44101 .add(
45102 HttpApiEndpoint.get("listSecrets")`/local/secrets`
46103 .addSuccess(Schema.Array(SecretListItemSchema))
packages/platform/api/src/local/http.ts+61−4
@@ -1,5 +1,9 @@
11 import { HttpApiBuilder } from "@effect/platform";
22 import * as Effect from "effect/Effect";
3+import {
4+ LocalInstanceConfigService,
5+ provideExecutorRuntime,
6+} from "@executor/platform-sdk/runtime";
37
48 import { ExecutorApi } from "../api";
59 import { ControlPlaneStorageError } from "../errors";
@@ -19,18 +23,71 @@ export const ExecutorLocalLive = HttpApiBuilder.group( · 13 unmodified lines
1923 handlers
2024 .handle("installation", () =>
2125 Effect.flatMap(getControlPlaneExecutor(), (executor) =>
22− executor.local.installation().pipe(
23− Effect.mapError(toStorageError("local.installation")),
24− )
26+ Effect.succeed(executor.installation)
2527 )
2628 )
2729 .handle("config", () =>
2830 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(
3038 Effect.mapError(toStorageError("local.config")),
3139 )
3240 )
3341 )
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+ )
3491 .handle("listSecrets", () =>
3592 Effect.flatMap(getControlPlaneExecutor(), (executor) =>
3693 executor.secrets.list().pipe(
packages/platform/sdk-file/src/config.test.ts+51−0
@@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"; · 7 unmodified lines
88 import {
99 loadLocalExecutorConfig,
1010 mergeLocalExecutorConfigs,
11+ migrateLegacyLocalExecutorConfigFile,
1112 resolveDefaultHomeConfigCandidates,
1213 resolveDefaultHomeStateDirectory,
1314 resolveLocalWorkspaceContext,
@@ -136,4 +137,54 @@ describe("local-config", () => { · 122 unmodified lines
136137
137138 expect(merged?.runtime).toBe("deno");
138139 });
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+ );
139190 });
packages/platform/sdk-file/src/config.ts+172−0
@@ -33,6 +33,9 @@ export type FileLoadedExecutorConfig = LoadedLocalExecutorConfig & { · 32 unmodified lines
3333 export const encodeLocalExecutorConfig = (config: LocalExecutorConfig): string =>
3434 `${JSON.stringify(config, null, 2)}\n`;
3535
36+const encodeUnknownConfigDocument = (config: unknown): string =>
37+ `${JSON.stringify(config, null, 2)}\n`;
38+
3639 export const resolveConfigRelativePath = (input: {
3740 path: string;
3841 scopeRoot: string;
@@ -223,6 +226,130 @@ const parseJsonc = (input: { path: string; content: string }): LocalExecutorConf · 184 unmodified lines
223226 }
224227 };
225228
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+
226353 const mergeSourceMaps = (
227354 base: Record<string, LocalConfigSource> | undefined,
228355 extra: Record<string, LocalConfigSource> | undefined,
@@ -426,6 +553,51 @@ export const readOptionalLocalExecutorConfig = ( · 197 unmodified lines
426553 });
427554 });
428555
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+
429601 export const loadLocalExecutorConfig = (
430602 context: ResolvedLocalWorkspaceContext,
431603 ): Effect.Effect<
packages/platform/sdk-file/src/executor-state-store.ts+174−3
@@ -12,6 +12,8 @@ import { · 11 unmodified lines
1212 ExecutionStepSchema,
1313 SecretMaterialSchema,
1414 type SecretMaterial,
15+ SecretStoreSchema,
16+ type SecretStore,
1517 } from "@executor/platform-sdk/schema";
1618 import * as Effect from "effect/Effect";
1719 import * as Option from "effect/Option";
@@ -26,9 +28,12 @@ import { · 8 unmodified lines
2628
2729 const LOCAL_EXECUTOR_STATE_VERSION = 1 as const;
2830 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";
2933
3034 const LocalExecutorStateSnapshotSchema = Schema.Struct({
3135 version: Schema.Literal(LOCAL_EXECUTOR_STATE_VERSION),
36+ secretStores: Schema.Array(SecretStoreSchema),
3237 secretMaterials: Schema.Array(SecretMaterialSchema),
3338 executions: Schema.Array(ExecutionSchema),
3439 executionInteractions: Schema.Array(ExecutionInteractionSchema),
@@ -48,6 +53,7 @@ const decodeLocalExecutorStateSnapshot = Schema.decodeUnknown( · 13 unmodified lines
4853
4954 const defaultLocalExecutorStateSnapshot = (): LocalExecutorStateSnapshot => ({
5055 version: LOCAL_EXECUTOR_STATE_VERSION,
56+ secretStores: [],
5157 secretMaterials: [],
5258 executions: [],
5359 executionInteractions: [],
@@ -64,8 +70,31 @@ const asRecord = ( · 10 unmodified lines
6470 ? (value as Record<string, unknown>)
6571 : null;
6672
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+
6793 const migrateLegacyExecutorStateValue = (
6894 value: unknown,
95+ input: {
96+ scopeId: string;
97+ },
6998 ): {
7099 value: unknown;
71100 migrated: boolean;
@@ -73,7 +102,7 @@ const migrateLegacyExecutorStateValue = ( · 1 unmodified line
73102 if (Array.isArray(value)) {
74103 let migrated = false;
75104 const next = value.map((item) => {
76− const result = migrateLegacyExecutorStateValue(item);
105+ const result = migrateLegacyExecutorStateValue(item, input);
77106 migrated = migrated || result.migrated;
78107 return result.value;
79108 });
@@ -104,11 +133,72 @@ const migrateLegacyExecutorStateValue = ( · 24 unmodified lines
104133 migrated = true;
105134 }
106135
107− const migratedEntry = migrateLegacyExecutorStateValue(entry);
136+ const migratedEntry = migrateLegacyExecutorStateValue(entry, input);
108137 migrated = migrated || migratedEntry.migrated;
109138 next[nextKey] = migratedEntry.value;
110139 }
111140
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+
112202 return { value: next, migrated };
113203 };
114204
@@ -168,7 +258,9 @@ const readStateFromDisk = ( · 53 unmodified lines
168258 try: () => JSON.parse(content) as unknown,
169259 catch: mapFileSystemError(path, "parse executor state"),
170260 });
171− const migrated = migrateLegacyExecutorStateValue(parsed);
261+ const migrated = migrateLegacyExecutorStateValue(parsed, {
262+ scopeId: deriveLocalInstallation(context).scopeId,
263+ });
172264 const decoded = yield* decodeLocalExecutorStateSnapshot(migrated.value).pipe(
173265 Effect.mapError(mapFileSystemError(path, "decode executor state")),
174266 );
@@ -233,6 +325,10 @@ export const mergeImportedLocalExecutorStateSnapshot = (input: { · 58 unmodified lines
233325 imported: Partial<Omit<LocalExecutorStateSnapshot, "version">>;
234326 }): LocalExecutorStateSnapshot => ({
235327 version: LOCAL_EXECUTOR_STATE_VERSION,
328+ secretStores: mergeById(
329+ input.current.secretStores,
330+ input.imported.secretStores ?? [],
331+ ),
236332 secretMaterials: mergeById(
237333 input.current.secretMaterials,
238334 input.imported.secretMaterials ?? [],
@@ -334,6 +430,81 @@ export const createLocalExecutorStateStore = ( · 95 unmodified lines
334430 const stateManager = createStateManager(context, fileSystem);
335431
336432 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+ },
337508 secretMaterials: {
338509 getById: (id: SecretMaterial["id"]) =>
339510 stateManager.read((state) => {
packages/platform/sdk-file/src/index.ts+31−11
@@ -31,6 +31,7 @@ import { · 30 unmodified lines
3131 import * as Effect from "effect/Effect";
3232 import {
3333 type LoadedLocalExecutorConfig,
34+ migrateLegacyLocalExecutorConfigs,
3435 type ResolvedLocalWorkspaceContext,
3536 loadLocalExecutorConfig,
3637 resolveConfigRelativePath,
@@ -44,6 +45,7 @@ import { · 7 unmodified lines
4445 createDefaultSecretMaterialResolver,
4546 createDefaultSecretMaterialStorer,
4647 createDefaultSecretMaterialUpdater,
48+ provisionBuiltinSecretStores,
4749 } from "./secret-material-providers";
4850 import {
4951 buildLocalSourceArtifact,
@@ -52,6 +54,7 @@ import { · 2 unmodified lines
5254 writeLocalSourceArtifact,
5355 } from "./source-artifacts";
5456 import {
57+ deriveLocalInstallation,
5558 getOrProvisionLocalInstallation,
5659 loadLocalInstallation,
5760 } from "./installation";
@@ -235,6 +238,10 @@ export const createLocalExecutorRepositoriesEffect = ( · 177 unmodified lines
235238 homeStateDirectory: options.homeStateDirectory,
236239 }),
237240 ).pipe(Effect.mapError(toError));
241+ yield* bindFileSystem(
242+ fileSystem,
243+ migrateLegacyLocalExecutorConfigs(workspaceContext),
244+ ).pipe(Effect.mapError(toError));
238245
239246 const executorStateStorage = createLocalExecutorStatePersistence(
240247 workspaceContext,
@@ -245,12 +252,14 @@ export const createLocalExecutorRepositoriesEffect = ( · 4 unmodified lines
245252 fileSystem,
246253 );
247254 const loadedConfig = yield* workspaceConfigStore.load();
255+ yield* provisionBuiltinSecretStores({
256+ executorState: executorStateStorage.executorState,
257+ scopeId: deriveLocalInstallation(workspaceContext).scopeId,
258+ });
248259 const resolveSecretMaterial: ResolveSecretMaterial =
249260 runtimeOptions.resolveSecretMaterial
250261 ?? createDefaultSecretMaterialResolver({
251262 executorState: executorStateStorage.executorState,
252− localConfig: loadedConfig.config,
253− workspaceRoot: workspaceContext.workspaceRoot,
254263 });
255264
256265 return {
@@ -274,16 +283,20 @@ export const createLocalExecutorRepositoriesEffect = ( · 17 unmodified lines
274283 createBoundSourceTypeDeclarationsRefresher(workspaceContext),
275284 },
276285 secrets: {
286+ secretStores: executorStateStorage.executorState.secretStores,
277287 ...executorStateStorage.executorState.secretMaterials,
278288 resolve: resolveSecretMaterial,
279289 store: createDefaultSecretMaterialStorer({
280290 executorState: executorStateStorage.executorState,
291+ resolveSecretMaterial,
281292 }),
282293 delete: createDefaultSecretMaterialDeleter({
283294 executorState: executorStateStorage.executorState,
295+ resolveSecretMaterial,
284296 }),
285297 update: createDefaultSecretMaterialUpdater({
286298 executorState: executorStateStorage.executorState,
299+ resolveSecretMaterial,
287300 }),
288301 },
289302 executions: {
@@ -293,7 +306,9 @@ export const createLocalExecutorRepositoriesEffect = ( · 3 unmodified lines
293306 steps: executorStateStorage.executorState.executionSteps,
294307 },
295308 instanceConfig: {
296− resolve: createLocalInstanceConfigResolver(),
309+ resolve: createLocalInstanceConfigResolver({
310+ executorState: executorStateStorage.executorState,
311+ }),
297312 },
298313 close: executorStateStorage.close,
299314 } satisfies ExecutorBackendRepositories;
@@ -329,27 +344,32 @@ export const createLocalExecutorEffect = < · 29 unmodified lines
329344 ): Effect.Effect<
330345 ExecutorEffect & ExecutorSdkPluginExtensions<TPlugins>,
331346 Error
332−> =>
333− createExecutorEffect({
347+> => {
348+ return createExecutorEffect({
334349 backend: createLocalExecutorBackend(options),
335− plugins: options.plugins,
350+ plugins: (options.plugins ?? []) as TPlugins,
336351 executionResolver: options.executionResolver,
337352 resolveSecretMaterial: options.resolveSecretMaterial,
338353 getLocalServerBaseUrl: options.getLocalServerBaseUrl,
339− } satisfies CreateExecutorEffectOptions & { plugins?: TPlugins });
354+ }) as Effect.Effect<
355+ ExecutorEffect & ExecutorSdkPluginExtensions<TPlugins>,
356+ Error
357+ >;
358+};
340359
341360 export const createLocalExecutor = <
342361 const TPlugins extends readonly ExecutorSdkPlugin<any, any>[] = [],
343362 >(
344363 options: CreateLocalExecutorOptions<TPlugins> = {},
345−): Promise<Executor<TPlugins>> =>
346− createExecutor({
364+) => {
365+ return createExecutor({
347366 backend: createLocalExecutorBackend(options),
348− plugins: options.plugins,
367+ plugins: (options.plugins ?? []) as TPlugins,
349368 executionResolver: options.executionResolver,
350369 resolveSecretMaterial: options.resolveSecretMaterial,
351370 getLocalServerBaseUrl: options.getLocalServerBaseUrl,
352− });
371+ }) as Promise<Executor<TPlugins>>;
372+};
353373
354374 export {
355375 deriveLocalInstallation,
packages/platform/sdk-file/src/secret-material-providers.ts+305−1133
@@ -1,115 +1,38 @@
11 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";
62
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,
1413 } from "@executor/platform-sdk/schema";
1514 import * as Effect from "effect/Effect";
1615 import * as Layer from "effect/Layer";
1716 import * as Option from "effect/Option";
1817
1918 import {
20− ExecutorStateStore,
21− runtimeEffectError,
19+ getSecretStoreContribution,
20+ registeredSecretStoreContributions,
2221 LocalInstanceConfigService,
22+ runtimeEffectError,
2323 SecretMaterialDeleterService,
2424 SecretMaterialResolverService,
2525 SecretMaterialStorerService,
2626 SecretMaterialUpdaterService,
27− fromConfigSecretProviderId,
28− getRuntimeLocalScopeOption,
27+ type DeleteSecretMaterial,
2928 type ExecutorStateStoreShape,
30− type ResolveSecretMaterial,
3129 type ResolveInstanceConfig,
32− type SecretMaterialResolveContext,
30+ type ResolveSecretMaterial,
3331 type StoreSecretMaterial,
34− type DeleteSecretMaterial,
3532 type UpdateSecretMaterial,
3633 } 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−};
9034
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";
11336
11437 const trimOrNull = (value: string | null | undefined): string | null => {
11538 if (value === null || value === undefined) {
@@ -120,1165 +43,414 @@ const trimOrNull = (value: string | null | undefined): string | null => { · 4 unmodified lines
12043 return trimmed.length > 0 ? trimmed : null;
12144 };
12245
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>;
12768
128−export const parseSecretStoreProviderId = (value: string | undefined): SecretStoreProviderId | null => {
69+const parseDefaultStoreKind = (value: string | undefined): string | null => {
12970 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;
15472 };
15573
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+ }
26986 }
27087
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+ );
33992
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+ }
34298 }
34399
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;
349101 };
350102
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;
373106 }) =>
374107 Effect.gen(function* () {
375108 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−};
473109
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"],
535113 );
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;
627116 }
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− }),
770117
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,
781124 });
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";
841125 }
842−
843− return stat.type === "File";
844126 });
845127
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;
870130 ref: SecretRef;
871− workspaceRoot: string;
872−}): Effect.Effect<string, Error, FileSystem.FileSystem> =>
131+}) =>
873132 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,
881135 );
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)) {
928137 return yield* runtimeEffectError(
929138 "local/secret-material-providers",
930− `Unsupported secret provider: ${input.ref.providerId}`,
139+ `Secret not found: ${input.ref.secretId}`,
931140 );
932141 }
933142
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+ });
966145
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)) {
986153 return yield* runtimeEffectError(
987154 "local/secret-material-providers",
988− `Exec secret provider command is outside trustedDirs: ${command}`,
155+ `Secret store not found: ${input.storeId}`,
989156 );
990157 }
991158
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;
1010160 });
1011161
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+
1012170 export const createDefaultSecretMaterialResolver = (input: {
1013171 executorState: ExecutorStateStoreShape;
1014− dangerouslyAllowEnvSecrets?: boolean;
1015172 keychainServiceName?: string;
1016− localConfig?: LocalExecutorConfig | null;
1017− workspaceRoot?: string | null;
1018173 }): ResolveSecretMaterial => {
1019− const providers = createSecretMaterialProviderRegistry();
1020− const runtime = createSecretMaterialProviderRuntime(input);
1021−
1022− return ({ ref, context }) =>
174+ let resolveSecretMaterial!: ResolveSecretMaterial;
175+ resolveSecretMaterial = ({ ref, context }) =>
1023176 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,
1043179 ref,
1044− context: context ?? {},
1045− runtime,
1046180 });
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;
1048197 };
1049198
1050199 export const createDefaultSecretMaterialStorer = (input: {
1051200 executorState: ExecutorStateStoreShape;
1052− dangerouslyAllowEnvSecrets?: boolean;
201+ resolveSecretMaterial: ResolveSecretMaterial;
1053202 keychainServiceName?: string;
1054203 }): StoreSecretMaterial => {
1055− const providers = createSecretMaterialProviderRegistry();
1056− const runtime = createSecretMaterialProviderRuntime(input);
1057−
1058− return ({ purpose, value, name, providerId }) =>
204+ return ({ purpose, value, name, storeId }) =>
1059205 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"],
1067218 });
219+ const contribution = resolveStoreContribution(store);
1068220
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+ );
1071227 }
1072228
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,
1074245 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;
1079256 });
1080257 };
1081258
1082259 export const createDefaultSecretMaterialUpdater = (input: {
1083260 executorState: ExecutorStateStoreShape;
1084− dangerouslyAllowEnvSecrets?: boolean;
261+ resolveSecretMaterial: ResolveSecretMaterial;
1085262 keychainServiceName?: string;
1086263 }): UpdateSecretMaterial => {
1087− const providers = createSecretMaterialProviderRegistry();
1088− const runtime = createSecretMaterialProviderRuntime(input);
1089−
1090264 return ({ ref, name, value }) =>
1091265 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,
1095269 });
270+ const store = yield* loadStoreById({
271+ executorState: input.executorState,
272+ storeId: material.storeId,
273+ });
274+ const contribution = resolveStoreContribution(store);
1096275
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+ );
1099282 }
1100283
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+ };
1107311 });
1108312 };
1109313
1110314 export const createDefaultSecretMaterialDeleter = (input: {
1111315 executorState: ExecutorStateStoreShape;
1112− dangerouslyAllowEnvSecrets?: boolean;
316+ resolveSecretMaterial: ResolveSecretMaterial;
1113317 keychainServiceName?: string;
1114318 }): DeleteSecretMaterial => {
1115− const providers = createSecretMaterialProviderRegistry();
1116− const runtime = createSecretMaterialProviderRuntime(input);
1117−
1118319 return (ref) =>
1119320 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,
1123324 });
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;
1124340
1125− if (!provider.remove) {
341+ if (!deleted) {
1126342 return false;
1127343 }
1128344
1129− return yield* provider.remove({
1130− ref,
1131− runtime,
1132− });
345+ return yield* input.executorState.secretMaterials.removeById(material.id);
1133346 });
1134347 };
1135348
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+};
1142356
357+export const createLocalInstanceConfigResolver = (input: {
358+ executorState: ExecutorStateStoreShape;
359+}): ResolveInstanceConfig => () =>
360+ Effect.gen(function* () {
361+ const stores = yield* input.executorState.secretStores.listAll();
1143362 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;
1151367 });
1152368
1153369 export const SecretMaterialResolverLive = (input: {
370+ executorState: ExecutorStateStoreShape;
1154371 resolveSecretMaterial?: ResolveSecretMaterial;
1155− dangerouslyAllowEnvSecrets?: boolean;
1156372 keychainServiceName?: string;
1157− localConfig?: LocalExecutorConfig | null;
1158− workspaceRoot?: string | null;
1159−} = {}) =>
373+}) =>
1160374 input.resolveSecretMaterial
1161375 ? Layer.succeed(SecretMaterialResolverService, input.resolveSecretMaterial)
1162− : Layer.effect(
376+ : Layer.succeed(
1163377 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),
1176379 );
1177380
1178381 export const SecretMaterialStorerLive = (input: {
1179− dangerouslyAllowEnvSecrets?: boolean;
382+ executorState: ExecutorStateStoreShape;
383+ resolveSecretMaterial: ResolveSecretMaterial;
1180384 keychainServiceName?: string;
1181−} = {}) =>
1182− Layer.effect(
385+}) =>
386+ Layer.succeed(
1183387 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),
1193389 );
1194390
1195391 export const SecretMaterialDeleterLive = (input: {
1196− dangerouslyAllowEnvSecrets?: boolean;
392+ executorState: ExecutorStateStoreShape;
393+ resolveSecretMaterial: ResolveSecretMaterial;
1197394 keychainServiceName?: string;
1198−} = {}) =>
1199− Layer.effect(
395+}) =>
396+ Layer.succeed(
1200397 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),
1210399 );
1211400
1212401 export const SecretMaterialUpdaterLive = (input: {
1213− dangerouslyAllowEnvSecrets?: boolean;
402+ executorState: ExecutorStateStoreShape;
403+ resolveSecretMaterial: ResolveSecretMaterial;
1214404 keychainServiceName?: string;
1215−} = {}) =>
1216− Layer.effect(
405+}) =>
406+ Layer.succeed(
1217407 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),
1227409 );
1228410
1229411 export const SecretMaterialLive = (input: {
412+ executorState: ExecutorStateStoreShape;
1230413 resolveSecretMaterial?: ResolveSecretMaterial;
1231− dangerouslyAllowEnvSecrets?: boolean;
1232414 keychainServiceName?: string;
1233− localConfig?: LocalExecutorConfig | null;
1234− workspaceRoot?: string | null;
1235−} = {}) =>
415+}) =>
1236416 Layer.mergeAll(
1237417 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+ }),
1246448 );
1247− const providers = [
1248− {
1249− id: LOCAL_SECRET_PROVIDER_ID,
1250− name: "Local store",
1251− canStore: true,
1252− },
1253− ];
1254449
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),
1280456 );
1281−};
1282−
1283−export const LocalInstanceConfigLive = () =>
1284− Layer.succeed(LocalInstanceConfigService, createLocalInstanceConfigResolver());
packages/platform/sdk/src/backend.ts+8−0
@@ -168,6 +168,14 @@ const toSourceTypeDeclarationsBackend = ( · 167 unmodified lines
168168 const toSecretsBackend = (
169169 input: ExecutorSecretRepository,
170170 ): 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+ },
171179 getById: (id) => toOptionEffect(input.getById(id)),
172180 listAll: () => toEffect(input.listAll()),
173181 upsert: (material) => toEffect(input.upsert(material)),
packages/platform/sdk/src/executor-effect.ts+85−0
@@ -4,6 +4,7 @@ import type { · 3 unmodified lines
44 ScopeId,
55 Execution,
66 LocalInstallation,
7+ SecretStore,
78 Source,
89 } from "./schema";
910 import type {
@@ -11,13 +12,28 @@ import type { · 1 unmodified line
1112 ResumeExecutionPayload,
1213 } from "./executions/contracts";
1314 import type {
15+ BrowseSecretStorePayload,
16+ BrowseSecretStoreResult,
1417 CreateSecretPayload,
18+ CreateSecretStorePayload,
19+ CreateSecretResult,
1520 DeleteSecretResult,
21+ DeleteSecretStoreResult,
22+ ImportSecretFromStorePayload,
1623 UpdateSecretPayload,
24+ UpdateSecretStorePayload,
1725 } from "./local/contracts";
1826 import {
1927 getLocalInstallation,
2028 } from "./local/operations";
29+import {
30+ createLocalSecretStore,
31+ browseLocalSecretStore,
32+ deleteLocalSecretStore,
33+ importLocalSecretFromStore,
34+ listLocalSecretStores,
35+ updateLocalSecretStore,
36+} from "./local/secret-stores";
2137 import {
2238 createLocalSecret,
2339 deleteLocalSecret,
@@ -49,6 +65,12 @@ import { · 25 unmodified lines
4965 removeSource,
5066 saveManagedSourceRecord,
5167 } from "./sources/operations";
68+import {
69+ createManagedSecretStoreRecord,
70+ getManagedSecretStore,
71+ removeManagedSecretStoreRecord,
72+ saveManagedSecretStoreRecord,
73+} from "./runtime/secret-stores";
5274 import type { ExecutorBackend } from "./backend";
5375 import {
5476 provideExecutorRuntime,
@@ -93,6 +115,33 @@ export type ExecutorEffect = { · 38 unmodified lines
93115 installation: () => ProvidedEffect<ReturnType<typeof getLocalInstallation>>;
94116 config: () => ProvidedEffect<ReturnType<typeof getLocalInstanceConfig>>;
95117 };
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+ };
96145 secrets: {
97146 list: () => ProvidedEffect<ReturnType<typeof listLocalSecrets>>;
98147 create: (
@@ -189,6 +238,14 @@ const fromRuntime = (runtime: ExecutorRuntime): ExecutorEffect => { · 90 unmodified lines
189238 installation: () => provide(getLocalInstallation()),
190239 config: () => provide(getLocalInstanceConfig()),
191240 },
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+ },
192249 secrets: {
193250 list: () => provide(listLocalSecrets()),
194251 create: (payload) => provide(createLocalSecret(payload)),
@@ -355,6 +412,34 @@ export const createExecutorEffect = < · 160 unmodified lines
355412 }).pipe(Effect.map((result) => result.removed)),
356413 ),
357414 },
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+ },
358443 };
359444 const host: ExecutorSdkPluginHost = sourceHost;
360445 const extensions = Object.fromEntries(
packages/platform/sdk/src/executor.ts+10−0
@@ -80,6 +80,15 @@ const toPromiseExecutor = < · 79 unmodified lines
8080 installation: () => run(executor.local.installation()),
8181 config: () => run(executor.local.config()),
8282 },
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+ },
8392 secrets: {
8493 list: () => run(executor.secrets.list()),
8594 create: (payload: Parameters<typeof executor.secrets.create>[0]) =>
@@ -142,6 +151,7 @@ const toPromiseExecutor = < · 56 unmodified lines
142151 "resolutionScopeIds",
143152 "close",
144153 "local",
154+ "secretStores",
145155 "secrets",
146156 "policies",
147157 "sources",
packages/platform/sdk/src/local/contracts.ts+86−9
@@ -4,15 +4,15 @@ import { · 3 unmodified lines
44 import * as Schema from "effect/Schema";
55
66 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,
1010 });
1111
1212 export const InstanceConfigSchema = Schema.Struct({
1313 platform: Schema.String,
14− secretProviders: Schema.Array(SecretProviderSchema),
15− defaultSecretStoreProvider: Schema.String,
14+ secretStorePlugins: Schema.Array(SecretProviderSchema),
15+ defaultSecretStoreId: Schema.NullOr(Schema.String),
1616 });
1717
1818 export type SecretProvider = typeof SecretProviderSchema.Type;
@@ -25,9 +25,21 @@ export const SecretLinkedSourceSchema = Schema.Struct({ · 6 unmodified lines
2525
2626 export type SecretLinkedSource = typeof SecretLinkedSourceSchema.Type;
2727
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+
2838 export const SecretListItemSchema = Schema.Struct({
2939 id: Schema.String,
30− providerId: Schema.String,
40+ storeId: Schema.String,
41+ storeName: Schema.String,
42+ storeKind: Schema.String,
3143 name: Schema.NullOr(Schema.String),
3244 purpose: Schema.String,
3345 createdAt: Schema.Number,
@@ -41,7 +53,7 @@ export const CreateSecretPayloadSchema = Schema.Struct({ · 7 unmodified lines
4153 name: Schema.String,
4254 value: Schema.String,
4355 purpose: Schema.optional(SecretMaterialPurposeSchema),
44− providerId: Schema.optional(Schema.String),
56+ storeId: Schema.optional(Schema.String),
4557 });
4658
4759 export type CreateSecretPayload = typeof CreateSecretPayloadSchema.Type;
@@ -49,7 +61,7 @@ export type CreateSecretPayload = typeof CreateSecretPayloadSchema.Type; · 1 unmodified line
4961 export const CreateSecretResultSchema = Schema.Struct({
5062 id: Schema.String,
5163 name: Schema.NullOr(Schema.String),
52− providerId: Schema.String,
64+ storeId: Schema.String,
5365 purpose: Schema.String,
5466 createdAt: Schema.Number,
5567 updatedAt: Schema.Number,
@@ -66,13 +78,78 @@ export type UpdateSecretPayload = typeof UpdateSecretPayloadSchema.Type; · 10 unmodified lines
6678
6779 export const UpdateSecretResultSchema = Schema.Struct({
6880 id: Schema.String,
69− providerId: Schema.String,
81+ storeId: Schema.String,
7082 name: Schema.NullOr(Schema.String),
7183 purpose: Schema.String,
7284 createdAt: Schema.Number,
7385 updatedAt: Schema.Number,
7486 });
7587
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+
76153 export type UpdateSecretResult = typeof UpdateSecretResultSchema.Type;
77154
78155 export const DeleteSecretResultSchema = Schema.Struct({
packages/platform/sdk/src/local/secret-stores.ts+441−0
@@ -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+ });
packages/platform/sdk/src/local/secrets.ts+21−9
@@ -3,6 +3,7 @@ import * as Option from "effect/Option"; · 2 unmodified lines
33
44 import {
55 SecretMaterialIdSchema,
6+ type SecretStore,
67 } from "../schema";
78 import {
89 type CreateSecretPayload,
@@ -72,8 +73,21 @@ export const listLocalSecrets = () => · 63 unmodified lines
7273 ),
7374 );
7475
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+
7587 return secretMaterials.map((row) => ({
7688 ...row,
89+ storeName: secretStoresById.get(row.storeId)?.name ?? row.storeId,
90+ storeKind: secretStoresById.get(row.storeId)?.kind ?? "unknown",
7791 linkedSources: linkedSourcesMap.get(row.id) ?? [],
7892 }));
7993 });
@@ -98,7 +112,7 @@ export const createLocalSecret = (payload: CreateSecretPayload) => · 18 unmodified lines
98112 name,
99113 purpose,
100114 value,
101− providerId: payload.providerId,
115+ storeId: payload.storeId,
102116 }).pipe(
103117 Effect.mapError((cause) =>
104118 secretStorageError(
@@ -107,7 +121,7 @@ export const createLocalSecret = (payload: CreateSecretPayload) => · 2 unmodified lines
107121 ),
108122 ),
109123 );
110− const secretId = SecretMaterialIdSchema.make(ref.handle);
124+ const secretId = SecretMaterialIdSchema.make(ref.secretId);
111125 const created = yield* store.secretMaterials
112126 .getById(secretId)
113127 .pipe(
@@ -122,14 +136,14 @@ export const createLocalSecret = (payload: CreateSecretPayload) => · 8 unmodified lines
122136 if (Option.isNone(created)) {
123137 return yield* secretStorageError(
124138 "secrets.create",
125− `Created secret not found: ${ref.handle}`,
139+ `Created secret not found: ${ref.secretId}`,
126140 );
127141 }
128142
129143 return {
130144 id: created.value.id,
131145 name: created.value.name,
132− providerId: created.value.providerId,
146+ storeId: created.value.storeId,
133147 purpose: created.value.purpose,
134148 createdAt: created.value.createdAt,
135149 updatedAt: created.value.updatedAt,
@@ -168,8 +182,7 @@ export const updateLocalSecret = (input: { · 32 unmodified lines
168182 const updateSecretMaterial = yield* SecretMaterialUpdaterService;
169183 const updated = yield* updateSecretMaterial({
170184 ref: {
171− providerId: existing.value.providerId,
172− handle: existing.value.id,
185+ secretId: existing.value.id,
173186 },
174187 ...update,
175188 }).pipe(
@@ -180,7 +193,7 @@ export const updateLocalSecret = (input: { · 4 unmodified lines
180193
181194 return {
182195 id: updated.id,
183− providerId: updated.providerId,
196+ storeId: updated.storeId,
184197 name: updated.name,
185198 purpose: updated.purpose,
186199 createdAt: updated.createdAt,
@@ -211,8 +224,7 @@ export const deleteLocalSecret = (secretId: string) => · 24 unmodified lines
211224
212225 const deleteSecretMaterial = yield* SecretMaterialDeleterService;
213226 const removed = yield* deleteSecretMaterial({
214− providerId: existing.value.providerId,
215− handle: existing.value.id,
227+ secretId: existing.value.id,
216228 }).pipe(
217229 Effect.mapError(() =>
218230 secretStorageError("secrets.delete", "Failed removing secret."),
packages/platform/sdk/src/plugins.ts+705−1
@@ -6,7 +6,13 @@ import type { · 5 unmodified lines
66 } from "@executor/source-core";
77 import type { ExecutorEffect } from "./executor-effect";
88 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";
1016 import type {
1117 SourceInvokeInput,
1218 SourceInvokeResult,
@@ -34,6 +40,7 @@ export type ExecutorSdkPluginStartContext< · 21 unmodified lines
3440
3541 type ExecutorSdkPluginInternals = {
3642 sources?: readonly ExecutorSourceContribution<any>[];
43+ secretStores?: readonly ExecutorSecretStoreContribution<any>[];
3744 };
3845
3946 const executorSdkPluginInternalsSymbol = Symbol.for(
@@ -78,6 +85,20 @@ type ExecutorSourcePluginInternalHost = { · 38 unmodified lines
7885 };
7986 };
8087
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+
81102 export type ExecutorSourcePluginStorage<TStored> = {
82103 get: (input: {
83104 scopeId: ExecutorSource["scopeId"];
@@ -94,6 +115,22 @@ export type ExecutorSourcePluginStorage<TStored> = { · 10 unmodified lines
94115 }) => Effect.Effect<void, Error, any>;
95116 };
96117
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+
97134 export type ExecutorSourcePluginApi<
98135 TConnectInput,
99136 TSourceConfig,
@@ -122,6 +159,31 @@ export type ExecutorSourcePluginApi< · 22 unmodified lines
122159 ) => Effect.Effect<boolean, Error, any>;
123160 };
124161
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+
125187 export type ExecutorSourcePluginDefinition<
126188 TAddInput,
127189 TConnectInput,
@@ -183,6 +245,135 @@ export type ExecutorSourcePluginDefinition< · 55 unmodified lines
183245 };
184246 };
185247
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+
186377 export type ExecutorSourcePluginInput<
187378 TKey extends string = string,
188379 TAddInput = unknown,
@@ -220,6 +411,43 @@ export type ExecutorSourcePluginInput< · 31 unmodified lines
220411 ) => Effect.Effect<PluginCleanup | void, Error, any>;
221412 };
222413
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+
223451 const loadSourceOfKind = (
224452 sourceId: ExecutorSource["id"],
225453 input: {
@@ -358,6 +586,135 @@ const createExecutorSourcePluginApi = < · 132 unmodified lines
358586 }),
359587 });
360588
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+
361718 type ExecutorSourceContribution<TInput = unknown> = {
362719 kind: string;
363720 displayName: string;
@@ -382,6 +739,83 @@ type ExecutorSourceContribution<TInput = unknown> = { · 18 unmodified lines
382739 ) => Effect.Effect<SourceInvokeResult, Error, any>;
383740 };
384741
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+
385819 const createExecutorSourceContribution = <
386820 TAddInput,
387821 TConnectInput,
@@ -437,6 +871,199 @@ const createExecutorSourceContribution = < · 49 unmodified lines
437871 ),
438872 });
439873
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+
4401067 export const defineExecutorSourcePlugin = <
4411068 const TKey extends string,
4421069 TAddInput,
@@ -487,6 +1114,56 @@ export const defineExecutorSourcePlugin = < · 44 unmodified lines
4871114 },
4881115 }))(input.extendExecutor, input.start);
4891116
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+
4901167 export type ExecutorSdkPluginExtensions<
4911168 TPlugins extends readonly ExecutorSdkPlugin<any, any>[],
4921169 > = {
@@ -501,6 +1178,7 @@ export const registerExecutorSdkPlugins = ( · 8 unmodified lines
5011178 ) => {
5021179 const pluginKeys = new Set<string>();
5031180 const sources = new Map<string, ExecutorSourceContribution<any>>();
1181+ const secretStores = new Map<string, ExecutorSecretStoreContribution<any>>();
5041182
5051183 for (const plugin of plugins) {
5061184 if (pluginKeys.has(plugin.key)) {
@@ -520,6 +1198,16 @@ export const registerExecutorSdkPlugins = ( · 13 unmodified lines
5201198
5211199 sources.set(source.kind, source);
5221200 }
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+ }
5231211 }
5241212
5251213 const getSourceContribution = (kind: string) => {
@@ -534,10 +1222,26 @@ export const registerExecutorSdkPlugins = ( · 8 unmodified lines
5341222 const getSourceContributionForSource = (source: Pick<ExecutorSource, "kind">) =>
5351223 getSourceContribution(source.kind);
5361224
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+
5371238 return {
5381239 plugins,
5391240 sources: [...sources.values()],
1241+ secretStores: [...secretStores.values()],
5401242 getSourceContribution,
5411243 getSourceContributionForSource,
1244+ getSecretStoreContribution,
1245+ getSecretStoreContributionForStore,
5421246 };
5431247 };
packages/platform/sdk/src/runtime/execution/scope/tool-invoker.ts+6−1
@@ -48,6 +48,9 @@ import { · 47 unmodified lines
4848 import {
4949 createExecutorToolMap,
5050 } from "../../sources/executor-tools";
51+import {
52+ registeredSourceContributions,
53+} from "../../sources/source-plugins";
5154 import {
5255 RuntimeSourceCatalogSyncService,
5356 } from "../../catalog/source/sync";
@@ -147,10 +150,12 @@ export const createScopeToolInvoker = (input: { · 93 unmodified lines
147150 return catalog;
148151 },
149152 });
153+ const hasLocalToolsSourceContribution = registeredSourceContributions()
154+ .some((source) => source.kind === "local-tools");
150155 const authoredTools = mergeToolMaps([
151156 systemTools,
152157 executorTools,
153− input.localToolRuntime.tools,
158+ ...(hasLocalToolsSourceContribution ? [] : [input.localToolRuntime.tools]),
154159 ]);
155160 const authoredCatalog = createToolCatalogFromTools({
156161 tools: authoredTools,
packages/platform/sdk/src/runtime/executor-state-store.ts+31−1
@@ -6,18 +6,48 @@ import type { · 5 unmodified lines
66 ExecutionInteraction,
77 ExecutionStep,
88 SecretMaterial,
9+ SecretStore,
910 } from "#schema";
1011
1112 type SecretMaterialSummary = {
1213 id: string;
13− providerId: string;
14+ storeId: string;
1415 name: string | null;
1516 purpose: string;
1617 createdAt: number;
1718 updatedAt: number;
1819 };
1920
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+
2032 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+ };
2151 secretMaterials: {
2252 getById: (
2353 id: SecretMaterial["id"],
packages/platform/sdk/src/runtime/index.ts+8−0
@@ -89,6 +89,7 @@ import { · 88 unmodified lines
8989 } from "./sources/source-store";
9090
9191 export * from "./execution/state";
92+export * from "./secret-stores";
9293 export * from "./sources/executor-tools";
9394 export * from "./execution/live";
9495 export * from "./catalog/schema-type-signature";
@@ -137,9 +138,12 @@ export type { · 42 unmodified lines
137138 } from "./local-tool-runtime";
138139 export {
139140 registeredSourceContributions,
141+ registeredSecretStoreContributions,
140142 hasRegisteredExternalSourcePlugins,
141143 getSourceContribution,
142144 getSourceContributionForSource,
145+ getSecretStoreContribution,
146+ getSecretStoreContributionForStore,
143147 } from "./sources/source-plugins";
144148
145149 export type ExecutorRuntimeOptions = {
@@ -219,6 +223,9 @@ export const prewarmWorkspaceSourceCatalogToolIndex = (input: { · 73 unmodified lines
219223 });
220224
221225 export type RuntimeSecretsStorageServices =
226+ & {
227+ secretStores: ExecutorStateStoreShape["secretStores"];
228+ }
222229 & ExecutorStateStoreShape["secretMaterials"]
223230 & RuntimeSecretMaterialServices;
224231
@@ -310,6 +317,7 @@ const makeInstanceConfigLayer = (input: RuntimeInstanceConfigService) => · 85 unmodified lines
310317 const toExecutorStateStoreShape = (
311318 input: RuntimeStorageServices,
312319 ): ExecutorStateStoreShape => ({
320+ secretStores: input.secrets.secretStores,
313321 secretMaterials: input.secrets,
314322 executions: input.executions.runs,
315323 executionInteractions: input.executions.interactions,
packages/platform/sdk/src/runtime/scope/secret-material-providers.ts+2−2
@@ -22,7 +22,7 @@ export type StoreSecretMaterial = (input: { · 21 unmodified lines
2222 purpose: SecretMaterialPurpose;
2323 value: string;
2424 name?: string | null;
25− providerId?: string;
25+ storeId?: string;
2626 }) => Effect.Effect<SecretRef, Error, never>;
2727
2828 export type DeleteSecretMaterial = (
@@ -35,7 +35,7 @@ export type UpdateSecretMaterial = (input: { · 6 unmodified lines
3535 value?: string;
3636 }) => Effect.Effect<{
3737 id: string;
38− providerId: string;
38+ storeId: string;
3939 name: string | null;
4040 purpose: string;
4141 createdAt: number;
packages/platform/sdk/src/runtime/secret-stores.ts+76−0
@@ -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+ });
packages/platform/sdk/src/runtime/sources/source-plugins/index.ts+6−0
@@ -18,12 +18,18 @@ export const configureExecutorSourcePlugins = ( · 17 unmodified lines
1818 };
1919
2020 export const registeredSourceContributions = () => registry.sources;
21+export const registeredSecretStoreContributions = () => registry.secretStores;
2122
2223 export const getSourceContribution = (kind: string) =>
2324 registry.getSourceContribution(kind);
2425 export const getSourceContributionForSource = (
2526 source: Parameters<typeof registry.getSourceContributionForSource>[0],
2627 ) => 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);
2733
2834 export const hasRegisteredExternalSourcePlugins = () =>
2935 configuredSourcePlugins.length > 0;
packages/platform/sdk/src/schema/ids.ts+4−0
@@ -11,6 +11,9 @@ export const SourceCatalogRevisionIdSchema = Schema.String.pipe( · 10 unmodified lines
1111 export const SecretMaterialIdSchema = Schema.String.pipe(
1212 Schema.brand("SecretMaterialId"),
1313 );
14+export const SecretStoreIdSchema = Schema.String.pipe(
15+ Schema.brand("SecretStoreId"),
16+);
1417 export const PolicyIdSchema = Schema.String.pipe(Schema.brand("PolicyId"));
1518 export const ExecutionIdSchema = Schema.String.pipe(Schema.brand("ExecutionId"));
1619 export const ExecutionInteractionIdSchema = Schema.String.pipe(
@@ -25,6 +28,7 @@ export type SourceId = typeof SourceIdSchema.Type; · 8 unmodified lines
2528 export type SourceCatalogId = typeof SourceCatalogIdSchema.Type;
2629 export type SourceCatalogRevisionId = typeof SourceCatalogRevisionIdSchema.Type;
2730 export type SecretMaterialId = typeof SecretMaterialIdSchema.Type;
31+export type SecretStoreId = typeof SecretStoreIdSchema.Type;
2832 export type PolicyId = typeof PolicyIdSchema.Type;
2933 export type ExecutionId = typeof ExecutionIdSchema.Type;
3034 export type ExecutionInteractionId = typeof ExecutionInteractionIdSchema.Type;
packages/platform/sdk/src/schema/index.ts+1−0
@@ -6,6 +6,7 @@ export * from "./models/source"; · 5 unmodified lines
66 export * from "./models/source-catalog";
77 export * from "./models/secret-ref";
88 export * from "./models/secret-material";
9+export * from "./models/secret-store";
910 export * from "./models/policy";
1011 export * from "./models/local-installation";
1112 export * from "./models/local-config";
packages/platform/sdk/src/schema/models/secret-material.ts+2−1
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
77 } from "../common";
88 import {
99 SecretMaterialIdSchema,
10+ SecretStoreIdSchema,
1011 } from "../ids";
1112
1213 export const SecretMaterialPurposeSchema = Schema.Literal(
@@ -20,7 +21,7 @@ export const SecretMaterialSchema = Schema.Struct({ · 7 unmodified lines
2021 id: SecretMaterialIdSchema,
2122 name: Schema.NullOr(Schema.String),
2223 purpose: SecretMaterialPurposeSchema,
23− providerId: Schema.String,
24+ storeId: SecretStoreIdSchema,
2425 handle: Schema.String,
2526 value: Schema.NullOr(Schema.String),
2627 createdAt: TimestampMsSchema,
packages/platform/sdk/src/schema/models/secret-ref.ts+4−2
@@ -1,8 +1,10 @@
11 import * as Schema from "effect/Schema";
2+import {
3+ SecretMaterialIdSchema,
4+} from "../ids";
25
36 export const SecretRefSchema = Schema.Struct({
4− providerId: Schema.String,
5− handle: Schema.String,
7+ secretId: SecretMaterialIdSchema,
68 });
79
810 export type SecretRef = typeof SecretRefSchema.Type;
packages/platform/sdk/src/schema/models/secret-store.ts+33−0
@@ -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;
packages/platform/server/:memory:/plugins/mcp/oauth-sessions/mcp_oauth_100f1f4b-14d2-48c5-a54b-493b3207c03b.json+55−0
@@ -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+}
packages/platform/server/:memory:/plugins/mcp/oauth-sessions/mcp_oauth_b0e04146-de7e-42e3-bd49-cab494c7f228.json+55−0
@@ -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+}
packages/platform/server/:memory:/plugins/mcp/sources/ws_local_23e7b12d3729f586/demo.json+13−0
@@ -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+}
packages/platform/server/:memory:/plugins/mcp/sources/ws_local_362335ffda3967d1/axiom.json+73−0
@@ -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+}
packages/platform/server/:memory:/plugins/mcp/sources/ws_local_49e62a1379b1ac86/demo.json+13−0
@@ -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+}
packages/platform/server/:memory:/plugins/mcp/sources/ws_local_6a36adfbe2442b43/demo.json+13−0
@@ -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+}
packages/platform/server/:memory:/plugins/mcp/sources/ws_local_8c0d810ef89e0233/demo.json+13−0
@@ -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+}
packages/platform/server/:memory:/plugins/mcp/sources/ws_local_cff5a2d1595b24ad/demo.json+13−0
@@ -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+}
packages/platform/server/:memory:/plugins/mcp/sources/ws_local_f6c616f206aeb799/demo.json+13−0
@@ -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+}
packages/platform/server/:memory:/plugins/openapi/sources/ws_local_0d19f5b81c734d12/github.json+10−0
@@ -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+}
packages/platform/server/:memory:/plugins/openapi/sources/ws_local_4bee124a76436ffd/github.json+10−0
@@ -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+}
packages/platform/server/:memory:/plugins/openapi/sources/ws_local_8d1d3aa6ceef50ab/dns.json+10−0
@@ -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+}
packages/platform/server/:memory:/plugins/openapi/sources/ws_local_9cca5d99347c43c1/dns.json+10−0
@@ -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+}
packages/platform/server/:memory:/plugins/openapi/sources/ws_local_a521c893e78de546/github.json+10−0
@@ -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+}
packages/platform/server/:memory:/plugins/openapi/sources/ws_local_b7e75e6d303b1e69/github.json+15−0
@@ -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+}
packages/platform/server/:memory:/plugins/openapi/sources/ws_local_c02b02b1312bd625/github.json+10−0
@@ -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+}
packages/platform/server/package.json+6−0
@@ -21,12 +21,18 @@ · 20 unmodified lines
2121 "@executor/plugin-graphql-http": "workspace:*",
2222 "@executor/plugin-graphql-shared": "workspace:*",
2323 "@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:*",
2427 "@executor/plugin-mcp-http": "workspace:*",
2528 "@executor/plugin-mcp-shared": "workspace:*",
2629 "@executor/plugin-mcp-sdk": "workspace:*",
2730 "@executor/plugin-openapi-http": "workspace:*",
2831 "@executor/plugin-openapi-shared": "workspace:*",
2932 "@executor/plugin-openapi-sdk": "workspace:*",
33+ "@executor/plugin-onepassword-http": "workspace:*",
34+ "@executor/plugin-onepassword-sdk": "workspace:*",
35+ "@executor/plugin-onepassword-shared": "workspace:*",
3036 "@executor/platform-api": "workspace:*",
3137 "@executor/platform-sdk": "workspace:*",
3238 "@executor/platform-sdk-file": "workspace:*",
packages/platform/server/src/index.test.ts+23−10
@@ -40,6 +40,7 @@ import { googleDiscoveryHttpPlugin } from "@executor/plugin-google-discovery-htt · 39 unmodified lines
4040 import { graphqlHttpPlugin } from "@executor/plugin-graphql-http";
4141 import { mcpHttpPlugin } from "@executor/plugin-mcp-http";
4242 import { openApiHttpPlugin } from "@executor/plugin-openapi-http";
43+import { onePasswordHttpPlugin } from "@executor/plugin-onepassword-http";
4344 import { makeSesExecutor } from "@executor/runtime-ses";
4445 import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4546 import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
@@ -62,6 +63,7 @@ const serverHttpPlugins = [ · 16 unmodified lines
6263 graphqlHttpPlugin(),
6364 googleDiscoveryHttpPlugin(),
6465 mcpHttpPlugin(),
66+ onePasswordHttpPlugin(),
6567 openApiHttpPlugin(),
6668 ] as const;
6769
@@ -385,6 +387,26 @@ const seedGithubOpenApiSourceInWorkspace = (input: { · 317 unmodified lines
385387 credentialEnvVar?: string;
386388 }) =>
387389 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+
388410 const existing = yield* input.client.sources.list({
389411 path: {
390412 workspaceId: input.workspaceId as never,
@@ -400,8 +422,7 @@ const seedGithubOpenApiSourceInWorkspace = (input: { · 9 unmodified lines
400422 headerName: "Authorization",
401423 prefix: "Bearer ",
402424 tokenSecretRef: {
403− providerId: "env",
404− handle: input.credentialEnvVar ?? "GITHUB_TOKEN",
425+ secretId: tokenSecret.id as never,
405426 },
406427 };
407428
@@ -1325,11 +1346,9 @@ describe("local-executor-server", () => { · 917 unmodified lines
13251346 );
13261347
13271348 const previousGithubToken = process.env.GITHUB_TOKEN;
1328− const previousAllowEnvSecrets = process.env.DANGEROUSLY_ALLOW_ENV_SECRETS;
13291349 yield* Effect.acquireRelease(
13301350 Effect.sync(() => {
13311351 process.env.GITHUB_TOKEN = "ghp_test_executor";
1332− process.env.DANGEROUSLY_ALLOW_ENV_SECRETS = "true";
13331352 }),
13341353 () =>
13351354 Effect.sync(() => {
@@ -1338,12 +1357,6 @@ describe("local-executor-server", () => { · 2 unmodified lines
13381357 } else {
13391358 process.env.GITHUB_TOKEN = previousGithubToken;
13401359 }
1341−
1342− if (previousAllowEnvSecrets === undefined) {
1343− delete process.env.DANGEROUSLY_ALLOW_ENV_SECRETS;
1344− } else {
1345− process.env.DANGEROUSLY_ALLOW_ENV_SECRETS = previousAllowEnvSecrets;
1346− }
13471360 }),
13481361 );
13491362
packages/platform/server/src/index.ts+19−0
@@ -12,12 +12,19 @@ import { googleDiscoveryHttpPlugin } from "@executor/plugin-google-discovery-htt · 11 unmodified lines
1212 import { googleDiscoverySdkPlugin } from "@executor/plugin-google-discovery-sdk";
1313 import { graphqlHttpPlugin } from "@executor/plugin-graphql-http";
1414 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";
1518 import { mcpHttpPlugin } from "@executor/plugin-mcp-http";
1619 import { mcpSdkPlugin } from "@executor/plugin-mcp-sdk";
1720 import { openApiHttpPlugin } from "@executor/plugin-openapi-http";
1821 import {
1922 openApiSdkPlugin,
2023 } from "@executor/plugin-openapi-sdk";
24+import { onePasswordHttpPlugin } from "@executor/plugin-onepassword-http";
25+import {
26+ onePasswordSdkPlugin,
27+} from "@executor/plugin-onepassword-sdk";
2128 import {
2229 createExecutorApiLayer,
2330 } from "@executor/platform-api/http";
@@ -59,6 +66,7 @@ import { createFileGraphqlSourceStorage } from "./graphql-source-storage"; · 35 unmodified lines
5966 import { createFileMcpOAuthSessionStorage } from "./mcp-oauth-session-storage";
6067 import { createFileMcpSourceStorage } from "./mcp-source-storage";
6168 import { createFileOpenApiSourceStorage } from "./openapi-source-storage";
69+import { createFileOnePasswordStoreStorage } from "./onepassword-store-storage";
6270
6371 export { createFileGoogleDiscoveryOAuthSessionStorage } from "./google-discovery-oauth-session-storage";
6472 export { createFileGoogleDiscoverySourceStorage } from "./google-discovery-source-storage";
@@ -66,6 +74,7 @@ export { createFileGraphqlSourceStorage } from "./graphql-source-storage"; · 1 unmodified line
6674 export { createFileMcpOAuthSessionStorage } from "./mcp-oauth-session-storage";
6775 export { createFileMcpSourceStorage } from "./mcp-source-storage";
6876 export { createFileOpenApiSourceStorage } from "./openapi-source-storage";
77+export { createFileOnePasswordStoreStorage } from "./onepassword-store-storage";
6978
7079 export {
7180 DEFAULT_EXECUTOR_DATA_DIR,
@@ -133,6 +142,7 @@ const executorHttpPlugins = [ · 61 unmodified lines
133142 graphqlHttpPlugin(),
134143 googleDiscoveryHttpPlugin(),
135144 mcpHttpPlugin(),
145+ onePasswordHttpPlugin(),
136146 openApiHttpPlugin(),
137147 ] as const;
138148
@@ -150,10 +160,14 @@ const createExecutorRuntime = ( · 11 unmodified lines
150160 ) =>
151161 createExecutorEffect({
152162 backend: createLocalExecutorBackend({
163+ cwd: options.workspaceRoot ?? process.cwd(),
153164 workspaceRoot: options.workspaceRoot,
154165 localDataDir,
155166 }),
156167 plugins: [
168+ localSecretStoreSdkPlugin,
169+ keychainSecretStoreSdkPlugin,
170+ localToolsSdkPlugin(),
157171 graphqlSdkPlugin({
158172 storage: createFileGraphqlSourceStorage({
159173 rootDir: resolve(localDataDir, "plugins", "graphql", "sources"),
@@ -180,6 +194,11 @@ const createExecutorRuntime = ( · 20 unmodified lines
180194 rootDir: resolve(localDataDir, "plugins", "openapi", "sources"),
181195 }),
182196 }),
197+ onePasswordSdkPlugin({
198+ storage: createFileOnePasswordStoreStorage({
199+ rootDir: resolve(localDataDir, "plugins", "onepassword", "stores"),
200+ }),
201+ }),
183202 ] as const,
184203 executionResolver: options.executionResolver,
185204 resolveSecretMaterial: options.resolveSecretMaterial,
packages/platform/server/src/onepassword-store-storage.ts+43−0
@@ -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+});
plugins/google-discovery/react/components.tsx+10−133
@@ -1,14 +1,13 @@
11 import { startTransition, useMemo, useState, type ReactNode } from "react";
22 import type { Source } from "@executor/react";
33 import {
4+ SecretReferenceField,
45 defineExecutorPluginHttpApiClient,
56 Result,
67 useAtomSet,
78 useAtomValue,
8− useCreateSecret,
99 useExecutorMutation,
1010 useLocalInstallation,
11− useSecrets,
1211 useSource,
1312 } from "@executor/react";
1413 import {
@@ -456,134 +455,6 @@ const openGoogleOauthPopup = (name: string): Window | null => { · 441 unmodified lines
456455 return popup;
457456 };
458457
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−
587458 const batchSourceInputFromTemplate = (
588459 template: GoogleDiscoveryBatchTemplate,
589460 ): GoogleDiscoveryBatchSourceInput => ({
@@ -768,10 +639,12 @@ function GoogleDiscoverySourceForm(props: { · 178 unmodified lines
768639
769640 {authKind === "bearer" && (
770641 <div className="border-l-2 border-border pl-4">
771− <SecretSelectOrCreateField
642+ <SecretReferenceField
772643 label="Secret"
773644 value={bearerSecretRef}
774645 emptyLabel="Select a secret"
646+ draftNamePlaceholder="Google bearer token"
647+ draftValuePlaceholder="Paste the token value"
775648 onChange={setBearerSecretRef}
776649 />
777650 </div>
@@ -787,10 +660,12 @@ function GoogleDiscoverySourceForm(props: { · 9 unmodified lines
787660 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"
788661 />
789662 </label>
790− <SecretSelectOrCreateField
663+ <SecretReferenceField
791664 label="Client Secret"
792665 value={clientSecretRef}
793666 emptyLabel="Public client / no secret"
667+ draftNamePlaceholder="Google client secret"
668+ draftValuePlaceholder="Paste the secret value"
794669 onChange={setClientSecretRef}
795670 />
796671 <div className="flex items-center gap-3">
@@ -1095,10 +970,12 @@ function GoogleDiscoveryBatchConnectPanel(props: { · 298 unmodified lines
1095970 />
1096971 </label>
1097972 <div className="md:col-span-2">
1098− <SecretSelectOrCreateField
973+ <SecretReferenceField
1099974 label="Client Secret"
1100975 value={clientSecretRef}
1101976 emptyLabel="Public client / no secret"
977+ draftNamePlaceholder="Google client secret"
978+ draftValuePlaceholder="Paste the secret value"
1102979 onChange={setClientSecretRef}
1103980 />
1104981 </div>
plugins/graphql/react/components.tsx+2−132
@@ -4,14 +4,13 @@ import type { · 3 unmodified lines
44 SourceInspectionToolDetail,
55 } from "@executor/react";
66 import {
7+ SecretReferenceField,
78 defineExecutorPluginHttpApiClient,
89 Result,
910 useAtomSet,
1011 useAtomValue,
11− useCreateSecret,
1212 useExecutorMutation,
1313 useLocalInstallation,
14− useSecrets,
1514 useSource,
1615 } from "@executor/react";
1716 import {
@@ -55,7 +54,6 @@ const defaultGraphqlInput = (): GraphqlConnectInput => ({ · 37 unmodified lines
5554
5655 const DEFAULT_BEARER_HEADER_NAME = "Authorization";
5756 const DEFAULT_BEARER_PREFIX = "Bearer ";
58−const CREATE_SECRET_VALUE = "__create_graphql_secret__";
5957
6058 const presetString = (
6159 search: Record<string, unknown>,
@@ -170,134 +168,6 @@ const authFromSecretValue = ( · 108 unmodified lines
170168 };
171169 };
172170
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−
301171 const graphqlToolRoutePath = (toolPath: string): string =>
302172 `tool/${encodeURIComponent(toolPath)}`;
303173
@@ -383,7 +253,7 @@ function GraphqlSourceForm(props: { · 79 unmodified lines
383253
384254 {authKind === "bearer" && (
385255 <div className="space-y-4 border-l-2 border-border pl-4">
386− <SecretSelectOrCreateField
256+ <SecretReferenceField
387257 label="Secret"
388258 value={secretRef}
389259 emptyLabel="Select a secret"
plugins/keychain-secret-store/sdk/index.ts+410−0
@@ -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+});
plugins/keychain-secret-store/sdk/package.json+23−0
@@ -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+}
plugins/keychain-secret-store/sdk/tsconfig.json+15−0
@@ -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+}
plugins/local-secret-store/sdk/index.ts+98−0
@@ -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+});
plugins/local-secret-store/sdk/package.json+23−0
@@ -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+}
plugins/local-secret-store/sdk/tsconfig.json+15−0
@@ -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+}
plugins/local-tools/react/components.tsx+174−0
@@ -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+}
plugins/local-tools/react/index.ts+24−0
@@ -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+});
plugins/local-tools/react/package.json+25−0
@@ -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+}
plugins/local-tools/react/tsconfig.json+17−0
@@ -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+}
plugins/local-tools/sdk/catalog.ts+241−0
@@ -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+ });
plugins/local-tools/sdk/index.ts+277−0
@@ -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+ });
plugins/local-tools/sdk/package.json+26−0
@@ -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+}
plugins/local-tools/sdk/tsconfig.json+13−0
@@ -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+}
plugins/onepassword/http/index.ts+111−0
@@ -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+});
plugins/onepassword/http/package.json+26−0
@@ -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+}
plugins/onepassword/http/tsconfig.json+15−0
@@ -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+}
plugins/onepassword/react/components.tsx+321−0
@@ -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+}
plugins/onepassword/react/index.ts+13−0
@@ -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+});
plugins/onepassword/react/package.json+26−0
@@ -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+}
plugins/onepassword/react/tsconfig.json+17−0
@@ -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+}
plugins/onepassword/sdk/index.ts+675−0
@@ -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+};
plugins/onepassword/sdk/package.json+25−0
@@ -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+}
plugins/onepassword/sdk/tsconfig.json+15−0
@@ -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+}
plugins/onepassword/shared/index.ts+163−0
@@ -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;
plugins/onepassword/shared/package.json+24−0
@@ -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+}
plugins/onepassword/shared/tsconfig.json+15−0
@@ -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+}
plugins/openapi/react/components.tsx+82−160
@@ -4,14 +4,13 @@ import type { · 3 unmodified lines
44 } from "@executor/react";
55 import {
66 Result,
7+ SecretReferenceField,
78 defineExecutorPluginHttpApiClient,
89 useAtomValue,
910 useAtomSet,
10− useCreateSecret,
1111 useExecutorMutation,
12− useSecrets,
1312 useSource,
14− useWorkspaceId,
13+ useWorkspaceRequestContext,
1514 } from "@executor/react";
1615 import {
1716 DocumentPanel,
@@ -49,7 +48,6 @@ const defaultOpenApiInput = (): OpenApiConnectInput => ({ · 31 unmodified lines
4948
5049 const DEFAULT_BEARER_HEADER_NAME = "Authorization";
5150 const DEFAULT_BEARER_PREFIX = "Bearer ";
52−const CREATE_SECRET_VALUE = "__create_openapi_secret__";
5351
5452 const presetString = (
5553 search: Record<string, unknown>,
@@ -237,136 +235,9 @@ const authFromSecretValue = ( · 181 unmodified lines
237235 };
238236 };
239237
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−
368238 function OpenApiSourceForm(props: {
369239 mode: "create" | "edit";
240+ workspaceId: Source["scopeId"];
370241 initialValue: OpenApiConnectInput;
371242 submitLabel: string;
372243 busyLabel: string;
@@ -377,7 +248,6 @@ function OpenApiSourceForm(props: { · 4 unmodified lines
377248 openApiHttpClient.mutation("openapi", "previewDocument"),
378249 { mode: "promise" },
379250 );
380− const workspaceId = useWorkspaceId();
381251 const [name, setName] = useState(props.initialValue.name);
382252 const [specUrl, setSpecUrl] = useState(props.initialValue.specUrl);
383253 const [baseUrl, setBaseUrl] = useState(props.initialValue.baseUrl ?? "");
@@ -403,7 +273,7 @@ function OpenApiSourceForm(props: { · 19 unmodified lines
403273 OpenApiPreviewResponse
404274 >(async (payload) =>
405275 previewDocument({
406− path: { workspaceId },
276+ path: { workspaceId: props.workspaceId },
407277 payload,
408278 })
409279 );
@@ -568,7 +438,7 @@ function OpenApiSourceForm(props: { · 158 unmodified lines
568438
569439 {authKind === "bearer" && (
570440 <div className="space-y-4 border-l-2 border-border pl-4">
571− <SecretSelectOrCreateField
441+ <SecretReferenceField
572442 label="Secret"
573443 value={tokenSecretRef}
574444 emptyLabel="Select a secret"
@@ -683,24 +553,33 @@ export function OpenApiAddSourcePage() { · 108 unmodified lines
683553 const navigation = useSourcePluginNavigation();
684554 const initialValue = openApiInputFromSearch(useSourcePluginSearch());
685555 const openApiHttpClient = getOpenApiHttpClient();
556+ const workspace = useWorkspaceRequestContext();
686557 const createSource = useAtomSet(
687558 openApiHttpClient.mutation("openapi", "createSource"),
688559 { mode: "promise" },
689560 );
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+ }
691569
692570 return (
693571 <OpenApiSourceForm
694572 mode="create"
573+ workspaceId={workspace.workspaceId}
695574 initialValue={initialValue}
696575 submitLabel="Create Source"
697576 busyLabel="Creating..."
698577 onSubmit={async (payload) => {
699578 const source = await createSource({
700− path: { workspaceId },
579+ path: { workspaceId: workspace.workspaceId },
701580 payload,
702581 reactivityKeys: {
703− sources: [workspaceId],
582+ sources: [workspace.workspaceId],
704583 },
705584 });
706585
@@ -716,18 +595,39 @@ export function OpenApiAddSourcePage() { · 9 unmodified lines
716595
717596 export function OpenApiEditSourcePage(props: {
718597 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"];
719620 }) {
720621 const navigation = useSourcePluginNavigation();
721622 const openApiHttpClient = getOpenApiHttpClient();
722− const workspaceId = useWorkspaceId();
723623 const configResult = useAtomValue(
724624 openApiHttpClient.query("openapi", "getSourceConfig", {
725625 path: {
726− workspaceId,
626+ workspaceId: props.workspaceId,
727627 sourceId: props.source.id,
728628 },
729629 reactivityKeys: {
730− source: [workspaceId, props.source.id],
630+ source: [props.workspaceId, props.source.id],
731631 },
732632 timeToLive: "30 seconds",
733633 }),
@@ -758,22 +658,23 @@ export function OpenApiEditSourcePage(props: { · 24 unmodified lines
758658 return (
759659 <OpenApiSourceForm
760660 mode="edit"
661+ workspaceId={props.workspaceId}
761662 initialValue={inputFromConfig(configResult.value)}
762663 submitLabel="Save Changes"
763664 busyLabel="Saving..."
764665 onSubmit={async (config) => {
765666 const source = await updateSource({
766667 path: {
767− workspaceId,
668+ workspaceId: props.workspaceId,
768669 sourceId: props.source.id,
769670 },
770671 payload: config,
771672 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],
777678 },
778679 });
779680
@@ -789,10 +690,31 @@ export function OpenApiEditSourcePage(props: { · 9 unmodified lines
789690
790691 export function OpenApiSourceDetailPage(props: {
791692 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"];
792715 }) {
793716 const navigation = useSourcePluginNavigation();
794717 const openApiHttpClient = getOpenApiHttpClient();
795− const workspaceId = useWorkspaceId();
796718 const removeSource = useAtomSet(
797719 openApiHttpClient.mutation("openapi", "removeSource"),
798720 { mode: "promise" },
@@ -804,30 +726,30 @@ export function OpenApiSourceDetailPage(props: { · 5 unmodified lines
804726 const refreshMutation = useExecutorMutation<Source["id"], Source>(async (sourceId) =>
805727 refreshSource({
806728 path: {
807− workspaceId,
729+ workspaceId: props.workspaceId,
808730 sourceId,
809731 },
810732 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],
816738 },
817739 })
818740 );
819741 const removeMutation = useExecutorMutation<Source["id"], { removed: boolean }>(async (sourceId) =>
820742 removeSource({
821743 path: {
822− workspaceId,
744+ workspaceId: props.workspaceId,
823745 sourceId,
824746 },
825747 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],
831753 },
832754 })
833755 );