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.
Unified · Split
| @@ -55,7 +55,7 @@ const createProtectedApp = (organizationId: string, organizationName: string) => · 54 unmodified lines | ||
| 55 | 55 | // already-captured shape. See notes/error-handling.md. |
| 56 | 56 | const wrapped = withCapture(executor); |
| 57 | 57 | const requestServices = Layer.mergeAll( |
| 58 | − Layer.succeed(ExecutorService, executor), | |
| 58 | + Layer.succeed(ExecutorService, wrapped), | |
| 59 | 59 | Layer.succeed(ExecutionEngineService, engine), |
| 60 | 60 | Layer.succeed(OpenApiExtensionService, wrapped.openapi), |
| 61 | 61 | Layer.succeed(McpExtensionService, wrapped.mcp), |
| @@ -219,7 +219,7 @@ const buildAppForScope = (scopeId: string, scopeName: string) => · 218 unmodified lines | ||
| 219 | 219 | const engine = createExecutionEngine({ executor }); |
| 220 | 220 | const wrapped = withCapture(executor); |
| 221 | 221 | const services = Layer.mergeAll( |
| 222 | − Layer.succeed(ExecutorService, executor), | |
| 222 | + Layer.succeed(ExecutorService, wrapped), | |
| 223 | 223 | Layer.succeed(ExecutionEngineService, engine), |
| 224 | 224 | Layer.succeed(OpenApiExtensionService, wrapped.openapi), |
| 225 | 225 | Layer.succeed(McpExtensionService, wrapped.mcp), |
| @@ -113,7 +113,7 @@ export const createServerHandlers = async (): Promise<ServerHandlers> => { · 112 unmodified lines | ||
| 113 | 113 | Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()), |
| 114 | 114 | Layer.provideMerge(LocalApiBase), |
| 115 | 115 | Layer.provideMerge(pluginExtensions), |
| 116 | − Layer.provideMerge(Layer.succeed(ExecutorService, executor)), | |
| 116 | + Layer.provideMerge(Layer.succeed(ExecutorService, wrapped)), | |
| 117 | 117 | Layer.provideMerge(Layer.succeed(ExecutionEngineService, engine)), |
| 118 | 118 | Layer.provideMerge(HttpServer.layerContext), |
| 119 | 119 | Layer.provideMerge(HttpRouter.setRouterConfig({ maxParamLength: 1000 })), |
| @@ -18,35 +18,33 @@ export const SecretsHandlers = HttpApiBuilder.group(ExecutorApi, "secrets", (han · 17 unmodified lines | ||
| 18 | 18 | .handle("list", () => |
| 19 | 19 | Effect.gen(function* () { |
| 20 | 20 | const executor = yield* ExecutorService; |
| 21 | − const refs = yield* executor.secrets.list().pipe(Effect.orDie); | |
| 21 | + const refs = yield* executor.secrets.list(); | |
| 22 | 22 | return refs.map(refToResponse); |
| 23 | 23 | }), |
| 24 | 24 | ) |
| 25 | 25 | .handle("status", ({ path }) => |
| 26 | 26 | Effect.gen(function* () { |
| 27 | 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 | 29 | return { secretId: path.secretId, status }; |
| 30 | 30 | }), |
| 31 | 31 | ) |
| 32 | 32 | .handle("set", ({ payload }) => |
| 33 | 33 | Effect.gen(function* () { |
| 34 | 34 | const executor = yield* ExecutorService; |
| 35 | − const ref = yield* executor.secrets | |
| 36 | − .set({ | |
| 37 | − id: payload.id, | |
| 38 | − name: payload.name, | |
| 39 | − value: payload.value, | |
| 40 | − provider: payload.provider, | |
| 41 | − }) | |
| 42 | − .pipe(Effect.orDie); | |
| 35 | + const ref = yield* executor.secrets.set({ | |
| 36 | + id: payload.id, | |
| 37 | + name: payload.name, | |
| 38 | + value: payload.value, | |
| 39 | + provider: payload.provider, | |
| 40 | + }); | |
| 43 | 41 | return refToResponse(ref); |
| 44 | 42 | }), |
| 45 | 43 | ) |
| 46 | 44 | .handle("resolve", ({ path }) => |
| 47 | 45 | Effect.gen(function* () { |
| 48 | 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 | 48 | if (value === null) { |
| 51 | 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 | 54 | .handle("remove", ({ path }) => |
| 57 | 55 | Effect.gen(function* () { |
| 58 | 56 | const executor = yield* ExecutorService; |
| 59 | − yield* executor.secrets.remove(path.secretId).pipe(Effect.orDie); | |
| 57 | + yield* executor.secrets.remove(path.secretId); | |
| 60 | 58 | return { removed: true }; |
| 61 | 59 | }), |
| 62 | 60 | ), |
| @@ -10,7 +10,7 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 9 unmodified lines | ||
| 10 | 10 | .handle("list", () => |
| 11 | 11 | Effect.gen(function* () { |
| 12 | 12 | const executor = yield* ExecutorService; |
| 13 | − const sources = yield* executor.sources.list().pipe(Effect.orDie); | |
| 13 | + const sources = yield* executor.sources.list(); | |
| 14 | 14 | return sources.map((s) => ({ |
| 15 | 15 | id: s.id, |
| 16 | 16 | name: s.name, |
| @@ -26,23 +26,21 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 9 unmodified lines | ||
| 26 | 26 | .handle("remove", ({ path }) => |
| 27 | 27 | Effect.gen(function* () { |
| 28 | 28 | const executor = yield* ExecutorService; |
| 29 | − yield* executor.sources.remove(path.sourceId).pipe(Effect.orDie); | |
| 29 | + yield* executor.sources.remove(path.sourceId); | |
| 30 | 30 | return { removed: true }; |
| 31 | 31 | }), |
| 32 | 32 | ) |
| 33 | 33 | .handle("refresh", ({ path }) => |
| 34 | 34 | Effect.gen(function* () { |
| 35 | 35 | const executor = yield* ExecutorService; |
| 36 | − yield* executor.sources.refresh(path.sourceId).pipe(Effect.orDie); | |
| 36 | + yield* executor.sources.refresh(path.sourceId); | |
| 37 | 37 | return { refreshed: true }; |
| 38 | 38 | }), |
| 39 | 39 | ) |
| 40 | 40 | .handle("tools", ({ path }) => |
| 41 | 41 | Effect.gen(function* () { |
| 42 | 42 | const executor = yield* ExecutorService; |
| 43 | − const tools = yield* executor.tools | |
| 44 | − .list({ sourceId: path.sourceId }) | |
| 45 | − .pipe(Effect.orDie); | |
| 43 | + const tools = yield* executor.tools.list({ sourceId: path.sourceId }); | |
| 46 | 44 | return tools.map((t) => ({ |
| 47 | 45 | id: ToolId.make(t.id), |
| 48 | 46 | pluginId: t.pluginId, |
| @@ -56,7 +54,7 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 7 unmodified lines | ||
| 56 | 54 | .handle("detect", ({ payload }) => |
| 57 | 55 | Effect.gen(function* () { |
| 58 | 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 | 58 | return results.map((r) => ({ |
| 61 | 59 | kind: r.kind, |
| 62 | 60 | confidence: r.confidence, |
| @@ -10,7 +10,7 @@ export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handler · 9 unmodified lines | ||
| 10 | 10 | .handle("list", () => |
| 11 | 11 | Effect.gen(function* () { |
| 12 | 12 | const executor = yield* ExecutorService; |
| 13 | − const tools = yield* executor.tools.list().pipe(Effect.orDie); | |
| 13 | + const tools = yield* executor.tools.list(); | |
| 14 | 14 | return tools.map((t) => ({ |
| 15 | 15 | id: ToolId.make(t.id), |
| 16 | 16 | pluginId: t.pluginId, |
| @@ -24,7 +24,7 @@ export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handler · 7 unmodified lines | ||
| 24 | 24 | .handle("schema", ({ path }) => |
| 25 | 25 | Effect.gen(function* () { |
| 26 | 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 | 28 | if (schema === null) { |
| 29 | 29 | return yield* Effect.fail(new ToolNotFoundError({ toolId: path.toolId })); |
| 30 | 30 | } |
| @@ -146,6 +146,33 @@ const isPlainObject = (v: unknown): v is Record<string | symbol, unknown> => { · 145 unmodified lines | ||
| 146 | 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. | |
| 163 | +type 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 | + | |
| 149 | 176 | export type Captured<T> = T extends ( |
| 150 | 177 | ...args: infer A |
| 151 | 178 | ) => Effect.Effect<infer X, infer E, infer R> |
| @@ -154,9 +181,11 @@ export type Captured<T> = T extends ( · 2 unmodified lines | ||
| 154 | 181 | ) => Effect.Effect<X, Exclude<E, StorageFailure> | InternalError, R> |
| 155 | 182 | : T extends (...args: infer A) => infer U |
| 156 | 183 | ? (...args: A) => U |
| 157 | − : T extends object | |
| 158 | − ? { readonly [K in keyof T]: Captured<T[K]> } | |
| 159 | − : T; | |
| 184 | + : T extends CapturedOpaque | |
| 185 | + ? T | |
| 186 | + : T extends object | |
| 187 | + ? { readonly [K in keyof T]: Captured<T[K]> } | |
| 188 | + : T; | |
| 160 | 189 | |
| 161 | 190 | export const withCapture = <T extends object>(value: T): Captured<T> => { |
| 162 | 191 | return new Proxy(value, { |
| @@ -2,6 +2,8 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; · 1 unmodified line | ||
| 2 | 2 | import { Schema } from "effect"; |
| 3 | 3 | import { ScopeId, SecretId, SecretNotFoundError, SecretResolutionError } from "@executor/sdk"; |
| 4 | 4 | |
| 5 | +import { InternalError } from "../observability"; | |
| 6 | + | |
| 5 | 7 | // --------------------------------------------------------------------------- |
| 6 | 8 | // Params |
| 7 | 9 | // --------------------------------------------------------------------------- |
| @@ -78,4 +80,5 @@ export class SecretsApi extends HttpApiGroup.make("secrets") · 70 unmodified lines | ||
| 78 | 80 | HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/secrets/${secretIdParam}` |
| 79 | 81 | .addSuccess(Schema.Struct({ removed: Schema.Boolean })) |
| 80 | 82 | .addError(SecretNotFound), |
| 81 | − ) {} | |
| 83 | + ) | |
| 84 | + .addError(InternalError) {} | |
| @@ -1,8 +1,12 @@ | ||
| 1 | 1 | import { Context } from "effect"; |
| 2 | 2 | import type { Executor } from "@executor/sdk"; |
| 3 | 3 | import type { ExecutionEngine } from "@executor/execution"; |
| 4 | +import type { Captured } from "./observability"; | |
| 4 | 5 | |
| 5 | −export class ExecutorService extends Context.Tag("ExecutorService")<ExecutorService, Executor>() {} | |
| 6 | +export class ExecutorService extends Context.Tag("ExecutorService")< | |
| 7 | + ExecutorService, | |
| 8 | + Captured<Executor> | |
| 9 | +>() {} | |
| 6 | 10 | |
| 7 | 11 | export class ExecutionEngineService extends Context.Tag("ExecutionEngineService")< |
| 8 | 12 | ExecutionEngineService, |
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | −import { ScopeId, ToolId } from "@executor/sdk"; | |
| 3 | +import { ScopeId, SourceRemovalNotAllowedError, ToolId } from "@executor/sdk"; | |
| 4 | + | |
| 5 | +import { InternalError } from "../observability"; | |
| 4 | 6 | |
| 5 | 7 | // --------------------------------------------------------------------------- |
| 6 | 8 | // Params |
| @@ -53,6 +55,14 @@ const DetectResultResponse = Schema.Struct({ · 46 unmodified lines | ||
| 53 | 55 | namespace: Schema.String, |
| 54 | 56 | }); |
| 55 | 57 | |
| 58 | +// --------------------------------------------------------------------------- | |
| 59 | +// Error schemas with HTTP status annotations | |
| 60 | +// --------------------------------------------------------------------------- | |
| 61 | + | |
| 62 | +const SourceRemovalNotAllowed = SourceRemovalNotAllowedError.annotations( | |
| 63 | + HttpApiSchema.annotations({ status: 409 }), | |
| 64 | +); | |
| 65 | + | |
| 56 | 66 | // --------------------------------------------------------------------------- |
| 57 | 67 | // Group |
| 58 | 68 | // --------------------------------------------------------------------------- |
| @@ -64,9 +74,9 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 5 unmodified lines | ||
| 64 | 74 | ), |
| 65 | 75 | ) |
| 66 | 76 | .add( |
| 67 | − HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/sources/${sourceIdParam}`.addSuccess( | |
| 68 | − SourceRemoveResponse, | |
| 69 | − ), | |
| 77 | + HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/sources/${sourceIdParam}` | |
| 78 | + .addSuccess(SourceRemoveResponse) | |
| 79 | + .addError(SourceRemovalNotAllowed), | |
| 70 | 80 | ) |
| 71 | 81 | .add( |
| 72 | 82 | HttpApiEndpoint.post( |
| @@ -82,4 +92,5 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 9 unmodified lines | ||
| 82 | 92 | HttpApiEndpoint.post("detect")`/scopes/${scopeIdParam}/sources/detect` |
| 83 | 93 | .setPayload(DetectRequest) |
| 84 | 94 | .addSuccess(Schema.Array(DetectResultResponse)), |
| 85 | − ) {} | |
| 95 | + ) | |
| 96 | + .addError(InternalError) {} | |
| @@ -2,6 +2,8 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; · 1 unmodified line | ||
| 2 | 2 | import { Schema } from "effect"; |
| 3 | 3 | import { ScopeId, ToolId, ToolNotFoundError } from "@executor/sdk"; |
| 4 | 4 | |
| 5 | +import { InternalError } from "../observability"; | |
| 6 | + | |
| 5 | 7 | // --------------------------------------------------------------------------- |
| 6 | 8 | // Params |
| 7 | 9 | // --------------------------------------------------------------------------- |
| @@ -53,4 +55,5 @@ export class ToolsApi extends HttpApiGroup.make("tools") · 45 unmodified lines | ||
| 53 | 55 | HttpApiEndpoint.get("schema")`/scopes/${scopeIdParam}/tools/${toolIdParam}/schema` |
| 54 | 56 | .addSuccess(ToolSchemaResponse) |
| 55 | 57 | .addError(ToolNotFound), |
| 56 | − ) {} | |
| 58 | + ) | |
| 59 | + .addError(InternalError) {} | |
| @@ -2,7 +2,12 @@ import { Effect, Schema } from "effect"; · 1 unmodified line | ||
| 2 | 2 | import * as fs from "node:fs"; |
| 3 | 3 | import * as path from "node:path"; |
| 4 | 4 | |
| 5 | −import { definePlugin, type PluginCtx, type SecretProvider } from "@executor/sdk"; | |
| 5 | +import { | |
| 6 | + definePlugin, | |
| 7 | + StorageError, | |
| 8 | + type PluginCtx, | |
| 9 | + type SecretProvider, | |
| 10 | +} from "@executor/sdk"; | |
| 6 | 11 | |
| 7 | 12 | // --------------------------------------------------------------------------- |
| 8 | 13 | // XDG data dir resolution |
| @@ -43,16 +48,28 @@ const decodeScopedAuthFile = Schema.decodeUnknownSync(ScopedAuthFile); · 34 unmodified lines | ||
| 43 | 48 | |
| 44 | 49 | // --------------------------------------------------------------------------- |
| 45 | 50 | // 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 | |
| 48 | 61 | const readFullFile = (filePath: string): Record<string, Record<string, string>> => { |
| 62 | + if (!fs.existsSync(filePath)) return {}; | |
| 63 | + let raw: string; | |
| 49 | 64 | try { |
| 50 | − if (!fs.existsSync(filePath)) return {}; | |
| 51 | − const raw = fs.readFileSync(filePath, "utf-8"); | |
| 52 | − return decodeScopedAuthFile(JSON.parse(raw)); | |
| 53 | − } catch { | |
| 54 | − return {}; | |
| 65 | + raw = fs.readFileSync(filePath, "utf-8"); | |
| 66 | + } catch (cause) { | |
| 67 | + // Treat "file disappeared between existsSync and readFileSync" as | |
| 68 | + // absence — anything else (EACCES, EISDIR, …) propagates. | |
| 69 | + if ((cause as NodeJS.ErrnoException).code === "ENOENT") return {}; | |
| 70 | + throw cause; | |
| 55 | 71 | } |
| 72 | + return decodeScopedAuthFile(JSON.parse(raw)); | |
| 56 | 73 | }; |
| 57 | 74 | |
| 58 | 75 | const readScopeSecrets = (filePath: string, scopeId: string): Record<string, string> => |
| @@ -100,36 +117,54 @@ export interface FileSecretsExtension { · 41 unmodified lines | ||
| 100 | 117 | // Provider factory (internal) |
| 101 | 118 | // --------------------------------------------------------------------------- |
| 102 | 119 | |
| 120 | +const toStorageError = (cause: unknown) => | |
| 121 | + new StorageError({ | |
| 122 | + message: cause instanceof Error ? cause.message : String(cause), | |
| 123 | + cause, | |
| 124 | + }); | |
| 125 | + | |
| 103 | 126 | const makeScopedProvider = (filePath: string, scopeId: string): SecretProvider => ({ |
| 104 | 127 | key: "file", |
| 105 | 128 | writable: true, |
| 106 | 129 | |
| 107 | 130 | get: (secretId) => |
| 108 | − Effect.sync(() => { | |
| 109 | − const data = readScopeSecrets(filePath, scopeId); | |
| 110 | − return data[secretId] ?? null; | |
| 131 | + Effect.try({ | |
| 132 | + try: () => { | |
| 133 | + const data = readScopeSecrets(filePath, scopeId); | |
| 134 | + return data[secretId] ?? null; | |
| 135 | + }, | |
| 136 | + catch: toStorageError, | |
| 111 | 137 | }), |
| 112 | 138 | |
| 113 | 139 | set: (secretId, value) => |
| 114 | − Effect.sync(() => { | |
| 115 | − const data = readScopeSecrets(filePath, scopeId); | |
| 116 | − data[secretId] = value; | |
| 117 | − writeScopeSecrets(filePath, scopeId, data); | |
| 140 | + Effect.try({ | |
| 141 | + try: () => { | |
| 142 | + const data = readScopeSecrets(filePath, scopeId); | |
| 143 | + data[secretId] = value; | |
| 144 | + writeScopeSecrets(filePath, scopeId, data); | |
| 145 | + }, | |
| 146 | + catch: toStorageError, | |
| 118 | 147 | }), |
| 119 | 148 | |
| 120 | 149 | delete: (secretId) => |
| 121 | − Effect.sync(() => { | |
| 122 | − const data = readScopeSecrets(filePath, scopeId); | |
| 123 | − const had = secretId in data; | |
| 124 | − delete data[secretId]; | |
| 125 | − if (had) writeScopeSecrets(filePath, scopeId, data); | |
| 126 | − return had; | |
| 150 | + Effect.try({ | |
| 151 | + try: () => { | |
| 152 | + const data = readScopeSecrets(filePath, scopeId); | |
| 153 | + const had = secretId in data; | |
| 154 | + delete data[secretId]; | |
| 155 | + if (had) writeScopeSecrets(filePath, scopeId, data); | |
| 156 | + return had; | |
| 157 | + }, | |
| 158 | + catch: toStorageError, | |
| 127 | 159 | }), |
| 128 | 160 | |
| 129 | 161 | list: () => |
| 130 | − Effect.sync(() => { | |
| 131 | − const data = readScopeSecrets(filePath, scopeId); | |
| 132 | − return Object.keys(data).map((k) => ({ id: k, name: k })); | |
| 162 | + Effect.try({ | |
| 163 | + try: () => { | |
| 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 | |
| @@ -1,21 +1,35 @@ | ||
| 1 | 1 | import { Effect } from "effect"; |
| 2 | 2 | |
| 3 | −import type { SecretProvider } from "@executor/sdk"; | |
| 3 | +import { StorageError, type SecretProvider } from "@executor/sdk"; | |
| 4 | 4 | |
| 5 | 5 | import { getPassword, setPassword, deletePassword } from "./keyring"; |
| 6 | 6 | |
| 7 | 7 | // --------------------------------------------------------------------------- |
| 8 | 8 | // 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 | |
| 21 | +const toStorageError = (cause: { readonly message: string; readonly cause?: unknown }) => | |
| 22 | + new StorageError({ message: cause.message, cause: cause.cause ?? cause }); | |
| 23 | + | |
| 11 | 24 | export const makeKeychainProvider = (serviceName: string): SecretProvider => ({ |
| 12 | 25 | key: "keychain", |
| 13 | 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 | 29 | set: (secretId, value) => |
| 16 | − setPassword(serviceName, secretId, value).pipe(Effect.orElseSucceed(() => undefined)), | |
| 30 | + setPassword(serviceName, secretId, value).pipe(Effect.mapError(toStorageError)), | |
| 17 | 31 | delete: (secretId) => |
| 18 | − deletePassword(serviceName, secretId).pipe(Effect.orElseSucceed(() => false)), | |
| 32 | + deletePassword(serviceName, secretId).pipe(Effect.mapError(toStorageError)), | |
| 19 | 33 | // Keychain doesn't support enumerating — you need to know the account name |
| 20 | 34 | list: undefined, |
| 21 | 35 | }); |