History

refactor(core,plugins): complete Captured migration — core handlers + secret providers

Wave 1 of the cleanup sweep: **Core API handlers** (wrap ExecutorService, drop orDie): - `ExecutorService` service tag flipped from `Executor` to `Captured<Executor>`. - 13 `.pipe(Effect.orDie)` sites across tools/sources/secrets handlers removed; handlers now just `yield* executor.X.foo(...)`. - Added `.addError(InternalError)` at the core tools/sources/secrets group level (was missing). - Promoted `SourceRemovalNotAllowedError` to a typed 409 on the `sources.remove` endpoint; previously orDie'd into a 500. - Host wiring in cloud `protected.ts`, local `main.ts`, and cloud test-harness flipped to pass `wrapped` (the `withCapture` proxy) to `ExecutorService` instead of the raw executor. **SecretProvider impls now emit StorageError with cause preserved:** - `keychain/src/provider.ts`: replaced `Effect.orElseSucceed(...)` (silent swallow) with `Effect.mapError(err → new StorageError({ message, cause }))`. Legitimate absences still return `null`/`false`; real keychain errors now surface as InternalError(traceId) with the original cause in Sentry. - `file-secrets/src/index.ts`: replaced `Effect.sync` + blanket try/catch in `readFullFile` with `Effect.try` + explicit ENOENT handling. JSON parse errors, schema decode failures, FS permission errors all propagate as StorageError instead of becoming defects or being swallowed. **Subtle `Captured<T>` type fix:** - Added a `CapturedOpaque` leaf (primitives + Date/Promise/Error/Array) before the recursive `extends object` branch in the `Captured<T>` conditional type. Previously, branded strings (`ScopeId = string & Brand<"ScopeId">`) satisfied `extends object` via the Brand marker's shape, so `Captured<>` expanded them into the whole string-prototype shape — breaking any handler that returned a branded id. Mirrors the runtime `isPlainObject` short-circuit. No casts needed. 34/34 typecheck, 35+7+27+23 tests green across sdk/api/cloud.

Rhys Sullivan committed 7 months ago

Commit e36b8361 parent 3d3d39f13 files · +156 −61Browse files

Unified · Split

13 files
  1. apps/cloud/src/api/protected.ts +1 −1
  2. apps/cloud/src/services/__test-harness__/api-harness.ts +1 −1
  3. apps/local/src/server/main.ts +1 −1
  4. packages/core/api/src/handlers/secrets.ts +10 −12
  5. packages/core/api/src/handlers/sources.ts +5 −7
  6. packages/core/api/src/handlers/tools.ts +2 −2
  7. packages/core/api/src/observability.ts +32 −3
  8. packages/core/api/src/secrets/api.ts +4 −1
  9. packages/core/api/src/services.ts +5 −1
  10. packages/core/api/src/sources/api.ts +16 −5
  11. packages/core/api/src/tools/api.ts +4 −1
  12. packages/plugins/file-secrets/src/index.ts +57 −22
  13. packages/plugins/keychain/src/provider.ts +18 −4
