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 @@
1import * as Schema from "effect/Schema";
2
3const DemoInputSchema = Schema.standardSchemaV1(
4 Schema.Struct({
5 name: Schema.optional(Schema.String),
6 enthusiasm: Schema.optional(Schema.Number),
7 }),
8);
9
10const 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
21export 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
19.executor/*19.executor/*
20!.executor/executor.json20!.executor/executor.json
21!.executor/executor.jsonc21!.executor/executor.jsonc
22!.executor/tools/
23!.executor/tools/**
22.executor/artifacts/24.executor/artifacts/
23.executor/state/25.executor/state/
24.executor/types/26.executor/types/
@@ -84,4 +86,4 @@ personal-notes/ · 59 unmodified lines
84.tmp/86.tmp/
85executor/.env.executor-push87executor/.env.executor-push
86ignored/88ignored/
87agent-notes/89agent-notes/
apps/executor/package.json+3−0
@@ -40,6 +40,9 @@ · 39 unmodified lines
40 "@executor/executor-mcp": "workspace:*",40 "@executor/executor-mcp": "workspace:*",
41 "@executor/plugin-google-discovery-sdk": "workspace:*",41 "@executor/plugin-google-discovery-sdk": "workspace:*",
42 "@executor/plugin-graphql-sdk": "workspace:*",42 "@executor/plugin-graphql-sdk": "workspace:*",
43 "@executor/plugin-keychain-secret-store-sdk": "workspace:*",
44 "@executor/plugin-local-secret-store-sdk": "workspace:*",
45 "@executor/plugin-local-tools-sdk": "workspace:*",
43 "@executor/plugin-mcp-sdk": "workspace:*",46 "@executor/plugin-mcp-sdk": "workspace:*",
44 "@executor/plugin-openapi-sdk": "workspace:*",47 "@executor/plugin-openapi-sdk": "workspace:*",
45 "@executor/platform-api": "workspace:*",48 "@executor/platform-api": "workspace:*",
apps/executor/src/cli/main.ts+16−1
@@ -20,6 +20,15 @@ import { · 19 unmodified lines
20import {20import {
21 graphqlSdkPlugin,21 graphqlSdkPlugin,
22} from "@executor/plugin-graphql-sdk";22} from "@executor/plugin-graphql-sdk";
23import {
24 keychainSecretStoreSdkPlugin,
25} from "@executor/plugin-keychain-secret-store-sdk";
26import {
27 localSecretStoreSdkPlugin,
28} from "@executor/plugin-local-secret-store-sdk";
29import {
30 localToolsSdkPlugin,
31} from "@executor/plugin-local-tools-sdk";
23import {32import {
24 mcpSdkPlugin,33 mcpSdkPlugin,
25} from "@executor/plugin-mcp-sdk";34} from "@executor/plugin-mcp-sdk";
@@ -34,7 +43,9 @@ import { · 8 unmodified lines
34 createExecutorEffect,43 createExecutorEffect,
35 type ExecutorEffect as Executor,44 type ExecutorEffect as Executor,
36} from "@executor/platform-sdk/effect";45} from "@executor/platform-sdk/effect";
37import { createLocalExecutorBackend } from "@executor/platform-sdk-file";46import {
47 createLocalExecutorBackend,
48} from "@executor/platform-sdk-file";
38import {49import {
39 ExecutionIdSchema,50 ExecutionIdSchema,
40 type ExecutionEnvelope,51 type ExecutionEnvelope,
@@ -300,9 +311,13 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 259 unmodified lines
300 Effect.acquireUseRelease(311 Effect.acquireUseRelease(
301 createExecutorEffect({312 createExecutorEffect({
302 backend: createLocalExecutorBackend({313 backend: createLocalExecutorBackend({
314 cwd: process.cwd(),
303 localDataDir: CLI_LOCAL_DATA_DIR,315 localDataDir: CLI_LOCAL_DATA_DIR,
304 }),316 }),
305 plugins: [317 plugins: [
318 localSecretStoreSdkPlugin,
319 keychainSecretStoreSdkPlugin,
320 localToolsSdkPlugin(),
306 graphqlSdkPlugin({321 graphqlSdkPlugin({
307 storage: createFileGraphqlSourceStorage({322 storage: createFileGraphqlSourceStorage({
308 rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/graphql/sources`,323 rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/graphql/sources`,
apps/web/package.json+2−0
@@ -12,8 +12,10 @@ · 11 unmodified lines
12 "@executor/plugin-execution-history-react": "workspace:*",12 "@executor/plugin-execution-history-react": "workspace:*",
13 "@executor/plugin-google-discovery-react": "workspace:*",13 "@executor/plugin-google-discovery-react": "workspace:*",
14 "@executor/plugin-graphql-react": "workspace:*",14 "@executor/plugin-graphql-react": "workspace:*",
15 "@executor/plugin-local-tools-react": "workspace:*",
15 "@executor/plugin-mcp-react": "workspace:*",16 "@executor/plugin-mcp-react": "workspace:*",
16 "@executor/plugin-openapi-react": "workspace:*",17 "@executor/plugin-openapi-react": "workspace:*",
18 "@executor/plugin-onepassword-react": "workspace:*",
17 "@executor/react": "workspace:*",19 "@executor/react": "workspace:*",
18 "@executor/server": "workspace:*",20 "@executor/server": "workspace:*",
19 "@shikijs/langs": "^4.0.1",21 "@shikijs/langs": "^4.0.1",
apps/web/src/components/icons.tsx+8−0
@@ -150,3 +150,11 @@ export function IconArrowLeft({ className, ...props }: IconProps) { · 149 unmodified lines
150 </svg>150 </svg>
151 );151 );
152}152}
153
154export 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
3import { useSources, type Source } from "@executor/react";3import { useSources, type Source } from "@executor/react";
4import { sourcePluginsIndexPath } from "@executor/react/plugins";4import { sourcePluginsIndexPath } from "@executor/react/plugins";
5import { cn } from "../lib/utils";5import { cn } from "../lib/utils";
6import { IconPlus, IconCopy, IconCheck } from "./icons";6import { IconPlus, IconCopy, IconCheck, IconClose, IconMenu } from "./icons";
7import { LoadableBlock } from "./loadable";7import { LoadableBlock } from "./loadable";
8import { SourceFavicon } from "./source-favicon";8import { SourceFavicon } from "./source-favicon";
9import {9import {
@@ -224,6 +224,7 @@ export function AppShell() { · 214 unmodified lines
224 const sources = useSources();224 const sources = useSources();
225 const location = useLocation();225 const location = useLocation();
226 const matchRoute = useMatchRoute();226 const matchRoute = useMatchRoute();
227 const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
227 const isHome = matchRoute({ to: "/" });228 const isHome = matchRoute({ to: "/" });
228 const isSecrets = matchRoute({ to: "/secrets" });229 const isSecrets = matchRoute({ to: "/secrets" });
229 const { latestVersion, updateAvailable, channel } = useLatestVersion(VITE_APP_VERSION);230 const { latestVersion, updateAvailable, channel } = useLatestVersion(VITE_APP_VERSION);
@@ -234,11 +235,125 @@ export function AppShell() { · 4 unmodified lines
234 ({ route }) => route.nav?.section === "sources",235 ({ route }) => route.nav?.section === "sources",
235 );236 );
236 237
238 useEffect(() => {
239 setMobileSidebarOpen(false);
240 }, [location.pathname]);
241
242 useEffect(() => {
243 if (!mobileSidebarOpen) {
244 return;
245 }
246
247 const previousOverflow = document.body.style.overflow;
248 document.body.style.overflow = "hidden";
249 return () => {
250 document.body.style.overflow = previousOverflow;
251 };
252 }, [mobileSidebarOpen]);
253
237 return (254 return (
238 <div className="flex h-screen overflow-hidden">255 <div className="flex h-screen overflow-hidden">
239 {/* Sidebar */}256 <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">
240 <aside className="flex w-52 shrink-0 flex-col border-r border-sidebar-border bg-sidebar lg:w-56">257 <SidebarContent
241 {/* Brand */}258 isHome={!!isHome}
259 isSecrets={!!isSecrets}
260 latestVersion={latestVersion}
261 updateAvailable={updateAvailable}
262 channel={channel}
263 locationPathname={location.pathname}
264 mainPluginNavItems={mainPluginNavItems}
265 sourcePluginNavItems={sourcePluginNavItems}
266 sources={sources}
267 />
268 </aside>
269
270 {mobileSidebarOpen && (
271 <div className="fixed inset-0 z-50 flex md:hidden">
272 <button
273 type="button"
274 aria-label="Close navigation"
275 className="absolute inset-0 bg-black/45 backdrop-blur-[1px]"
276 onClick={() => setMobileSidebarOpen(false)}
277 />
278 <div className="relative flex h-full w-[84vw] max-w-xs flex-col border-r border-sidebar-border bg-sidebar shadow-2xl">
279 <div className="flex h-12 shrink-0 items-center justify-between border-b border-sidebar-border px-4">
280 <Link to="/" className="flex items-center gap-1.5">
281 <span className="font-display text-base tracking-tight text-foreground">
282 executor
283 </span>
284 <span className="rounded bg-primary/15 px-1.5 py-0.5 text-[10px] font-medium text-primary">
285 v3
286 </span>
287 </Link>
288 <button
289 type="button"
290 aria-label="Close navigation"
291 onClick={() => setMobileSidebarOpen(false)}
292 className="inline-flex size-8 items-center justify-center rounded-md text-sidebar-foreground transition-colors hover:bg-sidebar-active hover:text-foreground"
293 >
294 <IconClose className="size-3.5" />
295 </button>
296 </div>
297 <SidebarContent
298 isHome={!!isHome}
299 isSecrets={!!isSecrets}
300 latestVersion={latestVersion}
301 updateAvailable={updateAvailable}
302 channel={channel}
303 locationPathname={location.pathname}
304 mainPluginNavItems={mainPluginNavItems}
305 sourcePluginNavItems={sourcePluginNavItems}
306 sources={sources}
307 onNavigate={() => setMobileSidebarOpen(false)}
308 showBrand={false}
309 />
310 </div>
311 </div>
312 )}
313
314 {/* Main content */}
315 <main className="flex min-h-0 flex-1 flex-col min-w-0 overflow-hidden">
316 <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background px-4 md:hidden">
317 <button
318 type="button"
319 aria-label="Open navigation"
320 onClick={() => setMobileSidebarOpen(true)}
321 className="inline-flex size-8 items-center justify-center rounded-md border border-border bg-card text-foreground transition-colors hover:bg-accent/50"
322 >
323 <IconMenu className="size-4" />
324 </button>
325 <Link to="/" className="flex items-center gap-1.5">
326 <span className="font-display text-base tracking-tight text-foreground">
327 executor
328 </span>
329 <span className="rounded bg-primary/15 px-1.5 py-0.5 text-[10px] font-medium text-primary">
330 v3
331 </span>
332 </Link>
333 <div className="w-8 shrink-0" />
334 </div>
335 <Outlet />
336 </main>
337 </div>
338 );
339}
340
341function SidebarContent(props: {
342 isHome: boolean;
343 isSecrets: boolean;
344 latestVersion: string | null;
345 updateAvailable: boolean;
346 channel: UpdateChannel;
347 locationPathname: string;
348 mainPluginNavItems: typeof registeredFrontendPluginNavRoutes;
349 sourcePluginNavItems: typeof registeredFrontendPluginNavRoutes;
350 sources: ReturnType<typeof useSources>;
351 onNavigate?: () => void;
352 showBrand?: boolean;
353}) {
354 return (
355 <>
356 {props.showBrand !== false && (
242 <div className="flex h-12 shrink-0 items-center border-b border-sidebar-border px-4">357 <div className="flex h-12 shrink-0 items-center border-b border-sidebar-border px-4">
243 <Link to="/" className="flex items-center gap-1.5">358 <Link to="/" className="flex items-center gap-1.5">
244 <span className="font-display text-base tracking-tight text-foreground">359 <span className="font-display text-base tracking-tight text-foreground">
@@ -249,99 +364,107 @@ export function AppShell() { · 4 unmodified lines
249 </span>364 </span>
250 </Link>365 </Link>
251 </div>366 </div>
367 )}
252 368
253 {/* Main nav */}369 <nav className="flex flex-1 flex-col overflow-y-auto p-2">
254 <nav className="flex flex-1 flex-col p-2 overflow-y-auto">370 <NavItem
255 <NavItem to="/" label="Dashboard" active={!!isHome} />371 to="/"
256 <NavItem to="/secrets" label="Secrets" active={!!isSecrets} />372 label="Dashboard"
257 {mainPluginNavItems.map(({ plugin, route, to }) => (373 active={props.isHome}
258 <NavItem374 onNavigate={props.onNavigate}
259 key={`${plugin.key}:${route.key}`}375 />
260 to={to}376 <NavItem
261 label={route.nav?.label ?? route.key}377 to="/secrets"
262 active={location.pathname === to || location.pathname.startsWith(`${to}/`)}378 label="Secrets"
263 />379 active={props.isSecrets}
264 ))}380 onNavigate={props.onNavigate}
265 381 />
266 {/* Sources */}382 {props.mainPluginNavItems.map(({ plugin, route, to }) => (
267 <div className="mt-5 mb-1 px-2.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground/50">383 <NavItem
268 <div className="flex items-center justify-between gap-2">384 key={`${plugin.key}:${route.key}`}
269 <span>Sources</span>385 to={to}
270 <Link386 label={route.nav?.label ?? route.key}
271 to={sourcePluginsIndexPath}387 active={
272 className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-[10px] font-medium normal-case tracking-normal text-primary transition-colors hover:bg-sidebar-active hover:text-foreground"388 props.locationPathname === to
273 >389 || props.locationPathname.startsWith(`${to}/`)
274 <IconPlus className="size-3" />
275 Add
276 </Link>
277 </div>
278 </div>
279 {sourcePluginNavItems.length > 0 && (
280 <div className="mb-2 flex flex-col gap-px">
281 {sourcePluginNavItems.map(({ plugin, route, to }) => (
282 <NavItem
283 key={`${plugin.key}:${route.key}`}
284 to={to}
285 label={route.nav?.label ?? route.key}
286 active={
287 location.pathname === to
288 || location.pathname.startsWith(`${to}/`)
289 }
290 />
291 ))}
292 </div>
293 )}
294 <LoadableBlock loadable={sources} loading="Loading...">
295 {(items) =>
296 !Array.isArray(items) ? (
297 <div className="px-2.5 py-2 text-[11px] leading-relaxed text-destructive">
298 Sources returned an unexpected payload.
299 </div>
300 ) : items.length === 0 ? (
301 <div className="px-2.5 py-2 text-[11px] leading-relaxed text-muted-foreground/40">
302 No sources yet
303 </div>
304 ) : (
305 <div className="flex flex-col gap-px">
306 {items.map((source) => (
307 <SourceItem
308 key={source.id}
309 pathname={location.pathname}
310 source={source}
311 />
312 ))}
313 </div>
314 )
315 }390 }
316 </LoadableBlock>391 onNavigate={props.onNavigate}
317 </nav>392 />
318 393 ))}
319 {/* Update available */}394
320 {updateAvailable && latestVersion && (395 <div className="mt-5 mb-1 px-2.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground/50">
321 <UpdateCard latestVersion={latestVersion} channel={channel} />396 <div className="flex items-center justify-between gap-2">
322 )}397 <span>Sources</span>
323 398 <Link
324 {/* Footer */}399 to={sourcePluginsIndexPath}
325 <div className="shrink-0 border-t border-sidebar-border px-4 py-2.5">400 onClick={props.onNavigate}
326 <div className="flex items-center justify-between text-[10px] leading-none">401 className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-[10px] font-medium normal-case tracking-normal text-primary transition-colors hover:bg-sidebar-active hover:text-foreground"
327 <span className="text-muted-foreground/70 tabular-nums">v{VITE_APP_VERSION}</span>
328 <a
329 href={VITE_GITHUB_URL}
330 target="_blank"
331 rel="noreferrer"
332 className="text-muted-foreground/70 transition-colors hover:text-foreground"
333 >402 >
334 GitHub403 <IconPlus className="size-3" />
335 </a>404 Add
405 </Link>
336 </div>406 </div>
337 </div>407 </div>
338 </aside>408 {props.sourcePluginNavItems.length > 0 && (
409 <div className="mb-2 flex flex-col gap-px">
410 {props.sourcePluginNavItems.map(({ plugin, route, to }) => (
411 <NavItem
412 key={`${plugin.key}:${route.key}`}
413 to={to}
414 label={route.nav?.label ?? route.key}
415 active={
416 props.locationPathname === to
417 || props.locationPathname.startsWith(`${to}/`)
418 }
419 onNavigate={props.onNavigate}
420 />
421 ))}
422 </div>
423 )}
424 <LoadableBlock loadable={props.sources} loading="Loading...">
425 {(items) =>
426 !Array.isArray(items) ? (
427 <div className="px-2.5 py-2 text-[11px] leading-relaxed text-destructive">
428 Sources returned an unexpected payload.
429 </div>
430 ) : items.length === 0 ? (
431 <div className="px-2.5 py-2 text-[11px] leading-relaxed text-muted-foreground/40">
432 No sources yet
433 </div>
434 ) : (
435 <div className="flex flex-col gap-px">
436 {items.map((source) => (
437 <SourceItem
438 key={source.id}
439 pathname={props.locationPathname}
440 source={source}
441 onNavigate={props.onNavigate}
442 />
443 ))}
444 </div>
445 )
446 }
447 </LoadableBlock>
448 </nav>
449
450 {props.updateAvailable && props.latestVersion && (
451 <UpdateCard latestVersion={props.latestVersion} channel={props.channel} />
452 )}
339 453
340 {/* Main content */}454 <div className="shrink-0 border-t border-sidebar-border px-4 py-2.5">
341 <main className="flex min-h-0 flex-1 flex-col min-w-0 overflow-hidden">455 <div className="flex items-center justify-between text-[10px] leading-none">
342 <Outlet />456 <span className="text-muted-foreground/70 tabular-nums">v{VITE_APP_VERSION}</span>
343 </main>457 <a
344 </div>458 href={VITE_GITHUB_URL}
459 target="_blank"
460 rel="noreferrer"
461 className="text-muted-foreground/70 transition-colors hover:text-foreground"
462 >
463 GitHub
464 </a>
465 </div>
466 </div>
467 </>
345 );468 );
346}469}
347 470
@@ -350,6 +473,7 @@ export function AppShell() { · 2 unmodified lines
350function SourceItem(props: {473function SourceItem(props: {
351 pathname: string;474 pathname: string;
352 source: Source;475 source: Source;
476 onNavigate?: () => void;
353}) {477}) {
354 const paths = getSourceFrontendPaths(props.source.kind);478 const paths = getSourceFrontendPaths(props.source.kind);
355 479
@@ -377,6 +501,7 @@ function SourceItem(props: { · 21 unmodified lines
377 <Link501 <Link
378 to={detailPath}502 to={detailPath}
379 search={{ tab: "model" }}503 search={{ tab: "model" }}
504 onClick={props.onNavigate}
380 className={cn(505 className={cn(
381 "group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors",506 "group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors",
382 active507 active
@@ -398,10 +523,16 @@ function SourceItem(props: { · 15 unmodified lines
398 523
399// ── NavItem ──────────────────────────────────────────────────────────────524// ── NavItem ──────────────────────────────────────────────────────────────
400 525
401function NavItem(props: { to: string; label: string; active: boolean }) {526function NavItem(props: {
527 to: string;
528 label: string;
529 active: boolean;
530 onNavigate?: () => void;
531}) {
402 return (532 return (
403 <Link533 <Link
404 to={props.to}534 to={props.to}
535 onClick={props.onNavigate}
405 className={cn(536 className={cn(
406 "flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-[13px] transition-colors",537 "flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-[13px] transition-colors",
407 props.active538 props.active
apps/web/src/plugins/index.ts+11−0
@@ -11,12 +11,18 @@ import { · 10 unmodified lines
11import {11import {
12 GraphqlReactPlugin,12 GraphqlReactPlugin,
13} from "@executor/plugin-graphql-react";13} from "@executor/plugin-graphql-react";
14import {
15 LocalToolsReactPlugin,
16} from "@executor/plugin-local-tools-react";
14import {17import {
15 McpReactPlugin,18 McpReactPlugin,
16} from "@executor/plugin-mcp-react";19} from "@executor/plugin-mcp-react";
17import {20import {
18 OpenApiReactPlugin,21 OpenApiReactPlugin,
19} from "@executor/plugin-openapi-react";22} from "@executor/plugin-openapi-react";
23import {
24 OnePasswordReactPlugin,
25} from "@executor/plugin-onepassword-react";
20import {26import {
21 createExecutorPluginPaths,27 createExecutorPluginPaths,
22 createSourcePluginPaths,28 createSourcePluginPaths,
@@ -26,10 +32,12 @@ import { · 3 unmodified lines
26 32
27const frontendPlugins = [33const frontendPlugins = [
28 ExecutionHistoryReactPlugin,34 ExecutionHistoryReactPlugin,
35 LocalToolsReactPlugin,
29 McpReactPlugin,36 McpReactPlugin,
30 GraphqlReactPlugin,37 GraphqlReactPlugin,
31 GoogleDiscoveryReactPlugin,38 GoogleDiscoveryReactPlugin,
32 OpenApiReactPlugin,39 OpenApiReactPlugin,
40 OnePasswordReactPlugin,
33] as const satisfies readonly ExecutorFrontendPlugin[];41] as const satisfies readonly ExecutorFrontendPlugin[];
34 42
35const frontendPluginRegistry = registerExecutorFrontendPlugins(frontendPlugins);43const frontendPluginRegistry = registerExecutorFrontendPlugins(frontendPlugins);
@@ -81,3 +89,6 @@ export const getSourceFrontendIconUrl = (source: Source) => · 45 unmodified lines
81 source.kind === "google-discovery"89 source.kind === "google-discovery"
82 ? getGoogleDiscoveryIconUrl(source)90 ? getGoogleDiscoveryIconUrl(source)
83 : null;91 : null;
92
93export 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 @@
1import { useEffect, useState, type ReactNode } from "react";1import { useCallback, useEffect, useState, type ReactNode } from "react";
2import {2import {
3 type BrowseSecretStoreResult,
4 getExecutorApiBaseUrl,
3 type InstanceConfig,5 type InstanceConfig,
4 type SecretListItem,
5 type Loadable,6 type Loadable,
6 useSecrets,7 type SecretListItem,
8 type SecretStore,
7 useCreateSecret,9 useCreateSecret,
8 useUpdateSecret,10 useCreateSecretStore,
9 useDeleteSecret,11 useDeleteSecret,
12 useDeleteSecretStore,
13 useExecutorMutation,
10 useInstanceConfig,14 useInstanceConfig,
15 useRefreshSecrets,
16 useSecretStores,
17 useSecrets,
18 useUpdateSecret,
11} from "@executor/react";19} from "@executor/react";
20
12import { LoadableBlock } from "../components/loadable";21import { LoadableBlock } from "../components/loadable";
13import { Button } from "../components/ui/button";
14import { Badge } from "../components/ui/badge";22import { Badge } from "../components/ui/badge";
15import { IconPlus, IconPencil, IconTrash, IconSpinner } from "../components/icons";23import { Button } from "../components/ui/button";
24import {
25 IconPencil,
26 IconPlus,
27 IconSpinner,
28 IconTrash,
29} from "../components/icons";
16import { cn } from "../lib/utils";30import { cn } from "../lib/utils";
31import { getSecretStoreFrontendPlugin } from "../plugins";
17 32
18// ---------------------------------------------------------------------------33const creatableStorePluginsFromConfig = (config: Loadable<InstanceConfig>) =>
19// Page
20// ---------------------------------------------------------------------------
21
22const storableProvidersFromConfig = (config: Loadable<InstanceConfig>) =>
23 config.status === "ready"34 config.status === "ready"
24 ? config.data.secretProviders.filter((provider) => provider.canStore)35 ? config.data.secretStorePlugins.filter((plugin) => plugin.canCreate)
25 : [];36 : [];
26 37
27const providerLabelForId = (config: Loadable<InstanceConfig>, providerId: string): string =>38const canManageStoreKind = (
39 config: Loadable<InstanceConfig>,
40 kind: string,
41): boolean =>
28 config.status === "ready"42 config.status === "ready"
29 ? config.data.secretProviders.find((provider) => provider.id === providerId)?.name ?? providerId43 ? config.data.secretStorePlugins.some((plugin) =>
30 : providerId;44 plugin.kind === kind && plugin.canCreate
45 )
46 : false;
47
48const 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
65type SecretStoreBrowserEntry = BrowseSecretStoreResult["entries"][number];
66type SecretStoreBrowserCrumb = {
67 key: string;
68 label: string;
69};
70
71const canBrowseAndImportStore = (store: SecretStore | null): boolean =>
72 store?.capabilities.canBrowseSecrets === true
73 && store.capabilities.canImportSecrets === true;
31 74
32export function SecretsPage() {75export function SecretsPage() {
33 const instanceConfig = useInstanceConfig();76 const instanceConfig = useInstanceConfig();
77 const secretStores = useSecretStores();
34 const secrets = useSecrets();78 const secrets = useSecrets();
35 const [showCreate, setShowCreate] = useState(false);79 const [showCreateStore, setShowCreateStore] = useState(false);
80 const [showCreateSecret, setShowCreateSecret] = useState(false);
36 const [editingId, setEditingId] = useState<string | null>(null);81 const [editingId, setEditingId] = useState<string | null>(null);
37 const storableProviders = storableProvidersFromConfig(instanceConfig);82
38 const defaultProviderLabel =83 const creatableStorePlugins = creatableStorePluginsFromConfig(instanceConfig);
39 instanceConfig.status === "ready"84 const defaultLabel = defaultStoreLabel(instanceConfig, secretStores);
40 ? providerLabelForId(instanceConfig, instanceConfig.data.defaultSecretStoreProvider)
41 : null;
42 85
43 return (86 return (
44 <div className="min-h-0 flex-1 overflow-y-auto">87 <div className="min-h-0 flex-1 overflow-y-auto">
45 <div className="mx-auto max-w-3xl px-6 py-10 lg:px-10 lg:py-14">88 <div className="mx-auto flex max-w-4xl flex-col gap-8 px-6 py-10 lg:px-10 lg:py-14">
46 {/* Header */}89 <div className="flex flex-wrap items-end justify-between gap-4">
47 <div className="flex items-end justify-between mb-8">
48 <div>90 <div>
49 <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl">91 <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl">
50 Secrets92 Secrets
51 </h1>93 </h1>
52 <p className="mt-1.5 text-[14px] text-muted-foreground">94 <p className="mt-1.5 text-[14px] text-muted-foreground">
53 Tokens and credentials stored by executor.95 Manage secret stores and the secrets linked into sources.
54 {defaultProviderLabel ? ` New secrets default to ${defaultProviderLabel}.` : ""}96 {defaultLabel ? ` New secrets default to ${defaultLabel}.` : ""}
55 </p>97 </p>
56 </div>98 </div>
57 <Button size="sm" onClick={() => { setShowCreate(true); setEditingId(null); }}>99 <div className="flex gap-2">
58 <IconPlus className="size-3.5" />100 <Button
59 Add secret101 size="sm"
60 </Button>102 variant="outline"
103 onClick={() => {
104 setShowCreateStore(true);
105 }}
106 disabled={creatableStorePlugins.length === 0}
107 >
108 <IconPlus className="size-3.5" />
109 Add store
110 </Button>
111 <Button
112 size="sm"
113 onClick={() => {
114 setShowCreateSecret(true);
115 setEditingId(null);
116 }}
117 >
118 <IconPlus className="size-3.5" />
119 Add secret
120 </Button>
121 </div>
61 </div>122 </div>
62 123
63 {/* Create form */}124 <section className="space-y-4">
64 {showCreate && (125 <div>
65 <CreateSecretForm126 <h2 className="text-sm font-semibold text-foreground">Stores</h2>
66 onClose={() => setShowCreate(false)}127 <p className="mt-1 text-[13px] text-muted-foreground">
67 className="mb-6"128 Local and external backends that hold managed secrets.
68 providerOptions={storableProviders}129 </p>
69 defaultProviderId={130 </div>
70 instanceConfig.status === "ready"131
71 ? instanceConfig.data.defaultSecretStoreProvider132 {showCreateStore && (
72 : null133 <CreateSecretStoreForm
134 className="mb-2"
135 onClose={() => setShowCreateStore(false)}
136 pluginOptions={creatableStorePlugins}
137 secrets={secrets}
138 />
139 )}
140
141 <LoadableBlock loadable={secretStores} loading="Loading stores...">
142 {(items) =>
143 items.length === 0 ? (
144 <SectionEmptyState
145 title="No secret stores yet"
146 description="Add a store to connect an external backend like 1Password."
147 />
148 ) : (
149 <div className="grid gap-3 md:grid-cols-2">
150 {items.map((store) => (
151 <SecretStoreCard
152 key={store.id}
153 store={store}
154 canManage={canManageStoreKind(instanceConfig, store.kind)}
155 />
156 ))}
157 </div>
158 )
73 }159 }
74 />160 </LoadableBlock>
75 )}161 </section>
76 162
77 {/* Secrets table */}163 <section className="space-y-4">
78 <LoadableBlock loadable={secrets} loading="Loading secrets...">164 <div>
79 {(items) =>165 <h2 className="text-sm font-semibold text-foreground">Managed Secrets</h2>
80 items.length === 0 && !showCreate ? (166 <p className="mt-1 text-[13px] text-muted-foreground">
81 <EmptyState onAdd={() => setShowCreate(true)} />167 Secret records stay stable even if their underlying store handle changes.
82 ) : (168 </p>
83 <div className="rounded-xl border border-border bg-card/80 divide-y divide-border overflow-hidden">169 </div>
84 {items.map((secret) => (170
85 <SecretRow171 {showCreateSecret && (
86 key={secret.id}172 <CreateSecretForm
87 secret={secret}173 className="mb-2"
88 providerLabel={providerLabelForId(instanceConfig, secret.providerId)}174 onClose={() => setShowCreateSecret(false)}
89 isEditing={editingId === secret.id}175 storeOptions={secretStores.status === "ready" ? secretStores.data : []}
90 onEdit={() => setEditingId(editingId === secret.id ? null : secret.id)}176 defaultStoreId={
91 onCancelEdit={() => setEditingId(null)}177 instanceConfig.status === "ready"
92 />178 ? instanceConfig.data.defaultSecretStoreId
93 ))}179 : null
94 {items.length === 0 && (180 }
95 <div className="px-5 py-8 text-center text-[13px] text-muted-foreground/60">181 />
96 No secrets yet. Add one above.182 )}
97 </div>183
98 )}184 <LoadableBlock loadable={secrets} loading="Loading secrets...">
99 </div>185 {(items) =>
100 )186 items.length === 0 && !showCreateSecret ? (
101 }187 <SectionEmptyState
102 </LoadableBlock>188 title="No secrets stored"
189 description="Add a secret to use it in source authentication and runtime resolution."
190 actionLabel="Add secret"
191 onAction={() => {
192 setShowCreateSecret(true);
193 setEditingId(null);
194 }}
195 />
196 ) : (
197 <div className="overflow-hidden rounded-xl border border-border bg-card/80">
198 {items.map((secret, index) => (
199 <div
200 key={secret.id}
201 className={cn(
202 "px-5 py-3.5",
203 index > 0 && "border-t border-border",
204 )}
205 >
206 <SecretRow
207 secret={secret}
208 isEditing={editingId === secret.id}
209 onEdit={() =>
210 setEditingId(editingId === secret.id ? null : secret.id)}
211 onCancelEdit={() => setEditingId(null)}
212 />
213 </div>
214 ))}
215 </div>
216 )
217 }
218 </LoadableBlock>
219 </section>
103 </div>220 </div>
104 </div>221 </div>
105 );222 );
106}223}
107 224
108// ---------------------------------------------------------------------------225function CreateSecretStoreForm(props: {
109// Empty state226 className?: string;
110// ---------------------------------------------------------------------------227 onClose: () => void;
228 pluginOptions: ReadonlyArray<{
229 kind: string;
230 displayName: string;
231 canCreate: boolean;
232 }>;
233 secrets: Loadable<ReadonlyArray<SecretListItem>>;
234}) {
235 const createSecretStore = useCreateSecretStore();
236 const [kind, setKind] = useState(props.pluginOptions[0]?.kind ?? "");
237 const [name, setName] = useState("");
238 const [error, setError] = useState<string | null>(null);
239 const selectedFrontendPlugin = getSecretStoreFrontendPlugin(kind);
240 const StoreCreateForm = selectedFrontendPlugin?.secretStore?.CreateStoreForm;
241
242 useEffect(() => {
243 if (kind.length > 0 || props.pluginOptions.length === 0) {
244 return;
245 }
246 setKind(props.pluginOptions[0]!.kind);
247 }, [kind, props.pluginOptions]);
248
249 const handleSubmit = async () => {
250 setError(null);
251
252 const trimmedName = name.trim();
253 if (!trimmedName) {
254 setError("Store name is required.");
255 return;
256 }
257
258 try {
259 await createSecretStore.mutateAsync({
260 kind,
261 name: trimmedName,
262 config: {},
263 });
264 props.onClose();
265 } catch (cause) {
266 setError(
267 cause instanceof Error ? cause.message : "Failed creating secret store.",
268 );
269 }
270 };
111 271
112function EmptyState(props: { onAdd: () => void }) {
113 return (272 return (
114 <div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-20">273 <FormCard
115 <div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground mb-4">274 className={props.className}
116 <KeyIcon className="size-5" />275 title="New secret store"
117 </div>276 onClose={props.onClose}
118 <p className="text-[14px] font-medium text-foreground/70 mb-1">277 >
119 No secrets stored278 {error && <ErrorBanner>{error}</ErrorBanner>}
120 </p>279 {props.pluginOptions.length === 0 ? (
121 <p className="text-[13px] text-muted-foreground/60 mb-5">280 <div className="rounded-lg border border-border bg-background/40 px-4 py-3 text-[13px] text-muted-foreground">
122 Secrets let you securely provide tokens for source authentication without putting them in workspace config.281 No external secret store plugins are available in this runtime.
123 </p>282 </div>
124 <Button size="sm" onClick={props.onAdd}>283 ) : (
125 <IconPlus className="size-3.5" />284 <>
126 Add secret285 <div className="grid gap-4 sm:grid-cols-2">
127 </Button>286 <FieldLabel label="Store type">
128 </div>287 <select
288 value={kind}
289 onChange={(event) => setKind(event.target.value)}
290 className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
291 >
292 {props.pluginOptions.map((plugin) => (
293 <option key={plugin.kind} value={plugin.kind}>
294 {plugin.displayName}
295 </option>
296 ))}
297 </select>
298 </FieldLabel>
299 {!StoreCreateForm && (
300 <FieldLabel label="Name">
301 <input
302 value={name}
303 onChange={(event) => setName(event.target.value)}
304 placeholder="Secret store"
305 className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
306 autoFocus
307 />
308 </FieldLabel>
309 )}
310 </div>
311
312 {StoreCreateForm ? (
313 <StoreCreateForm
314 isSubmitting={createSecretStore.status === "pending"}
315 onCancel={props.onClose}
316 onSubmit={async (input) => {
317 await createSecretStore.mutateAsync({
318 kind,
319 name: input.name,
320 config: input.config,
321 });
322 props.onClose();
323 }}
324 secrets={props.secrets}
325 />
326 ) : (
327 <div className="flex justify-end">
328 <Button
329 size="sm"
330 onClick={handleSubmit}
331 disabled={createSecretStore.status === "pending"}
332 >
333 {createSecretStore.status === "pending"
334 ? <IconSpinner className="size-3.5" />
335 : <IconPlus className="size-3.5" />}
336 Create store
337 </Button>
338 </div>
339 )}
340 </>
341 )}
342 </FormCard>
129 );343 );
130}344}
131 345
132// ---------------------------------------------------------------------------
133// Create form
134// ---------------------------------------------------------------------------
135
136function CreateSecretForm(props: {346function CreateSecretForm(props: {
137 onClose: () => void;
138 className?: string;347 className?: string;
139 providerOptions: ReadonlyArray<{ id: string; name: string }>;348 onClose: () => void;
140 defaultProviderId: string | null;349 storeOptions: ReadonlyArray<SecretStore>;
350 defaultStoreId: string | null;
141}) {351}) {
142 const createSecret = useCreateSecret();352 const createSecret = useCreateSecret();
353 const refreshSecrets = useRefreshSecrets();
354 const [mode, setMode] = useState<"create" | "import">("create");
143 const [name, setName] = useState("");355 const [name, setName] = useState("");
144 const [value, setValue] = useState("");356 const [value, setValue] = useState("");
145 const [providerId, setProviderId] = useState(props.defaultProviderId ?? "");357 const [storeId, setStoreId] = useState(props.defaultStoreId ?? "");
358 const [browserStack, setBrowserStack] = useState<ReadonlyArray<SecretStoreBrowserCrumb>>(
359 [],
360 );
361 const [browserQuery, setBrowserQuery] = useState("");
362 const [browserEntries, setBrowserEntries] = useState<
363 ReadonlyArray<SecretStoreBrowserEntry>
364 >([]);
365 const [browserStatus, setBrowserStatus] = useState<"idle" | "pending">("idle");
366 const [browserError, setBrowserError] = useState<string | null>(null);
367 const [selectedImportKey, setSelectedImportKey] = useState("");
368 const [selectedImportLabel, setSelectedImportLabel] = useState("");
146 const [error, setError] = useState<string | null>(null);369 const [error, setError] = useState<string | null>(null);
370 const selectedStore = props.storeOptions.find((store) => store.id === storeId) ?? null;
371 const canImportSecrets = canBrowseAndImportStore(selectedStore);
372 const canCreateSecrets = selectedStore?.capabilities.canCreateSecrets !== false;
373 const currentParentKey = browserStack[browserStack.length - 1]?.key ?? null;
374
375 const browseStore = useCallback(
376 async (input: {
377 storeId: string;
378 parentKey?: string | null;
379 query?: string | null;
380 }) => {
381 const response = await fetch(
382 `${getExecutorApiBaseUrl()}/v1/local/secret-stores/${input.storeId}/browse`,
383 {
384 method: "POST",
385 headers: {
386 "content-type": "application/json",
387 },
388 body: JSON.stringify({
389 ...(input.parentKey ? { parentKey: input.parentKey } : {}),
390 ...(input.query?.trim() ? { query: input.query.trim() } : {}),
391 }),
392 },
393 );
394
395 if (!response.ok) {
396 let message = "Failed browsing secret store.";
397 try {
398 const payload = await response.json() as {
399 message?: string;
400 details?: string;
401 };
402 message = payload.message ?? payload.details ?? message;
403 } catch {
404 // ignore response parsing errors
405 }
406 throw new Error(message);
407 }
408
409 return await response.json() as BrowseSecretStoreResult;
410 },
411 [],
412 );
413 const importSecretMutation = useExecutorMutation<void, { id: string }>(async () => {
414 const response = await fetch(
415 `${getExecutorApiBaseUrl()}/v1/local/secret-stores/${storeId}/import`,
416 {
417 method: "POST",
418 headers: {
419 "content-type": "application/json",
420 },
421 body: JSON.stringify({
422 selectionKey: selectedImportKey,
423 ...(name.trim() ? { name: name.trim() } : {}),
424 }),
425 },
426 );
427
428 if (!response.ok) {
429 let message = "Failed importing secret.";
430 try {
431 const payload = await response.json() as {
432 message?: string;
433 details?: string;
434 };
435 message = payload.message ?? payload.details ?? message;
436 } catch {
437 // ignore response parsing errors
438 }
439 throw new Error(message);
440 }
441
442 return await response.json() as { id: string };
443 });
147 444
148 useEffect(() => {445 useEffect(() => {
149 if (providerId.length > 0) {446 if (storeId.length > 0) {
150 return;447 return;
151 }448 }
152 if (props.defaultProviderId) {449 if (props.defaultStoreId) {
153 setProviderId(props.defaultProviderId);450 setStoreId(props.defaultStoreId);
451 return;
452 }
453 if (props.storeOptions.length > 0) {
454 setStoreId(props.storeOptions[0]!.id);
154 }455 }
155 }, [props.defaultProviderId, providerId]);456 }, [props.defaultStoreId, props.storeOptions, storeId]);
156 457
157 const handleSubmit = async () => {458 useEffect(() => {
158 setError(null);459 setBrowserStack([]);
159 const trimmedName = name.trim();460 setBrowserQuery("");
160 if (!trimmedName) {461 setBrowserEntries([]);
161 setError("Name is required.");462 setBrowserStatus("idle");
463 setBrowserError(null);
464 setSelectedImportKey("");
465 setSelectedImportLabel("");
466 if (canImportSecrets && !canCreateSecrets) {
467 setMode("import");
468 return;
469 }
470 if (!canImportSecrets && canCreateSecrets) {
471 setMode("create");
162 return;472 return;
163 }473 }
164 if (!value) {474 if (canImportSecrets) {
165 setError("Value is required.");475 setMode("import");
166 return;476 return;
167 }477 }
478 setMode("create");
479 }, [canCreateSecrets, canImportSecrets, selectedStore?.id]);
168 480
169 try {481 useEffect(() => {
170 await createSecret.mutateAsync({482 if (mode !== "import" || !canImportSecrets || !storeId) {
171 name: trimmedName,483 return;
172 value,484 }
173 ...(providerId ? { providerId } : {}),485
486 let active = true;
487 setBrowserStatus("pending");
488 void browseStore({
489 storeId,
490 parentKey: currentParentKey,
491 query: browserQuery,
492 })
493 .then((result) => {
494 if (!active) {
495 return;
496 }
497 setBrowserEntries(result.entries);
498 setBrowserError(null);
499 setBrowserStatus("idle");
500 })
501 .catch((cause) => {
502 if (!active) {
503 return;
504 }
505 setBrowserEntries([]);
506 setBrowserError(
507 cause instanceof Error ? cause.message : "Failed browsing secret store.",
508 );
509 setBrowserStatus("idle");
174 });510 });
511
512 return () => {
513 active = false;
514 };
515 }, [browserQuery, browseStore, canImportSecrets, currentParentKey, mode, storeId]);
516
517 useEffect(() => {
518 if (mode !== "import" || !selectedImportLabel || name.trim().length > 0) {
519 return;
520 }
521 setName(selectedImportLabel);
522 }, [mode, name, selectedImportLabel]);
523
524 const handleBrowserEntryClick = (entry: SecretStoreBrowserEntry) => {
525 setError(null);
526 if (entry.kind === "group") {
527 setSelectedImportKey("");
528 setSelectedImportLabel("");
529 setBrowserStack((current) => [...current, {
530 key: entry.key,
531 label: entry.label,
532 }]);
533 setBrowserQuery("");
534 return;
535 }
536
537 setSelectedImportKey(entry.key);
538 setSelectedImportLabel(entry.label);
539 };
540
541 const handleSubmit = async () => {
542 setError(null);
543 const trimmedName = name.trim();
544
545 try {
546 if (mode === "import") {
547 if (!selectedImportKey) {
548 setError("Select a secret to import.");
549 return;
550 }
551
552 await importSecretMutation.mutateAsync();
553 refreshSecrets();
554 } else {
555 if (!trimmedName) {
556 setError("Name is required.");
557 return;
558 }
559 if (!value) {
560 setError("Value is required.");
561 return;
562 }
563
564 await createSecret.mutateAsync({
565 name: trimmedName,
566 value,
567 ...(storeId ? { storeId } : {}),
568 });
569 }
570
175 props.onClose();571 props.onClose();
176 } catch (err) {572 } catch (cause) {
177 setError(err instanceof Error ? err.message : "Failed creating secret.");573 setError(cause instanceof Error ? cause.message : "Failed creating secret.");
178 }574 }
179 };575 };
180 576
181 return (577 return (
182 <div className={cn("rounded-xl border border-primary/20 bg-card/80", props.className)}>578 <FormCard
183 <div className="border-b border-border px-5 py-3 flex items-center justify-between">579 className={props.className}
184 <h2 className="text-sm font-semibold text-foreground">New secret</h2>580 title="New managed secret"
185 <button581 onClose={props.onClose}
186 type="button"582 >
187 onClick={props.onClose}583 {error && <ErrorBanner>{error}</ErrorBanner>}
188 className="text-[12px] text-muted-foreground hover:text-foreground transition-colors"584 <div className="grid gap-4 sm:grid-cols-2">
189 >585 <FieldLabel label="Store">
190 Cancel586 <select
191 </button>587 value={storeId}
192 </div>588 onChange={(event) => setStoreId(event.target.value)}
193 <div className="p-5 space-y-4">589 className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
194 {error && (590 >
195 <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-[13px] text-destructive">591 {props.storeOptions.map((store) => (
196 {error}592 <option key={store.id} value={store.id}>
197 </div>593 {store.name}
198 )}594 </option>
199 <div className="grid gap-4 sm:grid-cols-2">595 ))}
200 <FieldLabel label="Name">596 </select>
201 <input597 </FieldLabel>
202 value={name}598 {canImportSecrets && canCreateSecrets && (
203 onChange={(e) => setName(e.target.value)}599 <FieldLabel label="Mode">
204 placeholder="GitHub PAT"600 <select
205 className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"601 value={mode}
206 autoFocus602 onChange={(event) => setMode(event.target.value as "create" | "import")}
207 />603 className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
604 >
605 <option value="import">Import from store</option>
606 <option value="create">Create new secret</option>
607 </select>
208 </FieldLabel>608 </FieldLabel>
609 )}
610 <FieldLabel label="Name">
611 <input
612 value={name}
613 onChange={(event) => setName(event.target.value)}
614 placeholder={mode === "import" ? "Optional override name" : "GitHub PAT"}
615 className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
616 autoFocus
617 />
618 </FieldLabel>
619 {mode === "create" && (
209 <FieldLabel label="Value">620 <FieldLabel label="Value">
210 <input621 <input
211 type="password"622 type="password"
212 value={value}623 value={value}
213 onChange={(e) => setValue(e.target.value)}624 onChange={(event) => setValue(event.target.value)}
214 placeholder="ghp_..."625 placeholder="ghp_..."
215 className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"626 className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
216 />627 />
217 </FieldLabel>628 </FieldLabel>
218 <FieldLabel label="Store in">629 )}
219 <select630 </div>
220 value={providerId}631 {mode === "import" && canImportSecrets && (
221 onChange={(e) => setProviderId(e.target.value)}632 <div className="mt-4 space-y-4">
222 className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"633 <div className="flex flex-wrap items-center gap-2">
223 >634 {browserStack.length > 0 && (
224 {props.providerOptions.map((provider) => (635 <Button
225 <option key={provider.id} value={provider.id}>636 size="sm"
226 {provider.name}637 variant="outline"
227 </option>638 onClick={() => {
639 setSelectedImportKey("");
640 setSelectedImportLabel("");
641 setBrowserStack((current) => current.slice(0, -1));
642 }}
643 >
644 Back
645 </Button>
646 )}
647 <input
648 value={browserQuery}
649 onChange={(event) => setBrowserQuery(event.target.value)}
650 placeholder="Search this store"
651 className="h-9 min-w-0 flex-1 rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
652 />
653 </div>
654 {browserStack.length > 0 && (
655 <div className="text-[11px] text-muted-foreground">
656 {browserStack.map((crumb, index) => (
657 <span key={crumb.key}>
658 {index > 0 ? " / " : ""}
659 {crumb.label}
660 </span>
228 ))}661 ))}
229 </select>662 </div>
230 </FieldLabel>663 )}
664 <div className="rounded-xl border border-border bg-background/40">
665 {browserStatus === "pending" ? (
666 <div className="flex items-center gap-2 px-4 py-3 text-[13px] text-muted-foreground">
667 <IconSpinner className="size-3.5" />
668 Loading store contents...
669 </div>
670 ) : browserEntries.length === 0 ? (
671 <div className="px-4 py-3 text-[13px] text-muted-foreground">
672 {browserError ?? "No importable secrets found here."}
673 </div>
674 ) : (
675 browserEntries.map((entry, index) => (
676 <button
677 key={entry.key}
678 type="button"
679 onClick={() => handleBrowserEntryClick(entry)}
680 className={cn(
681 "flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/50",
682 index > 0 && "border-t border-border",
683 selectedImportKey === entry.key && "bg-accent/60",
684 )}
685 >
686 <div className="min-w-0">
687 <div className="truncate text-[13px] font-medium text-foreground">
688 {entry.label}
689 </div>
690 {entry.description && (
691 <div className="truncate text-[11px] text-muted-foreground">
692 {entry.description}
693 </div>
694 )}
695 </div>
696 <Badge variant="outline">
697 {entry.kind === "group" ? "Open" : "Secret"}
698 </Badge>
699 </button>
700 ))
701 )}
702 </div>
703 {browserError && browserEntries.length > 0 && (
704 <div className="text-[12px] text-destructive">{browserError}</div>
705 )}
231 </div>706 </div>
232 <div className="flex justify-end">707 )}
233 <Button708 <div className="flex justify-end">
234 size="sm"709 <Button
235 onClick={handleSubmit}710 size="sm"
236 disabled={createSecret.status === "pending"}711 onClick={handleSubmit}
237 >712 disabled={createSecret.status === "pending" || importSecretMutation.status === "pending"}
238 {createSecret.status === "pending" ? <IconSpinner className="size-3.5" /> : <IconPlus className="size-3.5" />}713 >
239 Store secret714 {(createSecret.status === "pending" || importSecretMutation.status === "pending")
240 </Button>715 ? <IconSpinner className="size-3.5" />
716 : <IconPlus className="size-3.5" />}
717 {mode === "import" ? "Import secret" : "Store secret"}
718 </Button>
719 </div>
720 </FormCard>
721 );
722}
723
724function SecretStoreCard(props: {
725 store: SecretStore;
726 canManage: boolean;
727}) {
728 const deleteSecretStore = useDeleteSecretStore();
729 const [confirmDelete, setConfirmDelete] = useState(false);
730 const [isDeleting, setIsDeleting] = useState(false);
731
732 const handleDelete = async () => {
733 setIsDeleting(true);
734 try {
735 await deleteSecretStore.mutateAsync(props.store.id);
736 } catch {
737 // refresh state will keep the store visible
738 } finally {
739 setIsDeleting(false);
740 setConfirmDelete(false);
741 }
742 };
743
744 return (
745 <div className="rounded-xl border border-border bg-card/80 p-4">
746 <div className="flex items-start justify-between gap-3">
747 <div className="min-w-0">
748 <div className="flex flex-wrap items-center gap-2">
749 <span className="truncate text-[13px] font-medium text-foreground">
750 {props.store.name}
751 </span>
752 <Badge variant="outline" className="text-[9px] uppercase">
753 {props.store.kind}
754 </Badge>
755 <Badge variant="outline" className="text-[9px]">
756 {props.store.status}
757 </Badge>
758 </div>
759 <div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground/55">
760 <span className="font-mono">{props.store.id}</span>
761 <span>Created {formatDate(props.store.createdAt)}</span>
762 </div>
241 </div>763 </div>
764 {props.canManage ? (
765 confirmDelete ? (
766 <div className="flex items-center gap-1">
767 <button
768 type="button"
769 onClick={() => setConfirmDelete(false)}
770 disabled={isDeleting}
771 className="rounded-md px-2 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:opacity-50"
772 >
773 Cancel
774 </button>
775 <button
776 type="button"
777 onClick={() => {
778 void handleDelete();
779 }}
780 disabled={isDeleting}
781 className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-destructive transition-colors hover:bg-destructive/8 disabled:opacity-50"
782 >
783 {isDeleting
784 ? <IconSpinner className="size-3" />
785 : <IconTrash className="size-3" />}
786 Delete
787 </button>
788 </div>
789 ) : (
790 <button
791 type="button"
792 onClick={() => setConfirmDelete(true)}
793 disabled={isDeleting}
794 className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-destructive/8 hover:text-destructive disabled:opacity-50"
795 >
796 {isDeleting
797 ? <IconSpinner className="size-3" />
798 : <IconTrash className="size-3" />}
799 Remove
800 </button>
801 )
802 ) : (
803 <span className="text-[10px] font-medium uppercase tracking-[0.12em] text-muted-foreground/45">
804 Built-in
805 </span>
806 )}
242 </div>807 </div>
243 </div>808 </div>
244 );809 );
245}810}
246 811
247// ---------------------------------------------------------------------------
248// Secret row
249// ---------------------------------------------------------------------------
250
251function SecretRow(props: {812function SecretRow(props: {
252 secret: SecretListItem;813 secret: SecretListItem;
253 providerLabel: string;
254 isEditing: boolean;814 isEditing: boolean;
255 onEdit: () => void;815 onEdit: () => void;
256 onCancelEdit: () => void;816 onCancelEdit: () => void;
257}) {817}) {
258 const { secret, providerLabel, isEditing } = props;
259 const deleteSecret = useDeleteSecret();818 const deleteSecret = useDeleteSecret();
260 const [isDeleting, setIsDeleting] = useState(false);819 const [isDeleting, setIsDeleting] = useState(false);
261 const [confirmDelete, setConfirmDelete] = useState(false);820 const [confirmDelete, setConfirmDelete] = useState(false);
@@ -263,72 +822,60 @@ function SecretRow(props: { · 1 unmodified line
263 const handleDelete = async () => {822 const handleDelete = async () => {
264 setIsDeleting(true);823 setIsDeleting(true);
265 try {824 try {
266 await deleteSecret.mutateAsync(secret.id);825 await deleteSecret.mutateAsync(props.secret.id);
267 } catch {826 } catch {
268 // refresh will show the secret still there827 // refresh state will keep the secret visible
269 } finally {828 } finally {
270 setConfirmDelete(false);
271 setIsDeleting(false);829 setIsDeleting(false);
830 setConfirmDelete(false);
272 }831 }
273 };832 };
274 833
275 const createdDate = new Date(secret.createdAt).toLocaleDateString(undefined, {
276 month: "short",
277 day: "numeric",
278 year: "numeric",
279 });
280
281 return (834 return (
282 <div className="px-5 py-3.5">835 <>
283 <div className="flex items-center justify-between gap-4">836 <div className="flex items-center justify-between gap-4">
284 <div className="flex items-center gap-3 min-w-0">837 <div className="min-w-0">
285 <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-primary">838 <div className="flex flex-wrap items-center gap-2">
286 <KeyIcon className="size-3.5" />839 <span className="truncate text-[13px] font-medium text-foreground">
840 {props.secret.name || "Unnamed secret"}
841 </span>
842 <Badge variant="outline" className="text-[9px] shrink-0">
843 {props.secret.purpose.replace(/_/g, " ")}
844 </Badge>
845 <Badge variant="outline" className="text-[9px] shrink-0">
846 {props.secret.storeName}
847 </Badge>
848 <Badge variant="outline" className="text-[9px] shrink-0 uppercase">
849 {props.secret.storeKind}
850 </Badge>
287 </div>851 </div>
288 <div className="min-w-0">852 <div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground/50">
289 <div className="flex items-center gap-2">853 <span className="font-mono">{props.secret.id}</span>
290 <span className="text-[13px] font-medium text-foreground truncate">854 <span>{formatDate(props.secret.createdAt)}</span>
291 {secret.name || "Unnamed secret"}
292 </span>
293 <Badge variant="outline" className="text-[9px] shrink-0">
294 {secret.purpose.replace(/_/g, " ")}
295 </Badge>
296 <Badge variant="outline" className="text-[9px] shrink-0">
297 {providerLabel}
298 </Badge>
299 </div>
300 <div className="flex items-center gap-2 mt-0.5">
301 <span className="text-[11px] font-mono text-muted-foreground/50 truncate">
302 {secret.id}
303 </span>
304 <span className="text-[11px] text-muted-foreground/40">
305 {createdDate}
306 </span>
307 </div>
308 {secret.linkedSources.length > 0 && (
309 <div className="flex items-center gap-1.5 mt-1 flex-wrap">
310 <span className="text-[10px] text-muted-foreground/50">Used by</span>
311 {secret.linkedSources.map((ls) => (
312 <span
313 key={ls.sourceId}
314 className="inline-flex items-center gap-1 rounded-md bg-accent/60 px-1.5 py-0.5 text-[10px] font-medium text-foreground/70"
315 >
316 {ls.sourceName}
317 </span>
318 ))}
319 </div>
320 )}
321 </div>855 </div>
856 {props.secret.linkedSources.length > 0 && (
857 <div className="mt-1 flex flex-wrap items-center gap-1.5">
858 <span className="text-[10px] text-muted-foreground/50">Used by</span>
859 {props.secret.linkedSources.map((linkedSource) => (
860 <span
861 key={linkedSource.sourceId}
862 className="inline-flex items-center rounded-md bg-accent/60 px-1.5 py-0.5 text-[10px] font-medium text-foreground/70"
863 >
864 {linkedSource.sourceName}
865 </span>
866 ))}
867 </div>
868 )}
322 </div>869 </div>
323 <div className="flex items-center gap-1.5 shrink-0">870 <div className="flex shrink-0 items-center gap-1.5">
324 <button871 <button
325 type="button"872 type="button"
326 onClick={props.onEdit}873 onClick={props.onEdit}
327 className={cn(874 className={cn(
328 "inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium transition-colors",875 "inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium transition-colors",
329 isEditing876 props.isEditing
330 ? "bg-primary/10 text-primary"877 ? "bg-primary/10 text-primary"
331 : "text-muted-foreground hover:text-foreground hover:bg-accent/50",878 : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
332 )}879 )}
333 >880 >
334 <IconPencil className="size-3" />881 <IconPencil className="size-3" />
@@ -336,14 +883,11 @@ function SecretRow(props: { · 1 unmodified line
336 </button>883 </button>
337 {confirmDelete ? (884 {confirmDelete ? (
338 <>885 <>
339 <span className="text-[10px] font-medium text-destructive">
340 Confirm delete?
341 </span>
342 <button886 <button
343 type="button"887 type="button"
344 onClick={() => setConfirmDelete(false)}888 onClick={() => setConfirmDelete(false)}
345 disabled={isDeleting}889 disabled={isDeleting}
346 className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:opacity-50"890 className="rounded-md px-2 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:opacity-50"
347 >891 >
348 Cancel892 Cancel
349 </button>893 </button>
@@ -355,8 +899,10 @@ function SecretRow(props: { · 5 unmodified lines
355 disabled={isDeleting}899 disabled={isDeleting}
356 className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-destructive transition-colors hover:bg-destructive/8 disabled:opacity-50"900 className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-destructive transition-colors hover:bg-destructive/8 disabled:opacity-50"
357 >901 >
358 {isDeleting ? <IconSpinner className="size-3" /> : <IconTrash className="size-3" />}902 {isDeleting
359 {isDeleting ? "Deleting..." : "Delete"}903 ? <IconSpinner className="size-3" />
904 : <IconTrash className="size-3" />}
905 Delete
360 </button>906 </button>
361 </>907 </>
362 ) : (908 ) : (
@@ -364,31 +910,28 @@ function SecretRow(props: { · 1 unmodified line
364 type="button"910 type="button"
365 onClick={() => setConfirmDelete(true)}911 onClick={() => setConfirmDelete(true)}
366 disabled={isDeleting}912 disabled={isDeleting}
367 className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:text-destructive hover:bg-destructive/8 disabled:opacity-50"913 className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-destructive/8 hover:text-destructive disabled:opacity-50"
368 >914 >
369 {isDeleting ? <IconSpinner className="size-3" /> : <IconTrash className="size-3" />}915 {isDeleting
916 ? <IconSpinner className="size-3" />
917 : <IconTrash className="size-3" />}
370 Delete918 Delete
371 </button>919 </button>
372 )}920 )}
373 </div>921 </div>
374 </div>922 </div>
375 923
376 {/* Inline edit */}924 {props.isEditing && (
377 {isEditing && (925 <EditSecretForm secret={props.secret} onClose={props.onCancelEdit} />
378 <EditSecretForm
379 secret={secret}
380 onClose={props.onCancelEdit}
381 />
382 )}926 )}
383 </div>927 </>
384 );928 );
385}929}
386 930
387// ---------------------------------------------------------------------------931function EditSecretForm(props: {
388// Edit form (inline)932 secret: SecretListItem;
389// ---------------------------------------------------------------------------933 onClose: () => void;
390 934}) {
391function EditSecretForm(props: { secret: SecretListItem; onClose: () => void }) {
392 const updateSecret = useUpdateSecret();935 const updateSecret = useUpdateSecret();
393 const [name, setName] = useState(props.secret.name ?? "");936 const [name, setName] = useState(props.secret.name ?? "");
394 const [value, setValue] = useState("");937 const [value, setValue] = useState("");
@@ -416,39 +959,35 @@ function EditSecretForm(props: { secret: SecretListItem; onClose: () => void }) · 21 unmodified lines
416 payload,959 payload,
417 });960 });
418 props.onClose();961 props.onClose();
419 } catch (err) {962 } catch (cause) {
420 setError(err instanceof Error ? err.message : "Failed updating secret.");963 setError(cause instanceof Error ? cause.message : "Failed updating secret.");
421 }964 }
422 };965 };
423 966
424 return (967 return (
425 <div className="mt-3 pt-3 border-t border-border/50">968 <div className="mt-3 border-t border-border/50 pt-3">
426 {error && (969 {error && <ErrorBanner className="mb-3">{error}</ErrorBanner>}
427 <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-[13px] text-destructive mb-3">
428 {error}
429 </div>
430 )}
431 <div className="grid gap-3 sm:grid-cols-2">970 <div className="grid gap-3 sm:grid-cols-2">
432 <FieldLabel label="Name">971 <FieldLabel label="Name">
433 <input972 <input
434 value={name}973 value={name}
435 onChange={(e) => setName(e.target.value)}974 onChange={(event) => setName(event.target.value)}
436 placeholder="Secret name"975 placeholder="Secret name"
437 className="h-8 w-full rounded-lg border border-input bg-background px-3 text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"976 className="h-8 w-full rounded-lg border border-input bg-background px-3 text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
438 autoFocus977 autoFocus
439 />978 />
440 </FieldLabel>979 </FieldLabel>
441 <FieldLabel label="New value (leave empty to keep current)">980 <FieldLabel label="New value">
442 <input981 <input
443 type="password"982 type="password"
444 value={value}983 value={value}
445 onChange={(e) => setValue(e.target.value)}984 onChange={(event) => setValue(event.target.value)}
446 placeholder="Leave empty to keep existing"985 placeholder="Leave empty to keep existing"
447 className="h-8 w-full rounded-lg border border-input bg-background px-3 font-mono text-[11px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"986 className="h-8 w-full rounded-lg border border-input bg-background px-3 font-mono text-[11px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
448 />987 />
449 </FieldLabel>988 </FieldLabel>
450 </div>989 </div>
451 <div className="flex items-center justify-end gap-2 mt-3">990 <div className="mt-3 flex items-center justify-end gap-2">
452 <button991 <button
453 type="button"992 type="button"
454 onClick={props.onClose}993 onClick={props.onClose}
@@ -469,29 +1008,84 @@ function EditSecretForm(props: { secret: SecretListItem; onClose: () => void }) · 14 unmodified lines
469 );1008 );
470}1009}
471 1010
472// ---------------------------------------------------------------------------1011function FormCard(props: {
473// Shared1012 title: string;
474// ---------------------------------------------------------------------------1013 onClose: () => void;
1014 className?: string;
1015 children: ReactNode;
1016}) {
1017 return (
1018 <div className={cn("rounded-xl border border-primary/20 bg-card/80", props.className)}>
1019 <div className="flex items-center justify-between border-b border-border px-5 py-3">
1020 <h3 className="text-sm font-semibold text-foreground">{props.title}</h3>
1021 <button
1022 type="button"
1023 onClick={props.onClose}
1024 className="text-[12px] text-muted-foreground transition-colors hover:text-foreground"
1025 >
1026 Cancel
1027 </button>
1028 </div>
1029 <div className="space-y-4 p-5">{props.children}</div>
1030 </div>
1031 );
1032}
475 1033
476function FieldLabel(props: { label: string; children: ReactNode }) {1034function ErrorBanner(props: {
1035 children: ReactNode;
1036 className?: string;
1037}) {
477 return (1038 return (
478 <label className="block space-y-1">1039 <div
479 <span className="text-[11px] font-medium text-muted-foreground">{props.label}</span>1040 className={cn(
1041 "rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-[13px] text-destructive",
1042 props.className,
1043 )}
1044 >
480 {props.children}1045 {props.children}
481 </label>1046 </div>
1047 );
1048}
1049
1050function SectionEmptyState(props: {
1051 title: string;
1052 description: string;
1053 actionLabel?: string;
1054 onAction?: () => void;
1055}) {
1056 return (
1057 <div className="rounded-xl border border-dashed border-border px-6 py-10 text-center">
1058 <p className="text-[14px] font-medium text-foreground/75">{props.title}</p>
1059 <p className="mt-1 text-[13px] text-muted-foreground">{props.description}</p>
1060 {props.actionLabel && props.onAction && (
1061 <div className="mt-4 flex justify-center">
1062 <Button size="sm" onClick={props.onAction}>
1063 <IconPlus className="size-3.5" />
1064 {props.actionLabel}
1065 </Button>
1066 </div>
1067 )}
1068 </div>
482 );1069 );
483}1070}
484 1071
485function KeyIcon(props: { className?: string }) {1072function FieldLabel(props: {
1073 label: string;
1074 children: ReactNode;
1075}) {
486 return (1076 return (
487 <svg viewBox="0 0 16 16" fill="none" className={cn("size-4", props.className)}>1077 <label className="block space-y-1">
488 <path1078 <span className="text-[11px] font-medium text-muted-foreground">
489 d="M10 6a4 4 0 10-4.9 3.9L2 13v1.5h2V13h1.5v-1.5H7l.6-.6A4 4 0 0010 6z"1079 {props.label}
490 stroke="currentColor"1080 </span>
491 strokeWidth="1.3"1081 {props.children}
492 strokeLinejoin="round"1082 </label>
493 />
494 <circle cx="10.5" cy="5.5" r="1" fill="currentColor" />
495 </svg>
496 );1083 );
497}1084}
1085
1086const 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
62 },62 },
63 server: {63 server: {
64 port: 8788,64 port: 8788,
65 allowedHosts: [
66 "rhyss-laptop.tail5665af.ts.net",
67 ".tail5665af.ts.net",
68 ],
65 watch: {69 watch: {
66 ignored: [70 ignored: [
67 "!**/node_modules/@executor/**",71 "!**/node_modules/@executor/**",
bun.lock+139−0
@@ -33,6 +33,9 @@ · 32 unmodified lines
33 "@executor/platform-sdk-file": "workspace:*",33 "@executor/platform-sdk-file": "workspace:*",
34 "@executor/plugin-google-discovery-sdk": "workspace:*",34 "@executor/plugin-google-discovery-sdk": "workspace:*",
35 "@executor/plugin-graphql-sdk": "workspace:*",35 "@executor/plugin-graphql-sdk": "workspace:*",
36 "@executor/plugin-keychain-secret-store-sdk": "workspace:*",
37 "@executor/plugin-local-secret-store-sdk": "workspace:*",
38 "@executor/plugin-local-tools-sdk": "workspace:*",
36 "@executor/plugin-mcp-sdk": "workspace:*",39 "@executor/plugin-mcp-sdk": "workspace:*",
37 "@executor/plugin-openapi-sdk": "workspace:*",40 "@executor/plugin-openapi-sdk": "workspace:*",
38 "@executor/server": "workspace:*",41 "@executor/server": "workspace:*",
@@ -57,7 +60,9 @@ · 18 unmodified lines
57 "@executor/plugin-execution-history-react": "workspace:*",60 "@executor/plugin-execution-history-react": "workspace:*",
58 "@executor/plugin-google-discovery-react": "workspace:*",61 "@executor/plugin-google-discovery-react": "workspace:*",
59 "@executor/plugin-graphql-react": "workspace:*",62 "@executor/plugin-graphql-react": "workspace:*",
63 "@executor/plugin-local-tools-react": "workspace:*",
60 "@executor/plugin-mcp-react": "workspace:*",64 "@executor/plugin-mcp-react": "workspace:*",
65 "@executor/plugin-onepassword-react": "workspace:*",
61 "@executor/plugin-openapi-react": "workspace:*",66 "@executor/plugin-openapi-react": "workspace:*",
62 "@executor/react": "workspace:*",67 "@executor/react": "workspace:*",
63 "@executor/server": "workspace:*",68 "@executor/server": "workspace:*",
@@ -373,9 +378,15 @@ · 309 unmodified lines
373 "@executor/plugin-graphql-http": "workspace:*",378 "@executor/plugin-graphql-http": "workspace:*",
374 "@executor/plugin-graphql-sdk": "workspace:*",379 "@executor/plugin-graphql-sdk": "workspace:*",
375 "@executor/plugin-graphql-shared": "workspace:*",380 "@executor/plugin-graphql-shared": "workspace:*",
381 "@executor/plugin-keychain-secret-store-sdk": "workspace:*",
382 "@executor/plugin-local-secret-store-sdk": "workspace:*",
383 "@executor/plugin-local-tools-sdk": "workspace:*",
376 "@executor/plugin-mcp-http": "workspace:*",384 "@executor/plugin-mcp-http": "workspace:*",
377 "@executor/plugin-mcp-sdk": "workspace:*",385 "@executor/plugin-mcp-sdk": "workspace:*",
378 "@executor/plugin-mcp-shared": "workspace:*",386 "@executor/plugin-mcp-shared": "workspace:*",
387 "@executor/plugin-onepassword-http": "workspace:*",
388 "@executor/plugin-onepassword-sdk": "workspace:*",
389 "@executor/plugin-onepassword-shared": "workspace:*",
379 "@executor/plugin-openapi-http": "workspace:*",390 "@executor/plugin-openapi-http": "workspace:*",
380 "@executor/plugin-openapi-sdk": "workspace:*",391 "@executor/plugin-openapi-sdk": "workspace:*",
381 "@executor/plugin-openapi-shared": "workspace:*",392 "@executor/plugin-openapi-shared": "workspace:*",
@@ -550,6 +561,58 @@ · 168 unmodified lines
550 "bun-types": "catalog:",561 "bun-types": "catalog:",
551 },562 },
552 },563 },
564 "plugins/keychain-secret-store/sdk": {
565 "name": "@executor/plugin-keychain-secret-store-sdk",
566 "version": "0.0.0",
567 "dependencies": {
568 "@executor/platform-sdk": "workspace:*",
569 "effect": "catalog:",
570 },
571 "devDependencies": {
572 "@types/node": "catalog:",
573 "bun-types": "catalog:",
574 },
575 },
576 "plugins/local-secret-store/sdk": {
577 "name": "@executor/plugin-local-secret-store-sdk",
578 "version": "0.0.0",
579 "dependencies": {
580 "@executor/platform-sdk": "workspace:*",
581 "effect": "catalog:",
582 },
583 "devDependencies": {
584 "@types/node": "catalog:",
585 "bun-types": "catalog:",
586 },
587 },
588 "plugins/local-tools/react": {
589 "name": "@executor/plugin-local-tools-react",
590 "version": "0.0.0",
591 "dependencies": {
592 "@executor/react": "workspace:*",
593 "react": "^19.2.0",
594 },
595 "devDependencies": {
596 "@types/node": "catalog:",
597 "@types/react": "^19.2.10",
598 "bun-types": "catalog:",
599 },
600 },
601 "plugins/local-tools/sdk": {
602 "name": "@executor/plugin-local-tools-sdk",
603 "version": "0.0.0",
604 "dependencies": {
605 "@executor/codemode-core": "workspace:*",
606 "@executor/ir": "workspace:*",
607 "@executor/platform-sdk": "workspace:*",
608 "@executor/source-core": "workspace:*",
609 "effect": "catalog:",
610 },
611 "devDependencies": {
612 "@types/node": "catalog:",
613 "bun-types": "catalog:",
614 },
615 },
553 "plugins/mcp/http": {616 "plugins/mcp/http": {
554 "name": "@executor/plugin-mcp-http",617 "name": "@executor/plugin-mcp-http",
555 "version": "0.0.0",618 "version": "0.0.0",
@@ -614,6 +677,62 @@ · 58 unmodified lines
614 "bun-types": "catalog:",677 "bun-types": "catalog:",
615 },678 },
616 },679 },
680 "plugins/onepassword/http": {
681 "name": "@executor/plugin-onepassword-http",
682 "version": "0.0.0",
683 "dependencies": {
684 "@effect/platform": "catalog:",
685 "@executor/platform-api": "workspace:*",
686 "@executor/platform-sdk": "workspace:*",
687 "@executor/plugin-onepassword-shared": "workspace:*",
688 "effect": "catalog:",
689 },
690 "devDependencies": {
691 "@types/node": "catalog:",
692 "bun-types": "catalog:",
693 },
694 },
695 "plugins/onepassword/react": {
696 "name": "@executor/plugin-onepassword-react",
697 "version": "0.0.0",
698 "dependencies": {
699 "@executor/plugin-onepassword-shared": "workspace:*",
700 "@executor/react": "workspace:*",
701 "react": "^19.2.0",
702 },
703 "devDependencies": {
704 "@types/node": "catalog:",
705 "@types/react": "^19.2.10",
706 "bun-types": "catalog:",
707 },
708 },
709 "plugins/onepassword/sdk": {
710 "name": "@executor/plugin-onepassword-sdk",
711 "version": "0.0.0",
712 "dependencies": {
713 "@1password/sdk": "0.4.1-beta.1",
714 "@executor/platform-sdk": "workspace:*",
715 "@executor/plugin-onepassword-shared": "workspace:*",
716 "effect": "catalog:",
717 },
718 "devDependencies": {
719 "@types/node": "catalog:",
720 "bun-types": "catalog:",
721 },
722 },
723 "plugins/onepassword/shared": {
724 "name": "@executor/plugin-onepassword-shared",
725 "version": "0.0.0",
726 "dependencies": {
727 "@effect/platform": "catalog:",
728 "@executor/platform-sdk": "workspace:*",
729 "effect": "catalog:",
730 },
731 "devDependencies": {
732 "@types/node": "catalog:",
733 "bun-types": "catalog:",
734 },
735 },
617 "plugins/openapi/http": {736 "plugins/openapi/http": {
618 "name": "@executor/plugin-openapi-http",737 "name": "@executor/plugin-openapi-http",
619 "version": "0.0.0",738 "version": "0.0.0",
@@ -692,6 +811,10 @@ · 72 unmodified lines
692 "zod": "^4.3.6",811 "zod": "^4.3.6",
693 },812 },
694 "packages": {813 "packages": {
814 "@1password/sdk": ["@1password/sdk@0.4.1-beta.1", "", { "dependencies": { "@1password/sdk-core": "0.4.1-beta.1" } }, "sha512-QWY6eIIEs9at6kAFbgjZ1vVxg7ygp/MwXJ6/BoBdPkqIk8vfulXu4pjDsVQ87HhddJAD5LVGBXSbQIa+svI3bA=="],
815
816 "@1password/sdk-core": ["@1password/sdk-core@0.4.1-beta.1", "", {}, "sha512-/otbg1JVhsEn6oUIeReoT9TmFr8J7KBwr9UuRVfJFwwGG3bHPF8ewT+LhRimQeJtypqQ69ZVuOYkxknD4iQHxw=="],
817
695 "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.66", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SIQ0YY0iMuv+07HLsZ+bB990zUJ6S4ujORAh+Jv1V2KGNn73qQKnGO0JBk+w+Res8YqOFSycwDoWcFlQrVxS4A=="],818 "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.66", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SIQ0YY0iMuv+07HLsZ+bB990zUJ6S4ujORAh+Jv1V2KGNn73qQKnGO0JBk+w+Res8YqOFSycwDoWcFlQrVxS4A=="],
696 819
697 "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],820 "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
@@ -922,6 +1045,14 @@ · 224 unmodified lines
922 1045
923 "@executor/plugin-graphql-shared": ["@executor/plugin-graphql-shared@workspace:plugins/graphql/shared"],1046 "@executor/plugin-graphql-shared": ["@executor/plugin-graphql-shared@workspace:plugins/graphql/shared"],
924 1047
1048 "@executor/plugin-keychain-secret-store-sdk": ["@executor/plugin-keychain-secret-store-sdk@workspace:plugins/keychain-secret-store/sdk"],
1049
1050 "@executor/plugin-local-secret-store-sdk": ["@executor/plugin-local-secret-store-sdk@workspace:plugins/local-secret-store/sdk"],
1051
1052 "@executor/plugin-local-tools-react": ["@executor/plugin-local-tools-react@workspace:plugins/local-tools/react"],
1053
1054 "@executor/plugin-local-tools-sdk": ["@executor/plugin-local-tools-sdk@workspace:plugins/local-tools/sdk"],
1055
925 "@executor/plugin-mcp-http": ["@executor/plugin-mcp-http@workspace:plugins/mcp/http"],1056 "@executor/plugin-mcp-http": ["@executor/plugin-mcp-http@workspace:plugins/mcp/http"],
926 1057
927 "@executor/plugin-mcp-react": ["@executor/plugin-mcp-react@workspace:plugins/mcp/react"],1058 "@executor/plugin-mcp-react": ["@executor/plugin-mcp-react@workspace:plugins/mcp/react"],
@@ -930,6 +1061,14 @@ · 2 unmodified lines
930 1061
931 "@executor/plugin-mcp-shared": ["@executor/plugin-mcp-shared@workspace:plugins/mcp/shared"],1062 "@executor/plugin-mcp-shared": ["@executor/plugin-mcp-shared@workspace:plugins/mcp/shared"],
932 1063
1064 "@executor/plugin-onepassword-http": ["@executor/plugin-onepassword-http@workspace:plugins/onepassword/http"],
1065
1066 "@executor/plugin-onepassword-react": ["@executor/plugin-onepassword-react@workspace:plugins/onepassword/react"],
1067
1068 "@executor/plugin-onepassword-sdk": ["@executor/plugin-onepassword-sdk@workspace:plugins/onepassword/sdk"],
1069
1070 "@executor/plugin-onepassword-shared": ["@executor/plugin-onepassword-shared@workspace:plugins/onepassword/shared"],
1071
933 "@executor/plugin-openapi-http": ["@executor/plugin-openapi-http@workspace:plugins/openapi/http"],1072 "@executor/plugin-openapi-http": ["@executor/plugin-openapi-http@workspace:plugins/openapi/http"],
934 1073
935 "@executor/plugin-openapi-react": ["@executor/plugin-openapi-react@workspace:plugins/openapi/react"],1074 "@executor/plugin-openapi-react": ["@executor/plugin-openapi-react@workspace:plugins/openapi/react"],
examples/sqlite-sdk-consumer/src/sqlite-backend.ts+69−45
@@ -27,6 +27,7 @@ import type { · 26 unmodified lines
27 LocalInstallation,27 LocalInstallation,
28 SecretMaterial,28 SecretMaterial,
29 SecretRef,29 SecretRef,
30 SecretStore,
30} from "@executor/platform-sdk/schema";31} from "@executor/platform-sdk/schema";
31import {32import {
32 ScopeIdSchema,33 ScopeIdSchema,
@@ -57,14 +58,15 @@ type SourceArtifactBuildInput = Parameters< · 24 unmodified lines
57 58
58type SecretMaterialSummary = {59type SecretMaterialSummary = {
59 id: string;60 id: string;
60 providerId: string;61 storeId: string;
61 name: string | null;62 name: string | null;
62 purpose: string;63 purpose: string;
63 createdAt: number;64 createdAt: number;
64 updatedAt: number;65 updatedAt: number;
65};66};
66 67
67const SQLITE_SECRET_PROVIDER_ID = "sqlite";68const SQLITE_SECRET_STORE_KIND = "sqlite";
69const SQLITE_SECRET_STORE_ID = "sts_sqlite";
68 70
69const installations = sqliteTable("installations", {71const installations = sqliteTable("installations", {
70 key: text("key").primaryKey(),72 key: text("key").primaryKey(),
@@ -250,8 +252,39 @@ const openSqliteStore = (databasePath: string) => { · 179 unmodified lines
250 252
251type SqliteStore = ReturnType<typeof openSqliteStore>;253type SqliteStore = ReturnType<typeof openSqliteStore>;
252 254
253const createStorageDomains = (store: SqliteStore) => ({255const createStorageDomains = (
256 store: SqliteStore,
257 installation: LocalInstallation,
258) => ({
254 secrets: {259 secrets: {
260 secretStores: {
261 getById: (id: SecretStore["id"]) =>
262 id === SQLITE_SECRET_STORE_ID
263 ? {
264 id: SQLITE_SECRET_STORE_ID as SecretStore["id"],
265 scopeId: installation.scopeId,
266 kind: SQLITE_SECRET_STORE_KIND,
267 name: "SQLite Store",
268 status: "connected" as const,
269 enabled: true,
270 createdAt: 0,
271 updatedAt: 0,
272 }
273 : null,
274 listAll: () => [{
275 id: SQLITE_SECRET_STORE_ID,
276 scopeId: installation.scopeId,
277 kind: SQLITE_SECRET_STORE_KIND,
278 name: "SQLite Store",
279 status: "connected",
280 enabled: true,
281 createdAt: 0,
282 updatedAt: 0,
283 }],
284 upsert: (_secretStore: SecretStore) => {},
285 updateById: (_id: SecretStore["id"], _update: Partial<Pick<SecretStore, "name" | "status" | "enabled">>) => null,
286 removeById: (_id: SecretStore["id"]) => false,
287 },
255 getById: (id: SecretMaterial["id"]) => {288 getById: (id: SecretMaterial["id"]) => {
256 const row = store.db.select().from(secretMaterials).where(eq(secretMaterials.id, id)).get();289 const row = store.db.select().from(secretMaterials).where(eq(secretMaterials.id, id)).get();
257 return row ? parseJson<SecretMaterial>(row.json) : null;290 return row ? parseJson<SecretMaterial>(row.json) : null;
@@ -259,7 +292,7 @@ const createStorageDomains = (store: SqliteStore) => ({ · 1 unmodified line
259 listAll: (): readonly SecretMaterialSummary[] =>292 listAll: (): readonly SecretMaterialSummary[] =>
260 store.db.select().from(secretMaterials).all().map((row) => ({293 store.db.select().from(secretMaterials).all().map((row) => ({
261 id: row.id,294 id: row.id,
262 providerId: row.providerId,295 storeId: row.providerId,
263 name: row.name,296 name: row.name,
264 purpose: row.purpose,297 purpose: row.purpose,
265 createdAt: row.createdAt,298 createdAt: row.createdAt,
@@ -268,7 +301,7 @@ const createStorageDomains = (store: SqliteStore) => ({ · 2 unmodified lines
268 upsert: (material: SecretMaterial) =>301 upsert: (material: SecretMaterial) =>
269 store.db.insert(secretMaterials).values({302 store.db.insert(secretMaterials).values({
270 id: material.id,303 id: material.id,
271 providerId: material.providerId,304 providerId: material.storeId,
272 name: material.name,305 name: material.name,
273 purpose: material.purpose,306 purpose: material.purpose,
274 createdAt: material.createdAt,307 createdAt: material.createdAt,
@@ -277,7 +310,7 @@ const createStorageDomains = (store: SqliteStore) => ({ · 2 unmodified lines
277 }).onConflictDoUpdate({310 }).onConflictDoUpdate({
278 target: secretMaterials.id,311 target: secretMaterials.id,
279 set: {312 set: {
280 providerId: material.providerId,313 providerId: material.storeId,
281 name: material.name,314 name: material.name,
282 purpose: material.purpose,315 purpose: material.purpose,
283 createdAt: material.createdAt,316 createdAt: material.createdAt,
@@ -299,7 +332,7 @@ const createStorageDomains = (store: SqliteStore) => ({ · 15 unmodified lines
299 updatedAt: Date.now(),332 updatedAt: Date.now(),
300 };333 };
301 store.db.update(secretMaterials).set({334 store.db.update(secretMaterials).set({
302 providerId: next.providerId,335 providerId: next.storeId,
303 name: next.name,336 name: next.name,
304 purpose: next.purpose,337 purpose: next.purpose,
305 createdAt: next.createdAt,338 createdAt: next.createdAt,
@@ -308,7 +341,7 @@ const createStorageDomains = (store: SqliteStore) => ({ · 2 unmodified lines
308 }).where(eq(secretMaterials.id, id)).run();341 }).where(eq(secretMaterials.id, id)).run();
309 return {342 return {
310 id: next.id,343 id: next.id,
311 providerId: next.providerId,344 storeId: next.storeId,
312 name: next.name,345 name: next.name,
313 purpose: next.purpose,346 purpose: next.purpose,
314 createdAt: next.createdAt,347 createdAt: next.createdAt,
@@ -471,7 +504,19 @@ export const createSqliteExecutorBackend = ( · 156 unmodified lines
471 return createExecutorBackend({504 return createExecutorBackend({
472 loadRepositories: () => {505 loadRepositories: () => {
473 const store = openSqliteStore(databasePath);506 const store = openSqliteStore(databasePath);
474 const { secrets, executions } = createStorageDomains(store);507 const installation = (() => {
508 const row = store.db.select().from(installations).where(eq(installations.key, "active")).get();
509 return row
510 ? {
511 scopeId: row.scopeId as LocalInstallation["scopeId"],
512 actorScopeId: row.actorScopeId as LocalInstallation["actorScopeId"],
513 resolutionScopeIds: parseJson<LocalInstallation["resolutionScopeIds"]>(
514 row.resolutionScopeIdsJson,
515 ),
516 }
517 : createInstallation(options);
518 })();
519 const { secrets, executions } = createStorageDomains(store, installation);
475 520
476 return {521 return {
477 scope: {522 scope: {
@@ -484,30 +529,9 @@ export const createSqliteExecutorBackend = ( · 6 unmodified lines
484 },529 },
485 installation: {530 installation: {
486 load: () => {531 load: () => {
487 const row = store.db.select().from(installations).where(eq(installations.key, "active")).get();532 return installation;
488 return row
489 ? {
490 scopeId: row.scopeId as LocalInstallation["scopeId"],
491 actorScopeId: row.actorScopeId as LocalInstallation["actorScopeId"],
492 resolutionScopeIds: parseJson<LocalInstallation["resolutionScopeIds"]>(
493 row.resolutionScopeIdsJson,
494 ),
495 }
496 : createInstallation(options);
497 },533 },
498 getOrProvision: () => {534 getOrProvision: () => {
499 const installation = (() => {
500 const row = store.db.select().from(installations).where(eq(installations.key, "active")).get();
501 return row
502 ? {
503 scopeId: row.scopeId as LocalInstallation["scopeId"],
504 actorScopeId: row.actorScopeId as LocalInstallation["actorScopeId"],
505 resolutionScopeIds: parseJson<LocalInstallation["resolutionScopeIds"]>(
506 row.resolutionScopeIdsJson,
507 ),
508 }
509 : createInstallation(options);
510 })();
511 store.db.insert(installations).values({535 store.db.insert(installations).values({
512 key: "active",536 key: "active",
513 scopeId: installation.scopeId,537 scopeId: installation.scopeId,
@@ -587,21 +611,22 @@ export const createSqliteExecutorBackend = ( · 73 unmodified lines
587 },611 },
588 secrets: {612 secrets: {
589 ...secrets,613 ...secrets,
614 secretStores: secrets.secretStores,
590 resolve: ({ ref }) => {615 resolve: ({ ref }) => {
591 const material = secrets.getById(616 const material = secrets.getById(
592 ref.handle as SecretMaterial["id"],617 ref.secretId as SecretMaterial["id"],
593 );618 );
594 if (!material || material.value === null) {619 if (!material || material.value === null) {
595 throw new Error(`Missing secret material ${ref.handle}`);620 throw new Error(`Missing secret material ${ref.secretId}`);
596 }621 }
597 return material.value;622 return material.value;
598 },623 },
599 store: ({ purpose, value, name, providerId }) => {624 store: ({ purpose, value, name, storeId }) => {
600 const now = Date.now();625 const now = Date.now();
601 const id = SecretMaterialIdSchema.make(`secret_${randomUUID()}`);626 const id = SecretMaterialIdSchema.make(`secret_${randomUUID()}`);
602 const material: SecretMaterial = {627 const material: SecretMaterial = {
603 id,628 id,
604 providerId: providerId ?? SQLITE_SECRET_PROVIDER_ID,629 storeId: (storeId ?? SQLITE_SECRET_STORE_ID) as SecretMaterial["storeId"],
605 handle: id,630 handle: id,
606 name: name ?? null,631 name: name ?? null,
607 purpose,632 purpose,
@@ -611,19 +636,18 @@ export const createSqliteExecutorBackend = ( · 3 unmodified lines
611 };636 };
612 secrets.upsert(material);637 secrets.upsert(material);
613 return {638 return {
614 providerId: material.providerId,639 secretId: material.id,
615 handle: material.handle,
616 } satisfies SecretRef;640 } satisfies SecretRef;
617 },641 },
618 delete: (ref) =>642 delete: (ref) =>
619 secrets.removeById(ref.handle as SecretMaterial["id"]),643 secrets.removeById(ref.secretId as SecretMaterial["id"]),
620 update: ({ ref, name, value }) => {644 update: ({ ref, name, value }) => {
621 const updated = secrets.updateById(645 const updated = secrets.updateById(
622 ref.handle as SecretMaterial["id"],646 ref.secretId as SecretMaterial["id"],
623 { name, value },647 { name, value },
624 );648 );
625 if (!updated) {649 if (!updated) {
626 throw new Error(`Missing secret material ${ref.handle}`);650 throw new Error(`Missing secret material ${ref.secretId}`);
627 }651 }
628 return updated;652 return updated;
629 },653 },
@@ -632,14 +656,14 @@ export const createSqliteExecutorBackend = ( · 2 unmodified lines
632 instanceConfig: {656 instanceConfig: {
633 resolve: () => ({657 resolve: () => ({
634 platform: "sqlite-sdk-example",658 platform: "sqlite-sdk-example",
635 secretProviders: [659 secretStorePlugins: [
636 {660 {
637 id: SQLITE_SECRET_PROVIDER_ID,661 kind: SQLITE_SECRET_STORE_KIND,
638 name: "SQLite",662 displayName: "SQLite",
639 canStore: true,663 canCreate: false,
640 },664 },
641 ],665 ],
642 defaultSecretStoreProvider: SQLITE_SECRET_PROVIDER_ID,666 defaultSecretStoreId: SQLITE_SECRET_STORE_ID,
643 }),667 }),
644 },668 },
645 close: async () => {669 close: async () => {
packages/clients/react/src/components/secret-reference-field.tsx+453−0
@@ -0,0 +1,453 @@
1import {
2 useAtomSet,
3} from "@effect-atom/atom-react";
4import type {
5 BrowseSecretStoreResult,
6 CreateSecretResult,
7 SecretListItem,
8} from "@executor/platform-api";
9import { useCallback, useEffect, useMemo, useState } from "react";
10
11import {
12 getExecutorApiHttpClient,
13} from "../core/http-client";
14import {
15 useCreateSecret,
16 useRefreshSecrets,
17 useSecrets,
18 useSecretStores,
19} from "../hooks/secrets";
20import {
21 useExecutorMutation,
22} from "../hooks/mutations";
23
24type SecretReferenceFieldProps = {
25 label: string;
26 value: string;
27 emptyLabel: string;
28 draftNamePlaceholder: string;
29 draftValuePlaceholder: string;
30 onChange: (value: string) => void;
31};
32
33type BrowserEntry = BrowseSecretStoreResult["entries"][number];
34type RemoteSecretEntry = {
35 kind: "remote";
36 storeId: string;
37 storeName: string;
38 entry: BrowserEntry;
39};
40type ManagedSecretEntry = {
41 kind: "managed";
42 secret: SecretListItem;
43};
44type UnifiedSecretEntry = ManagedSecretEntry | RemoteSecretEntry;
45
46const 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
49export 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
2 Atom,2 Atom,
3} from "@effect-atom/atom-react";3} from "@effect-atom/atom-react";
4import type {4import type {
5 SecretStore,
5 InstanceConfig,6 InstanceConfig,
6 LocalInstallation,7 LocalInstallation,
7 SecretListItem,8 SecretListItem,
@@ -50,6 +51,12 @@ export const secretsAtom = (baseUrl: string = getExecutorApiBaseUrl()) => · 42 unmodified lines
50 timeToLive: "1 minute",51 timeToLive: "1 minute",
51 });52 });
52 53
54export const secretStoresAtom = (baseUrl: string = getExecutorApiBaseUrl()) =>
55 getExecutorApiHttpClient(baseUrl).query("local", "listSecretStores", {
56 reactivityKeys: secretsReactivityKey(),
57 timeToLive: "1 minute",
58 });
59
53export const executionsAtom = (workspaceId: Execution["scopeId"]) =>60export const executionsAtom = (workspaceId: Execution["scopeId"]) =>
54 getExecutorApiHttpClient().query("executions", "list", {61 getExecutorApiHttpClient().query("executions", "list", {
55 path: {62 path: {
@@ -168,6 +175,7 @@ export type ExecutorApiAtoms = { · 112 unmodified lines
168 localInstallationAtom: typeof localInstallationAtom;175 localInstallationAtom: typeof localInstallationAtom;
169 instanceConfigAtom: typeof instanceConfigAtom;176 instanceConfigAtom: typeof instanceConfigAtom;
170 secretsAtom: typeof secretsAtom;177 secretsAtom: typeof secretsAtom;
178 secretStoresAtom: typeof secretStoresAtom;
171 executionsAtom: typeof executionsAtom;179 executionsAtom: typeof executionsAtom;
172 executionAtom: typeof executionAtom;180 executionAtom: typeof executionAtom;
173 sourcesAtom: typeof sourcesAtom;181 sourcesAtom: typeof sourcesAtom;
@@ -181,6 +189,7 @@ export type ExecutorApiAtomValues = { · 7 unmodified lines
181 localInstallation: LocalInstallation;189 localInstallation: LocalInstallation;
182 instanceConfig: InstanceConfig;190 instanceConfig: InstanceConfig;
183 secrets: ReadonlyArray<SecretListItem>;191 secrets: ReadonlyArray<SecretListItem>;
192 secretStores: ReadonlyArray<SecretStore>;
184 executions: ReadonlyArray<Execution>;193 executions: ReadonlyArray<Execution>;
185 execution: ExecutionEnvelope;194 execution: ExecutionEnvelope;
186 sources: ReadonlyArray<Source>;195 sources: ReadonlyArray<Source>;
packages/clients/react/src/core/http-client.ts+5−3
@@ -7,10 +7,12 @@ import { · 6 unmodified lines
7import type * as HttpApi from "@effect/platform/HttpApi";7import type * as HttpApi from "@effect/platform/HttpApi";
8import type * as HttpApiGroup from "@effect/platform/HttpApiGroup";8import type * as HttpApiGroup from "@effect/platform/HttpApiGroup";
9import {9import {
10 ExecutorApi,
11 createExecutorApi,10 createExecutorApi,
12 type ExecutorHttpApiExtension,11 ExecutorApi,
13} from "@executor/platform-api";12} from "@executor/platform-api/api";
13import type {
14 ExecutorHttpApiExtension,
15} from "@executor/platform-api/plugins";
14 16
15import { getExecutorApiBaseUrl } from "./base-url";17import { getExecutorApiBaseUrl } from "./base-url";
16 18
packages/clients/react/src/hooks/secrets.ts+69−1
@@ -3,10 +3,14 @@ import { · 2 unmodified lines
3 useAtomSet,3 useAtomSet,
4} from "@effect-atom/atom-react";4} from "@effect-atom/atom-react";
5import type {5import type {
6 CreateSecretStorePayload,
6 CreateSecretPayload,7 CreateSecretPayload,
8 DeleteSecretStoreResult,
7 CreateSecretResult,9 CreateSecretResult,
8 DeleteSecretResult,10 DeleteSecretResult,
11 SecretStore,
9 SecretListItem,12 SecretListItem,
13 UpdateSecretStorePayload,
10 UpdateSecretPayload,14 UpdateSecretPayload,
11 UpdateSecretResult,15 UpdateSecretResult,
12} from "@executor/platform-api";16} from "@executor/platform-api";
@@ -14,7 +18,7 @@ import * as React from "react"; · 1 unmodified line
14 18
15import { getExecutorApiBaseUrl } from "../core/base-url";19import { getExecutorApiBaseUrl } from "../core/base-url";
16import { getExecutorApiHttpClient } from "../core/http-client";20import { getExecutorApiHttpClient } from "../core/http-client";
17import { secretsAtom } from "../core/api-atoms";21import { secretStoresAtom, secretsAtom } from "../core/api-atoms";
18import { useLoadableAtom } from "../core/loadable";22import { useLoadableAtom } from "../core/loadable";
19import { secretsReactivityKey } from "../core/reactivity";23import { secretsReactivityKey } from "../core/reactivity";
20import type { Loadable } from "../core/types";24import type { Loadable } from "../core/types";
@@ -23,9 +27,73 @@ import { useExecutorMutation } from "./mutations"; · 2 unmodified lines
23export const useSecrets = (): Loadable<ReadonlyArray<SecretListItem>> =>27export const useSecrets = (): Loadable<ReadonlyArray<SecretListItem>> =>
24 useLoadableAtom(secretsAtom(getExecutorApiBaseUrl()));28 useLoadableAtom(secretsAtom(getExecutorApiBaseUrl()));
25 29
30export const useSecretStores = (): Loadable<ReadonlyArray<SecretStore>> =>
31 useLoadableAtom(secretStoresAtom(getExecutorApiBaseUrl()));
32
26export const useRefreshSecrets = (): (() => void) =>33export const useRefreshSecrets = (): (() => void) =>
27 useAtomRefresh(secretsAtom(getExecutorApiBaseUrl()));34 useAtomRefresh(secretsAtom(getExecutorApiBaseUrl()));
28 35
36export const useRefreshSecretStores = (): (() => void) =>
37 useAtomRefresh(secretStoresAtom(getExecutorApiBaseUrl()));
38
39export 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
57export 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
79export 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
29export const useCreateSecret = () => {97export const useCreateSecret = () => {
30 const mutate = useAtomSet(98 const mutate = useAtomSet(
31 getExecutorApiHttpClient().mutation("local", "createSecret"),99 getExecutorApiHttpClient().mutation("local", "createSecret"),
packages/clients/react/src/index.ts+13−0
@@ -10,12 +10,17 @@ export { · 9 unmodified lines
10} from "@effect-atom/atom-react";10} from "@effect-atom/atom-react";
11 11
12export type {12export type {
13 BrowseSecretStoreResult,
14 CreateSecretStorePayload,
13 CreateSecretPayload,15 CreateSecretPayload,
14 CreateSecretResult,16 CreateSecretResult,
17 DeleteSecretStoreResult,
15 DeleteSecretResult,18 DeleteSecretResult,
16 InstanceConfig,19 InstanceConfig,
17 LocalInstallation,20 LocalInstallation,
18 SecretListItem,21 SecretListItem,
22 SecretStore,
23 UpdateSecretStorePayload,
19 UpdateSecretPayload,24 UpdateSecretPayload,
20 UpdateSecretResult,25 UpdateSecretResult,
21} from "@executor/platform-api";26} from "@executor/platform-api";
@@ -51,6 +56,9 @@ export { · 29 unmodified lines
51export {56export {
52 ExecutorReactProvider,57 ExecutorReactProvider,
53} from "./provider";58} from "./provider";
59export {
60 SecretReferenceField,
61} from "./components/secret-reference-field";
54export {62export {
55 useExecutorMutation,63 useExecutorMutation,
56} from "./hooks/mutations";64} from "./hooks/mutations";
@@ -60,10 +68,15 @@ export { · 3 unmodified lines
60 useRefreshLocalInstallation,68 useRefreshLocalInstallation,
61} from "./hooks/local";69} from "./hooks/local";
62export {70export {
71 useCreateSecretStore,
63 useCreateSecret,72 useCreateSecret,
73 useDeleteSecretStore,
64 useDeleteSecret,74 useDeleteSecret,
75 useRefreshSecretStores,
65 useRefreshSecrets,76 useRefreshSecrets,
77 useSecretStores,
66 useSecrets,78 useSecrets,
79 useUpdateSecretStore,
67 useUpdateSecret,80 useUpdateSecret,
68} from "./hooks/secrets";81} from "./hooks/secrets";
69export {82export {
packages/clients/react/src/plugins/index.ts+2−0
@@ -5,6 +5,8 @@ export type { · 4 unmodified lines
5 FrontendPluginRouteDefinition,5 FrontendPluginRouteDefinition,
6 FrontendPluginRouteParams,6 FrontendPluginRouteParams,
7 FrontendPluginRouteSearch,7 FrontendPluginRouteSearch,
8 SecretStoreCreateFormProps,
9 SecretStoreFrontendDefinition,
8 SourcePluginNavigation,10 SourcePluginNavigation,
9 SourcePluginRouteParams,11 SourcePluginRouteParams,
10 SourcePluginRouteSearch,12 SourcePluginRouteSearch,
packages/clients/react/src/plugins/registry.ts+11−0
@@ -28,6 +28,7 @@ export const registerExecutorFrontendPlugins = ( · 27 unmodified lines
28) => {28) => {
29 const pluginsByKey = new Map<string, ExecutorFrontendPlugin>();29 const pluginsByKey = new Map<string, ExecutorFrontendPlugin>();
30 const routesByPluginAndKey = new Map<string, RegisteredFrontendPluginRoute>();30 const routesByPluginAndKey = new Map<string, RegisteredFrontendPluginRoute>();
31 const secretStoresByKind = new Map<string, ExecutorFrontendPlugin>();
31 32
32 for (const plugin of plugins) {33 for (const plugin of plugins) {
33 if (pluginsByKey.has(plugin.key)) {34 if (pluginsByKey.has(plugin.key)) {
@@ -36,6 +37,16 @@ export const registerExecutorFrontendPlugins = ( · 2 unmodified lines
36 37
37 pluginsByKey.set(plugin.key, plugin);38 pluginsByKey.set(plugin.key, plugin);
38 39
40 if (plugin.secretStore) {
41 if (secretStoresByKind.has(plugin.secretStore.kind)) {
42 throw new Error(
43 `Duplicate frontend secret store registration: ${plugin.secretStore.kind}`,
44 );
45 }
46
47 secretStoresByKind.set(plugin.secretStore.kind, plugin);
48 }
49
39 const pluginRouteKeys = new Set<string>();50 const pluginRouteKeys = new Set<string>();
40 const pluginRoutePaths = new Set<string>();51 const pluginRoutePaths = new Set<string>();
41 52
packages/clients/react/src/plugins/types.ts+18−0
@@ -1,9 +1,11 @@
1import type { ComponentType } from "react";1import type { ComponentType } from "react";
2import type { SecretListItem } from "@executor/platform-api";
2 3
3import type {4import type {
4 ExecutorPluginPaths,5 ExecutorPluginPaths,
5 SourcePluginPaths,6 SourcePluginPaths,
6} from "./paths";7} from "./paths";
8import type { Loadable } from "../core/types";
7 9
8export type FrontendPluginRouteSearch = Record<string, unknown>;10export type FrontendPluginRouteSearch = Record<string, unknown>;
9export type FrontendPluginRouteParams = Readonly<Record<string, string | undefined>>;11export type FrontendPluginRouteParams = Readonly<Record<string, string | undefined>>;
@@ -50,9 +52,25 @@ export type SourcePluginNavigation = { · 40 unmodified lines
50 updateSearch: (search: SourcePluginRouteSearch) => void | Promise<void>;52 updateSearch: (search: SourcePluginRouteSearch) => void | Promise<void>;
51};53};
52 54
55export 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
65export type SecretStoreFrontendDefinition = {
66 kind: string;
67 CreateStoreForm?: ComponentType<SecretStoreCreateFormProps>;
68};
69
53export type ExecutorFrontendPlugin = {70export type ExecutorFrontendPlugin = {
54 key: string;71 key: string;
55 displayName?: string;72 displayName?: string;
56 description?: string;73 description?: string;
57 routes?: readonly FrontendPluginRouteDefinition[];74 routes?: readonly FrontendPluginRouteDefinition[];
75 secretStore?: SecretStoreFrontendDefinition;
58};76};
packages/platform/api/src/index.ts+8−0
@@ -31,8 +31,16 @@ export { · 30 unmodified lines
31 31
32export {32export {
33 LocalApi,33 LocalApi,
34 type BrowseSecretStorePayload,
35 type BrowseSecretStoreResult,
34 type SecretProvider,36 type SecretProvider,
37 type SecretStore,
38 type SecretStoreBrowseEntry,
39 type ImportSecretFromStorePayload,
35 type InstanceConfig,40 type InstanceConfig,
41 type CreateSecretStorePayload,
42 type UpdateSecretStorePayload,
43 type DeleteSecretStoreResult,
36 type SecretListItem,44 type SecretListItem,
37 type CreateSecretPayload,45 type CreateSecretPayload,
38 type CreateSecretResult,46 type CreateSecretResult,
packages/platform/api/src/local/api.ts+57−0
@@ -1,12 +1,19 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import {2import {
3 BrowseSecretStorePayloadSchema,
4 BrowseSecretStoreResultSchema,
3 CreateSecretPayloadSchema,5 CreateSecretPayloadSchema,
4 CreateSecretResultSchema,6 CreateSecretResultSchema,
7 CreateSecretStorePayloadSchema,
5 DeleteSecretResultSchema,8 DeleteSecretResultSchema,
9 DeleteSecretStoreResultSchema,
10 ImportSecretFromStorePayloadSchema,
6 InstanceConfigSchema,11 InstanceConfigSchema,
7 SecretListItemSchema,12 SecretListItemSchema,
13 SecretStoreSchema,
8 UpdateSecretPayloadSchema,14 UpdateSecretPayloadSchema,
9 UpdateSecretResultSchema,15 UpdateSecretResultSchema,
16 UpdateSecretStorePayloadSchema,
10} from "@executor/platform-sdk/contracts";17} from "@executor/platform-sdk/contracts";
11import {18import {
12 ControlPlaneBadRequestError,19 ControlPlaneBadRequestError,
@@ -17,15 +24,23 @@ import { LocalInstallationSchema } from "@executor/platform-sdk/schema"; · 4 unmodified lines
17import * as Schema from "effect/Schema";24import * as Schema from "effect/Schema";
18 25
19export type {26export type {
27 BrowseSecretStorePayload,
28 BrowseSecretStoreResult,
20 CreateSecretPayload,29 CreateSecretPayload,
21 CreateSecretResult,30 CreateSecretResult,
22 DeleteSecretResult,31 DeleteSecretResult,
32 DeleteSecretStoreResult,
33 CreateSecretStorePayload,
34 ImportSecretFromStorePayload,
23 InstanceConfig,35 InstanceConfig,
24 SecretLinkedSource,36 SecretLinkedSource,
25 SecretListItem,37 SecretListItem,
26 SecretProvider,38 SecretProvider,
39 SecretStoreBrowseEntry,
40 SecretStore,
27 UpdateSecretPayload,41 UpdateSecretPayload,
28 UpdateSecretResult,42 UpdateSecretResult,
43 UpdateSecretStorePayload,
29} from "@executor/platform-sdk/contracts";44} from "@executor/platform-sdk/contracts";
30 45
31export class LocalApi extends HttpApiGroup.make("local")46export class LocalApi extends HttpApiGroup.make("local")
@@ -41,6 +56,48 @@ export class LocalApi extends HttpApiGroup.make("local") · 9 unmodified lines
41 .addSuccess(InstanceConfigSchema)56 .addSuccess(InstanceConfigSchema)
42 .addError(ControlPlaneStorageError),57 .addError(ControlPlaneStorageError),
43 )58 )
59 .add(
60 HttpApiEndpoint.get("listSecretStores")`/local/secret-stores`
61 .addSuccess(Schema.Array(SecretStoreSchema))
62 .addError(ControlPlaneStorageError),
63 )
64 .add(
65 HttpApiEndpoint.post("createSecretStore")`/local/secret-stores`
66 .setPayload(CreateSecretStorePayloadSchema)
67 .addSuccess(SecretStoreSchema)
68 .addError(ControlPlaneBadRequestError)
69 .addError(ControlPlaneStorageError),
70 )
71 .add(
72 HttpApiEndpoint.patch("updateSecretStore")`/local/secret-stores/${HttpApiSchema.param("storeId", Schema.String)}`
73 .setPayload(UpdateSecretStorePayloadSchema)
74 .addSuccess(SecretStoreSchema)
75 .addError(ControlPlaneBadRequestError)
76 .addError(ControlPlaneNotFoundError)
77 .addError(ControlPlaneStorageError),
78 )
79 .add(
80 HttpApiEndpoint.del("deleteSecretStore")`/local/secret-stores/${HttpApiSchema.param("storeId", Schema.String)}`
81 .addSuccess(DeleteSecretStoreResultSchema)
82 .addError(ControlPlaneNotFoundError)
83 .addError(ControlPlaneStorageError),
84 )
85 .add(
86 HttpApiEndpoint.post("browseSecretStore")`/local/secret-stores/${HttpApiSchema.param("storeId", Schema.String)}/browse`
87 .setPayload(BrowseSecretStorePayloadSchema)
88 .addSuccess(BrowseSecretStoreResultSchema)
89 .addError(ControlPlaneBadRequestError)
90 .addError(ControlPlaneNotFoundError)
91 .addError(ControlPlaneStorageError),
92 )
93 .add(
94 HttpApiEndpoint.post("importSecretFromStore")`/local/secret-stores/${HttpApiSchema.param("storeId", Schema.String)}/import`
95 .setPayload(ImportSecretFromStorePayloadSchema)
96 .addSuccess(CreateSecretResultSchema)
97 .addError(ControlPlaneBadRequestError)
98 .addError(ControlPlaneNotFoundError)
99 .addError(ControlPlaneStorageError),
100 )
44 .add(101 .add(
45 HttpApiEndpoint.get("listSecrets")`/local/secrets`102 HttpApiEndpoint.get("listSecrets")`/local/secrets`
46 .addSuccess(Schema.Array(SecretListItemSchema))103 .addSuccess(Schema.Array(SecretListItemSchema))
packages/platform/api/src/local/http.ts+61−4
@@ -1,5 +1,9 @@
1import { HttpApiBuilder } from "@effect/platform";1import { HttpApiBuilder } from "@effect/platform";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import {
4 LocalInstanceConfigService,
5 provideExecutorRuntime,
6} from "@executor/platform-sdk/runtime";
3 7
4import { ExecutorApi } from "../api";8import { ExecutorApi } from "../api";
5import { ControlPlaneStorageError } from "../errors";9import { ControlPlaneStorageError } from "../errors";
@@ -19,18 +23,71 @@ export const ExecutorLocalLive = HttpApiBuilder.group( · 13 unmodified lines
19 handlers23 handlers
20 .handle("installation", () =>24 .handle("installation", () =>
21 Effect.flatMap(getControlPlaneExecutor(), (executor) =>25 Effect.flatMap(getControlPlaneExecutor(), (executor) =>
22 executor.local.installation().pipe(26 Effect.succeed(executor.installation)
23 Effect.mapError(toStorageError("local.installation")),
24 )
25 )27 )
26 )28 )
27 .handle("config", () =>29 .handle("config", () =>
28 Effect.flatMap(getControlPlaneExecutor(), (executor) =>30 Effect.flatMap(getControlPlaneExecutor(), (executor) =>
29 executor.local.config().pipe(31 provideExecutorRuntime(
32 Effect.flatMap(
33 LocalInstanceConfigService,
34 (resolveInstanceConfig) => resolveInstanceConfig(),
35 ),
36 executor.runtime,
37 ).pipe(
30 Effect.mapError(toStorageError("local.config")),38 Effect.mapError(toStorageError("local.config")),
31 )39 )
32 )40 )
33 )41 )
42 .handle("listSecretStores", () =>
43 Effect.flatMap(getControlPlaneExecutor(), (executor) =>
44 executor.secretStores.list().pipe(
45 Effect.mapError(toStorageError("local.listSecretStores")),
46 )
47 )
48 )
49 .handle("createSecretStore", ({ payload }) =>
50 Effect.flatMap(getControlPlaneExecutor(), (executor) =>
51 executor.secretStores.create(payload).pipe(
52 Effect.mapError(toStorageError("local.createSecretStore")),
53 )
54 )
55 )
56 .handle("updateSecretStore", ({ path, payload }) =>
57 Effect.flatMap(getControlPlaneExecutor(), (executor) =>
58 executor.secretStores.update({
59 storeId: path.storeId,
60 payload,
61 }).pipe(Effect.mapError(toStorageError("local.updateSecretStore")))
62 )
63 )
64 .handle("deleteSecretStore", ({ path }) =>
65 Effect.flatMap(getControlPlaneExecutor(), (executor) =>
66 executor.secretStores.remove(path.storeId).pipe(
67 Effect.mapError(toStorageError("local.deleteSecretStore")),
68 )
69 )
70 )
71 .handle("browseSecretStore", ({ path, payload }) =>
72 Effect.flatMap(getControlPlaneExecutor(), (executor) =>
73 executor.secretStores.browse({
74 storeId: path.storeId,
75 payload,
76 }).pipe(
77 Effect.mapError(toStorageError("local.browseSecretStore")),
78 )
79 )
80 )
81 .handle("importSecretFromStore", ({ path, payload }) =>
82 Effect.flatMap(getControlPlaneExecutor(), (executor) =>
83 executor.secretStores.import({
84 storeId: path.storeId,
85 payload,
86 }).pipe(
87 Effect.mapError(toStorageError("local.importSecretFromStore")),
88 )
89 )
90 )
34 .handle("listSecrets", () =>91 .handle("listSecrets", () =>
35 Effect.flatMap(getControlPlaneExecutor(), (executor) =>92 Effect.flatMap(getControlPlaneExecutor(), (executor) =>
36 executor.secrets.list().pipe(93 executor.secrets.list().pipe(
packages/platform/sdk-file/src/config.test.ts+51−0
@@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"; · 7 unmodified lines
8import {8import {
9 loadLocalExecutorConfig,9 loadLocalExecutorConfig,
10 mergeLocalExecutorConfigs,10 mergeLocalExecutorConfigs,
11 migrateLegacyLocalExecutorConfigFile,
11 resolveDefaultHomeConfigCandidates,12 resolveDefaultHomeConfigCandidates,
12 resolveDefaultHomeStateDirectory,13 resolveDefaultHomeStateDirectory,
13 resolveLocalWorkspaceContext,14 resolveLocalWorkspaceContext,
@@ -136,4 +137,54 @@ describe("local-config", () => { · 122 unmodified lines
136 137
137 expect(merged?.runtime).toBe("deno");138 expect(merged?.runtime).toBe("deno");
138 });139 });
140
141 it.effect("migrates legacy preset source stubs before strict decode", () =>
142 Effect.gen(function* () {
143 const fs = yield* FileSystem.FileSystem;
144 const workspaceRoot = yield* makeWorkspaceRoot();
145 const homeConfigPath = join(workspaceRoot, "executor-home.jsonc");
146
147 yield* fs.writeFileString(
148 homeConfigPath,
149 `{
150 "sources": {
151 "axiom": {
152 "name": "Axiom MCP",
153 "kind": "mcp"
154 },
155 "stripe-api": {
156 "name": "Stripe API",
157 "namespace": "stripe.api",
158 "kind": "openapi"
159 },
160 "kept": {
161 "name": "Kept",
162 "kind": "openapi",
163 "connection": {
164 "endpoint": "https://example.com"
165 },
166 "binding": {
167 "specUrl": "https://example.com/openapi.json"
168 }
169 }
170 }
171}
172`,
173 );
174
175 const changed = yield* migrateLegacyLocalExecutorConfigFile(homeConfigPath);
176 const migratedContent = yield* fs.readFileString(homeConfigPath, "utf8");
177 const context = yield* resolveLocalWorkspaceContext({
178 workspaceRoot,
179 homeConfigPath,
180 });
181 const loaded = yield* loadLocalExecutorConfig(context);
182
183 expect(changed).toBe(true);
184 expect(migratedContent).not.toContain("\"axiom\"");
185 expect(migratedContent).not.toContain("\"stripe-api\"");
186 expect(loaded.config?.sources?.kept?.kind).toBe("openapi");
187 expect(loaded.config?.sources?.axiom).toBeUndefined();
188 }).pipe(Effect.provide(NodeFileSystem.layer)),
189 );
139});190});
packages/platform/sdk-file/src/config.ts+172−0
@@ -33,6 +33,9 @@ export type FileLoadedExecutorConfig = LoadedLocalExecutorConfig & { · 32 unmodified lines
33export const encodeLocalExecutorConfig = (config: LocalExecutorConfig): string =>33export const encodeLocalExecutorConfig = (config: LocalExecutorConfig): string =>
34 `${JSON.stringify(config, null, 2)}\n`;34 `${JSON.stringify(config, null, 2)}\n`;
35 35
36const encodeUnknownConfigDocument = (config: unknown): string =>
37 `${JSON.stringify(config, null, 2)}\n`;
38
36export const resolveConfigRelativePath = (input: {39export const resolveConfigRelativePath = (input: {
37 path: string;40 path: string;
38 scopeRoot: string;41 scopeRoot: string;
@@ -223,6 +226,130 @@ const parseJsonc = (input: { path: string; content: string }): LocalExecutorConf · 184 unmodified lines
223 }226 }
224};227};
225 228
229const 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
260const 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
265const 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
315const 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
226const mergeSourceMaps = (353const mergeSourceMaps = (
227 base: Record<string, LocalConfigSource> | undefined,354 base: Record<string, LocalConfigSource> | undefined,
228 extra: Record<string, LocalConfigSource> | undefined,355 extra: Record<string, LocalConfigSource> | undefined,
@@ -426,6 +553,51 @@ export const readOptionalLocalExecutorConfig = ( · 197 unmodified lines
426 });553 });
427 });554 });
428 555
556export 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
589export 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
429export const loadLocalExecutorConfig = (601export const loadLocalExecutorConfig = (
430 context: ResolvedLocalWorkspaceContext,602 context: ResolvedLocalWorkspaceContext,
431): Effect.Effect<603): Effect.Effect<
packages/platform/sdk-file/src/executor-state-store.ts+174−3
@@ -12,6 +12,8 @@ import { · 11 unmodified lines
12 ExecutionStepSchema,12 ExecutionStepSchema,
13 SecretMaterialSchema,13 SecretMaterialSchema,
14 type SecretMaterial,14 type SecretMaterial,
15 SecretStoreSchema,
16 type SecretStore,
15} from "@executor/platform-sdk/schema";17} from "@executor/platform-sdk/schema";
16import * as Effect from "effect/Effect";18import * as Effect from "effect/Effect";
17import * as Option from "effect/Option";19import * as Option from "effect/Option";
@@ -26,9 +28,12 @@ import { · 8 unmodified lines
26 28
27const LOCAL_EXECUTOR_STATE_VERSION = 1 as const;29const LOCAL_EXECUTOR_STATE_VERSION = 1 as const;
28const LOCAL_EXECUTOR_STATE_BASENAME = "executor-state.json";30const LOCAL_EXECUTOR_STATE_BASENAME = "executor-state.json";
31const LEGACY_LOCAL_SECRET_STORE_ID = "sts_builtin_local";
32const LEGACY_KEYCHAIN_SECRET_STORE_ID = "sts_builtin_keychain";
29 33
30const LocalExecutorStateSnapshotSchema = Schema.Struct({34const LocalExecutorStateSnapshotSchema = Schema.Struct({
31 version: Schema.Literal(LOCAL_EXECUTOR_STATE_VERSION),35 version: Schema.Literal(LOCAL_EXECUTOR_STATE_VERSION),
36 secretStores: Schema.Array(SecretStoreSchema),
32 secretMaterials: Schema.Array(SecretMaterialSchema),37 secretMaterials: Schema.Array(SecretMaterialSchema),
33 executions: Schema.Array(ExecutionSchema),38 executions: Schema.Array(ExecutionSchema),
34 executionInteractions: Schema.Array(ExecutionInteractionSchema),39 executionInteractions: Schema.Array(ExecutionInteractionSchema),
@@ -48,6 +53,7 @@ const decodeLocalExecutorStateSnapshot = Schema.decodeUnknown( · 13 unmodified lines
48 53
49const defaultLocalExecutorStateSnapshot = (): LocalExecutorStateSnapshot => ({54const defaultLocalExecutorStateSnapshot = (): LocalExecutorStateSnapshot => ({
50 version: LOCAL_EXECUTOR_STATE_VERSION,55 version: LOCAL_EXECUTOR_STATE_VERSION,
56 secretStores: [],
51 secretMaterials: [],57 secretMaterials: [],
52 executions: [],58 executions: [],
53 executionInteractions: [],59 executionInteractions: [],
@@ -64,8 +70,31 @@ const asRecord = ( · 10 unmodified lines
64 ? (value as Record<string, unknown>)70 ? (value as Record<string, unknown>)
65 : null;71 : null;
66 72
73const 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
80const 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
67const migrateLegacyExecutorStateValue = (93const migrateLegacyExecutorStateValue = (
68 value: unknown,94 value: unknown,
95 input: {
96 scopeId: string;
97 },
69): {98): {
70 value: unknown;99 value: unknown;
71 migrated: boolean;100 migrated: boolean;
@@ -73,7 +102,7 @@ const migrateLegacyExecutorStateValue = ( · 1 unmodified line
73 if (Array.isArray(value)) {102 if (Array.isArray(value)) {
74 let migrated = false;103 let migrated = false;
75 const next = value.map((item) => {104 const next = value.map((item) => {
76 const result = migrateLegacyExecutorStateValue(item);105 const result = migrateLegacyExecutorStateValue(item, input);
77 migrated = migrated || result.migrated;106 migrated = migrated || result.migrated;
78 return result.value;107 return result.value;
79 });108 });
@@ -104,11 +133,72 @@ const migrateLegacyExecutorStateValue = ( · 24 unmodified lines
104 migrated = true;133 migrated = true;
105 }134 }
106 135
107 const migratedEntry = migrateLegacyExecutorStateValue(entry);136 const migratedEntry = migrateLegacyExecutorStateValue(entry, input);
108 migrated = migrated || migratedEntry.migrated;137 migrated = migrated || migratedEntry.migrated;
109 next[nextKey] = migratedEntry.value;138 next[nextKey] = migratedEntry.value;
110 }139 }
111 140
141 if (next.version === LOCAL_EXECUTOR_STATE_VERSION) {
142 const secretMaterialsValue = Array.isArray(next.secretMaterials)
143 ? next.secretMaterials
144 : [];
145 const secretStoresValue = Array.isArray(next.secretStores)
146 ? next.secretStores
147 : [];
148 const secretStoresById = new Map<string, Record<string, unknown>>();
149
150 for (const entry of secretStoresValue) {
151 const record = asRecord(entry);
152 if (record !== null && typeof record.id === "string") {
153 secretStoresById.set(record.id, record);
154 }
155 }
156
157 const migratedSecretMaterials = secretMaterialsValue.map((entry) => {
158 const record = asRecord(entry);
159 if (record === null) {
160 return entry;
161 }
162
163 if (typeof record.providerId !== "string" || typeof record.storeId === "string") {
164 return entry;
165 }
166
167 const providerId = record.providerId;
168 const storeId = legacySecretStoreIdForProviderId(providerId);
169 const createdAt =
170 typeof record.createdAt === "number" ? record.createdAt : 0;
171 const updatedAt =
172 typeof record.updatedAt === "number" ? record.updatedAt : createdAt;
173
174 secretStoresById.set(storeId, {
175 id: storeId,
176 scopeId: input.scopeId,
177 name: legacySecretStoreNameForProviderId(providerId),
178 kind: providerId,
179 status: providerId === "local" || providerId === "keychain"
180 ? "connected"
181 : "error",
182 enabled: true,
183 createdAt,
184 updatedAt,
185 });
186
187 migrated = true;
188 return {
189 ...record,
190 storeId,
191 };
192 });
193
194 if (!Array.isArray(next.secretStores)) {
195 migrated = true;
196 }
197
198 next.secretStores = [...secretStoresById.values()];
199 next.secretMaterials = migratedSecretMaterials;
200 }
201
112 return { value: next, migrated };202 return { value: next, migrated };
113};203};
114 204
@@ -168,7 +258,9 @@ const readStateFromDisk = ( · 53 unmodified lines
168 try: () => JSON.parse(content) as unknown,258 try: () => JSON.parse(content) as unknown,
169 catch: mapFileSystemError(path, "parse executor state"),259 catch: mapFileSystemError(path, "parse executor state"),
170 });260 });
171 const migrated = migrateLegacyExecutorStateValue(parsed);261 const migrated = migrateLegacyExecutorStateValue(parsed, {
262 scopeId: deriveLocalInstallation(context).scopeId,
263 });
172 const decoded = yield* decodeLocalExecutorStateSnapshot(migrated.value).pipe(264 const decoded = yield* decodeLocalExecutorStateSnapshot(migrated.value).pipe(
173 Effect.mapError(mapFileSystemError(path, "decode executor state")),265 Effect.mapError(mapFileSystemError(path, "decode executor state")),
174 );266 );
@@ -233,6 +325,10 @@ export const mergeImportedLocalExecutorStateSnapshot = (input: { · 58 unmodified lines
233 imported: Partial<Omit<LocalExecutorStateSnapshot, "version">>;325 imported: Partial<Omit<LocalExecutorStateSnapshot, "version">>;
234}): LocalExecutorStateSnapshot => ({326}): LocalExecutorStateSnapshot => ({
235 version: LOCAL_EXECUTOR_STATE_VERSION,327 version: LOCAL_EXECUTOR_STATE_VERSION,
328 secretStores: mergeById(
329 input.current.secretStores,
330 input.imported.secretStores ?? [],
331 ),
236 secretMaterials: mergeById(332 secretMaterials: mergeById(
237 input.current.secretMaterials,333 input.current.secretMaterials,
238 input.imported.secretMaterials ?? [],334 input.imported.secretMaterials ?? [],
@@ -334,6 +430,81 @@ export const createLocalExecutorStateStore = ( · 95 unmodified lines
334 const stateManager = createStateManager(context, fileSystem);430 const stateManager = createStateManager(context, fileSystem);
335 431
336 return {432 return {
433 secretStores: {
434 getById: (id: SecretStore["id"]) =>
435 stateManager.read((state) => {
436 const store = state.secretStores.find(
437 (candidate) => candidate.id === id,
438 );
439 return store
440 ? Option.some(cloneValue(store))
441 : Option.none<SecretStore>();
442 }),
443
444 listAll: () =>
445 stateManager.read((state) => sortByUpdatedAtAndIdDesc(state.secretStores)),
446
447 upsert: (store: SecretStore) =>
448 stateManager.mutate((state) => {
449 const nextStores = state.secretStores.filter(
450 (candidate) => candidate.id !== store.id,
451 );
452 nextStores.push(cloneValue(store));
453
454 return {
455 state: {
456 ...state,
457 secretStores: nextStores,
458 },
459 value: undefined,
460 } satisfies StateMutationResult<void>;
461 }),
462
463 updateById: (
464 id: SecretStore["id"],
465 update: Partial<
466 Pick<SecretStore, "name" | "status" | "enabled">
467 >,
468 ) =>
469 stateManager.mutate((state) => {
470 let updated: SecretStore | null = null;
471 const nextStores = state.secretStores.map((store) => {
472 if (store.id !== id) {
473 return store;
474 }
475
476 updated = {
477 ...store,
478 ...cloneValue(update),
479 updatedAt: Date.now(),
480 } satisfies SecretStore;
481 return updated;
482 });
483
484 return {
485 state: {
486 ...state,
487 secretStores: nextStores,
488 },
489 value: updated ? Option.some(cloneValue(updated)) : Option.none<SecretStore>(),
490 } satisfies StateMutationResult<Option.Option<SecretStore>>;
491 }),
492
493 removeById: (id: SecretStore["id"]) =>
494 stateManager.mutate((state) => {
495 const nextStores = state.secretStores.filter(
496 (candidate) => candidate.id !== id,
497 );
498
499 return {
500 state: {
501 ...state,
502 secretStores: nextStores,
503 },
504 value: nextStores.length !== state.secretStores.length,
505 } satisfies StateMutationResult<boolean>;
506 }),
507 },
337 secretMaterials: {508 secretMaterials: {
338 getById: (id: SecretMaterial["id"]) =>509 getById: (id: SecretMaterial["id"]) =>
339 stateManager.read((state) => {510 stateManager.read((state) => {
packages/platform/sdk-file/src/index.ts+31−11
@@ -31,6 +31,7 @@ import { · 30 unmodified lines
31import * as Effect from "effect/Effect";31import * as Effect from "effect/Effect";
32import {32import {
33 type LoadedLocalExecutorConfig,33 type LoadedLocalExecutorConfig,
34 migrateLegacyLocalExecutorConfigs,
34 type ResolvedLocalWorkspaceContext,35 type ResolvedLocalWorkspaceContext,
35 loadLocalExecutorConfig,36 loadLocalExecutorConfig,
36 resolveConfigRelativePath,37 resolveConfigRelativePath,
@@ -44,6 +45,7 @@ import { · 7 unmodified lines
44 createDefaultSecretMaterialResolver,45 createDefaultSecretMaterialResolver,
45 createDefaultSecretMaterialStorer,46 createDefaultSecretMaterialStorer,
46 createDefaultSecretMaterialUpdater,47 createDefaultSecretMaterialUpdater,
48 provisionBuiltinSecretStores,
47} from "./secret-material-providers";49} from "./secret-material-providers";
48import {50import {
49 buildLocalSourceArtifact,51 buildLocalSourceArtifact,
@@ -52,6 +54,7 @@ import { · 2 unmodified lines
52 writeLocalSourceArtifact,54 writeLocalSourceArtifact,
53} from "./source-artifacts";55} from "./source-artifacts";
54import {56import {
57 deriveLocalInstallation,
55 getOrProvisionLocalInstallation,58 getOrProvisionLocalInstallation,
56 loadLocalInstallation,59 loadLocalInstallation,
57} from "./installation";60} from "./installation";
@@ -235,6 +238,10 @@ export const createLocalExecutorRepositoriesEffect = ( · 177 unmodified lines
235 homeStateDirectory: options.homeStateDirectory,238 homeStateDirectory: options.homeStateDirectory,
236 }),239 }),
237 ).pipe(Effect.mapError(toError));240 ).pipe(Effect.mapError(toError));
241 yield* bindFileSystem(
242 fileSystem,
243 migrateLegacyLocalExecutorConfigs(workspaceContext),
244 ).pipe(Effect.mapError(toError));
238 245
239 const executorStateStorage = createLocalExecutorStatePersistence(246 const executorStateStorage = createLocalExecutorStatePersistence(
240 workspaceContext,247 workspaceContext,
@@ -245,12 +252,14 @@ export const createLocalExecutorRepositoriesEffect = ( · 4 unmodified lines
245 fileSystem,252 fileSystem,
246 );253 );
247 const loadedConfig = yield* workspaceConfigStore.load();254 const loadedConfig = yield* workspaceConfigStore.load();
255 yield* provisionBuiltinSecretStores({
256 executorState: executorStateStorage.executorState,
257 scopeId: deriveLocalInstallation(workspaceContext).scopeId,
258 });
248 const resolveSecretMaterial: ResolveSecretMaterial =259 const resolveSecretMaterial: ResolveSecretMaterial =
249 runtimeOptions.resolveSecretMaterial260 runtimeOptions.resolveSecretMaterial
250 ?? createDefaultSecretMaterialResolver({261 ?? createDefaultSecretMaterialResolver({
251 executorState: executorStateStorage.executorState,262 executorState: executorStateStorage.executorState,
252 localConfig: loadedConfig.config,
253 workspaceRoot: workspaceContext.workspaceRoot,
254 });263 });
255 264
256 return {265 return {
@@ -274,16 +283,20 @@ export const createLocalExecutorRepositoriesEffect = ( · 17 unmodified lines
274 createBoundSourceTypeDeclarationsRefresher(workspaceContext),283 createBoundSourceTypeDeclarationsRefresher(workspaceContext),
275 },284 },
276 secrets: {285 secrets: {
286 secretStores: executorStateStorage.executorState.secretStores,
277 ...executorStateStorage.executorState.secretMaterials,287 ...executorStateStorage.executorState.secretMaterials,
278 resolve: resolveSecretMaterial,288 resolve: resolveSecretMaterial,
279 store: createDefaultSecretMaterialStorer({289 store: createDefaultSecretMaterialStorer({
280 executorState: executorStateStorage.executorState,290 executorState: executorStateStorage.executorState,
291 resolveSecretMaterial,
281 }),292 }),
282 delete: createDefaultSecretMaterialDeleter({293 delete: createDefaultSecretMaterialDeleter({
283 executorState: executorStateStorage.executorState,294 executorState: executorStateStorage.executorState,
295 resolveSecretMaterial,
284 }),296 }),
285 update: createDefaultSecretMaterialUpdater({297 update: createDefaultSecretMaterialUpdater({
286 executorState: executorStateStorage.executorState,298 executorState: executorStateStorage.executorState,
299 resolveSecretMaterial,
287 }),300 }),
288 },301 },
289 executions: {302 executions: {
@@ -293,7 +306,9 @@ export const createLocalExecutorRepositoriesEffect = ( · 3 unmodified lines
293 steps: executorStateStorage.executorState.executionSteps,306 steps: executorStateStorage.executorState.executionSteps,
294 },307 },
295 instanceConfig: {308 instanceConfig: {
296 resolve: createLocalInstanceConfigResolver(),309 resolve: createLocalInstanceConfigResolver({
310 executorState: executorStateStorage.executorState,
311 }),
297 },312 },
298 close: executorStateStorage.close,313 close: executorStateStorage.close,
299 } satisfies ExecutorBackendRepositories;314 } satisfies ExecutorBackendRepositories;
@@ -329,27 +344,32 @@ export const createLocalExecutorEffect = < · 29 unmodified lines
329): Effect.Effect<344): Effect.Effect<
330 ExecutorEffect & ExecutorSdkPluginExtensions<TPlugins>,345 ExecutorEffect & ExecutorSdkPluginExtensions<TPlugins>,
331 Error346 Error
332> =>347> => {
333 createExecutorEffect({348 return createExecutorEffect({
334 backend: createLocalExecutorBackend(options),349 backend: createLocalExecutorBackend(options),
335 plugins: options.plugins,350 plugins: (options.plugins ?? []) as TPlugins,
336 executionResolver: options.executionResolver,351 executionResolver: options.executionResolver,
337 resolveSecretMaterial: options.resolveSecretMaterial,352 resolveSecretMaterial: options.resolveSecretMaterial,
338 getLocalServerBaseUrl: options.getLocalServerBaseUrl,353 getLocalServerBaseUrl: options.getLocalServerBaseUrl,
339 } satisfies CreateExecutorEffectOptions & { plugins?: TPlugins });354 }) as Effect.Effect<
355 ExecutorEffect & ExecutorSdkPluginExtensions<TPlugins>,
356 Error
357 >;
358};
340 359
341export const createLocalExecutor = <360export const createLocalExecutor = <
342 const TPlugins extends readonly ExecutorSdkPlugin<any, any>[] = [],361 const TPlugins extends readonly ExecutorSdkPlugin<any, any>[] = [],
343>(362>(
344 options: CreateLocalExecutorOptions<TPlugins> = {},363 options: CreateLocalExecutorOptions<TPlugins> = {},
345): Promise<Executor<TPlugins>> =>364) => {
346 createExecutor({365 return createExecutor({
347 backend: createLocalExecutorBackend(options),366 backend: createLocalExecutorBackend(options),
348 plugins: options.plugins,367 plugins: (options.plugins ?? []) as TPlugins,
349 executionResolver: options.executionResolver,368 executionResolver: options.executionResolver,
350 resolveSecretMaterial: options.resolveSecretMaterial,369 resolveSecretMaterial: options.resolveSecretMaterial,
351 getLocalServerBaseUrl: options.getLocalServerBaseUrl,370 getLocalServerBaseUrl: options.getLocalServerBaseUrl,
352 });371 }) as Promise<Executor<TPlugins>>;
372};
353 373
354export {374export {
355 deriveLocalInstallation,375 deriveLocalInstallation,
packages/platform/sdk-file/src/secret-material-providers.ts+305−1133
@@ -1,115 +1,38 @@
1import { randomUUID } from "node:crypto";1import { randomUUID } from "node:crypto";
2import { spawn } from "node:child_process";
3import { isAbsolute } from "node:path";
4import { FileSystem } from "@effect/platform";
5import { NodeFileSystem } from "@effect/platform-node";
6 2
7import {3import type {
8 type LocalConfigSecretProvider,4 InstanceConfig,
9 type LocalExecutorConfig,5 SecretProvider,
10 type SecretMaterialPurpose,6} from "@executor/platform-sdk/contracts";
11 type SecretRef,7import type {
12 type SecretMaterial,8 SecretMaterial,
13 SecretMaterialIdSchema,9 SecretMaterialPurpose,
10 SecretRef,
11 SecretStore,
12 SecretStoreId,
14} from "@executor/platform-sdk/schema";13} from "@executor/platform-sdk/schema";
15import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";15import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";16import * as Option from "effect/Option";
18 17
19import {18import {
20 ExecutorStateStore,19 getSecretStoreContribution,
21 runtimeEffectError,20 registeredSecretStoreContributions,
22 LocalInstanceConfigService,21 LocalInstanceConfigService,
22 runtimeEffectError,
23 SecretMaterialDeleterService,23 SecretMaterialDeleterService,
24 SecretMaterialResolverService,24 SecretMaterialResolverService,
25 SecretMaterialStorerService,25 SecretMaterialStorerService,
26 SecretMaterialUpdaterService,26 SecretMaterialUpdaterService,
27 fromConfigSecretProviderId,27 type DeleteSecretMaterial,
28 getRuntimeLocalScopeOption,
29 type ExecutorStateStoreShape,28 type ExecutorStateStoreShape,
30 type ResolveSecretMaterial,
31 type ResolveInstanceConfig,29 type ResolveInstanceConfig,
32 type SecretMaterialResolveContext,30 type ResolveSecretMaterial,
33 type StoreSecretMaterial,31 type StoreSecretMaterial,
34 type DeleteSecretMaterial,
35 type UpdateSecretMaterial,32 type UpdateSecretMaterial,
36} from "@executor/platform-sdk/runtime";33} from "@executor/platform-sdk/runtime";
37import { resolveConfigRelativePath } from "./config";
38
39export const ENV_SECRET_PROVIDER_ID = "env";
40export const PARAMS_SECRET_PROVIDER_ID = "params";
41export const KEYCHAIN_SECRET_PROVIDER_ID = "keychain";
42export const LOCAL_SECRET_PROVIDER_ID = "local";
43
44const toError = (cause: unknown): Error =>
45 cause instanceof Error ? cause : new Error(String(cause));
46
47export type SecretStoreProviderId =
48 | typeof KEYCHAIN_SECRET_PROVIDER_ID
49 | typeof LOCAL_SECRET_PROVIDER_ID;
50
51type SecretMaterialProviderRuntime = {
52 executorState: ExecutorStateStoreShape;
53 env: NodeJS.ProcessEnv;
54 dangerouslyAllowEnvSecrets: boolean;
55 keychainServiceName: string;
56 localConfig: LocalExecutorConfig | null;
57 workspaceRoot: string | null;
58};
59
60type SecretMaterialProvider = {
61 resolve: (input: {
62 ref: SecretRef;
63 context: SecretMaterialResolveContext;
64 runtime: SecretMaterialProviderRuntime;
65 }) => Effect.Effect<string, Error, never>;
66 store?: (input: {
67 purpose: SecretMaterialPurpose;
68 value: string;
69 name?: string | null;
70 runtime: SecretMaterialProviderRuntime;
71 }) => Effect.Effect<SecretRef, Error, never>;
72 update?: (input: {
73 ref: SecretRef;
74 name?: string | null;
75 value?: string;
76 runtime: SecretMaterialProviderRuntime;
77 }) => Effect.Effect<{
78 id: string;
79 providerId: string;
80 name: string | null;
81 purpose: string;
82 createdAt: number;
83 updatedAt: number;
84 }, Error, never>;
85 remove?: (input: {
86 ref: SecretRef;
87 runtime: SecretMaterialProviderRuntime;
88 }) => Effect.Effect<boolean, Error, never>;
89};
90 34
91type SecretMaterialProviderRegistry = ReadonlyMap<string, SecretMaterialProvider>;35const SECRET_STORE_KIND_ENV = "EXECUTOR_SECRET_STORE_PROVIDER";
92
93type SpawnResult = {
94 exitCode: number;
95 stdout: string;
96 stderr: string;
97};
98
99const DEFAULT_KEYCHAIN_SERVICE_NAME = "executor";
100const KEYCHAIN_COMMAND_TIMEOUT_MS = 5_000;
101const DANGEROUSLY_ALLOW_ENV_SECRETS_ENV = "DANGEROUSLY_ALLOW_ENV_SECRETS";
102const SECRET_STORE_PROVIDER_ENV = "EXECUTOR_SECRET_STORE_PROVIDER";
103const KEYCHAIN_SERVICE_NAME_ENV = "EXECUTOR_KEYCHAIN_SERVICE_NAME";
104
105type SecretMaterialSummary = {
106 id: string;
107 providerId: string;
108 name: string | null;
109 purpose: string;
110 createdAt: number;
111 updatedAt: number;
112};
113 36
114const trimOrNull = (value: string | null | undefined): string | null => {37const trimOrNull = (value: string | null | undefined): string | null => {
115 if (value === null || value === undefined) {38 if (value === null || value === undefined) {
@@ -120,1165 +43,414 @@ const trimOrNull = (value: string | null | undefined): string | null => { · 4 unmodified lines
120 return trimmed.length > 0 ? trimmed : null;43 return trimmed.length > 0 ? trimmed : null;
121};44};
122 45
123const parseBooleanEnv = (value: string | undefined): boolean => {46const builtinSecretStoreContributions = () =>
124 const normalized = trimOrNull(value)?.toLowerCase();47 registeredSecretStoreContributions()
125 return normalized === "1" || normalized === "true" || normalized === "yes";48 .filter((entry) => entry.builtin !== undefined)
126};49 .filter((entry) => entry.builtin?.enabled?.() ?? true)
50 .map((entry) => ({
51 contribution: entry,
52 builtin: entry.builtin!,
53 }));
54
55const resolveStoreContribution = (store: SecretStore) =>
56 getSecretStoreContribution(store.kind);
57
58const loadStoreCapabilities = (store: SecretStore) =>
59 resolveStoreContribution(store).getCapabilities({
60 store,
61 }) as Effect.Effect<{
62 canCreateSecrets: boolean;
63 canUpdateSecrets: boolean;
64 canDeleteSecrets: boolean;
65 canBrowseSecrets: boolean;
66 canImportSecrets: boolean;
67 }, Error, never>;
127 68
128export const parseSecretStoreProviderId = (value: string | undefined): SecretStoreProviderId | null => {69const parseDefaultStoreKind = (value: string | undefined): string | null => {
129 const normalized = trimOrNull(value)?.toLowerCase();70 const normalized = trimOrNull(value)?.toLowerCase();
130 if (normalized === KEYCHAIN_SECRET_PROVIDER_ID) {71 return normalized ?? null;
131 return KEYCHAIN_SECRET_PROVIDER_ID;
132 }
133
134 if (
135 normalized === LOCAL_SECRET_PROVIDER_ID
136 ) {
137 return LOCAL_SECRET_PROVIDER_ID;
138 }
139
140 return null;
141};
142
143const resolveDangerouslyAllowEnvSecrets = (value: boolean | undefined): boolean =>
144 value ?? parseBooleanEnv(process.env[DANGEROUSLY_ALLOW_ENV_SECRETS_ENV]);
145
146const resolveKeychainServiceName = (value: string | undefined): string =>
147 trimOrNull(value)
148 ?? trimOrNull(process.env[KEYCHAIN_SERVICE_NAME_ENV])
149 ?? DEFAULT_KEYCHAIN_SERVICE_NAME;
150
151const ensureNonEmptyString = (value: string | undefined): string | null => {
152 const trimmed = value?.trim();
153 return trimmed && trimmed.length > 0 ? trimmed : null;
154};72};
155 73
156const toSecretMaterialSummary = (74const resolveDefaultStoreId = (
157 material: Pick<SecretMaterial, "id" | "providerId" | "name" | "purpose" | "createdAt" | "updatedAt">,75 stores: ReadonlyArray<{
158): SecretMaterialSummary => ({76 id: string;
159 id: material.id,77 kind: string;
160 providerId: material.providerId,78 }>,
161 name: material.name,79): SecretStoreId | null => {
162 purpose: material.purpose,80 const explicitKind = parseDefaultStoreKind(process.env[SECRET_STORE_KIND_ENV]);
163 createdAt: material.createdAt,81 if (explicitKind) {
164 updatedAt: material.updatedAt,82 const explicitStore = stores.find((store) => store.kind === explicitKind);
165});83 if (explicitStore) {
166 84 return explicitStore.id as SecretStoreId;
167const keychainCommandForPlatform = (85 }
168 platform: NodeJS.Platform = process.platform,
169): string | null => {
170 if (platform === "darwin") {
171 return "security";
172 }
173
174 if (platform === "linux") {
175 return "secret-tool";
176 }
177
178 return null;
179};
180
181const 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
262const ensureCommandSuccess = (input: {
263 result: SpawnResult;
264 operation: string;
265 message: string;
266}): Effect.Effect<SpawnResult, Error, never> => {
267 if (input.result.exitCode === 0) {
268 return Effect.succeed(input.result);
269 }86 }
270 87
271 const details = ensureNonEmptyString(input.result.stderr)88 const prioritizedBuiltins = builtinSecretStoreContributions()
272 ?? ensureNonEmptyString(input.result.stdout)89 .sort((left, right) =>
273 ?? "command returned non-zero exit code";90 (right.builtin.defaultPriority ?? 0) - (left.builtin.defaultPriority ?? 0)
274 91 );
275 return Effect.fail(runtimeEffectError("local/secret-material-providers", `${input.operation}: ${input.message}: ${details}`));
276};
277
278const commandAvailabilityCache = new Map<string, Promise<boolean>>();
279
280const 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
320const 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
327export const resolveDefaultSecretStoreProviderId = (input: {
328 storeProviderId?: SecretStoreProviderId;
329 env?: NodeJS.ProcessEnv;
330 platform?: NodeJS.Platform;
331} = {}): Effect.Effect<SecretStoreProviderId, never, never> => {
332 const localProviderId = LOCAL_SECRET_PROVIDER_ID as SecretStoreProviderId;
333 const explicit =
334 input.storeProviderId
335 ?? parseSecretStoreProviderId((input.env ?? process.env)[SECRET_STORE_PROVIDER_ENV]);
336 if (explicit) {
337 return Effect.succeed(explicit);
338 }
339 92
340 if ((input.platform ?? process.platform) !== "darwin") {93 for (const entry of prioritizedBuiltins) {
341 return Effect.succeed(localProviderId);94 const builtinStore = stores.find((store) => store.id === entry.builtin.storeId);
95 if (builtinStore) {
96 return builtinStore.id as SecretStoreId;
97 }
342 }98 }
343 99
344 return isKeychainProviderAvailable(input.platform).pipe(100 return (stores[0]?.id as SecretStoreId | undefined) ?? null;
345 Effect.map((available): SecretStoreProviderId =>
346 available ? KEYCHAIN_SECRET_PROVIDER_ID : localProviderId),
347 Effect.catchAll(() => Effect.succeed(localProviderId)),
348 ) as Effect.Effect<SecretStoreProviderId, never, never>;
349};101};
350 102
351const loadStoredSecretMaterial = (input: {103export const provisionBuiltinSecretStores = (input: {
352 id: string;104 executorState: ExecutorStateStoreShape;
353 runtime: SecretMaterialProviderRuntime;105 scopeId: string;
354 operation: string;
355}) =>
356 Effect.gen(function* () {
357 const materialId = SecretMaterialIdSchema.make(input.id);
358 const stored = yield* input.runtime.executorState.secretMaterials.getById(materialId);
359 if (Option.isNone(stored)) {
360 return yield* runtimeEffectError("local/secret-material-providers", `${input.operation}: secret material not found: ${input.id}`);
361 }
362
363 return stored.value;
364 });
365
366const createSecretMaterialMetadata = (input: {
367 providerId: SecretStoreProviderId;
368 providerHandle: string;
369 purpose: SecretMaterialPurpose;
370 value: string | null;
371 name?: string | null;
372 runtime: SecretMaterialProviderRuntime;
373}) =>106}) =>
374 Effect.gen(function* () {107 Effect.gen(function* () {
375 const now = Date.now();108 const now = Date.now();
376 const id = SecretMaterialIdSchema.make(`sec_${randomUUID()}`);
377 yield* input.runtime.executorState.secretMaterials.upsert({
378 id,
379 providerId: input.providerId,
380 handle: input.providerHandle,
381 name: trimOrNull(input.name),
382 purpose: input.purpose,
383 value: input.value,
384 createdAt: now,
385 updatedAt: now,
386 });
387
388 return {
389 providerId: input.providerId,
390 handle: id,
391 } satisfies SecretRef;
392 });
393
394const 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
417const readKeychainSecretValue = (input: {
418 providerHandle: string;
419 runtime: SecretMaterialProviderRuntime;
420}) => {
421 switch (process.platform) {
422 case "darwin":
423 return runCommand({
424 command: "security",
425 args: [
426 "find-generic-password",
427 "-a",
428 input.providerHandle,
429 "-s",
430 input.runtime.keychainServiceName,
431 "-w",
432 ],
433 operation: "keychain.get",
434 timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS,
435 }).pipe(
436 Effect.flatMap((result) =>
437 ensureCommandSuccess({
438 result,
439 operation: "keychain.get",
440 message: "Failed loading secret from macOS keychain",
441 }),
442 ),
443 Effect.map((result) => result.stdout.trimEnd()),
444 );
445 case "linux":
446 return runCommand({
447 command: "secret-tool",
448 args: [
449 "lookup",
450 "service",
451 input.runtime.keychainServiceName,
452 "account",
453 input.providerHandle,
454 ],
455 operation: "keychain.get",
456 timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS,
457 }).pipe(
458 Effect.flatMap((result) =>
459 ensureCommandSuccess({
460 result,
461 operation: "keychain.get",
462 message: "Failed loading secret from desktop keyring",
463 }),
464 ),
465 Effect.map((result) => result.stdout.trimEnd()),
466 );
467 default:
468 return Effect.fail(
469 runtimeEffectError("local/secret-material-providers", `keychain.get: keychain provider is unsupported on platform '${process.platform}'`),
470 );
471 }
472};
473 109
474const writeKeychainSecretValue = (input: {110 for (const entry of builtinSecretStoreContributions()) {
475 providerHandle: string;111 const existing = yield* input.executorState.secretStores.getById(
476 name?: string | null;112 entry.builtin.storeId as SecretStore["id"],
477 value: string;
478 runtime: SecretMaterialProviderRuntime;
479}) => {
480 const secretName = trimOrNull(input.name);
481
482 switch (process.platform) {
483 case "darwin":
484 return runCommand({
485 command: "security",
486 args: [
487 "add-generic-password",
488 "-a",
489 input.providerHandle,
490 "-s",
491 input.runtime.keychainServiceName,
492 "-w",
493 input.value,
494 ...(secretName ? ["-l", secretName] : []),
495 "-U",
496 ],
497 operation: "keychain.put",
498 timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS,
499 }).pipe(
500 Effect.flatMap((result) =>
501 ensureCommandSuccess({
502 result,
503 operation: "keychain.put",
504 message: "Failed storing secret in macOS keychain",
505 }),
506 ),
507 );
508 case "linux":
509 return runCommand({
510 command: "secret-tool",
511 args: [
512 "store",
513 "--label",
514 secretName ?? input.runtime.keychainServiceName,
515 "service",
516 input.runtime.keychainServiceName,
517 "account",
518 input.providerHandle,
519 ],
520 stdin: input.value,
521 operation: "keychain.put",
522 timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS,
523 }).pipe(
524 Effect.flatMap((result) =>
525 ensureCommandSuccess({
526 result,
527 operation: "keychain.put",
528 message: "Failed storing secret in desktop keyring",
529 }),
530 ),
531 );
532 default:
533 return Effect.fail(
534 runtimeEffectError("local/secret-material-providers", `keychain.put: keychain provider is unsupported on platform '${process.platform}'`),
535 );113 );
536 }114 if (Option.isSome(existing)) {
537};115 continue;
538
539const 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
581const 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
594const 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
615const createLocalSecretMaterialProvider = (): SecretMaterialProvider => ({
616 resolve: ({ ref, runtime }) =>
617 Effect.gen(function* () {
618 const stored = yield* loadStoredSecretMaterial({
619 id: ref.handle,
620 runtime,
621 operation: "local.get",
622 });
623 if (stored.providerId !== LOCAL_SECRET_PROVIDER_ID) {
624 return yield* runtimeEffectError("local/secret-material-providers",
625 `local.get: secret ${stored.id} is stored in provider '${stored.providerId}', not '${LOCAL_SECRET_PROVIDER_ID}'`,
626 );
627 }116 }
628 if (stored.value === null) {
629 return yield* runtimeEffectError("local/secret-material-providers", `local.get: secret ${stored.id} does not have a local value`);
630 }
631
632 return stored.value;
633 }),
634
635 store: ({ purpose, value, name, runtime }) =>
636 createSecretMaterialMetadata({
637 providerId: LOCAL_SECRET_PROVIDER_ID,
638 providerHandle: `local:${randomUUID()}`,
639 purpose,
640 value,
641 name,
642 runtime,
643 }),
644
645 update: ({ ref, name, value, runtime }) =>
646 Effect.gen(function* () {
647 const stored = yield* loadStoredSecretMaterial({
648 id: ref.handle,
649 runtime,
650 operation: "local.update",
651 });
652 if (stored.providerId !== LOCAL_SECRET_PROVIDER_ID) {
653 return yield* runtimeEffectError("local/secret-material-providers",
654 `local.update: secret ${stored.id} is stored in provider '${stored.providerId}', not '${LOCAL_SECRET_PROVIDER_ID}'`,
655 );
656 }
657
658 if (name === undefined && value === undefined) {
659 return toSecretMaterialSummary(stored);
660 }
661
662 const updated = yield* runtime.executorState.secretMaterials.updateById(
663 stored.id,
664 {
665 ...(name !== undefined ? { name } : {}),
666 ...(value !== undefined ? { value } : {}),
667 },
668 );
669 if (Option.isNone(updated)) {
670 return yield* runtimeEffectError("local/secret-material-providers", `local.update: secret material not found: ${stored.id}`);
671 }
672
673 return {
674 id: updated.value.id,
675 providerId: updated.value.providerId,
676 name: updated.value.name,
677 purpose: updated.value.purpose,
678 createdAt: updated.value.createdAt,
679 updatedAt: updated.value.updatedAt,
680 } satisfies SecretMaterialSummary;
681 }),
682
683 remove: ({ ref, runtime }) =>
684 Effect.gen(function* () {
685 const materialId = SecretMaterialIdSchema.make(ref.handle);
686 return yield* runtime.executorState.secretMaterials.removeById(materialId);
687 }),
688});
689
690const createKeychainSecretMaterialProvider = (): SecretMaterialProvider => ({
691 resolve: ({ ref, runtime }) =>
692 Effect.gen(function* () {
693 const loaded = yield* loadManagedKeychainRef({
694 ref,
695 runtime,
696 operation: "keychain.get",
697 });
698
699 return yield* readKeychainSecretValue({
700 providerHandle: loaded.providerHandle,
701 runtime,
702 });
703 }),
704
705 store: ({ purpose, value, name, runtime }) =>
706 Effect.gen(function* () {
707 const providerHandle = randomUUID();
708 yield* writeKeychainSecretValue({
709 providerHandle,
710 name,
711 value,
712 runtime,
713 });
714
715 return yield* createSecretMaterialMetadata({
716 providerId: KEYCHAIN_SECRET_PROVIDER_ID,
717 providerHandle,
718 purpose,
719 value: null,
720 name,
721 runtime,
722 });
723 }),
724
725 update: ({ ref, name, value, runtime }) =>
726 Effect.gen(function* () {
727 const loaded = yield* loadManagedKeychainRef({
728 ref,
729 runtime,
730 operation: "keychain.update",
731 });
732
733 if (name === undefined && value === undefined) {
734 return toSecretMaterialSummary(loaded.material);
735 }
736
737 const nextName = name ?? loaded.material.name;
738 const nextValue = value
739 ?? (yield* readKeychainSecretValue({
740 providerHandle: loaded.providerHandle,
741 runtime,
742 }));
743
744 yield* writeKeychainSecretValue({
745 providerHandle: loaded.providerHandle,
746 name: nextName,
747 value: nextValue,
748 runtime,
749 });
750
751 const updated = yield* runtime.executorState.secretMaterials.updateById(
752 loaded.material.id,
753 {
754 name: nextName,
755 },
756 );
757 if (Option.isNone(updated)) {
758 return yield* runtimeEffectError("local/secret-material-providers", `keychain.update: secret material not found: ${loaded.material.id}`);
759 }
760
761 return {
762 id: updated.value.id,
763 providerId: updated.value.providerId,
764 name: updated.value.name,
765 purpose: updated.value.purpose,
766 createdAt: updated.value.createdAt,
767 updatedAt: updated.value.updatedAt,
768 } satisfies SecretMaterialSummary;
769 }),
770 117
771 remove: ({ ref, runtime }) =>118 yield* input.executorState.secretStores.upsert({
772 Effect.gen(function* () {119 id: entry.builtin.storeId as SecretStoreId,
773 const loaded = yield* loadManagedKeychainRef({120 scopeId: input.scopeId as SecretStore["scopeId"],
774 ref,121 ...entry.builtin.createStore(),
775 runtime,122 createdAt: now,
776 operation: "keychain.delete",123 updatedAt: now,
777 });
778 const deleted = yield* deleteKeychainSecretValue({
779 providerHandle: loaded.providerHandle,
780 runtime,
781 });124 });
782
783 if (!deleted) {
784 return false;
785 }
786
787 return yield* runtime.executorState.secretMaterials.removeById(loaded.material.id);
788 }),
789});
790
791const 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
799const 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
811const 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
826const isRegularFilePath = (
827 path: string,
828 allowSymlink: boolean,
829): Effect.Effect<boolean, Error, FileSystem.FileSystem> =>
830 Effect.gen(function* () {
831 const fs = yield* FileSystem.FileSystem;
832 const stat = yield* fs.stat(path).pipe(Effect.mapError(toError));
833 if (stat.type === "SymbolicLink") {
834 if (!allowSymlink) {
835 return false;
836 }
837
838 const resolvedPath = yield* fs.realPath(path).pipe(Effect.mapError(toError));
839 const targetStat = yield* fs.stat(resolvedPath).pipe(Effect.mapError(toError));
840 return targetStat.type === "File";
841 }125 }
842
843 return stat.type === "File";
844 });126 });
845 127
846const ensureTrustedDir = (128const loadMaterialByRef = (input: {
847 path: string,129 executorState: ExecutorStateStoreShape;
848 trustedDirs: readonly string[] | undefined,
849): Effect.Effect<boolean, Error, FileSystem.FileSystem> =>
850 Effect.gen(function* () {
851 if (!trustedDirs || trustedDirs.length === 0) {
852 return true;
853 }
854
855 const fs = yield* FileSystem.FileSystem;
856 const real = yield* fs.realPath(path).pipe(Effect.mapError(toError));
857
858 for (const dir of trustedDirs) {
859 const trusted = yield* fs.realPath(dir).pipe(Effect.mapError(toError));
860 if (real === trusted || real.startsWith(`${trusted}/`)) {
861 return true;
862 }
863 }
864
865 return false;
866 });
867
868const readFileSecretValue = (input: {
869 provider: Extract<LocalConfigSecretProvider, { source: "file" }>;
870 ref: SecretRef;130 ref: SecretRef;
871 workspaceRoot: string;131}) =>
872}): Effect.Effect<string, Error, FileSystem.FileSystem> =>
873 Effect.gen(function* () {132 Effect.gen(function* () {
874 const fs = yield* FileSystem.FileSystem;133 const material = yield* input.executorState.secretMaterials.getById(
875 const resolvedPath = resolveConfigRelativePath({134 input.ref.secretId,
876 path: input.provider.path,
877 scopeRoot: input.workspaceRoot,
878 });
879 const raw = yield* fs.readFileString(resolvedPath, "utf8").pipe(
880 Effect.mapError(toError),
881 );135 );
882 const mode = input.provider.mode ?? "singleValue";136 if (Option.isNone(material)) {
883 if (mode === "singleValue") {
884 return raw.trim();
885 }
886
887 return yield* Effect.try({
888 try: () => {
889 const parsed = JSON.parse(raw) as unknown;
890 if (input.ref.handle.startsWith("/")) {
891 const segments = input.ref.handle
892 .split("/")
893 .slice(1)
894 .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"));
895 let current: unknown = parsed;
896 for (const segment of segments) {
897 if (typeof current !== "object" || current === null || !(segment in current)) {
898 throw new Error(`Secret path not found in ${resolvedPath}: ${input.ref.handle}`);
899 }
900 current = (current as Record<string, unknown>)[segment];
901 }
902 if (typeof current !== "string" || current.trim().length === 0) {
903 throw new Error(`Secret path did not resolve to a string: ${input.ref.handle}`);
904 }
905 return current;
906 }
907
908 if (typeof parsed !== "object" || parsed === null) {
909 throw new Error(`JSON secret provider must resolve to an object: ${resolvedPath}`);
910 }
911 const value = (parsed as Record<string, unknown>)[input.ref.handle];
912 if (typeof value !== "string" || value.trim().length === 0) {
913 throw new Error(`Secret key not found in ${resolvedPath}: ${input.ref.handle}`);
914 }
915 return value;
916 },
917 catch: toError,
918 });
919 });
920
921const resolveConfiguredSecretProvider = (input: {
922 ref: SecretRef;
923 runtime: SecretMaterialProviderRuntime;
924}): Effect.Effect<string, Error, FileSystem.FileSystem> =>
925 Effect.gen(function* () {
926 const providerAlias = fromConfigSecretProviderId(input.ref.providerId);
927 if (providerAlias === null) {
928 return yield* runtimeEffectError(137 return yield* runtimeEffectError(
929 "local/secret-material-providers",138 "local/secret-material-providers",
930 `Unsupported secret provider: ${input.ref.providerId}`,139 `Secret not found: ${input.ref.secretId}`,
931 );140 );
932 }141 }
933 142
934 const provider = input.runtime.localConfig?.secrets?.providers?.[providerAlias];143 return material.value;
935 if (!provider) {144 });
936 return yield* runtimeEffectError(
937 "local/secret-material-providers",
938 `Config secret provider "${providerAlias}" is not configured`,
939 );
940 }
941 if (input.runtime.workspaceRoot === null) {
942 return yield* runtimeEffectError(
943 "local/secret-material-providers",
944 `Config secret provider "${providerAlias}" requires a workspace root`,
945 );
946 }
947
948 if (provider.source === "env") {
949 const value = ensureNonEmptyString(input.runtime.env[input.ref.handle]);
950 if (value === null) {
951 return yield* runtimeEffectError(
952 "local/secret-material-providers",
953 `Environment variable ${input.ref.handle} is not set`,
954 );
955 }
956 return value;
957 }
958
959 if (provider.source === "file") {
960 return yield* readFileSecretValue({
961 provider,
962 ref: input.ref,
963 workspaceRoot: input.runtime.workspaceRoot,
964 });
965 }
966 145
967 const command = provider.command.trim();146const loadStoreById = (input: {
968 if (!isAbsolute(command)) {147 executorState: ExecutorStateStoreShape;
969 return yield* runtimeEffectError(148 storeId: SecretStore["id"];
970 "local/secret-material-providers",149}) =>
971 `Exec secret provider command must be absolute: ${command}`,150 Effect.gen(function* () {
972 );151 const store = yield* input.executorState.secretStores.getById(input.storeId);
973 }152 if (Option.isNone(store)) {
974 const isRegularCommand = yield* isRegularFilePath(
975 command,
976 provider.allowSymlinkCommand ?? false,
977 );
978 if (!isRegularCommand) {
979 return yield* runtimeEffectError(
980 "local/secret-material-providers",
981 `Exec secret provider command is not an allowed regular file: ${command}`,
982 );
983 }
984 const isTrustedCommand = yield* ensureTrustedDir(command, provider.trustedDirs);
985 if (!isTrustedCommand) {
986 return yield* runtimeEffectError(153 return yield* runtimeEffectError(
987 "local/secret-material-providers",154 "local/secret-material-providers",
988 `Exec secret provider command is outside trustedDirs: ${command}`,155 `Secret store not found: ${input.storeId}`,
989 );156 );
990 }157 }
991 158
992 return yield* runCommand({159 return store.value;
993 command,
994 args: [...(provider.args ?? []), input.ref.handle],
995 env: {
996 ...input.runtime.env,
997 ...provider.env,
998 },
999 operation: `config-secret.get:${providerAlias}`,
1000 }).pipe(
1001 Effect.flatMap((result) =>
1002 ensureCommandSuccess({
1003 result,
1004 operation: `config-secret.get:${providerAlias}`,
1005 message: "Failed resolving configured exec secret",
1006 }),
1007 ),
1008 Effect.map((result) => result.stdout.trimEnd()),
1009 );
1010 });160 });
1011 161
162const 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
1012export const createDefaultSecretMaterialResolver = (input: {170export const createDefaultSecretMaterialResolver = (input: {
1013 executorState: ExecutorStateStoreShape;171 executorState: ExecutorStateStoreShape;
1014 dangerouslyAllowEnvSecrets?: boolean;
1015 keychainServiceName?: string;172 keychainServiceName?: string;
1016 localConfig?: LocalExecutorConfig | null;
1017 workspaceRoot?: string | null;
1018}): ResolveSecretMaterial => {173}): ResolveSecretMaterial => {
1019 const providers = createSecretMaterialProviderRegistry();174 let resolveSecretMaterial!: ResolveSecretMaterial;
1020 const runtime = createSecretMaterialProviderRuntime(input);175 resolveSecretMaterial = ({ ref, context }) =>
1021
1022 return ({ ref, context }) =>
1023 Effect.gen(function* () {176 Effect.gen(function* () {
1024 const provider = yield* getSecretMaterialProvider({177 const material = yield* loadMaterialByRef({
1025 providers,178 executorState: input.executorState,
1026 providerId: ref.providerId,
1027 }).pipe(
1028 Effect.catchAll(() =>
1029 fromConfigSecretProviderId(ref.providerId) !== null
1030 ? Effect.succeed(null)
1031 : Effect.fail(runtimeEffectError("local/secret-material-providers", `Unsupported secret provider: ${ref.providerId}`)),
1032 ),
1033 );
1034
1035 if (provider === null) {
1036 return yield* resolveConfiguredSecretProvider({
1037 ref,
1038 runtime,
1039 });
1040 }
1041
1042 return yield* provider.resolve({
1043 ref,179 ref,
1044 context: context ?? {},
1045 runtime,
1046 });180 });
1047 }).pipe(Effect.provide(NodeFileSystem.layer));181 const store = yield* loadStoreById({
182 executorState: input.executorState,
183 storeId: material.storeId,
184 });
185 const contribution = resolveStoreContribution(store);
186 return yield* provideSecretResolver(
187 contribution.resolveSecret({
188 secret: material,
189 store,
190 context,
191 }),
192 resolveSecretMaterial,
193 );
194 });
195
196 return resolveSecretMaterial;
1048};197};
1049 198
1050export const createDefaultSecretMaterialStorer = (input: {199export const createDefaultSecretMaterialStorer = (input: {
1051 executorState: ExecutorStateStoreShape;200 executorState: ExecutorStateStoreShape;
1052 dangerouslyAllowEnvSecrets?: boolean;201 resolveSecretMaterial: ResolveSecretMaterial;
1053 keychainServiceName?: string;202 keychainServiceName?: string;
1054}): StoreSecretMaterial => {203}): StoreSecretMaterial => {
1055 const providers = createSecretMaterialProviderRegistry();204 return ({ purpose, value, name, storeId }) =>
1056 const runtime = createSecretMaterialProviderRuntime(input);
1057
1058 return ({ purpose, value, name, providerId }) =>
1059 Effect.gen(function* () {205 Effect.gen(function* () {
1060 const defaultStoreProviderId = yield* resolveDefaultSecretStoreProviderId({206 const stores = yield* input.executorState.secretStores.listAll();
1061 storeProviderId: parseSecretStoreProviderId(providerId) ?? undefined,207 const resolvedStoreId = storeId ?? resolveDefaultStoreId(stores);
1062 env: runtime.env,208 if (resolvedStoreId === null) {
1063 });209 return yield* runtimeEffectError(
1064 const provider = yield* getSecretMaterialProvider({210 "local/secret-material-providers",
1065 providers,211 "No secret stores are registered in this runtime.",
1066 providerId: defaultStoreProviderId,212 );
213 }
214
215 const store = yield* loadStoreById({
216 executorState: input.executorState,
217 storeId: resolvedStoreId as SecretStore["id"],
1067 });218 });
219 const contribution = resolveStoreContribution(store);
1068 220
1069 if (!provider.store) {221 const capabilities = yield* loadStoreCapabilities(store);
1070 return yield* runtimeEffectError("local/secret-material-providers", `Secret provider ${defaultStoreProviderId} does not support storing secret material`);222 if (!capabilities.canCreateSecrets) {
223 return yield* runtimeEffectError(
224 "local/secret-material-providers",
225 `Secret store ${store.id} does not support creating secrets`,
226 );
1071 }227 }
1072 228
1073 return yield* provider.store({229 const created = yield* provideSecretResolver(
230 contribution.createSecret({
231 store,
232 purpose,
233 value,
234 name,
235 }),
236 input.resolveSecretMaterial,
237 );
238
239 const now = Date.now();
240 const material: SecretMaterial = {
241 id: `sec_${randomUUID()}` as SecretMaterial["id"],
242 storeId: store.id,
243 handle: created.handle,
244 name: created.name,
1074 purpose,245 purpose,
1075 value,246 value: created.value ?? null,
1076 name,247 createdAt: now,
1077 runtime,248 updatedAt: now,
1078 });249 };
250
251 yield* input.executorState.secretMaterials.upsert(material);
252
253 return {
254 secretId: material.id,
255 } satisfies SecretRef;
1079 });256 });
1080};257};
1081 258
1082export const createDefaultSecretMaterialUpdater = (input: {259export const createDefaultSecretMaterialUpdater = (input: {
1083 executorState: ExecutorStateStoreShape;260 executorState: ExecutorStateStoreShape;
1084 dangerouslyAllowEnvSecrets?: boolean;261 resolveSecretMaterial: ResolveSecretMaterial;
1085 keychainServiceName?: string;262 keychainServiceName?: string;
1086}): UpdateSecretMaterial => {263}): UpdateSecretMaterial => {
1087 const providers = createSecretMaterialProviderRegistry();
1088 const runtime = createSecretMaterialProviderRuntime(input);
1089
1090 return ({ ref, name, value }) =>264 return ({ ref, name, value }) =>
1091 Effect.gen(function* () {265 Effect.gen(function* () {
1092 const provider = yield* getSecretMaterialProvider({266 const material = yield* loadMaterialByRef({
1093 providers,267 executorState: input.executorState,
1094 providerId: ref.providerId,268 ref,
1095 });269 });
270 const store = yield* loadStoreById({
271 executorState: input.executorState,
272 storeId: material.storeId,
273 });
274 const contribution = resolveStoreContribution(store);
1096 275
1097 if (!provider.update) {276 const capabilities = yield* loadStoreCapabilities(store);
1098 return yield* runtimeEffectError("local/secret-material-providers", `Secret provider ${ref.providerId} does not support updating secret material`);277 if (!capabilities.canUpdateSecrets) {
278 return yield* runtimeEffectError(
279 "local/secret-material-providers",
280 `Secret store ${store.id} does not support updating secrets`,
281 );
1099 }282 }
1100 283
1101 return yield* provider.update({284 const updated = yield* provideSecretResolver(
1102 ref,285 contribution.updateSecret({
1103 name,286 secret: material,
1104 value,287 store,
1105 runtime,288 name,
1106 });289 value,
290 }),
291 input.resolveSecretMaterial,
292 );
293
294 const nextMaterial: SecretMaterial = {
295 ...material,
296 ...(updated.handle ? { handle: updated.handle } : {}),
297 name: updated.name,
298 value: updated.value ?? material.value,
299 updatedAt: Date.now(),
300 };
301 yield* input.executorState.secretMaterials.upsert(nextMaterial);
302
303 return {
304 id: nextMaterial.id,
305 storeId: nextMaterial.storeId,
306 name: nextMaterial.name,
307 purpose: nextMaterial.purpose,
308 createdAt: nextMaterial.createdAt,
309 updatedAt: nextMaterial.updatedAt,
310 };
1107 });311 });
1108};312};
1109 313
1110export const createDefaultSecretMaterialDeleter = (input: {314export const createDefaultSecretMaterialDeleter = (input: {
1111 executorState: ExecutorStateStoreShape;315 executorState: ExecutorStateStoreShape;
1112 dangerouslyAllowEnvSecrets?: boolean;316 resolveSecretMaterial: ResolveSecretMaterial;
1113 keychainServiceName?: string;317 keychainServiceName?: string;
1114}): DeleteSecretMaterial => {318}): DeleteSecretMaterial => {
1115 const providers = createSecretMaterialProviderRegistry();
1116 const runtime = createSecretMaterialProviderRuntime(input);
1117
1118 return (ref) =>319 return (ref) =>
1119 Effect.gen(function* () {320 Effect.gen(function* () {
1120 const provider = yield* getSecretMaterialProvider({321 const material = yield* loadMaterialByRef({
1121 providers,322 executorState: input.executorState,
1122 providerId: ref.providerId,323 ref,
1123 });324 });
325 const store = yield* loadStoreById({
326 executorState: input.executorState,
327 storeId: material.storeId,
328 });
329 const contribution = resolveStoreContribution(store);
330 const capabilities = yield* loadStoreCapabilities(store);
331 const deleted = capabilities.canDeleteSecrets
332 ? yield* provideSecretResolver(
333 contribution.deleteSecret({
334 secret: material,
335 store,
336 }),
337 input.resolveSecretMaterial,
338 )
339 : false;
1124 340
1125 if (!provider.remove) {341 if (!deleted) {
1126 return false;342 return false;
1127 }343 }
1128 344
1129 return yield* provider.remove({345 return yield* input.executorState.secretMaterials.removeById(material.id);
1130 ref,
1131 runtime,
1132 });
1133 });346 });
1134};347};
1135 348
1136const resolveRuntimeSecretMaterialConfig = (input: {349const listAvailableSecretStorePlugins = (): SecretProvider[] => {
1137 localConfig?: LocalExecutorConfig | null;350 return registeredSecretStoreContributions().map((entry) => ({
1138 workspaceRoot?: string | null;351 kind: entry.kind,
1139}) =>352 displayName: entry.displayName,
1140 Effect.gen(function* () {353 canCreate: entry.canCreate,
1141 const runtimeLocalScope = yield* getRuntimeLocalScopeOption();354 }));
355};
1142 356
357export const createLocalInstanceConfigResolver = (input: {
358 executorState: ExecutorStateStoreShape;
359}): ResolveInstanceConfig => () =>
360 Effect.gen(function* () {
361 const stores = yield* input.executorState.secretStores.listAll();
1143 return {362 return {
1144 localConfig: input.localConfig363 platform: process.platform,
1145 ?? runtimeLocalScope?.loadedConfig.config364 secretStorePlugins: listAvailableSecretStorePlugins(),
1146 ?? null,365 defaultSecretStoreId: resolveDefaultStoreId(stores),
1147 workspaceRoot: input.workspaceRoot366 } satisfies InstanceConfig;
1148 ?? runtimeLocalScope?.scope.scopeRoot
1149 ?? null,
1150 };
1151 });367 });
1152 368
1153export const SecretMaterialResolverLive = (input: {369export const SecretMaterialResolverLive = (input: {
370 executorState: ExecutorStateStoreShape;
1154 resolveSecretMaterial?: ResolveSecretMaterial;371 resolveSecretMaterial?: ResolveSecretMaterial;
1155 dangerouslyAllowEnvSecrets?: boolean;
1156 keychainServiceName?: string;372 keychainServiceName?: string;
1157 localConfig?: LocalExecutorConfig | null;373}) =>
1158 workspaceRoot?: string | null;
1159} = {}) =>
1160 input.resolveSecretMaterial374 input.resolveSecretMaterial
1161 ? Layer.succeed(SecretMaterialResolverService, input.resolveSecretMaterial)375 ? Layer.succeed(SecretMaterialResolverService, input.resolveSecretMaterial)
1162 : Layer.effect(376 : Layer.succeed(
1163 SecretMaterialResolverService,377 SecretMaterialResolverService,
1164 Effect.gen(function* () {378 createDefaultSecretMaterialResolver(input),
1165 const executorState = yield* ExecutorStateStore;
1166 const runtimeConfig = yield* resolveRuntimeSecretMaterialConfig(input);
1167
1168 return createDefaultSecretMaterialResolver({
1169 executorState,
1170 dangerouslyAllowEnvSecrets: input.dangerouslyAllowEnvSecrets,
1171 keychainServiceName: input.keychainServiceName,
1172 localConfig: runtimeConfig.localConfig,
1173 workspaceRoot: runtimeConfig.workspaceRoot,
1174 });
1175 }),
1176 );379 );
1177 380
1178export const SecretMaterialStorerLive = (input: {381export const SecretMaterialStorerLive = (input: {
1179 dangerouslyAllowEnvSecrets?: boolean;382 executorState: ExecutorStateStoreShape;
383 resolveSecretMaterial: ResolveSecretMaterial;
1180 keychainServiceName?: string;384 keychainServiceName?: string;
1181} = {}) =>385}) =>
1182 Layer.effect(386 Layer.succeed(
1183 SecretMaterialStorerService,387 SecretMaterialStorerService,
1184 Effect.gen(function* () {388 createDefaultSecretMaterialStorer(input),
1185 const executorState = yield* ExecutorStateStore;
1186
1187 return createDefaultSecretMaterialStorer({
1188 executorState,
1189 dangerouslyAllowEnvSecrets: input.dangerouslyAllowEnvSecrets,
1190 keychainServiceName: input.keychainServiceName,
1191 });
1192 }),
1193 );389 );
1194 390
1195export const SecretMaterialDeleterLive = (input: {391export const SecretMaterialDeleterLive = (input: {
1196 dangerouslyAllowEnvSecrets?: boolean;392 executorState: ExecutorStateStoreShape;
393 resolveSecretMaterial: ResolveSecretMaterial;
1197 keychainServiceName?: string;394 keychainServiceName?: string;
1198} = {}) =>395}) =>
1199 Layer.effect(396 Layer.succeed(
1200 SecretMaterialDeleterService,397 SecretMaterialDeleterService,
1201 Effect.gen(function* () {398 createDefaultSecretMaterialDeleter(input),
1202 const executorState = yield* ExecutorStateStore;
1203
1204 return createDefaultSecretMaterialDeleter({
1205 executorState,
1206 dangerouslyAllowEnvSecrets: input.dangerouslyAllowEnvSecrets,
1207 keychainServiceName: input.keychainServiceName,
1208 });
1209 }),
1210 );399 );
1211 400
1212export const SecretMaterialUpdaterLive = (input: {401export const SecretMaterialUpdaterLive = (input: {
1213 dangerouslyAllowEnvSecrets?: boolean;402 executorState: ExecutorStateStoreShape;
403 resolveSecretMaterial: ResolveSecretMaterial;
1214 keychainServiceName?: string;404 keychainServiceName?: string;
1215} = {}) =>405}) =>
1216 Layer.effect(406 Layer.succeed(
1217 SecretMaterialUpdaterService,407 SecretMaterialUpdaterService,
1218 Effect.gen(function* () {408 createDefaultSecretMaterialUpdater(input),
1219 const executorState = yield* ExecutorStateStore;
1220
1221 return createDefaultSecretMaterialUpdater({
1222 executorState,
1223 dangerouslyAllowEnvSecrets: input.dangerouslyAllowEnvSecrets,
1224 keychainServiceName: input.keychainServiceName,
1225 });
1226 }),
1227 );409 );
1228 410
1229export const SecretMaterialLive = (input: {411export const SecretMaterialLive = (input: {
412 executorState: ExecutorStateStoreShape;
1230 resolveSecretMaterial?: ResolveSecretMaterial;413 resolveSecretMaterial?: ResolveSecretMaterial;
1231 dangerouslyAllowEnvSecrets?: boolean;
1232 keychainServiceName?: string;414 keychainServiceName?: string;
1233 localConfig?: LocalExecutorConfig | null;415}) =>
1234 workspaceRoot?: string | null;
1235} = {}) =>
1236 Layer.mergeAll(416 Layer.mergeAll(
1237 SecretMaterialResolverLive(input),417 SecretMaterialResolverLive(input),
1238 SecretMaterialStorerLive(input),418 SecretMaterialStorerLive({
1239 SecretMaterialDeleterLive(input),419 executorState: input.executorState,
1240 SecretMaterialUpdaterLive(input),420 resolveSecretMaterial:
1241 );421 input.resolveSecretMaterial
1242 422 ?? createDefaultSecretMaterialResolver({
1243export const createLocalInstanceConfigResolver = (): ResolveInstanceConfig => () => {423 executorState: input.executorState,
1244 const explicitDefaultStoreProvider = parseSecretStoreProviderId(424 keychainServiceName: input.keychainServiceName,
1245 process.env[SECRET_STORE_PROVIDER_ENV],425 }),
426 keychainServiceName: input.keychainServiceName,
427 }),
428 SecretMaterialDeleterLive({
429 executorState: input.executorState,
430 resolveSecretMaterial:
431 input.resolveSecretMaterial
432 ?? createDefaultSecretMaterialResolver({
433 executorState: input.executorState,
434 keychainServiceName: input.keychainServiceName,
435 }),
436 keychainServiceName: input.keychainServiceName,
437 }),
438 SecretMaterialUpdaterLive({
439 executorState: input.executorState,
440 resolveSecretMaterial:
441 input.resolveSecretMaterial
442 ?? createDefaultSecretMaterialResolver({
443 executorState: input.executorState,
444 keychainServiceName: input.keychainServiceName,
445 }),
446 keychainServiceName: input.keychainServiceName,
447 }),
1246 );448 );
1247 const providers = [
1248 {
1249 id: LOCAL_SECRET_PROVIDER_ID,
1250 name: "Local store",
1251 canStore: true,
1252 },
1253 ];
1254 449
1255 if (process.platform === "darwin" || process.platform === "linux") {450export const LocalInstanceConfigLive = (input: {
1256 providers.push({451 executorState: ExecutorStateStoreShape;
1257 id: KEYCHAIN_SECRET_PROVIDER_ID,452}) =>
1258 name:453 Layer.succeed(
1259 process.platform === "darwin" ? "macOS Keychain" : "Desktop Keyring",454 LocalInstanceConfigService,
1260 canStore:455 createLocalInstanceConfigResolver(input),
1261 process.platform === "darwin" ||
1262 explicitDefaultStoreProvider === KEYCHAIN_SECRET_PROVIDER_ID,
1263 });
1264 }
1265
1266 providers.push({
1267 id: ENV_SECRET_PROVIDER_ID,
1268 name: "Environment variable",
1269 canStore: false,
1270 });
1271
1272 return resolveDefaultSecretStoreProviderId({
1273 storeProviderId: explicitDefaultStoreProvider ?? undefined,
1274 }).pipe(
1275 Effect.map((resolvedDefaultStoreProvider) => ({
1276 platform: process.platform,
1277 secretProviders: providers,
1278 defaultSecretStoreProvider: resolvedDefaultStoreProvider,
1279 })),
1280 );456 );
1281};
1282
1283export const LocalInstanceConfigLive = () =>
1284 Layer.succeed(LocalInstanceConfigService, createLocalInstanceConfigResolver());
packages/platform/sdk/src/backend.ts+8−0
@@ -168,6 +168,14 @@ const toSourceTypeDeclarationsBackend = ( · 167 unmodified lines
168const toSecretsBackend = (168const toSecretsBackend = (
169 input: ExecutorSecretRepository,169 input: ExecutorSecretRepository,
170): RuntimeSecretsStorageServices => ({170): RuntimeSecretsStorageServices => ({
171 secretStores: {
172 getById: (id) => toOptionEffect(input.secretStores.getById(id)),
173 listAll: () => toEffect(input.secretStores.listAll()),
174 upsert: (store) => toEffect(input.secretStores.upsert(store)),
175 updateById: (id, patch) =>
176 toOptionEffect(input.secretStores.updateById(id, patch)),
177 removeById: (id) => toEffect(input.secretStores.removeById(id)),
178 },
171 getById: (id) => toOptionEffect(input.getById(id)),179 getById: (id) => toOptionEffect(input.getById(id)),
172 listAll: () => toEffect(input.listAll()),180 listAll: () => toEffect(input.listAll()),
173 upsert: (material) => toEffect(input.upsert(material)),181 upsert: (material) => toEffect(input.upsert(material)),
packages/platform/sdk/src/executor-effect.ts+85−0
@@ -4,6 +4,7 @@ import type { · 3 unmodified lines
4 ScopeId,4 ScopeId,
5 Execution,5 Execution,
6 LocalInstallation,6 LocalInstallation,
7 SecretStore,
7 Source,8 Source,
8} from "./schema";9} from "./schema";
9import type {10import type {
@@ -11,13 +12,28 @@ import type { · 1 unmodified line
11 ResumeExecutionPayload,12 ResumeExecutionPayload,
12} from "./executions/contracts";13} from "./executions/contracts";
13import type {14import type {
15 BrowseSecretStorePayload,
16 BrowseSecretStoreResult,
14 CreateSecretPayload,17 CreateSecretPayload,
18 CreateSecretStorePayload,
19 CreateSecretResult,
15 DeleteSecretResult,20 DeleteSecretResult,
21 DeleteSecretStoreResult,
22 ImportSecretFromStorePayload,
16 UpdateSecretPayload,23 UpdateSecretPayload,
24 UpdateSecretStorePayload,
17} from "./local/contracts";25} from "./local/contracts";
18import {26import {
19 getLocalInstallation,27 getLocalInstallation,
20} from "./local/operations";28} from "./local/operations";
29import {
30 createLocalSecretStore,
31 browseLocalSecretStore,
32 deleteLocalSecretStore,
33 importLocalSecretFromStore,
34 listLocalSecretStores,
35 updateLocalSecretStore,
36} from "./local/secret-stores";
21import {37import {
22 createLocalSecret,38 createLocalSecret,
23 deleteLocalSecret,39 deleteLocalSecret,
@@ -49,6 +65,12 @@ import { · 25 unmodified lines
49 removeSource,65 removeSource,
50 saveManagedSourceRecord,66 saveManagedSourceRecord,
51} from "./sources/operations";67} from "./sources/operations";
68import {
69 createManagedSecretStoreRecord,
70 getManagedSecretStore,
71 removeManagedSecretStoreRecord,
72 saveManagedSecretStoreRecord,
73} from "./runtime/secret-stores";
52import type { ExecutorBackend } from "./backend";74import type { ExecutorBackend } from "./backend";
53import {75import {
54 provideExecutorRuntime,76 provideExecutorRuntime,
@@ -93,6 +115,33 @@ export type ExecutorEffect = { · 38 unmodified lines
93 installation: () => ProvidedEffect<ReturnType<typeof getLocalInstallation>>;115 installation: () => ProvidedEffect<ReturnType<typeof getLocalInstallation>>;
94 config: () => ProvidedEffect<ReturnType<typeof getLocalInstanceConfig>>;116 config: () => ProvidedEffect<ReturnType<typeof getLocalInstanceConfig>>;
95 };117 };
118 secretStores: {
119 list: () => ProvidedEffect<ReturnType<typeof listLocalSecretStores>>;
120 create: (
121 payload: CreateSecretStorePayload,
122 ) => ProvidedEffect<ReturnType<typeof createLocalSecretStore>>;
123 update: (input: {
124 storeId: string;
125 payload: UpdateSecretStorePayload;
126 }) => ProvidedEffect<ReturnType<typeof updateLocalSecretStore>>;
127 browse: (input: {
128 storeId: string;
129 payload: BrowseSecretStorePayload;
130 }) => ProvidedEffect<ReturnType<typeof browseLocalSecretStore>>;
131 import: (input: {
132 storeId: string;
133 payload: ImportSecretFromStorePayload;
134 }) => MappedProvidedEffect<
135 ReturnType<typeof importLocalSecretFromStore>,
136 CreateSecretResult
137 >;
138 remove: (
139 storeId: string,
140 ) => MappedProvidedEffect<
141 ReturnType<typeof deleteLocalSecretStore>,
142 DeleteSecretStoreResult
143 >;
144 };
96 secrets: {145 secrets: {
97 list: () => ProvidedEffect<ReturnType<typeof listLocalSecrets>>;146 list: () => ProvidedEffect<ReturnType<typeof listLocalSecrets>>;
98 create: (147 create: (
@@ -189,6 +238,14 @@ const fromRuntime = (runtime: ExecutorRuntime): ExecutorEffect => { · 90 unmodified lines
189 installation: () => provide(getLocalInstallation()),238 installation: () => provide(getLocalInstallation()),
190 config: () => provide(getLocalInstanceConfig()),239 config: () => provide(getLocalInstanceConfig()),
191 },240 },
241 secretStores: {
242 list: () => provide(listLocalSecretStores()),
243 create: (payload) => provide(createLocalSecretStore(payload)),
244 update: (input) => provide(updateLocalSecretStore(input)),
245 browse: (input) => provide(browseLocalSecretStore(input)),
246 import: (input) => provide(importLocalSecretFromStore(input)),
247 remove: (storeId) => provide(deleteLocalSecretStore(storeId)),
248 },
192 secrets: {249 secrets: {
193 list: () => provide(listLocalSecrets()),250 list: () => provide(listLocalSecrets()),
194 create: (payload) => provide(createLocalSecret(payload)),251 create: (payload) => provide(createLocalSecret(payload)),
@@ -355,6 +412,34 @@ export const createExecutorEffect = < · 160 unmodified lines
355 }).pipe(Effect.map((result) => result.removed)),412 }).pipe(Effect.map((result) => result.removed)),
356 ),413 ),
357 },414 },
415 secretStores: {
416 create: ({
417 store,
418 }: {
419 store: Omit<
420 SecretStore,
421 "id" | "scopeId" | "createdAt" | "updatedAt"
422 >;
423 }) =>
424 providePluginHostEffect(
425 createManagedSecretStoreRecord({
426 scopeId: executor.scopeId,
427 store,
428 }),
429 ),
430 get: (storeId: SecretStore["id"]) =>
431 providePluginHostEffect(
432 getManagedSecretStore(storeId),
433 ),
434 save: (store: SecretStore) =>
435 providePluginHostEffect(
436 saveManagedSecretStoreRecord(store),
437 ),
438 remove: (storeId: SecretStore["id"]) =>
439 providePluginHostEffect(
440 removeManagedSecretStoreRecord(storeId),
441 ),
442 },
358 };443 };
359 const host: ExecutorSdkPluginHost = sourceHost;444 const host: ExecutorSdkPluginHost = sourceHost;
360 const extensions = Object.fromEntries(445 const extensions = Object.fromEntries(
packages/platform/sdk/src/executor.ts+10−0
@@ -80,6 +80,15 @@ const toPromiseExecutor = < · 79 unmodified lines
80 installation: () => run(executor.local.installation()),80 installation: () => run(executor.local.installation()),
81 config: () => run(executor.local.config()),81 config: () => run(executor.local.config()),
82 },82 },
83 secretStores: {
84 list: () => run(executor.secretStores.list()),
85 create: (payload: Parameters<typeof executor.secretStores.create>[0]) =>
86 run(executor.secretStores.create(payload)),
87 update: (input: Parameters<typeof executor.secretStores.update>[0]) =>
88 run(executor.secretStores.update(input)),
89 remove: (storeId: Parameters<typeof executor.secretStores.remove>[0]) =>
90 run(executor.secretStores.remove(storeId)),
91 },
83 secrets: {92 secrets: {
84 list: () => run(executor.secrets.list()),93 list: () => run(executor.secrets.list()),
85 create: (payload: Parameters<typeof executor.secrets.create>[0]) =>94 create: (payload: Parameters<typeof executor.secrets.create>[0]) =>
@@ -142,6 +151,7 @@ const toPromiseExecutor = < · 56 unmodified lines
142 "resolutionScopeIds",151 "resolutionScopeIds",
143 "close",152 "close",
144 "local",153 "local",
154 "secretStores",
145 "secrets",155 "secrets",
146 "policies",156 "policies",
147 "sources",157 "sources",
packages/platform/sdk/src/local/contracts.ts+86−9
@@ -4,15 +4,15 @@ import { · 3 unmodified lines
4import * as Schema from "effect/Schema";4import * as Schema from "effect/Schema";
5 5
6export const SecretProviderSchema = Schema.Struct({6export const SecretProviderSchema = Schema.Struct({
7 id: Schema.String,7 kind: Schema.String,
8 name: Schema.String,8 displayName: Schema.String,
9 canStore: Schema.Boolean,9 canCreate: Schema.Boolean,
10});10});
11 11
12export const InstanceConfigSchema = Schema.Struct({12export const InstanceConfigSchema = Schema.Struct({
13 platform: Schema.String,13 platform: Schema.String,
14 secretProviders: Schema.Array(SecretProviderSchema),14 secretStorePlugins: Schema.Array(SecretProviderSchema),
15 defaultSecretStoreProvider: Schema.String,15 defaultSecretStoreId: Schema.NullOr(Schema.String),
16});16});
17 17
18export type SecretProvider = typeof SecretProviderSchema.Type;18export type SecretProvider = typeof SecretProviderSchema.Type;
@@ -25,9 +25,21 @@ export const SecretLinkedSourceSchema = Schema.Struct({ · 6 unmodified lines
25 25
26export type SecretLinkedSource = typeof SecretLinkedSourceSchema.Type;26export type SecretLinkedSource = typeof SecretLinkedSourceSchema.Type;
27 27
28export 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
36export type SecretStoreCapabilities = typeof SecretStoreCapabilitiesSchema.Type;
37
28export const SecretListItemSchema = Schema.Struct({38export const SecretListItemSchema = Schema.Struct({
29 id: Schema.String,39 id: Schema.String,
30 providerId: Schema.String,40 storeId: Schema.String,
41 storeName: Schema.String,
42 storeKind: Schema.String,
31 name: Schema.NullOr(Schema.String),43 name: Schema.NullOr(Schema.String),
32 purpose: Schema.String,44 purpose: Schema.String,
33 createdAt: Schema.Number,45 createdAt: Schema.Number,
@@ -41,7 +53,7 @@ export const CreateSecretPayloadSchema = Schema.Struct({ · 7 unmodified lines
41 name: Schema.String,53 name: Schema.String,
42 value: Schema.String,54 value: Schema.String,
43 purpose: Schema.optional(SecretMaterialPurposeSchema),55 purpose: Schema.optional(SecretMaterialPurposeSchema),
44 providerId: Schema.optional(Schema.String),56 storeId: Schema.optional(Schema.String),
45});57});
46 58
47export type CreateSecretPayload = typeof CreateSecretPayloadSchema.Type;59export type CreateSecretPayload = typeof CreateSecretPayloadSchema.Type;
@@ -49,7 +61,7 @@ export type CreateSecretPayload = typeof CreateSecretPayloadSchema.Type; · 1 unmodified line
49export const CreateSecretResultSchema = Schema.Struct({61export const CreateSecretResultSchema = Schema.Struct({
50 id: Schema.String,62 id: Schema.String,
51 name: Schema.NullOr(Schema.String),63 name: Schema.NullOr(Schema.String),
52 providerId: Schema.String,64 storeId: Schema.String,
53 purpose: Schema.String,65 purpose: Schema.String,
54 createdAt: Schema.Number,66 createdAt: Schema.Number,
55 updatedAt: Schema.Number,67 updatedAt: Schema.Number,
@@ -66,13 +78,78 @@ export type UpdateSecretPayload = typeof UpdateSecretPayloadSchema.Type; · 10 unmodified lines
66 78
67export const UpdateSecretResultSchema = Schema.Struct({79export const UpdateSecretResultSchema = Schema.Struct({
68 id: Schema.String,80 id: Schema.String,
69 providerId: Schema.String,81 storeId: Schema.String,
70 name: Schema.NullOr(Schema.String),82 name: Schema.NullOr(Schema.String),
71 purpose: Schema.String,83 purpose: Schema.String,
72 createdAt: Schema.Number,84 createdAt: Schema.Number,
73 updatedAt: Schema.Number,85 updatedAt: Schema.Number,
74});86});
75 87
88export 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
99export type SecretStore = typeof SecretStoreSchema.Type;
100
101export 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
108export type SecretStoreBrowseEntry = typeof SecretStoreBrowseEntrySchema.Type;
109
110export const BrowseSecretStorePayloadSchema = Schema.Struct({
111 parentKey: Schema.optional(Schema.String),
112 query: Schema.optional(Schema.String),
113});
114
115export type BrowseSecretStorePayload = typeof BrowseSecretStorePayloadSchema.Type;
116
117export const BrowseSecretStoreResultSchema = Schema.Struct({
118 entries: Schema.Array(SecretStoreBrowseEntrySchema),
119});
120
121export type BrowseSecretStoreResult = typeof BrowseSecretStoreResultSchema.Type;
122
123export const ImportSecretFromStorePayloadSchema = Schema.Struct({
124 selectionKey: Schema.String,
125 name: Schema.optional(Schema.String),
126 purpose: Schema.optional(SecretMaterialPurposeSchema),
127});
128
129export type ImportSecretFromStorePayload =
130 typeof ImportSecretFromStorePayloadSchema.Type;
131
132export const CreateSecretStorePayloadSchema = Schema.Struct({
133 kind: Schema.String,
134 name: Schema.String,
135 config: Schema.Unknown,
136});
137
138export type CreateSecretStorePayload = typeof CreateSecretStorePayloadSchema.Type;
139
140export const UpdateSecretStorePayloadSchema = Schema.Struct({
141 name: Schema.optional(Schema.String),
142 config: Schema.optional(Schema.Unknown),
143});
144
145export type UpdateSecretStorePayload = typeof UpdateSecretStorePayloadSchema.Type;
146
147export const DeleteSecretStoreResultSchema = Schema.Struct({
148 removed: Schema.Boolean,
149});
150
151export type DeleteSecretStoreResult = typeof DeleteSecretStoreResultSchema.Type;
152
76export type UpdateSecretResult = typeof UpdateSecretResultSchema.Type;153export type UpdateSecretResult = typeof UpdateSecretResultSchema.Type;
77 154
78export const DeleteSecretResultSchema = Schema.Struct({155export const DeleteSecretResultSchema = Schema.Struct({
packages/platform/sdk/src/local/secret-stores.ts+441−0
@@ -0,0 +1,441 @@
1import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";
3
4import {
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";
15import {
16 ControlPlaneBadRequestError,
17 ControlPlaneNotFoundError,
18 ControlPlaneStorageError,
19} from "../errors";
20import {
21 createManagedSecretStoreRecord,
22 getManagedSecretStore,
23 getSecretStoreContribution,
24 listManagedSecretStores,
25 removeManagedSecretStoreRecord,
26 saveManagedSecretStoreRecord,
27} from "../runtime";
28import {
29 ExecutorStateStore,
30} from "../runtime/executor-state-store";
31import {
32 requireRuntimeLocalScope,
33} from "../runtime/scope/runtime-context";
34import type {
35 SecretMaterial,
36 SecretMaterialPurpose,
37 SecretStore,
38} from "../schema";
39import {
40 SecretMaterialIdSchema,
41} from "../schema";
42
43const secretStoreStorageError = (operation: string, message: string) =>
44 new ControlPlaneStorageError({
45 operation,
46 message,
47 details: message,
48 });
49
50const 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
73const 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
78const 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
86const defaultCapabilities: SecretStoreCapabilities = {
87 canCreateSecrets: false,
88 canUpdateSecrets: false,
89 canDeleteSecrets: false,
90 canBrowseSecrets: false,
91 canImportSecrets: false,
92};
93
94const 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
115const toSecretStoreContract = (
116 store: SecretStore,
117)=>
118 Effect.map(loadStoreCapabilities(store), (capabilities) => ({
119 ...store,
120 capabilities,
121 }));
122
123const 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
156export 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
176export 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
234export 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
309export 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
347export 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
387export 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
3 3
4import {4import {
5 SecretMaterialIdSchema,5 SecretMaterialIdSchema,
6 type SecretStore,
6} from "../schema";7} from "../schema";
7import {8import {
8 type CreateSecretPayload,9 type CreateSecretPayload,
@@ -72,8 +73,21 @@ export const listLocalSecrets = () => · 63 unmodified lines
72 ),73 ),
73 );74 );
74 75
76 const secretStores = yield* store.secretStores
77 .listAll()
78 .pipe(
79 Effect.mapError(() =>
80 secretStorageError("secrets.list", "Failed listing secret stores."),
81 ),
82 );
83 const secretStoresById = new Map(
84 secretStores.map((secretStore) => [secretStore.id, secretStore]),
85 );
86
75 return secretMaterials.map((row) => ({87 return secretMaterials.map((row) => ({
76 ...row,88 ...row,
89 storeName: secretStoresById.get(row.storeId)?.name ?? row.storeId,
90 storeKind: secretStoresById.get(row.storeId)?.kind ?? "unknown",
77 linkedSources: linkedSourcesMap.get(row.id) ?? [],91 linkedSources: linkedSourcesMap.get(row.id) ?? [],
78 }));92 }));
79 });93 });
@@ -98,7 +112,7 @@ export const createLocalSecret = (payload: CreateSecretPayload) => · 18 unmodified lines
98 name,112 name,
99 purpose,113 purpose,
100 value,114 value,
101 providerId: payload.providerId,115 storeId: payload.storeId,
102 }).pipe(116 }).pipe(
103 Effect.mapError((cause) =>117 Effect.mapError((cause) =>
104 secretStorageError(118 secretStorageError(
@@ -107,7 +121,7 @@ export const createLocalSecret = (payload: CreateSecretPayload) => · 2 unmodified lines
107 ),121 ),
108 ),122 ),
109 );123 );
110 const secretId = SecretMaterialIdSchema.make(ref.handle);124 const secretId = SecretMaterialIdSchema.make(ref.secretId);
111 const created = yield* store.secretMaterials125 const created = yield* store.secretMaterials
112 .getById(secretId)126 .getById(secretId)
113 .pipe(127 .pipe(
@@ -122,14 +136,14 @@ export const createLocalSecret = (payload: CreateSecretPayload) => · 8 unmodified lines
122 if (Option.isNone(created)) {136 if (Option.isNone(created)) {
123 return yield* secretStorageError(137 return yield* secretStorageError(
124 "secrets.create",138 "secrets.create",
125 `Created secret not found: ${ref.handle}`,139 `Created secret not found: ${ref.secretId}`,
126 );140 );
127 }141 }
128 142
129 return {143 return {
130 id: created.value.id,144 id: created.value.id,
131 name: created.value.name,145 name: created.value.name,
132 providerId: created.value.providerId,146 storeId: created.value.storeId,
133 purpose: created.value.purpose,147 purpose: created.value.purpose,
134 createdAt: created.value.createdAt,148 createdAt: created.value.createdAt,
135 updatedAt: created.value.updatedAt,149 updatedAt: created.value.updatedAt,
@@ -168,8 +182,7 @@ export const updateLocalSecret = (input: { · 32 unmodified lines
168 const updateSecretMaterial = yield* SecretMaterialUpdaterService;182 const updateSecretMaterial = yield* SecretMaterialUpdaterService;
169 const updated = yield* updateSecretMaterial({183 const updated = yield* updateSecretMaterial({
170 ref: {184 ref: {
171 providerId: existing.value.providerId,185 secretId: existing.value.id,
172 handle: existing.value.id,
173 },186 },
174 ...update,187 ...update,
175 }).pipe(188 }).pipe(
@@ -180,7 +193,7 @@ export const updateLocalSecret = (input: { · 4 unmodified lines
180 193
181 return {194 return {
182 id: updated.id,195 id: updated.id,
183 providerId: updated.providerId,196 storeId: updated.storeId,
184 name: updated.name,197 name: updated.name,
185 purpose: updated.purpose,198 purpose: updated.purpose,
186 createdAt: updated.createdAt,199 createdAt: updated.createdAt,
@@ -211,8 +224,7 @@ export const deleteLocalSecret = (secretId: string) => · 24 unmodified lines
211 224
212 const deleteSecretMaterial = yield* SecretMaterialDeleterService;225 const deleteSecretMaterial = yield* SecretMaterialDeleterService;
213 const removed = yield* deleteSecretMaterial({226 const removed = yield* deleteSecretMaterial({
214 providerId: existing.value.providerId,227 secretId: existing.value.id,
215 handle: existing.value.id,
216 }).pipe(228 }).pipe(
217 Effect.mapError(() =>229 Effect.mapError(() =>
218 secretStorageError("secrets.delete", "Failed removing secret."),230 secretStorageError("secrets.delete", "Failed removing secret."),
packages/platform/sdk/src/plugins.ts+705−1
@@ -6,7 +6,13 @@ import type { · 5 unmodified lines
6} from "@executor/source-core";6} from "@executor/source-core";
7import type { ExecutorEffect } from "./executor-effect";7import type { ExecutorEffect } from "./executor-effect";
8import type { ExecutorScopeContext } from "./scope";8import type { ExecutorScopeContext } from "./scope";
9import type { Source as ExecutorSource } from "./schema";9import type {
10 Source as ExecutorSource,
11 SecretMaterial,
12 SecretMaterialPurpose,
13 SecretRef,
14 SecretStore,
15} from "./schema";
10import type {16import type {
11 SourceInvokeInput,17 SourceInvokeInput,
12 SourceInvokeResult,18 SourceInvokeResult,
@@ -34,6 +40,7 @@ export type ExecutorSdkPluginStartContext< · 21 unmodified lines
34 40
35type ExecutorSdkPluginInternals = {41type ExecutorSdkPluginInternals = {
36 sources?: readonly ExecutorSourceContribution<any>[];42 sources?: readonly ExecutorSourceContribution<any>[];
43 secretStores?: readonly ExecutorSecretStoreContribution<any>[];
37};44};
38 45
39const executorSdkPluginInternalsSymbol = Symbol.for(46const executorSdkPluginInternalsSymbol = Symbol.for(
@@ -78,6 +85,20 @@ type ExecutorSourcePluginInternalHost = { · 38 unmodified lines
78 };85 };
79};86};
80 87
88type 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
81export type ExecutorSourcePluginStorage<TStored> = {102export type ExecutorSourcePluginStorage<TStored> = {
82 get: (input: {103 get: (input: {
83 scopeId: ExecutorSource["scopeId"];104 scopeId: ExecutorSource["scopeId"];
@@ -94,6 +115,22 @@ export type ExecutorSourcePluginStorage<TStored> = { · 10 unmodified lines
94 }) => Effect.Effect<void, Error, any>;115 }) => Effect.Effect<void, Error, any>;
95};116};
96 117
118export 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
97export type ExecutorSourcePluginApi<134export type ExecutorSourcePluginApi<
98 TConnectInput,135 TConnectInput,
99 TSourceConfig,136 TSourceConfig,
@@ -122,6 +159,31 @@ export type ExecutorSourcePluginApi< · 22 unmodified lines
122 ) => Effect.Effect<boolean, Error, any>;159 ) => Effect.Effect<boolean, Error, any>;
123};160};
124 161
162export 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
125export type ExecutorSourcePluginDefinition<187export type ExecutorSourcePluginDefinition<
126 TAddInput,188 TAddInput,
127 TConnectInput,189 TConnectInput,
@@ -183,6 +245,135 @@ export type ExecutorSourcePluginDefinition< · 55 unmodified lines
183 };245 };
184};246};
185 247
248export type ExecutorSecretStorePluginCapabilities = {
249 canCreateSecrets: boolean;
250 canUpdateSecrets: boolean;
251 canDeleteSecrets: boolean;
252 canBrowseSecrets: boolean;
253 canImportSecrets: boolean;
254};
255
256export type ExecutorSecretStoreBrowseEntry = {
257 key: string;
258 label: string;
259 description: string | null;
260 kind: "group" | "secret";
261};
262
263export type ExecutorSecretStoreBuiltinDefinition = {
264 storeId: string;
265 defaultPriority?: number;
266 enabled?: () => boolean;
267 createStore: () => Omit<
268 SecretStore,
269 "id" | "scopeId" | "createdAt" | "updatedAt"
270 >;
271};
272
273export 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
186export type ExecutorSourcePluginInput<377export type ExecutorSourcePluginInput<
187 TKey extends string = string,378 TKey extends string = string,
188 TAddInput = unknown,379 TAddInput = unknown,
@@ -220,6 +411,43 @@ export type ExecutorSourcePluginInput< · 31 unmodified lines
220 ) => Effect.Effect<PluginCleanup | void, Error, any>;411 ) => Effect.Effect<PluginCleanup | void, Error, any>;
221};412};
222 413
414export 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
223const loadSourceOfKind = (451const loadSourceOfKind = (
224 sourceId: ExecutorSource["id"],452 sourceId: ExecutorSource["id"],
225 input: {453 input: {
@@ -358,6 +586,135 @@ const createExecutorSourcePluginApi = < · 132 unmodified lines
358 }),586 }),
359});587});
360 588
589const 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
608const 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
361type ExecutorSourceContribution<TInput = unknown> = {718type ExecutorSourceContribution<TInput = unknown> = {
362 kind: string;719 kind: string;
363 displayName: string;720 displayName: string;
@@ -382,6 +739,83 @@ type ExecutorSourceContribution<TInput = unknown> = { · 18 unmodified lines
382 ) => Effect.Effect<SourceInvokeResult, Error, any>;739 ) => Effect.Effect<SourceInvokeResult, Error, any>;
383};740};
384 741
742type 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
385const createExecutorSourceContribution = <819const createExecutorSourceContribution = <
386 TAddInput,820 TAddInput,
387 TConnectInput,821 TConnectInput,
@@ -437,6 +871,199 @@ const createExecutorSourceContribution = < · 49 unmodified lines
437 ),871 ),
438});872});
439 873
874const 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
440export const defineExecutorSourcePlugin = <1067export const defineExecutorSourcePlugin = <
441 const TKey extends string,1068 const TKey extends string,
442 TAddInput,1069 TAddInput,
@@ -487,6 +1114,56 @@ export const defineExecutorSourcePlugin = < · 44 unmodified lines
487 },1114 },
488 }))(input.extendExecutor, input.start);1115 }))(input.extendExecutor, input.start);
489 1116
1117export 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
490export type ExecutorSdkPluginExtensions<1167export type ExecutorSdkPluginExtensions<
491 TPlugins extends readonly ExecutorSdkPlugin<any, any>[],1168 TPlugins extends readonly ExecutorSdkPlugin<any, any>[],
492> = {1169> = {
@@ -501,6 +1178,7 @@ export const registerExecutorSdkPlugins = ( · 8 unmodified lines
501) => {1178) => {
502 const pluginKeys = new Set<string>();1179 const pluginKeys = new Set<string>();
503 const sources = new Map<string, ExecutorSourceContribution<any>>();1180 const sources = new Map<string, ExecutorSourceContribution<any>>();
1181 const secretStores = new Map<string, ExecutorSecretStoreContribution<any>>();
504 1182
505 for (const plugin of plugins) {1183 for (const plugin of plugins) {
506 if (pluginKeys.has(plugin.key)) {1184 if (pluginKeys.has(plugin.key)) {
@@ -520,6 +1198,16 @@ export const registerExecutorSdkPlugins = ( · 13 unmodified lines
520 1198
521 sources.set(source.kind, source);1199 sources.set(source.kind, source);
522 }1200 }
1201
1202 for (const secretStore of internals?.secretStores ?? []) {
1203 if (secretStores.has(secretStore.kind)) {
1204 throw new Error(
1205 `Duplicate secret store registration: ${secretStore.kind}`,
1206 );
1207 }
1208
1209 secretStores.set(secretStore.kind, secretStore);
1210 }
523 }1211 }
524 1212
525 const getSourceContribution = (kind: string) => {1213 const getSourceContribution = (kind: string) => {
@@ -534,10 +1222,26 @@ export const registerExecutorSdkPlugins = ( · 8 unmodified lines
534 const getSourceContributionForSource = (source: Pick<ExecutorSource, "kind">) =>1222 const getSourceContributionForSource = (source: Pick<ExecutorSource, "kind">) =>
535 getSourceContribution(source.kind);1223 getSourceContribution(source.kind);
536 1224
1225 const getSecretStoreContribution = (kind: string) => {
1226 const definition = secretStores.get(kind);
1227 if (!definition) {
1228 throw new Error(`Unsupported secret store kind: ${kind}`);
1229 }
1230
1231 return definition;
1232 };
1233
1234 const getSecretStoreContributionForStore = (
1235 store: Pick<SecretStore, "kind">,
1236 ) => getSecretStoreContribution(store.kind);
1237
537 return {1238 return {
538 plugins,1239 plugins,
539 sources: [...sources.values()],1240 sources: [...sources.values()],
1241 secretStores: [...secretStores.values()],
540 getSourceContribution,1242 getSourceContribution,
541 getSourceContributionForSource,1243 getSourceContributionForSource,
1244 getSecretStoreContribution,
1245 getSecretStoreContributionForStore,
542 };1246 };
543};1247};
packages/platform/sdk/src/runtime/execution/scope/tool-invoker.ts+6−1
@@ -48,6 +48,9 @@ import { · 47 unmodified lines
48import {48import {
49 createExecutorToolMap,49 createExecutorToolMap,
50} from "../../sources/executor-tools";50} from "../../sources/executor-tools";
51import {
52 registeredSourceContributions,
53} from "../../sources/source-plugins";
51import {54import {
52 RuntimeSourceCatalogSyncService,55 RuntimeSourceCatalogSyncService,
53} from "../../catalog/source/sync";56} from "../../catalog/source/sync";
@@ -147,10 +150,12 @@ export const createScopeToolInvoker = (input: { · 93 unmodified lines
147 return catalog;150 return catalog;
148 },151 },
149 });152 });
153 const hasLocalToolsSourceContribution = registeredSourceContributions()
154 .some((source) => source.kind === "local-tools");
150 const authoredTools = mergeToolMaps([155 const authoredTools = mergeToolMaps([
151 systemTools,156 systemTools,
152 executorTools,157 executorTools,
153 input.localToolRuntime.tools,158 ...(hasLocalToolsSourceContribution ? [] : [input.localToolRuntime.tools]),
154 ]);159 ]);
155 const authoredCatalog = createToolCatalogFromTools({160 const authoredCatalog = createToolCatalogFromTools({
156 tools: authoredTools,161 tools: authoredTools,
packages/platform/sdk/src/runtime/executor-state-store.ts+31−1
@@ -6,18 +6,48 @@ import type { · 5 unmodified lines
6 ExecutionInteraction,6 ExecutionInteraction,
7 ExecutionStep,7 ExecutionStep,
8 SecretMaterial,8 SecretMaterial,
9 SecretStore,
9} from "#schema";10} from "#schema";
10 11
11type SecretMaterialSummary = {12type SecretMaterialSummary = {
12 id: string;13 id: string;
13 providerId: string;14 storeId: string;
14 name: string | null;15 name: string | null;
15 purpose: string;16 purpose: string;
16 createdAt: number;17 createdAt: number;
17 updatedAt: number;18 updatedAt: number;
18};19};
19 20
21type 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
20export type ExecutorStateStoreShape = {32export type ExecutorStateStoreShape = {
33 secretStores: {
34 getById: (
35 id: SecretStore["id"],
36 ) => Effect.Effect<import("effect/Option").Option<SecretStore>, Error, never>;
37 listAll: () => Effect.Effect<readonly SecretStoreSummary[], Error, never>;
38 upsert: (store: SecretStore) => Effect.Effect<void, Error, never>;
39 updateById: (
40 id: SecretStore["id"],
41 update: Partial<
42 Pick<SecretStore, "name" | "status" | "enabled">
43 >,
44 ) => Effect.Effect<
45 import("effect/Option").Option<SecretStoreSummary>,
46 Error,
47 never
48 >;
49 removeById: (id: SecretStore["id"]) => Effect.Effect<boolean, Error, never>;
50 };
21 secretMaterials: {51 secretMaterials: {
22 getById: (52 getById: (
23 id: SecretMaterial["id"],53 id: SecretMaterial["id"],
packages/platform/sdk/src/runtime/index.ts+8−0
@@ -89,6 +89,7 @@ import { · 88 unmodified lines
89} from "./sources/source-store";89} from "./sources/source-store";
90 90
91export * from "./execution/state";91export * from "./execution/state";
92export * from "./secret-stores";
92export * from "./sources/executor-tools";93export * from "./sources/executor-tools";
93export * from "./execution/live";94export * from "./execution/live";
94export * from "./catalog/schema-type-signature";95export * from "./catalog/schema-type-signature";
@@ -137,9 +138,12 @@ export type { · 42 unmodified lines
137} from "./local-tool-runtime";138} from "./local-tool-runtime";
138export {139export {
139 registeredSourceContributions,140 registeredSourceContributions,
141 registeredSecretStoreContributions,
140 hasRegisteredExternalSourcePlugins,142 hasRegisteredExternalSourcePlugins,
141 getSourceContribution,143 getSourceContribution,
142 getSourceContributionForSource,144 getSourceContributionForSource,
145 getSecretStoreContribution,
146 getSecretStoreContributionForStore,
143} from "./sources/source-plugins";147} from "./sources/source-plugins";
144 148
145export type ExecutorRuntimeOptions = {149export type ExecutorRuntimeOptions = {
@@ -219,6 +223,9 @@ export const prewarmWorkspaceSourceCatalogToolIndex = (input: { · 73 unmodified lines
219 });223 });
220 224
221export type RuntimeSecretsStorageServices =225export type RuntimeSecretsStorageServices =
226 & {
227 secretStores: ExecutorStateStoreShape["secretStores"];
228 }
222 & ExecutorStateStoreShape["secretMaterials"]229 & ExecutorStateStoreShape["secretMaterials"]
223 & RuntimeSecretMaterialServices;230 & RuntimeSecretMaterialServices;
224 231
@@ -310,6 +317,7 @@ const makeInstanceConfigLayer = (input: RuntimeInstanceConfigService) => · 85 unmodified lines
310const toExecutorStateStoreShape = (317const toExecutorStateStoreShape = (
311 input: RuntimeStorageServices,318 input: RuntimeStorageServices,
312): ExecutorStateStoreShape => ({319): ExecutorStateStoreShape => ({
320 secretStores: input.secrets.secretStores,
313 secretMaterials: input.secrets,321 secretMaterials: input.secrets,
314 executions: input.executions.runs,322 executions: input.executions.runs,
315 executionInteractions: input.executions.interactions,323 executionInteractions: input.executions.interactions,
packages/platform/sdk/src/runtime/scope/secret-material-providers.ts+2−2
@@ -22,7 +22,7 @@ export type StoreSecretMaterial = (input: { · 21 unmodified lines
22 purpose: SecretMaterialPurpose;22 purpose: SecretMaterialPurpose;
23 value: string;23 value: string;
24 name?: string | null;24 name?: string | null;
25 providerId?: string;25 storeId?: string;
26}) => Effect.Effect<SecretRef, Error, never>;26}) => Effect.Effect<SecretRef, Error, never>;
27 27
28export type DeleteSecretMaterial = (28export type DeleteSecretMaterial = (
@@ -35,7 +35,7 @@ export type UpdateSecretMaterial = (input: { · 6 unmodified lines
35 value?: string;35 value?: string;
36}) => Effect.Effect<{36}) => Effect.Effect<{
37 id: string;37 id: string;
38 providerId: string;38 storeId: string;
39 name: string | null;39 name: string | null;
40 purpose: string;40 purpose: string;
41 createdAt: number;41 createdAt: number;
packages/platform/sdk/src/runtime/secret-stores.ts+76−0
@@ -0,0 +1,76 @@
1import {
2 type ScopeId,
3 SecretStoreIdSchema,
4 type SecretStore,
5} from "#schema";
6import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";
8
9import {
10 ExecutorStateStore,
11} from "./executor-state-store";
12import {
13 runtimeEffectError,
14} from "./effect-errors";
15
16export const listManagedSecretStores = () =>
17 Effect.flatMap(ExecutorStateStore, (store) => store.secretStores.listAll());
18
19export 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
33export 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
57export 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
70export 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
18};18};
19 19
20export const registeredSourceContributions = () => registry.sources;20export const registeredSourceContributions = () => registry.sources;
21export const registeredSecretStoreContributions = () => registry.secretStores;
21 22
22export const getSourceContribution = (kind: string) =>23export const getSourceContribution = (kind: string) =>
23 registry.getSourceContribution(kind);24 registry.getSourceContribution(kind);
24export const getSourceContributionForSource = (25export const getSourceContributionForSource = (
25 source: Parameters<typeof registry.getSourceContributionForSource>[0],26 source: Parameters<typeof registry.getSourceContributionForSource>[0],
26) => registry.getSourceContributionForSource(source);27) => registry.getSourceContributionForSource(source);
28export const getSecretStoreContribution = (kind: string) =>
29 registry.getSecretStoreContribution(kind);
30export const getSecretStoreContributionForStore = (
31 store: Parameters<typeof registry.getSecretStoreContributionForStore>[0],
32) => registry.getSecretStoreContributionForStore(store);
27 33
28export const hasRegisteredExternalSourcePlugins = () =>34export const hasRegisteredExternalSourcePlugins = () =>
29 configuredSourcePlugins.length > 0;35 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
11export const SecretMaterialIdSchema = Schema.String.pipe(11export const SecretMaterialIdSchema = Schema.String.pipe(
12 Schema.brand("SecretMaterialId"),12 Schema.brand("SecretMaterialId"),
13);13);
14export const SecretStoreIdSchema = Schema.String.pipe(
15 Schema.brand("SecretStoreId"),
16);
14export const PolicyIdSchema = Schema.String.pipe(Schema.brand("PolicyId"));17export const PolicyIdSchema = Schema.String.pipe(Schema.brand("PolicyId"));
15export const ExecutionIdSchema = Schema.String.pipe(Schema.brand("ExecutionId"));18export const ExecutionIdSchema = Schema.String.pipe(Schema.brand("ExecutionId"));
16export const ExecutionInteractionIdSchema = Schema.String.pipe(19export const ExecutionInteractionIdSchema = Schema.String.pipe(
@@ -25,6 +28,7 @@ export type SourceId = typeof SourceIdSchema.Type; · 8 unmodified lines
25export type SourceCatalogId = typeof SourceCatalogIdSchema.Type;28export type SourceCatalogId = typeof SourceCatalogIdSchema.Type;
26export type SourceCatalogRevisionId = typeof SourceCatalogRevisionIdSchema.Type;29export type SourceCatalogRevisionId = typeof SourceCatalogRevisionIdSchema.Type;
27export type SecretMaterialId = typeof SecretMaterialIdSchema.Type;30export type SecretMaterialId = typeof SecretMaterialIdSchema.Type;
31export type SecretStoreId = typeof SecretStoreIdSchema.Type;
28export type PolicyId = typeof PolicyIdSchema.Type;32export type PolicyId = typeof PolicyIdSchema.Type;
29export type ExecutionId = typeof ExecutionIdSchema.Type;33export type ExecutionId = typeof ExecutionIdSchema.Type;
30export type ExecutionInteractionId = typeof ExecutionInteractionIdSchema.Type;34export 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
6export * from "./models/source-catalog";6export * from "./models/source-catalog";
7export * from "./models/secret-ref";7export * from "./models/secret-ref";
8export * from "./models/secret-material";8export * from "./models/secret-material";
9export * from "./models/secret-store";
9export * from "./models/policy";10export * from "./models/policy";
10export * from "./models/local-installation";11export * from "./models/local-installation";
11export * from "./models/local-config";12export * from "./models/local-config";
packages/platform/sdk/src/schema/models/secret-material.ts+2−1
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
7} from "../common";7} from "../common";
8import {8import {
9 SecretMaterialIdSchema,9 SecretMaterialIdSchema,
10 SecretStoreIdSchema,
10} from "../ids";11} from "../ids";
11 12
12export const SecretMaterialPurposeSchema = Schema.Literal(13export const SecretMaterialPurposeSchema = Schema.Literal(
@@ -20,7 +21,7 @@ export const SecretMaterialSchema = Schema.Struct({ · 7 unmodified lines
20 id: SecretMaterialIdSchema,21 id: SecretMaterialIdSchema,
21 name: Schema.NullOr(Schema.String),22 name: Schema.NullOr(Schema.String),
22 purpose: SecretMaterialPurposeSchema,23 purpose: SecretMaterialPurposeSchema,
23 providerId: Schema.String,24 storeId: SecretStoreIdSchema,
24 handle: Schema.String,25 handle: Schema.String,
25 value: Schema.NullOr(Schema.String),26 value: Schema.NullOr(Schema.String),
26 createdAt: TimestampMsSchema,27 createdAt: TimestampMsSchema,
packages/platform/sdk/src/schema/models/secret-ref.ts+4−2
@@ -1,8 +1,10 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2import {
3 SecretMaterialIdSchema,
4} from "../ids";
2 5
3export const SecretRefSchema = Schema.Struct({6export const SecretRefSchema = Schema.Struct({
4 providerId: Schema.String,7 secretId: SecretMaterialIdSchema,
5 handle: Schema.String,
6});8});
7 9
8export type SecretRef = typeof SecretRefSchema.Type;10export type SecretRef = typeof SecretRefSchema.Type;
packages/platform/sdk/src/schema/models/secret-store.ts+33−0
@@ -0,0 +1,33 @@
1import {
2 Schema,
3} from "effect";
4
5import {
6 TimestampMsSchema,
7} from "../common";
8import {
9 ScopeIdSchema,
10 SecretStoreIdSchema,
11} from "../ids";
12
13export const SecretStoreKindSchema = Schema.String;
14
15export const SecretStoreStatusSchema = Schema.Literal(
16 "connected",
17 "error",
18);
19
20export 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
31export type SecretStoreKind = typeof SecretStoreKindSchema.Type;
32export type SecretStoreStatus = typeof SecretStoreStatusSchema.Type;
33export 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
21 "@executor/plugin-graphql-http": "workspace:*",21 "@executor/plugin-graphql-http": "workspace:*",
22 "@executor/plugin-graphql-shared": "workspace:*",22 "@executor/plugin-graphql-shared": "workspace:*",
23 "@executor/plugin-graphql-sdk": "workspace:*",23 "@executor/plugin-graphql-sdk": "workspace:*",
24 "@executor/plugin-keychain-secret-store-sdk": "workspace:*",
25 "@executor/plugin-local-secret-store-sdk": "workspace:*",
26 "@executor/plugin-local-tools-sdk": "workspace:*",
24 "@executor/plugin-mcp-http": "workspace:*",27 "@executor/plugin-mcp-http": "workspace:*",
25 "@executor/plugin-mcp-shared": "workspace:*",28 "@executor/plugin-mcp-shared": "workspace:*",
26 "@executor/plugin-mcp-sdk": "workspace:*",29 "@executor/plugin-mcp-sdk": "workspace:*",
27 "@executor/plugin-openapi-http": "workspace:*",30 "@executor/plugin-openapi-http": "workspace:*",
28 "@executor/plugin-openapi-shared": "workspace:*",31 "@executor/plugin-openapi-shared": "workspace:*",
29 "@executor/plugin-openapi-sdk": "workspace:*",32 "@executor/plugin-openapi-sdk": "workspace:*",
33 "@executor/plugin-onepassword-http": "workspace:*",
34 "@executor/plugin-onepassword-sdk": "workspace:*",
35 "@executor/plugin-onepassword-shared": "workspace:*",
30 "@executor/platform-api": "workspace:*",36 "@executor/platform-api": "workspace:*",
31 "@executor/platform-sdk": "workspace:*",37 "@executor/platform-sdk": "workspace:*",
32 "@executor/platform-sdk-file": "workspace:*",38 "@executor/platform-sdk-file": "workspace:*",
packages/platform/server/src/index.test.ts+23−10
@@ -40,6 +40,7 @@ import { googleDiscoveryHttpPlugin } from "@executor/plugin-google-discovery-htt · 39 unmodified lines
40import { graphqlHttpPlugin } from "@executor/plugin-graphql-http";40import { graphqlHttpPlugin } from "@executor/plugin-graphql-http";
41import { mcpHttpPlugin } from "@executor/plugin-mcp-http";41import { mcpHttpPlugin } from "@executor/plugin-mcp-http";
42import { openApiHttpPlugin } from "@executor/plugin-openapi-http";42import { openApiHttpPlugin } from "@executor/plugin-openapi-http";
43import { onePasswordHttpPlugin } from "@executor/plugin-onepassword-http";
43import { makeSesExecutor } from "@executor/runtime-ses";44import { makeSesExecutor } from "@executor/runtime-ses";
44import { Client } from "@modelcontextprotocol/sdk/client/index.js";45import { Client } from "@modelcontextprotocol/sdk/client/index.js";
45import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";46import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
@@ -62,6 +63,7 @@ const serverHttpPlugins = [ · 16 unmodified lines
62 graphqlHttpPlugin(),63 graphqlHttpPlugin(),
63 googleDiscoveryHttpPlugin(),64 googleDiscoveryHttpPlugin(),
64 mcpHttpPlugin(),65 mcpHttpPlugin(),
66 onePasswordHttpPlugin(),
65 openApiHttpPlugin(),67 openApiHttpPlugin(),
66] as const;68] as const;
67 69
@@ -385,6 +387,26 @@ const seedGithubOpenApiSourceInWorkspace = (input: { · 317 unmodified lines
385 credentialEnvVar?: string;387 credentialEnvVar?: string;
386}) =>388}) =>
387 Effect.gen(function* () {389 Effect.gen(function* () {
390 const tokenEnvVar = input.credentialEnvVar ?? "GITHUB_TOKEN";
391 const tokenValue = process.env[tokenEnvVar];
392 if (!tokenValue) {
393 return yield* Effect.fail(
394 new Error(`Missing token value in environment variable ${tokenEnvVar}`),
395 );
396 }
397
398 const existingSecrets = yield* input.client.local.listSecrets({});
399 const existingTokenSecret = existingSecrets.find((secret) =>
400 secret.name === `${input.name} token`
401 );
402 const tokenSecret = existingTokenSecret
403 ?? (yield* input.client.local.createSecret({
404 payload: {
405 name: `${input.name} token`,
406 value: tokenValue,
407 },
408 }));
409
388 const existing = yield* input.client.sources.list({410 const existing = yield* input.client.sources.list({
389 path: {411 path: {
390 workspaceId: input.workspaceId as never,412 workspaceId: input.workspaceId as never,
@@ -400,8 +422,7 @@ const seedGithubOpenApiSourceInWorkspace = (input: { · 9 unmodified lines
400 headerName: "Authorization",422 headerName: "Authorization",
401 prefix: "Bearer ",423 prefix: "Bearer ",
402 tokenSecretRef: {424 tokenSecretRef: {
403 providerId: "env",425 secretId: tokenSecret.id as never,
404 handle: input.credentialEnvVar ?? "GITHUB_TOKEN",
405 },426 },
406 };427 };
407 428
@@ -1325,11 +1346,9 @@ describe("local-executor-server", () => { · 917 unmodified lines
1325 );1346 );
1326 1347
1327 const previousGithubToken = process.env.GITHUB_TOKEN;1348 const previousGithubToken = process.env.GITHUB_TOKEN;
1328 const previousAllowEnvSecrets = process.env.DANGEROUSLY_ALLOW_ENV_SECRETS;
1329 yield* Effect.acquireRelease(1349 yield* Effect.acquireRelease(
1330 Effect.sync(() => {1350 Effect.sync(() => {
1331 process.env.GITHUB_TOKEN = "ghp_test_executor";1351 process.env.GITHUB_TOKEN = "ghp_test_executor";
1332 process.env.DANGEROUSLY_ALLOW_ENV_SECRETS = "true";
1333 }),1352 }),
1334 () =>1353 () =>
1335 Effect.sync(() => {1354 Effect.sync(() => {
@@ -1338,12 +1357,6 @@ describe("local-executor-server", () => { · 2 unmodified lines
1338 } else {1357 } else {
1339 process.env.GITHUB_TOKEN = previousGithubToken;1358 process.env.GITHUB_TOKEN = previousGithubToken;
1340 }1359 }
1341
1342 if (previousAllowEnvSecrets === undefined) {
1343 delete process.env.DANGEROUSLY_ALLOW_ENV_SECRETS;
1344 } else {
1345 process.env.DANGEROUSLY_ALLOW_ENV_SECRETS = previousAllowEnvSecrets;
1346 }
1347 }),1360 }),
1348 );1361 );
1349 1362
packages/platform/server/src/index.ts+19−0
@@ -12,12 +12,19 @@ import { googleDiscoveryHttpPlugin } from "@executor/plugin-google-discovery-htt · 11 unmodified lines
12import { googleDiscoverySdkPlugin } from "@executor/plugin-google-discovery-sdk";12import { googleDiscoverySdkPlugin } from "@executor/plugin-google-discovery-sdk";
13import { graphqlHttpPlugin } from "@executor/plugin-graphql-http";13import { graphqlHttpPlugin } from "@executor/plugin-graphql-http";
14import { graphqlSdkPlugin } from "@executor/plugin-graphql-sdk";14import { graphqlSdkPlugin } from "@executor/plugin-graphql-sdk";
15import { keychainSecretStoreSdkPlugin } from "@executor/plugin-keychain-secret-store-sdk";
16import { localSecretStoreSdkPlugin } from "@executor/plugin-local-secret-store-sdk";
17import { localToolsSdkPlugin } from "@executor/plugin-local-tools-sdk";
15import { mcpHttpPlugin } from "@executor/plugin-mcp-http";18import { mcpHttpPlugin } from "@executor/plugin-mcp-http";
16import { mcpSdkPlugin } from "@executor/plugin-mcp-sdk";19import { mcpSdkPlugin } from "@executor/plugin-mcp-sdk";
17import { openApiHttpPlugin } from "@executor/plugin-openapi-http";20import { openApiHttpPlugin } from "@executor/plugin-openapi-http";
18import {21import {
19 openApiSdkPlugin,22 openApiSdkPlugin,
20} from "@executor/plugin-openapi-sdk";23} from "@executor/plugin-openapi-sdk";
24import { onePasswordHttpPlugin } from "@executor/plugin-onepassword-http";
25import {
26 onePasswordSdkPlugin,
27} from "@executor/plugin-onepassword-sdk";
21import {28import {
22 createExecutorApiLayer,29 createExecutorApiLayer,
23} from "@executor/platform-api/http";30} from "@executor/platform-api/http";
@@ -59,6 +66,7 @@ import { createFileGraphqlSourceStorage } from "./graphql-source-storage"; · 35 unmodified lines
59import { createFileMcpOAuthSessionStorage } from "./mcp-oauth-session-storage";66import { createFileMcpOAuthSessionStorage } from "./mcp-oauth-session-storage";
60import { createFileMcpSourceStorage } from "./mcp-source-storage";67import { createFileMcpSourceStorage } from "./mcp-source-storage";
61import { createFileOpenApiSourceStorage } from "./openapi-source-storage";68import { createFileOpenApiSourceStorage } from "./openapi-source-storage";
69import { createFileOnePasswordStoreStorage } from "./onepassword-store-storage";
62 70
63export { createFileGoogleDiscoveryOAuthSessionStorage } from "./google-discovery-oauth-session-storage";71export { createFileGoogleDiscoveryOAuthSessionStorage } from "./google-discovery-oauth-session-storage";
64export { createFileGoogleDiscoverySourceStorage } from "./google-discovery-source-storage";72export { createFileGoogleDiscoverySourceStorage } from "./google-discovery-source-storage";
@@ -66,6 +74,7 @@ export { createFileGraphqlSourceStorage } from "./graphql-source-storage"; · 1 unmodified line
66export { createFileMcpOAuthSessionStorage } from "./mcp-oauth-session-storage";74export { createFileMcpOAuthSessionStorage } from "./mcp-oauth-session-storage";
67export { createFileMcpSourceStorage } from "./mcp-source-storage";75export { createFileMcpSourceStorage } from "./mcp-source-storage";
68export { createFileOpenApiSourceStorage } from "./openapi-source-storage";76export { createFileOpenApiSourceStorage } from "./openapi-source-storage";
77export { createFileOnePasswordStoreStorage } from "./onepassword-store-storage";
69 78
70export {79export {
71 DEFAULT_EXECUTOR_DATA_DIR,80 DEFAULT_EXECUTOR_DATA_DIR,
@@ -133,6 +142,7 @@ const executorHttpPlugins = [ · 61 unmodified lines
133 graphqlHttpPlugin(),142 graphqlHttpPlugin(),
134 googleDiscoveryHttpPlugin(),143 googleDiscoveryHttpPlugin(),
135 mcpHttpPlugin(),144 mcpHttpPlugin(),
145 onePasswordHttpPlugin(),
136 openApiHttpPlugin(),146 openApiHttpPlugin(),
137] as const;147] as const;
138 148
@@ -150,10 +160,14 @@ const createExecutorRuntime = ( · 11 unmodified lines
150) =>160) =>
151 createExecutorEffect({161 createExecutorEffect({
152 backend: createLocalExecutorBackend({162 backend: createLocalExecutorBackend({
163 cwd: options.workspaceRoot ?? process.cwd(),
153 workspaceRoot: options.workspaceRoot,164 workspaceRoot: options.workspaceRoot,
154 localDataDir,165 localDataDir,
155 }),166 }),
156 plugins: [167 plugins: [
168 localSecretStoreSdkPlugin,
169 keychainSecretStoreSdkPlugin,
170 localToolsSdkPlugin(),
157 graphqlSdkPlugin({171 graphqlSdkPlugin({
158 storage: createFileGraphqlSourceStorage({172 storage: createFileGraphqlSourceStorage({
159 rootDir: resolve(localDataDir, "plugins", "graphql", "sources"),173 rootDir: resolve(localDataDir, "plugins", "graphql", "sources"),
@@ -180,6 +194,11 @@ const createExecutorRuntime = ( · 20 unmodified lines
180 rootDir: resolve(localDataDir, "plugins", "openapi", "sources"),194 rootDir: resolve(localDataDir, "plugins", "openapi", "sources"),
181 }),195 }),
182 }),196 }),
197 onePasswordSdkPlugin({
198 storage: createFileOnePasswordStoreStorage({
199 rootDir: resolve(localDataDir, "plugins", "onepassword", "stores"),
200 }),
201 }),
183 ] as const,202 ] as const,
184 executionResolver: options.executionResolver,203 executionResolver: options.executionResolver,
185 resolveSecretMaterial: options.resolveSecretMaterial,204 resolveSecretMaterial: options.resolveSecretMaterial,
packages/platform/server/src/onepassword-store-storage.ts+43−0
@@ -0,0 +1,43 @@
1import type { OnePasswordStoreStorage } from "@executor/plugin-onepassword-sdk";
2import {
3 OnePasswordStoredStoreDataSchema,
4} from "@executor/plugin-onepassword-shared";
5
6import {
7 pluginSourceStoragePath,
8 readJsonFile,
9 removeJsonFile,
10 writeJsonFile,
11} from "./json-file-storage";
12
13export 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 @@
1import { startTransition, useMemo, useState, type ReactNode } from "react";1import { startTransition, useMemo, useState, type ReactNode } from "react";
2import type { Source } from "@executor/react";2import type { Source } from "@executor/react";
3import {3import {
4 SecretReferenceField,
4 defineExecutorPluginHttpApiClient,5 defineExecutorPluginHttpApiClient,
5 Result,6 Result,
6 useAtomSet,7 useAtomSet,
7 useAtomValue,8 useAtomValue,
8 useCreateSecret,
9 useExecutorMutation,9 useExecutorMutation,
10 useLocalInstallation,10 useLocalInstallation,
11 useSecrets,
12 useSource,11 useSource,
13} from "@executor/react";12} from "@executor/react";
14import {13import {
@@ -456,134 +455,6 @@ const openGoogleOauthPopup = (name: string): Window | null => { · 441 unmodified lines
456 return popup;455 return popup;
457};456};
458 457
459const CREATE_SECRET_VALUE = "__create_google_secret__";
460
461function 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
587const batchSourceInputFromTemplate = (458const batchSourceInputFromTemplate = (
588 template: GoogleDiscoveryBatchTemplate,459 template: GoogleDiscoveryBatchTemplate,
589): GoogleDiscoveryBatchSourceInput => ({460): GoogleDiscoveryBatchSourceInput => ({
@@ -768,10 +639,12 @@ function GoogleDiscoverySourceForm(props: { · 178 unmodified lines
768 639
769 {authKind === "bearer" && (640 {authKind === "bearer" && (
770 <div className="border-l-2 border-border pl-4">641 <div className="border-l-2 border-border pl-4">
771 <SecretSelectOrCreateField642 <SecretReferenceField
772 label="Secret"643 label="Secret"
773 value={bearerSecretRef}644 value={bearerSecretRef}
774 emptyLabel="Select a secret"645 emptyLabel="Select a secret"
646 draftNamePlaceholder="Google bearer token"
647 draftValuePlaceholder="Paste the token value"
775 onChange={setBearerSecretRef}648 onChange={setBearerSecretRef}
776 />649 />
777 </div>650 </div>
@@ -787,10 +660,12 @@ function GoogleDiscoverySourceForm(props: { · 9 unmodified lines
787 className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"660 className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
788 />661 />
789 </label>662 </label>
790 <SecretSelectOrCreateField663 <SecretReferenceField
791 label="Client Secret"664 label="Client Secret"
792 value={clientSecretRef}665 value={clientSecretRef}
793 emptyLabel="Public client / no secret"666 emptyLabel="Public client / no secret"
667 draftNamePlaceholder="Google client secret"
668 draftValuePlaceholder="Paste the secret value"
794 onChange={setClientSecretRef}669 onChange={setClientSecretRef}
795 />670 />
796 <div className="flex items-center gap-3">671 <div className="flex items-center gap-3">
@@ -1095,10 +970,12 @@ function GoogleDiscoveryBatchConnectPanel(props: { · 298 unmodified lines
1095 />970 />
1096 </label>971 </label>
1097 <div className="md:col-span-2">972 <div className="md:col-span-2">
1098 <SecretSelectOrCreateField973 <SecretReferenceField
1099 label="Client Secret"974 label="Client Secret"
1100 value={clientSecretRef}975 value={clientSecretRef}
1101 emptyLabel="Public client / no secret"976 emptyLabel="Public client / no secret"
977 draftNamePlaceholder="Google client secret"
978 draftValuePlaceholder="Paste the secret value"
1102 onChange={setClientSecretRef}979 onChange={setClientSecretRef}
1103 />980 />
1104 </div>981 </div>
plugins/graphql/react/components.tsx+2−132
@@ -4,14 +4,13 @@ import type { · 3 unmodified lines
4 SourceInspectionToolDetail,4 SourceInspectionToolDetail,
5} from "@executor/react";5} from "@executor/react";
6import {6import {
7 SecretReferenceField,
7 defineExecutorPluginHttpApiClient,8 defineExecutorPluginHttpApiClient,
8 Result,9 Result,
9 useAtomSet,10 useAtomSet,
10 useAtomValue,11 useAtomValue,
11 useCreateSecret,
12 useExecutorMutation,12 useExecutorMutation,
13 useLocalInstallation,13 useLocalInstallation,
14 useSecrets,
15 useSource,14 useSource,
16} from "@executor/react";15} from "@executor/react";
17import {16import {
@@ -55,7 +54,6 @@ const defaultGraphqlInput = (): GraphqlConnectInput => ({ · 37 unmodified lines
55 54
56const DEFAULT_BEARER_HEADER_NAME = "Authorization";55const DEFAULT_BEARER_HEADER_NAME = "Authorization";
57const DEFAULT_BEARER_PREFIX = "Bearer ";56const DEFAULT_BEARER_PREFIX = "Bearer ";
58const CREATE_SECRET_VALUE = "__create_graphql_secret__";
59 57
60const presetString = (58const presetString = (
61 search: Record<string, unknown>,59 search: Record<string, unknown>,
@@ -170,134 +168,6 @@ const authFromSecretValue = ( · 108 unmodified lines
170 };168 };
171};169};
172 170
173function 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
301const graphqlToolRoutePath = (toolPath: string): string =>171const graphqlToolRoutePath = (toolPath: string): string =>
302 `tool/${encodeURIComponent(toolPath)}`;172 `tool/${encodeURIComponent(toolPath)}`;
303 173
@@ -383,7 +253,7 @@ function GraphqlSourceForm(props: { · 79 unmodified lines
383 253
384 {authKind === "bearer" && (254 {authKind === "bearer" && (
385 <div className="space-y-4 border-l-2 border-border pl-4">255 <div className="space-y-4 border-l-2 border-border pl-4">
386 <SecretSelectOrCreateField256 <SecretReferenceField
387 label="Secret"257 label="Secret"
388 value={secretRef}258 value={secretRef}
389 emptyLabel="Select a secret"259 emptyLabel="Select a secret"
plugins/keychain-secret-store/sdk/index.ts+410−0
@@ -0,0 +1,410 @@
1import { randomUUID } from "node:crypto";
2import { spawn } from "node:child_process";
3
4import * as Effect from "effect/Effect";
5
6import {
7 defineExecutorSecretStorePlugin,
8} from "@executor/platform-sdk/plugins";
9import {
10 runtimeEffectError,
11} from "@executor/platform-sdk/runtime";
12
13export const KEYCHAIN_SECRET_STORE_KIND = "keychain";
14export const KEYCHAIN_SECRET_STORE_ID = "sts_builtin_keychain";
15
16const DEFAULT_KEYCHAIN_SERVICE_NAME = "executor";
17const KEYCHAIN_COMMAND_TIMEOUT_MS = 5_000;
18const KEYCHAIN_SERVICE_NAME_ENV = "EXECUTOR_KEYCHAIN_SERVICE_NAME";
19
20type SpawnResult = {
21 exitCode: number;
22 stdout: string;
23 stderr: string;
24};
25
26const toError = (cause: unknown): Error =>
27 cause instanceof Error ? cause : new Error(String(cause));
28
29const 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
38const ensureNonEmptyString = (value: string | undefined | null): string | null => {
39 const trimmed = value?.trim();
40 return trimmed && trimmed.length > 0 ? trimmed : null;
41};
42
43const resolveKeychainServiceName = (value: string | undefined): string =>
44 trimOrNull(value)
45 ?? trimOrNull(process.env[KEYCHAIN_SERVICE_NAME_ENV])
46 ?? DEFAULT_KEYCHAIN_SERVICE_NAME;
47
48const 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
125const 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
146const 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
204const 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
272const 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
313const builtinSecretStoreStorage = <TStored>(value: TStored) => ({
314 get: () => Effect.succeed(value),
315 put: () => Effect.void,
316 remove: () => Effect.void,
317});
318
319export 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 @@
1import { randomUUID } from "node:crypto";
2
3import * as Effect from "effect/Effect";
4
5import {
6 defineExecutorSecretStorePlugin,
7} from "@executor/platform-sdk/plugins";
8import {
9 runtimeEffectError,
10} from "@executor/platform-sdk/runtime";
11
12export const LOCAL_SECRET_STORE_KIND = "local";
13export const LOCAL_SECRET_STORE_ID = "sts_builtin_local";
14
15const builtinSecretStoreStorage = <TStored>(value: TStored) => ({
16 get: () => Effect.succeed(value),
17 put: () => Effect.void,
18 remove: () => Effect.void,
19});
20
21const 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
30export 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 @@
1import { startTransition, type ReactNode } from "react";
2import type {
3 Source,
4} from "@executor/react";
5import {
6 useSource,
7 useSources,
8} from "@executor/react";
9import {
10 SourceToolExplorer,
11 parseSourceToolExplorerSearch,
12 useSourcePluginNavigation,
13 useSourcePluginRouteParams,
14 useSourcePluginSearch,
15 type SourceToolExplorerSearch,
16} from "@executor/react/plugins";
17
18const LOCAL_TOOLS_SOURCE_KIND = "local-tools";
19
20const 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
27function 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
62function 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
93export 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
168export 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 @@
1import { defineExecutorFrontendPlugin } from "@executor/react/plugins";
2
3import {
4 LocalToolsAddPage,
5 LocalToolsDetailRoute,
6} from "./components";
7
8export 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 @@
1import {
2 CapabilityIdSchema,
3 DocumentIdSchema,
4 ExecutableIdSchema,
5 ResponseSymbolIdSchema,
6 ScopeIdSchema,
7} from "@executor/ir/ids";
8import type {
9 Capability,
10 Executable,
11 ResponseSymbol,
12} from "@executor/ir/model";
13import type {
14 ToolDescriptor,
15} from "@executor/codemode-core";
16import {
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
33export type LocalToolsCatalogOperationInput = {
34 descriptor: ToolDescriptor;
35 inputSchema?: unknown;
36 outputSchema?: unknown;
37};
38
39const leafFromPath = (path: string): string =>
40 path.split(".").filter((segment) => segment.length > 0).at(-1) ?? path;
41
42const toolPathSegmentsFromDescriptor = (descriptor: ToolDescriptor): string[] =>
43 descriptor.path.split(".").filter((segment) => segment.length > 0);
44
45const invocationInputMode = (
46 schema: unknown,
47): "direct" | "wrapped" =>
48 schema === undefined || isObjectLikeJsonSchema(schema) ? "direct" : "wrapped";
49
50const 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
221export 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 @@
1import {
2 makeToolInvokerFromTools,
3 toolDescriptorsFromTools,
4 type ToolDescriptor,
5 type ToolInput,
6 type ToolMap,
7} from "@executor/codemode-core";
8import {
9 createCatalogImportMetadata,
10 createSourceCatalogSyncResult,
11 stableHash,
12} from "@executor/source-core";
13import {
14 defineExecutorSourcePlugin,
15} from "@executor/platform-sdk/plugins";
16import {
17 LocalToolRuntimeLoaderService,
18 provideExecutorRuntime,
19 runtimeEffectError,
20} from "@executor/platform-sdk/runtime";
21import type {
22 Source,
23} from "@executor/platform-sdk/schema";
24import * as Effect from "effect/Effect";
25import * as Schema from "effect/Schema";
26
27import {
28 createLocalToolsCatalogFragment,
29} from "./catalog";
30
31export const LOCAL_TOOLS_SOURCE_KIND = "local-tools";
32export const LOCAL_TOOLS_SOURCE_NAME = "Local Tools";
33
34const LocalToolsStoredSchema = Schema.Struct({
35 kind: Schema.Literal(LOCAL_TOOLS_SOURCE_KIND),
36});
37
38type LocalToolsStored = typeof LocalToolsStoredSchema.Type;
39
40const LOCAL_TOOLS_STORED: LocalToolsStored = {
41 kind: LOCAL_TOOLS_SOURCE_KIND,
42};
43
44const LocalToolsAddInputSchema = Schema.Struct({
45 kind: Schema.Literal(LOCAL_TOOLS_SOURCE_KIND),
46 name: Schema.optional(Schema.String),
47});
48
49type LocalToolsAddInput = typeof LocalToolsAddInputSchema.Type;
50
51type LocalToolsSourceConfig = {
52 name: string;
53};
54
55type LocalToolsUpdateSourceInput = {
56 sourceId: string;
57 config: LocalToolsSourceConfig;
58};
59
60const asRecord = (value: unknown): Record<string, unknown> =>
61 typeof value === "object" && value !== null && !Array.isArray(value)
62 ? (value as Record<string, unknown>)
63 : {};
64
65const loadLocalToolRuntime = () =>
66 Effect.flatMap(LocalToolRuntimeLoaderService, (loader) => loader.load());
67
68const localToolDescriptors = (input: {
69 source: Source;
70 tools: ToolMap;
71}): ToolDescriptor[] =>
72 toolDescriptorsFromTools({
73 tools: input.tools,
74 sourceKey: input.source.id,
75 });
76
77const 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
90const 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
112const 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
120const wrappedInvocationPayload = (
121 args: unknown,
122): unknown => asRecord(args).input;
123
124export 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 @@
1import {
2 HttpApi,
3 HttpApiBuilder,
4} from "@effect/platform";
5import {
6 ControlPlaneBadRequestError,
7 ControlPlaneForbiddenError,
8 ControlPlaneStorageError,
9 type ExecutorHttpPlugin,
10} from "@executor/platform-api";
11import { resolveRequestedLocalWorkspace } from "@executor/platform-api/local-context";
12import * as Effect from "effect/Effect";
13
14import {
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
29type 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
46const OnePasswordHttpApi = HttpApi.make("executor").add(onePasswordHttpGroup);
47
48const 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
55export 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 @@
1import { useEffect, useState, type ReactNode } from "react";
2
3import {
4 getExecutorApiBaseUrl,
5 useExecutorMutation,
6 useWorkspaceRequestContext,
7} from "@executor/react";
8import type {
9 SecretStoreCreateFormProps,
10} from "@executor/react/plugins";
11import {
12 type OnePasswordDiscoverVaultsInput,
13 type OnePasswordDiscoverVaultsResult,
14 type OnePasswordStoreAuth,
15 type OnePasswordVault,
16} from "@executor/plugin-onepassword-shared";
17
18const 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
21const 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
24const 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
27function 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
39export 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 @@
1import { defineExecutorFrontendPlugin } from "@executor/react/plugins";
2import { ONEPASSWORD_SECRET_STORE_KIND } from "@executor/plugin-onepassword-shared";
3
4import { OnePasswordSecretStoreCreateForm } from "./components";
5
6export 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 @@
1import {
2 createClient,
3 DesktopAuth,
4 type ItemField,
5 ItemCategory,
6 ItemFieldType,
7 type Item,
8} from "@1password/sdk";
9import * as Effect from "effect/Effect";
10
11import {
12 defineExecutorSecretStorePlugin,
13} from "@executor/platform-sdk/plugins";
14import {
15 SecretMaterialResolverService,
16} from "@executor/platform-sdk/runtime";
17import type {
18 SecretMaterial,
19 SecretMaterialPurpose,
20 SecretStore,
21} from "@executor/platform-sdk/schema";
22import {
23 SecretMaterialIdSchema,
24} from "@executor/platform-sdk/schema";
25import {
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
45export 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
61const ONEPASSWORD_REQUEST_TIMEOUT_MS = 15_000;
62
63const 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
68const 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
89const 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
99const 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
121const 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
132const importableFieldTypes = new Set<string>([
133 ItemFieldType.Concealed,
134 ItemFieldType.Text,
135]);
136const ONEPASSWORD_BROWSE_ITEM_LIMIT = 12;
137
138const 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
154const itemSelectionKey = (itemId: string) => `item:${itemId}`;
155const secretSelectionKey = (itemId: string, fieldId: string) =>
156 `secret:${itemId}:${fieldId}`;
157
158const 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
188const 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
210const 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
234const 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
254const 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
304const 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
346const 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
382const 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
422const 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
439const 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
455export 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
672export {
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 @@
1import {
2 HttpApiEndpoint,
3 HttpApiGroup,
4 HttpApiSchema,
5} from "@effect/platform";
6import * as Schema from "effect/Schema";
7
8import {
9 ScopeIdSchema,
10 SecretRefSchema,
11} from "@executor/platform-sdk/schema";
12
13export const ONEPASSWORD_SECRET_STORE_KIND = "onepassword";
14export const ONEPASSWORD_SECRET_FIELD_ID = "credential";
15
16export 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
27export 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
34export const OnePasswordStoreConfigPayloadSchema = OnePasswordConnectInputSchema;
35
36export const OnePasswordUpdateStoreInputSchema = Schema.Struct({
37 storeId: Schema.String,
38 config: OnePasswordStoreConfigPayloadSchema,
39});
40
41export const OnePasswordStoredStoreDataSchema = Schema.Struct({
42 vaultId: Schema.String,
43 auth: OnePasswordStoreAuthSchema,
44});
45
46export const OnePasswordDiscoverVaultsInputSchema = Schema.Struct({
47 auth: OnePasswordStoreAuthSchema,
48});
49
50export const OnePasswordVaultSchema = Schema.Struct({
51 id: Schema.String,
52 name: Schema.String,
53});
54
55export const OnePasswordDiscoverVaultsResultSchema = Schema.Struct({
56 vaults: Schema.Array(OnePasswordVaultSchema),
57});
58
59export const OnePasswordDiscoverStoreItemsInputSchema = Schema.Struct({
60 storeId: Schema.String,
61});
62
63export const OnePasswordItemFieldSchema = Schema.Struct({
64 id: Schema.String,
65 title: Schema.String,
66 fieldType: Schema.String,
67 sectionId: Schema.optional(Schema.String),
68});
69
70export 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
77export const OnePasswordDiscoverStoreItemsResultSchema = Schema.Struct({
78 items: Schema.Array(OnePasswordItemSchema),
79});
80
81export const OnePasswordDiscoverItemFieldsInputSchema = Schema.Struct({
82 storeId: Schema.String,
83 itemId: Schema.String,
84});
85
86export const OnePasswordDiscoverItemFieldsResultSchema = Schema.Struct({
87 itemId: Schema.String,
88 fields: Schema.Array(OnePasswordItemFieldSchema),
89});
90
91export const OnePasswordImportSecretInputSchema = Schema.Struct({
92 storeId: Schema.String,
93 itemId: Schema.String,
94 fieldId: Schema.String,
95 name: Schema.optional(Schema.String),
96});
97
98export 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
107const workspaceIdParam = HttpApiSchema.param("workspaceId", ScopeIdSchema);
108
109export 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
132export const onePasswordHttpApiExtension = {
133 key: "onepassword",
134 group: onePasswordHttpGroup,
135} as const;
136
137export type OnePasswordStoreAuth = typeof OnePasswordStoreAuthSchema.Type;
138export type OnePasswordConnectInput = typeof OnePasswordConnectInputSchema.Type;
139export type OnePasswordStoreConfigPayload =
140 typeof OnePasswordStoreConfigPayloadSchema.Type;
141export type OnePasswordUpdateStoreInput =
142 typeof OnePasswordUpdateStoreInputSchema.Type;
143export type OnePasswordStoredStoreData =
144 typeof OnePasswordStoredStoreDataSchema.Type;
145export type OnePasswordDiscoverVaultsInput =
146 typeof OnePasswordDiscoverVaultsInputSchema.Type;
147export type OnePasswordVault = typeof OnePasswordVaultSchema.Type;
148export type OnePasswordDiscoverVaultsResult =
149 typeof OnePasswordDiscoverVaultsResultSchema.Type;
150export type OnePasswordDiscoverStoreItemsInput =
151 typeof OnePasswordDiscoverStoreItemsInputSchema.Type;
152export type OnePasswordItemField = typeof OnePasswordItemFieldSchema.Type;
153export type OnePasswordItem = typeof OnePasswordItemSchema.Type;
154export type OnePasswordDiscoverStoreItemsResult =
155 typeof OnePasswordDiscoverStoreItemsResultSchema.Type;
156export type OnePasswordDiscoverItemFieldsInput =
157 typeof OnePasswordDiscoverItemFieldsInputSchema.Type;
158export type OnePasswordDiscoverItemFieldsResult =
159 typeof OnePasswordDiscoverItemFieldsResultSchema.Type;
160export type OnePasswordImportSecretInput =
161 typeof OnePasswordImportSecretInputSchema.Type;
162export 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
4} from "@executor/react";4} from "@executor/react";
5import {5import {
6 Result,6 Result,
7 SecretReferenceField,
7 defineExecutorPluginHttpApiClient,8 defineExecutorPluginHttpApiClient,
8 useAtomValue,9 useAtomValue,
9 useAtomSet,10 useAtomSet,
10 useCreateSecret,
11 useExecutorMutation,11 useExecutorMutation,
12 useSecrets,
13 useSource,12 useSource,
14 useWorkspaceId,13 useWorkspaceRequestContext,
15} from "@executor/react";14} from "@executor/react";
16import {15import {
17 DocumentPanel,16 DocumentPanel,
@@ -49,7 +48,6 @@ const defaultOpenApiInput = (): OpenApiConnectInput => ({ · 31 unmodified lines
49 48
50const DEFAULT_BEARER_HEADER_NAME = "Authorization";49const DEFAULT_BEARER_HEADER_NAME = "Authorization";
51const DEFAULT_BEARER_PREFIX = "Bearer ";50const DEFAULT_BEARER_PREFIX = "Bearer ";
52const CREATE_SECRET_VALUE = "__create_openapi_secret__";
53 51
54const presetString = (52const presetString = (
55 search: Record<string, unknown>,53 search: Record<string, unknown>,
@@ -237,136 +235,9 @@ const authFromSecretValue = ( · 181 unmodified lines
237 };235 };
238};236};
239 237
240function 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
368function OpenApiSourceForm(props: {238function OpenApiSourceForm(props: {
369 mode: "create" | "edit";239 mode: "create" | "edit";
240 workspaceId: Source["scopeId"];
370 initialValue: OpenApiConnectInput;241 initialValue: OpenApiConnectInput;
371 submitLabel: string;242 submitLabel: string;
372 busyLabel: string;243 busyLabel: string;
@@ -377,7 +248,6 @@ function OpenApiSourceForm(props: { · 4 unmodified lines
377 openApiHttpClient.mutation("openapi", "previewDocument"),248 openApiHttpClient.mutation("openapi", "previewDocument"),
378 { mode: "promise" },249 { mode: "promise" },
379 );250 );
380 const workspaceId = useWorkspaceId();
381 const [name, setName] = useState(props.initialValue.name);251 const [name, setName] = useState(props.initialValue.name);
382 const [specUrl, setSpecUrl] = useState(props.initialValue.specUrl);252 const [specUrl, setSpecUrl] = useState(props.initialValue.specUrl);
383 const [baseUrl, setBaseUrl] = useState(props.initialValue.baseUrl ?? "");253 const [baseUrl, setBaseUrl] = useState(props.initialValue.baseUrl ?? "");
@@ -403,7 +273,7 @@ function OpenApiSourceForm(props: { · 19 unmodified lines
403 OpenApiPreviewResponse273 OpenApiPreviewResponse
404 >(async (payload) =>274 >(async (payload) =>
405 previewDocument({275 previewDocument({
406 path: { workspaceId },276 path: { workspaceId: props.workspaceId },
407 payload,277 payload,
408 })278 })
409 );279 );
@@ -568,7 +438,7 @@ function OpenApiSourceForm(props: { · 158 unmodified lines
568 438
569 {authKind === "bearer" && (439 {authKind === "bearer" && (
570 <div className="space-y-4 border-l-2 border-border pl-4">440 <div className="space-y-4 border-l-2 border-border pl-4">
571 <SecretSelectOrCreateField441 <SecretReferenceField
572 label="Secret"442 label="Secret"
573 value={tokenSecretRef}443 value={tokenSecretRef}
574 emptyLabel="Select a secret"444 emptyLabel="Select a secret"
@@ -683,24 +553,33 @@ export function OpenApiAddSourcePage() { · 108 unmodified lines
683 const navigation = useSourcePluginNavigation();553 const navigation = useSourcePluginNavigation();
684 const initialValue = openApiInputFromSearch(useSourcePluginSearch());554 const initialValue = openApiInputFromSearch(useSourcePluginSearch());
685 const openApiHttpClient = getOpenApiHttpClient();555 const openApiHttpClient = getOpenApiHttpClient();
556 const workspace = useWorkspaceRequestContext();
686 const createSource = useAtomSet(557 const createSource = useAtomSet(
687 openApiHttpClient.mutation("openapi", "createSource"),558 openApiHttpClient.mutation("openapi", "createSource"),
688 { mode: "promise" },559 { mode: "promise" },
689 );560 );
690 const workspaceId = useWorkspaceId();561
562 if (!workspace.enabled) {
563 return (
564 <Section title="Add Source">
565 <div className="text-sm text-muted-foreground">Loading workspace...</div>
566 </Section>
567 );
568 }
691 569
692 return (570 return (
693 <OpenApiSourceForm571 <OpenApiSourceForm
694 mode="create"572 mode="create"
573 workspaceId={workspace.workspaceId}
695 initialValue={initialValue}574 initialValue={initialValue}
696 submitLabel="Create Source"575 submitLabel="Create Source"
697 busyLabel="Creating..."576 busyLabel="Creating..."
698 onSubmit={async (payload) => {577 onSubmit={async (payload) => {
699 const source = await createSource({578 const source = await createSource({
700 path: { workspaceId },579 path: { workspaceId: workspace.workspaceId },
701 payload,580 payload,
702 reactivityKeys: {581 reactivityKeys: {
703 sources: [workspaceId],582 sources: [workspace.workspaceId],
704 },583 },
705 });584 });
706 585
@@ -716,18 +595,39 @@ export function OpenApiAddSourcePage() { · 9 unmodified lines
716 595
717export function OpenApiEditSourcePage(props: {596export function OpenApiEditSourcePage(props: {
718 source: Source;597 source: Source;
598}) {
599 const workspace = useWorkspaceRequestContext();
600
601 if (!workspace.enabled) {
602 return (
603 <Section title="Edit Source">
604 <div className="text-sm text-muted-foreground">Loading workspace...</div>
605 </Section>
606 );
607 }
608
609 return (
610 <OpenApiEditSourcePageReady
611 source={props.source}
612 workspaceId={workspace.workspaceId}
613 />
614 );
615}
616
617function OpenApiEditSourcePageReady(props: {
618 source: Source;
619 workspaceId: Source["scopeId"];
719}) {620}) {
720 const navigation = useSourcePluginNavigation();621 const navigation = useSourcePluginNavigation();
721 const openApiHttpClient = getOpenApiHttpClient();622 const openApiHttpClient = getOpenApiHttpClient();
722 const workspaceId = useWorkspaceId();
723 const configResult = useAtomValue(623 const configResult = useAtomValue(
724 openApiHttpClient.query("openapi", "getSourceConfig", {624 openApiHttpClient.query("openapi", "getSourceConfig", {
725 path: {625 path: {
726 workspaceId,626 workspaceId: props.workspaceId,
727 sourceId: props.source.id,627 sourceId: props.source.id,
728 },628 },
729 reactivityKeys: {629 reactivityKeys: {
730 source: [workspaceId, props.source.id],630 source: [props.workspaceId, props.source.id],
731 },631 },
732 timeToLive: "30 seconds",632 timeToLive: "30 seconds",
733 }),633 }),
@@ -758,22 +658,23 @@ export function OpenApiEditSourcePage(props: { · 24 unmodified lines
758 return (658 return (
759 <OpenApiSourceForm659 <OpenApiSourceForm
760 mode="edit"660 mode="edit"
661 workspaceId={props.workspaceId}
761 initialValue={inputFromConfig(configResult.value)}662 initialValue={inputFromConfig(configResult.value)}
762 submitLabel="Save Changes"663 submitLabel="Save Changes"
763 busyLabel="Saving..."664 busyLabel="Saving..."
764 onSubmit={async (config) => {665 onSubmit={async (config) => {
765 const source = await updateSource({666 const source = await updateSource({
766 path: {667 path: {
767 workspaceId,668 workspaceId: props.workspaceId,
768 sourceId: props.source.id,669 sourceId: props.source.id,
769 },670 },
770 payload: config,671 payload: config,
771 reactivityKeys: {672 reactivityKeys: {
772 sources: [workspaceId],673 sources: [props.workspaceId],
773 source: [workspaceId, props.source.id],674 source: [props.workspaceId, props.source.id],
774 sourceInspection: [workspaceId, props.source.id],675 sourceInspection: [props.workspaceId, props.source.id],
775 sourceInspectionTool: [workspaceId, props.source.id],676 sourceInspectionTool: [props.workspaceId, props.source.id],
776 sourceDiscovery: [workspaceId, props.source.id],677 sourceDiscovery: [props.workspaceId, props.source.id],
777 },678 },
778 });679 });
779 680
@@ -789,10 +690,31 @@ export function OpenApiEditSourcePage(props: { · 9 unmodified lines
789 690
790export function OpenApiSourceDetailPage(props: {691export function OpenApiSourceDetailPage(props: {
791 source: Source;692 source: Source;
693}) {
694 const workspace = useWorkspaceRequestContext();
695
696 if (!workspace.enabled) {
697 return (
698 <Section title="Source">
699 <div className="text-sm text-muted-foreground">Loading workspace...</div>
700 </Section>
701 );
702 }
703
704 return (
705 <OpenApiSourceDetailPageReady
706 source={props.source}
707 workspaceId={workspace.workspaceId}
708 />
709 );
710}
711
712function OpenApiSourceDetailPageReady(props: {
713 source: Source;
714 workspaceId: Source["scopeId"];
792}) {715}) {
793 const navigation = useSourcePluginNavigation();716 const navigation = useSourcePluginNavigation();
794 const openApiHttpClient = getOpenApiHttpClient();717 const openApiHttpClient = getOpenApiHttpClient();
795 const workspaceId = useWorkspaceId();
796 const removeSource = useAtomSet(718 const removeSource = useAtomSet(
797 openApiHttpClient.mutation("openapi", "removeSource"),719 openApiHttpClient.mutation("openapi", "removeSource"),
798 { mode: "promise" },720 { mode: "promise" },
@@ -804,30 +726,30 @@ export function OpenApiSourceDetailPage(props: { · 5 unmodified lines
804 const refreshMutation = useExecutorMutation<Source["id"], Source>(async (sourceId) =>726 const refreshMutation = useExecutorMutation<Source["id"], Source>(async (sourceId) =>
805 refreshSource({727 refreshSource({
806 path: {728 path: {
807 workspaceId,729 workspaceId: props.workspaceId,
808 sourceId,730 sourceId,
809 },731 },
810 reactivityKeys: {732 reactivityKeys: {
811 sources: [workspaceId],733 sources: [props.workspaceId],
812 source: [workspaceId, sourceId],734 source: [props.workspaceId, sourceId],
813 sourceInspection: [workspaceId, sourceId],735 sourceInspection: [props.workspaceId, sourceId],
814 sourceInspectionTool: [workspaceId, sourceId],736 sourceInspectionTool: [props.workspaceId, sourceId],
815 sourceDiscovery: [workspaceId, sourceId],737 sourceDiscovery: [props.workspaceId, sourceId],
816 },738 },
817 })739 })
818 );740 );
819 const removeMutation = useExecutorMutation<Source["id"], { removed: boolean }>(async (sourceId) =>741 const removeMutation = useExecutorMutation<Source["id"], { removed: boolean }>(async (sourceId) =>
820 removeSource({742 removeSource({
821 path: {743 path: {
822 workspaceId,744 workspaceId: props.workspaceId,
823 sourceId,745 sourceId,
824 },746 },
825 reactivityKeys: {747 reactivityKeys: {
826 sources: [workspaceId],748 sources: [props.workspaceId],
827 source: [workspaceId, sourceId],749 source: [props.workspaceId, sourceId],
828 sourceInspection: [workspaceId, sourceId],750 sourceInspection: [props.workspaceId, sourceId],
829 sourceInspectionTool: [workspaceId, sourceId],751 sourceInspectionTool: [props.workspaceId, sourceId],
830 sourceDiscovery: [workspaceId, sourceId],752 sourceDiscovery: [props.workspaceId, sourceId],
831 },753 },
832 })754 })
833 );755 );