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 | // 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), |
| @@ -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), |
| @@ -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 })), |
| @@ -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.secrets | 35 | 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 | ), |
| @@ -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.tools | 43 | 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, |
| @@ -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 | } |
| @@ -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. | ||
| 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 | export type Captured<T> = T extends ( | 176 | export type Captured<T> = T extends ( |
| 150 | ...args: infer A | 177 | ...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 U | 182 | : T extends (...args: infer A) => infer U |
| 156 | ? (...args: A) => U | 183 | ? (...args: A) => U |
| 157 | : T extends object | 184 | : 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 | ||
| 161 | export const withCapture = <T extends object>(value: T): Captured<T> => { | 190 | export const withCapture = <T extends object>(value: T): Captured<T> => { |
| 162 | return new Proxy(value, { | 191 | return new Proxy(value, { |
| @@ -2,6 +2,8 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; · 1 unmodified line | |||
| 2 | import { Schema } from "effect"; | 2 | import { Schema } from "effect"; |
| 3 | import { ScopeId, SecretId, SecretNotFoundError, SecretResolutionError } from "@executor/sdk"; | 3 | import { ScopeId, SecretId, SecretNotFoundError, SecretResolutionError } from "@executor/sdk"; |
| 4 | 4 | ||
| 5 | import { InternalError } from "../observability"; | ||
| 6 | |||
| 5 | // --------------------------------------------------------------------------- | 7 | // --------------------------------------------------------------------------- |
| 6 | // Params | 8 | // 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) {} | ||
| @@ -1,8 +1,12 @@ | |||
| 1 | import { Context } from "effect"; | 1 | import { Context } from "effect"; |
| 2 | import type { Executor } from "@executor/sdk"; | 2 | import type { Executor } from "@executor/sdk"; |
| 3 | import type { ExecutionEngine } from "@executor/execution"; | 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 | export class ExecutionEngineService extends Context.Tag("ExecutionEngineService")< | 11 | export class ExecutionEngineService extends Context.Tag("ExecutionEngineService")< |
| 8 | ExecutionEngineService, | 12 | ExecutionEngineService, |
| @@ -1,6 +1,8 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | import { Schema } from "effect"; | 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 | // Params | 8 | // 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 | |||
| 62 | const SourceRemovalNotAllowed = SourceRemovalNotAllowedError.annotations( | ||
| 63 | HttpApiSchema.annotations({ status: 409 }), | ||
| 64 | ); | ||
| 65 | |||
| 56 | // --------------------------------------------------------------------------- | 66 | // --------------------------------------------------------------------------- |
| 57 | // Group | 67 | // 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) {} | ||
| @@ -2,6 +2,8 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; · 1 unmodified line | |||
| 2 | import { Schema } from "effect"; | 2 | import { Schema } from "effect"; |
| 3 | import { ScopeId, ToolId, ToolNotFoundError } from "@executor/sdk"; | 3 | import { ScopeId, ToolId, ToolNotFoundError } from "@executor/sdk"; |
| 4 | 4 | ||
| 5 | import { InternalError } from "../observability"; | ||
| 6 | |||
| 5 | // --------------------------------------------------------------------------- | 7 | // --------------------------------------------------------------------------- |
| 6 | // Params | 8 | // 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) {} | ||
| @@ -2,7 +2,12 @@ import { Effect, Schema } from "effect"; · 1 unmodified line | |||
| 2 | import * as fs from "node:fs"; | 2 | import * as fs from "node:fs"; |
| 3 | import * as path from "node:path"; | 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 | // XDG data dir resolution | 13 | // 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 permissions | 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 | const readFullFile = (filePath: string): Record<string, Record<string, string>> => { | 61 | const 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 | ||
| 58 | const readScopeSecrets = (filePath: string, scopeId: string): Record<string, string> => | 75 | const 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 | ||
| 120 | const toStorageError = (cause: unknown) => | ||
| 121 | new StorageError({ | ||
| 122 | message: cause instanceof Error ? cause.message : String(cause), | ||
| 123 | cause, | ||
| 124 | }); | ||
| 125 | |||
| 103 | const makeScopedProvider = (filePath: string, scopeId: string): SecretProvider => ({ | 126 | const 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 | ||
| @@ -1,21 +1,35 @@ | |||
| 1 | import { Effect } from "effect"; | 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 | import { getPassword, setPassword, deletePassword } from "./keyring"; | 5 | import { getPassword, setPassword, deletePassword } from "./keyring"; |
| 6 | 6 | ||
| 7 | // --------------------------------------------------------------------------- | 7 | // --------------------------------------------------------------------------- |
| 8 | // SecretProvider adapter — bridges keyring into SDK resolution chain | 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 | export const makeKeychainProvider = (serviceName: string): SecretProvider => ({ | 24 | export 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 name | 33 | // Keychain doesn't support enumerating — you need to know the account name |
| 20 | list: undefined, | 34 | list: undefined, |
| 21 | }); | 35 | }); |