apps/cloud/src/api/protected.ts+1−1
@@ -55,7 +55,7 @@ const createProtectedApp = (organizationId: string, organizationName: string) => · 54 unmodified lines
55 // already-captured shape. See notes/error-handling.md.55 // already-captured shape. See notes/error-handling.md.
56 const wrapped = withCapture(executor);56 const wrapped = withCapture(executor);
57 const requestServices = Layer.mergeAll(57 const requestServices = Layer.mergeAll(
58 Layer.succeed(ExecutorService, executor),58 Layer.succeed(ExecutorService, wrapped),
59 Layer.succeed(ExecutionEngineService, engine),59 Layer.succeed(ExecutionEngineService, engine),
60 Layer.succeed(OpenApiExtensionService, wrapped.openapi),60 Layer.succeed(OpenApiExtensionService, wrapped.openapi),
61 Layer.succeed(McpExtensionService, wrapped.mcp),61 Layer.succeed(McpExtensionService, wrapped.mcp),
apps/cloud/src/services/__test-harness__/api-harness.ts+1−1
@@ -219,7 +219,7 @@ const buildAppForScope = (scopeId: string, scopeName: string) => · 218 unmodified lines
219 const engine = createExecutionEngine({ executor });219 const engine = createExecutionEngine({ executor });
220 const wrapped = withCapture(executor);220 const wrapped = withCapture(executor);
221 const services = Layer.mergeAll(221 const services = Layer.mergeAll(
222 Layer.succeed(ExecutorService, executor),222 Layer.succeed(ExecutorService, wrapped),
223 Layer.succeed(ExecutionEngineService, engine),223 Layer.succeed(ExecutionEngineService, engine),
224 Layer.succeed(OpenApiExtensionService, wrapped.openapi),224 Layer.succeed(OpenApiExtensionService, wrapped.openapi),
225 Layer.succeed(McpExtensionService, wrapped.mcp),225 Layer.succeed(McpExtensionService, wrapped.mcp),
apps/local/src/server/main.ts+1−1
@@ -113,7 +113,7 @@ export const createServerHandlers = async (): Promise<ServerHandlers> => { · 112 unmodified lines
113 Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()),113 Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()),
114 Layer.provideMerge(LocalApiBase),114 Layer.provideMerge(LocalApiBase),
115 Layer.provideMerge(pluginExtensions),115 Layer.provideMerge(pluginExtensions),
116 Layer.provideMerge(Layer.succeed(ExecutorService, executor)),116 Layer.provideMerge(Layer.succeed(ExecutorService, wrapped)),
117 Layer.provideMerge(Layer.succeed(ExecutionEngineService, engine)),117 Layer.provideMerge(Layer.succeed(ExecutionEngineService, engine)),
118 Layer.provideMerge(HttpServer.layerContext),118 Layer.provideMerge(HttpServer.layerContext),
119 Layer.provideMerge(HttpRouter.setRouterConfig({ maxParamLength: 1000 })),119 Layer.provideMerge(HttpRouter.setRouterConfig({ maxParamLength: 1000 })),
packages/core/api/src/handlers/secrets.ts+10−12
@@ -18,35 +18,33 @@ export const SecretsHandlers = HttpApiBuilder.group(ExecutorApi, "secrets", (han · 17 unmodified lines
18 .handle("list", () =>18 .handle("list", () =>
19 Effect.gen(function* () {19 Effect.gen(function* () {
20 const executor = yield* ExecutorService;20 const executor = yield* ExecutorService;
21 const refs = yield* executor.secrets.list().pipe(Effect.orDie);21 const refs = yield* executor.secrets.list();
22 return refs.map(refToResponse);22 return refs.map(refToResponse);
23 }),23 }),
24 )24 )
25 .handle("status", ({ path }) =>25 .handle("status", ({ path }) =>
26 Effect.gen(function* () {26 Effect.gen(function* () {
27 const executor = yield* ExecutorService;27 const executor = yield* ExecutorService;
28 const status = yield* executor.secrets.status(path.secretId).pipe(Effect.orDie);28 const status = yield* executor.secrets.status(path.secretId);
29 return { secretId: path.secretId, status };29 return { secretId: path.secretId, status };
30 }),30 }),
31 )31 )
32 .handle("set", ({ payload }) =>32 .handle("set", ({ payload }) =>
33 Effect.gen(function* () {33 Effect.gen(function* () {
34 const executor = yield* ExecutorService;34 const executor = yield* ExecutorService;
35 const ref = yield* executor.secrets35 const ref = yield* executor.secrets.set({
36 .set({36 id: payload.id,
37 id: payload.id,37 name: payload.name,
38 name: payload.name,38 value: payload.value,
39 value: payload.value,39 provider: payload.provider,
40 provider: payload.provider,40 });
41 })
42 .pipe(Effect.orDie);
43 return refToResponse(ref);41 return refToResponse(ref);
44 }),42 }),
45 )43 )
46 .handle("resolve", ({ path }) =>44 .handle("resolve", ({ path }) =>
47 Effect.gen(function* () {45 Effect.gen(function* () {
48 const executor = yield* ExecutorService;46 const executor = yield* ExecutorService;
49 const value = yield* executor.secrets.get(path.secretId).pipe(Effect.orDie);47 const value = yield* executor.secrets.get(path.secretId);
50 if (value === null) {48 if (value === null) {
51 return yield* Effect.fail(new SecretNotFoundError({ secretId: path.secretId }));49 return yield* Effect.fail(new SecretNotFoundError({ secretId: path.secretId }));
52 }50 }
@@ -56,7 +54,7 @@ export const SecretsHandlers = HttpApiBuilder.group(ExecutorApi, "secrets", (han · 3 unmodified lines
56 .handle("remove", ({ path }) =>54 .handle("remove", ({ path }) =>
57 Effect.gen(function* () {55 Effect.gen(function* () {
58 const executor = yield* ExecutorService;56 const executor = yield* ExecutorService;
59 yield* executor.secrets.remove(path.secretId).pipe(Effect.orDie);57 yield* executor.secrets.remove(path.secretId);
60 return { removed: true };58 return { removed: true };
61 }),59 }),
62 ),60 ),
packages/core/api/src/handlers/sources.ts+5−7
@@ -10,7 +10,7 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 9 unmodified lines
10 .handle("list", () =>10 .handle("list", () =>
11 Effect.gen(function* () {11 Effect.gen(function* () {
12 const executor = yield* ExecutorService;12 const executor = yield* ExecutorService;
13 const sources = yield* executor.sources.list().pipe(Effect.orDie);13 const sources = yield* executor.sources.list();
14 return sources.map((s) => ({14 return sources.map((s) => ({
15 id: s.id,15 id: s.id,
16 name: s.name,16 name: s.name,
@@ -26,23 +26,21 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 9 unmodified lines
26 .handle("remove", ({ path }) =>26 .handle("remove", ({ path }) =>
27 Effect.gen(function* () {27 Effect.gen(function* () {
28 const executor = yield* ExecutorService;28 const executor = yield* ExecutorService;
29 yield* executor.sources.remove(path.sourceId).pipe(Effect.orDie);29 yield* executor.sources.remove(path.sourceId);
30 return { removed: true };30 return { removed: true };
31 }),31 }),
32 )32 )
33 .handle("refresh", ({ path }) =>33 .handle("refresh", ({ path }) =>
34 Effect.gen(function* () {34 Effect.gen(function* () {
35 const executor = yield* ExecutorService;35 const executor = yield* ExecutorService;
36 yield* executor.sources.refresh(path.sourceId).pipe(Effect.orDie);36 yield* executor.sources.refresh(path.sourceId);
37 return { refreshed: true };37 return { refreshed: true };
38 }),38 }),
39 )39 )
40 .handle("tools", ({ path }) =>40 .handle("tools", ({ path }) =>
41 Effect.gen(function* () {41 Effect.gen(function* () {
42 const executor = yield* ExecutorService;42 const executor = yield* ExecutorService;
43 const tools = yield* executor.tools43 const tools = yield* executor.tools.list({ sourceId: path.sourceId });
44 .list({ sourceId: path.sourceId })
45 .pipe(Effect.orDie);
46 return tools.map((t) => ({44 return tools.map((t) => ({
47 id: ToolId.make(t.id),45 id: ToolId.make(t.id),
48 pluginId: t.pluginId,46 pluginId: t.pluginId,
@@ -56,7 +54,7 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 7 unmodified lines
56 .handle("detect", ({ payload }) =>54 .handle("detect", ({ payload }) =>
57 Effect.gen(function* () {55 Effect.gen(function* () {
58 const executor = yield* ExecutorService;56 const executor = yield* ExecutorService;
59 const results = yield* executor.sources.detect(payload.url).pipe(Effect.orDie);57 const results = yield* executor.sources.detect(payload.url);
60 return results.map((r) => ({58 return results.map((r) => ({
61 kind: r.kind,59 kind: r.kind,
62 confidence: r.confidence,60 confidence: r.confidence,
packages/core/api/src/handlers/tools.ts+2−2
@@ -10,7 +10,7 @@ export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handler · 9 unmodified lines
10 .handle("list", () =>10 .handle("list", () =>
11 Effect.gen(function* () {11 Effect.gen(function* () {
12 const executor = yield* ExecutorService;12 const executor = yield* ExecutorService;
13 const tools = yield* executor.tools.list().pipe(Effect.orDie);13 const tools = yield* executor.tools.list();
14 return tools.map((t) => ({14 return tools.map((t) => ({
15 id: ToolId.make(t.id),15 id: ToolId.make(t.id),
16 pluginId: t.pluginId,16 pluginId: t.pluginId,
@@ -24,7 +24,7 @@ export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handler · 7 unmodified lines
24 .handle("schema", ({ path }) =>24 .handle("schema", ({ path }) =>
25 Effect.gen(function* () {25 Effect.gen(function* () {
26 const executor = yield* ExecutorService;26 const executor = yield* ExecutorService;
27 const schema = yield* executor.tools.schema(path.toolId).pipe(Effect.orDie);27 const schema = yield* executor.tools.schema(path.toolId);
28 if (schema === null) {28 if (schema === null) {
29 return yield* Effect.fail(new ToolNotFoundError({ toolId: path.toolId }));29 return yield* Effect.fail(new ToolNotFoundError({ toolId: path.toolId }));
30 }30 }
packages/core/api/src/observability.ts+32−3
@@ -146,6 +146,33 @@ const isPlainObject = (v: unknown): v is Record<string | symbol, unknown> => { · 145 unmodified lines
146 * Captured<McpPluginExtension>146 * Captured<McpPluginExtension>
147 * >() {}147 * >() {}
148 */148 */
149// Opaque leaves that we DO NOT want `Captured<T>` to descend into at the
150// type level — the runtime Proxy also leaves these alone (via
151// `isPlainObject` returning false for class instances). Includes:
152//
153// - primitives (branded strings/numbers show up as intersections with
154// `{ [BrandTypeId]: ... }`, which TS reads as `extends object` — we
155// have to exclude them explicitly),
156// - common stdlib boxed types (Date, Promise, Error, Array),
157// - Schema-class instances and tagged errors. These all carry a
158// non-Object prototype at runtime so the Proxy short-circuits; the
159// type mirror stops with the same shape.
160//
161// Anything else that's a plain object (the executor surface, plugin
162// extensions, their namespaced sub-objects) walks recursively.
163type CapturedOpaque =
164 | string
165 | number
166 | boolean
167 | bigint
168 | symbol
169 | null
170 | undefined
171 | Date
172 | Promise<unknown>
173 | Error
174 | readonly unknown[];
175
149export type Captured<T> = T extends (176export type Captured<T> = T extends (
150 ...args: infer A177 ...args: infer A
151) => Effect.Effect<infer X, infer E, infer R>178) => Effect.Effect<infer X, infer E, infer R>
@@ -154,9 +181,11 @@ export type Captured<T> = T extends ( · 2 unmodified lines
154 ) => Effect.Effect<X, Exclude<E, StorageFailure> | InternalError, R>181 ) => Effect.Effect<X, Exclude<E, StorageFailure> | InternalError, R>
155 : T extends (...args: infer A) => infer U182 : T extends (...args: infer A) => infer U
156 ? (...args: A) => U183 ? (...args: A) => U
157 : T extends object184 : T extends CapturedOpaque
158 ? { readonly [K in keyof T]: Captured<T[K]> }185 ? T
159 : T;186 : T extends object
187 ? { readonly [K in keyof T]: Captured<T[K]> }
188 : T;
160 189
161export const withCapture = <T extends object>(value: T): Captured<T> => {190export const withCapture = <T extends object>(value: T): Captured<T> => {
162 return new Proxy(value, {191 return new Proxy(value, {
packages/core/api/src/secrets/api.ts+4−1
@@ -2,6 +2,8 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; · 1 unmodified line
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ScopeId, SecretId, SecretNotFoundError, SecretResolutionError } from "@executor/sdk";3import { ScopeId, SecretId, SecretNotFoundError, SecretResolutionError } from "@executor/sdk";
4 4
5import { InternalError } from "../observability";
6
5// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
6// Params8// Params
7// ---------------------------------------------------------------------------9// ---------------------------------------------------------------------------
@@ -78,4 +80,5 @@ export class SecretsApi extends HttpApiGroup.make("secrets") · 70 unmodified lines
78 HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/secrets/${secretIdParam}`80 HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/secrets/${secretIdParam}`
79 .addSuccess(Schema.Struct({ removed: Schema.Boolean }))81 .addSuccess(Schema.Struct({ removed: Schema.Boolean }))
80 .addError(SecretNotFound),82 .addError(SecretNotFound),
81 ) {}83 )
84 .addError(InternalError) {}
packages/core/api/src/services.ts+5−1
@@ -1,8 +1,12 @@
1import { Context } from "effect";1import { Context } from "effect";
2import type { Executor } from "@executor/sdk";2import type { Executor } from "@executor/sdk";
3import type { ExecutionEngine } from "@executor/execution";3import type { ExecutionEngine } from "@executor/execution";
4import type { Captured } from "./observability";
4 5
5export class ExecutorService extends Context.Tag("ExecutorService")<ExecutorService, Executor>() {}6export class ExecutorService extends Context.Tag("ExecutorService")<
7 ExecutorService,
8 Captured<Executor>
9>() {}
6 10
7export class ExecutionEngineService extends Context.Tag("ExecutionEngineService")<11export class ExecutionEngineService extends Context.Tag("ExecutionEngineService")<
8 ExecutionEngineService,12 ExecutionEngineService,
packages/core/api/src/sources/api.ts+16−5
@@ -1,6 +1,8 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ScopeId, ToolId } from "@executor/sdk";3import { ScopeId, SourceRemovalNotAllowedError, ToolId } from "@executor/sdk";
4
5import { InternalError } from "../observability";
4 6
5// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
6// Params8// Params
@@ -53,6 +55,14 @@ const DetectResultResponse = Schema.Struct({ · 46 unmodified lines
53 namespace: Schema.String,55 namespace: Schema.String,
54});56});
55 57
58// ---------------------------------------------------------------------------
59// Error schemas with HTTP status annotations
60// ---------------------------------------------------------------------------
61
62const SourceRemovalNotAllowed = SourceRemovalNotAllowedError.annotations(
63 HttpApiSchema.annotations({ status: 409 }),
64);
65
56// ---------------------------------------------------------------------------66// ---------------------------------------------------------------------------
57// Group67// Group
58// ---------------------------------------------------------------------------68// ---------------------------------------------------------------------------
@@ -64,9 +74,9 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 5 unmodified lines
64 ),74 ),
65 )75 )
66 .add(76 .add(
67 HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/sources/${sourceIdParam}`.addSuccess(77 HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/sources/${sourceIdParam}`
68 SourceRemoveResponse,78 .addSuccess(SourceRemoveResponse)
69 ),79 .addError(SourceRemovalNotAllowed),
70 )80 )
71 .add(81 .add(
72 HttpApiEndpoint.post(82 HttpApiEndpoint.post(
@@ -82,4 +92,5 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 9 unmodified lines
82 HttpApiEndpoint.post("detect")`/scopes/${scopeIdParam}/sources/detect`92 HttpApiEndpoint.post("detect")`/scopes/${scopeIdParam}/sources/detect`
83 .setPayload(DetectRequest)93 .setPayload(DetectRequest)
84 .addSuccess(Schema.Array(DetectResultResponse)),94 .addSuccess(Schema.Array(DetectResultResponse)),
85 ) {}95 )
96 .addError(InternalError) {}
packages/core/api/src/tools/api.ts+4−1
@@ -2,6 +2,8 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; · 1 unmodified line
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ScopeId, ToolId, ToolNotFoundError } from "@executor/sdk";3import { ScopeId, ToolId, ToolNotFoundError } from "@executor/sdk";
4 4
5import { InternalError } from "../observability";
6
5// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
6// Params8// Params
7// ---------------------------------------------------------------------------9// ---------------------------------------------------------------------------
@@ -53,4 +55,5 @@ export class ToolsApi extends HttpApiGroup.make("tools") · 45 unmodified lines
53 HttpApiEndpoint.get("schema")`/scopes/${scopeIdParam}/tools/${toolIdParam}/schema`55 HttpApiEndpoint.get("schema")`/scopes/${scopeIdParam}/tools/${toolIdParam}/schema`
54 .addSuccess(ToolSchemaResponse)56 .addSuccess(ToolSchemaResponse)
55 .addError(ToolNotFound),57 .addError(ToolNotFound),
56 ) {}58 )
59 .addError(InternalError) {}
packages/plugins/file-secrets/src/index.ts+57−22
@@ -2,7 +2,12 @@ import { Effect, Schema } from "effect"; · 1 unmodified line
2import * as fs from "node:fs";2import * as fs from "node:fs";
3import * as path from "node:path";3import * as path from "node:path";
4 4
5import { definePlugin, type PluginCtx, type SecretProvider } from "@executor/sdk";5import {
6 definePlugin,
7 StorageError,
8 type PluginCtx,
9 type SecretProvider,
10} from "@executor/sdk";
6 11
7// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
8// XDG data dir resolution13// XDG data dir resolution
@@ -43,16 +48,28 @@ const decodeScopedAuthFile = Schema.decodeUnknownSync(ScopedAuthFile); · 34 unmodified lines
43 48
44// ---------------------------------------------------------------------------49// ---------------------------------------------------------------------------
45// File I/O with restricted permissions50// File I/O with restricted permissions
51//
52// These helpers throw on real I/O or decode failures — the provider wraps
53// every call in `Effect.try` so those throws surface as typed
54// `StorageError` on the Effect error channel. Previously `readFullFile`
55// used a blanket `try { ... } catch { return {}; }` which masked JSON
56// parse errors, schema decode failures, and permission errors as
57// "empty file", making misconfigured installs silently return null from
58// every `get`.
46// ---------------------------------------------------------------------------59// ---------------------------------------------------------------------------
47 60
48const readFullFile = (filePath: string): Record<string, Record<string, string>> => {61const readFullFile = (filePath: string): Record<string, Record<string, string>> => {
62 if (!fs.existsSync(filePath)) return {};
63 let raw: string;
49 try {64 try {
50 if (!fs.existsSync(filePath)) return {};65 raw = fs.readFileSync(filePath, "utf-8");
51 const raw = fs.readFileSync(filePath, "utf-8");66 } catch (cause) {
52 return decodeScopedAuthFile(JSON.parse(raw));67 // Treat "file disappeared between existsSync and readFileSync" as
53 } catch {68 // absence — anything else (EACCES, EISDIR, …) propagates.
54 return {};69 if ((cause as NodeJS.ErrnoException).code === "ENOENT") return {};
70 throw cause;
55 }71 }
72 return decodeScopedAuthFile(JSON.parse(raw));
56};73};
57 74
58const readScopeSecrets = (filePath: string, scopeId: string): Record<string, string> =>75const readScopeSecrets = (filePath: string, scopeId: string): Record<string, string> =>
@@ -100,36 +117,54 @@ export interface FileSecretsExtension { · 41 unmodified lines
100// Provider factory (internal)117// Provider factory (internal)
101// ---------------------------------------------------------------------------118// ---------------------------------------------------------------------------
102 119
120const toStorageError = (cause: unknown) =>
121 new StorageError({
122 message: cause instanceof Error ? cause.message : String(cause),
123 cause,
124 });
125
103const makeScopedProvider = (filePath: string, scopeId: string): SecretProvider => ({126const makeScopedProvider = (filePath: string, scopeId: string): SecretProvider => ({
104 key: "file",127 key: "file",
105 writable: true,128 writable: true,
106 129
107 get: (secretId) =>130 get: (secretId) =>
108 Effect.sync(() => {131 Effect.try({
109 const data = readScopeSecrets(filePath, scopeId);132 try: () => {
110 return data[secretId] ?? null;133 const data = readScopeSecrets(filePath, scopeId);
134 return data[secretId] ?? null;
135 },
136 catch: toStorageError,
111 }),137 }),
112 138
113 set: (secretId, value) =>139 set: (secretId, value) =>
114 Effect.sync(() => {140 Effect.try({
115 const data = readScopeSecrets(filePath, scopeId);141 try: () => {
116 data[secretId] = value;142 const data = readScopeSecrets(filePath, scopeId);
117 writeScopeSecrets(filePath, scopeId, data);143 data[secretId] = value;
144 writeScopeSecrets(filePath, scopeId, data);
145 },
146 catch: toStorageError,
118 }),147 }),
119 148
120 delete: (secretId) =>149 delete: (secretId) =>
121 Effect.sync(() => {150 Effect.try({
122 const data = readScopeSecrets(filePath, scopeId);151 try: () => {
123 const had = secretId in data;152 const data = readScopeSecrets(filePath, scopeId);
124 delete data[secretId];153 const had = secretId in data;
125 if (had) writeScopeSecrets(filePath, scopeId, data);154 delete data[secretId];
126 return had;155 if (had) writeScopeSecrets(filePath, scopeId, data);
156 return had;
157 },
158 catch: toStorageError,
127 }),159 }),
128 160
129 list: () =>161 list: () =>
130 Effect.sync(() => {162 Effect.try({
131 const data = readScopeSecrets(filePath, scopeId);163 try: () => {
132 return Object.keys(data).map((k) => ({ id: k, name: k }));164 const data = readScopeSecrets(filePath, scopeId);
165 return Object.keys(data).map((k) => ({ id: k, name: k }));
166 },
167 catch: toStorageError,
133 }),168 }),
134});169});
135 170
packages/plugins/keychain/src/provider.ts+18−4
@@ -1,21 +1,35 @@
1import { Effect } from "effect";1import { Effect } from "effect";
2 2
3import type { SecretProvider } from "@executor/sdk";3import { StorageError, type SecretProvider } from "@executor/sdk";
4 4
5import { getPassword, setPassword, deletePassword } from "./keyring";5import { getPassword, setPassword, deletePassword } from "./keyring";
6 6
7// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
8// SecretProvider adapter — bridges keyring into SDK resolution chain8// SecretProvider adapter — bridges keyring into SDK resolution chain
9//
10// The underlying `@napi-rs/keyring` sync API encodes "no entry" as an
11// ordinary return value (`getPassword()` → `null`, `deletePassword()` →
12// `false`), and only throws on real failures (keychain locked, permission
13// denied, platform init failure, etc.). `keyring.ts` wraps those thrown
14// failures as `KeychainError`. We translate `KeychainError` →
15// `StorageError` so the HTTP edge can capture it to telemetry and surface
16// an opaque `InternalError({ traceId })` — previously `orElseSucceed`
17// silently converted every failure into "nothing found", which made it
18// impossible to debug why secrets weren't resolving.
9// ---------------------------------------------------------------------------19// ---------------------------------------------------------------------------
10 20
21const toStorageError = (cause: { readonly message: string; readonly cause?: unknown }) =>
22 new StorageError({ message: cause.message, cause: cause.cause ?? cause });
23
11export const makeKeychainProvider = (serviceName: string): SecretProvider => ({24export const makeKeychainProvider = (serviceName: string): SecretProvider => ({
12 key: "keychain",25 key: "keychain",
13 writable: true,26 writable: true,
14 get: (secretId) => getPassword(serviceName, secretId).pipe(Effect.orElseSucceed(() => null)),27 get: (secretId) =>
28 getPassword(serviceName, secretId).pipe(Effect.mapError(toStorageError)),
15 set: (secretId, value) =>29 set: (secretId, value) =>
16 setPassword(serviceName, secretId, value).pipe(Effect.orElseSucceed(() => undefined)),30 setPassword(serviceName, secretId, value).pipe(Effect.mapError(toStorageError)),
17 delete: (secretId) =>31 delete: (secretId) =>
18 deletePassword(serviceName, secretId).pipe(Effect.orElseSucceed(() => false)),32 deletePassword(serviceName, secretId).pipe(Effect.mapError(toStorageError)),
19 // Keychain doesn't support enumerating — you need to know the account name33 // Keychain doesn't support enumerating — you need to know the account name
20 list: undefined,34 list: undefined,
21});35});