* feat(tool-result): add ToolResult union and dual-shape invoker Adds `ToolResult<T>` / `ToolError` to @executor-js/sdk and a `ToolResult.ok` / `ToolResult.fail` constructor pair. Updates the sandbox tool dispatcher to accept both the new typed union (passes through unchanged) and the legacy `{ data, error }` envelope. The legacy branch now walks known upstream shapes (Microsoft Graph, DealCloud, JSON:API, plain message-bearing bodies) before falling back to a clamped JSON.stringify of the body, so structured 4xx payloads no longer collapse to 'Tool execution failed'. The cause/defect branch keeps its strict .message-only discipline. * feat(plugins): emit ToolResult from openapi, graphql, mcp, google-discovery Migrates each dynamic plugin's invokeTool to wrap its result in the typed ToolResult<T> union. OpenAPI and Google Discovery map non-2xx responses to ToolResult.fail with an extracted upstream message and the raw body in error.details; 2xx responses to ToolResult.ok with { status, headers, data }. GraphQL maps 200-with-errors bodies to ToolResult.fail({ code: 'graphql_errors', ... }) and bare data to ToolResult.ok. MCP maps { isError: true } to ToolResult.fail and content arrays to ToolResult.ok. Static plugin tools (executor.openapi.previewSpec / addSource, executor.graphql.addSource) now also wrap their plain values in ToolResult.ok for shape consistency. Updates plugin tests that asserted on the old { status, headers, data, error } envelope or the bare MCP { content } shape. Tests that consume the OpenAPI plugin's invocation result use the new unwrapInvocation helper from @executor-js/plugin-openapi/testing. * feat(tool-result): make ToolResult mandatory and route defects opaquely Removes the legacy { data, error } envelope shim from the sandbox tool dispatcher. The invoker now passes ToolResult<T> through unchanged and wraps any other plain-value plugin return in ToolResult.ok so the sandbox surface is uniform. Plugin/infra defects no longer pass their message into the sandbox. The dispatcher generates a short hex correlation id, logs the full cause with that id under executor.correlation_id, and rejects with `Internal tool error [<corrId>]`. The QuickJS bridge and the dynamic-worker module template defensively re-stamp the same opaque shape; the MCP host server's top-level execute failure path does the same. ExecutionToolError in-band messages from the execution package's built-in validators (tools.search arg checks, etc.) are still passed through at the QuickJS bridge so model-facing input errors keep their useful diagnostic. Tests: - ToolResult.ok / fail / isToolResult constructor unit tests. - repro tests assert structured upstream payloads now reach the sandbox through ToolResult.error.details (not through .message). - leak tests pin the new invariant: plugin defects only escape as the opaque generic + correlation id; no token / connection string / file path leaks into Error.message. - QuickJS end-to-end defect test confirms the same shape at the sandbox boundary. - Cloud HTTP integration tests, MCP host tests, dynamic-worker invocation tests, and plugin tests updated for the new ToolResult wire shape and the opaque-generic defect contract. * Fix tool result contracts
Unified · Split
| @@ -207,9 +207,15 @@ describe("sources api (HTTP)", () => { · 206 unmodified lines | |||
| 207 | expect(execution.structured).toMatchObject({ | 207 | expect(execution.structured).toMatchObject({ |
| 208 | status: "completed", | 208 | status: "completed", |
| 209 | result: { | 209 | result: { |
| 210 | message: "hello", | 210 | ok: true, |
| 211 | suffix: "world", | 211 | data: { |
| 212 | path: "/echo/hello", | 212 | status: 200, |
| 213 | data: { | ||
| 214 | message: "hello", | ||
| 215 | suffix: "world", | ||
| 216 | path: "/echo/hello", | ||
| 217 | }, | ||
| 218 | }, | ||
| 213 | }, | 219 | }, |
| 214 | logs: [], | 220 | logs: [], |
| 215 | }); | 221 | }); |
| @@ -315,7 +321,7 @@ describe("sources api (HTTP)", () => { · 99 unmodified lines | |||
| 315 | expect(execution.isError).toBe(false); | 321 | expect(execution.isError).toBe(false); |
| 316 | expect(execution.structured).toMatchObject({ | 322 | expect(execution.structured).toMatchObject({ |
| 317 | status: "completed", | 323 | status: "completed", |
| 318 | result: { hello: "Hello Ada" }, | 324 | result: { ok: true, data: { hello: "Hello Ada" } }, |
| 319 | }); | 325 | }); |
| 320 | const requests = yield* server.requests; | 326 | const requests = yield* server.requests; |
| 321 | expect(requests.some((request) => request.payload.query?.includes("__schema"))).toBe(true); | 327 | expect(requests.some((request) => request.payload.query?.includes("__schema"))).toBe(true); |
| @@ -391,7 +397,8 @@ describe("sources api (HTTP)", () => { · 69 unmodified lines | |||
| 391 | expect(execution.structured).toMatchObject({ | 397 | expect(execution.structured).toMatchObject({ |
| 392 | status: "completed", | 398 | status: "completed", |
| 393 | result: { | 399 | result: { |
| 394 | content: [{ type: "text", text: "cloud-mcp-ok" }], | 400 | ok: true, |
| 401 | data: { content: [{ type: "text", text: "cloud-mcp-ok" }] }, | ||
| 395 | }, | 402 | }, |
| 396 | }); | 403 | }); |
| 397 | expect((yield* server.requests).length).toBeGreaterThanOrEqual(2); | 404 | expect((yield* server.requests).length).toBeGreaterThanOrEqual(2); |
| @@ -159,7 +159,7 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 158 unmodified lines | |||
| 159 | `); | 159 | `); |
| 160 | 160 | ||
| 161 | db.prepare("INSERT INTO `openapi_source` (id, scope_id, oauth2) VALUES (?, ?, ?)").run( | 161 | db.prepare("INSERT INTO `openapi_source` (id, scope_id, oauth2) VALUES (?, ?, ?)").run( |
| 162 | "dealcloud_api", | 162 | "example_api", |
| 163 | "org-1", | 163 | "org-1", |
| 164 | JSON.stringify({ | 164 | JSON.stringify({ |
| 165 | kind: "oauth2", | 165 | kind: "oauth2", |
| @@ -174,27 +174,27 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 8 unmodified lines | |||
| 174 | "INSERT INTO `connection` (id, scope_id, provider, provider_state, scope, updated_at) VALUES (?, ?, ?, ?, ?, ?)", | 174 | "INSERT INTO `connection` (id, scope_id, provider, provider_state, scope, updated_at) VALUES (?, ?, ?, ?, ?, ?)", |
| 175 | ); | 175 | ); |
| 176 | insertConnection.run( | 176 | insertConnection.run( |
| 177 | "openapi-oauth2-app-dealcloud_api", | 177 | "openapi-oauth2-app-example_api", |
| 178 | "org-1", | 178 | "org-1", |
| 179 | "oauth2", | 179 | "oauth2", |
| 180 | JSON.stringify({ | 180 | JSON.stringify({ |
| 181 | kind: "client-credentials", | 181 | kind: "client-credentials", |
| 182 | tokenEndpoint: "https://resolve.dealcloud.com/oauth/token", | 182 | tokenEndpoint: "https://auth.example.test/oauth/token", |
| 183 | clientIdSecretId: "dealcloud-client-id", | 183 | clientIdSecretId: "example-client-id", |
| 184 | clientSecretSecretId: "dealcloud-client-secret", | 184 | clientSecretSecretId: "example-client-secret", |
| 185 | }), | 185 | }), |
| 186 | null, | 186 | null, |
| 187 | now, | 187 | now, |
| 188 | ); | 188 | ); |
| 189 | insertConnection.run( | 189 | insertConnection.run( |
| 190 | "openapi-oauth2-app-dealcloud_api", | 190 | "openapi-oauth2-app-example_api", |
| 191 | "user-org:user-jd:org-1", | 191 | "user-org:user-jd:org-1", |
| 192 | "openapi:oauth2", | 192 | "openapi:oauth2", |
| 193 | JSON.stringify({ | 193 | JSON.stringify({ |
| 194 | kind: "client-credentials", | 194 | kind: "client-credentials", |
| 195 | tokenEndpoint: "https://resolve.dealcloud.com/oauth/token", | 195 | tokenEndpoint: "https://auth.example.test/oauth/token", |
| 196 | clientIdSecretId: "dealcloud-client-id-jd", | 196 | clientIdSecretId: "example-client-id-jd", |
| 197 | clientSecretSecretId: "dealcloud-client-secret-jd", | 197 | clientSecretSecretId: "example-client-secret-jd", |
| 198 | }), | 198 | }), |
| 199 | null, | 199 | null, |
| 200 | now, | 200 | now, |
| @@ -207,12 +207,12 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 6 unmodified lines | |||
| 207 | "org-client-id", | 207 | "org-client-id", |
| 208 | "org-1", | 208 | "org-1", |
| 209 | "openapi", | 209 | "openapi", |
| 210 | "dealcloud_api", | 210 | "example_api", |
| 211 | "org-1", | 211 | "org-1", |
| 212 | "oauth2:oauth2:client-id", | 212 | "oauth2:oauth2:client-id", |
| 213 | "secret", | 213 | "secret", |
| 214 | null, | 214 | null, |
| 215 | "dealcloud-client-id-jd", | 215 | "example-client-id-jd", |
| 216 | null, | 216 | null, |
| 217 | now, | 217 | now, |
| 218 | now, | 218 | now, |
| @@ -221,12 +221,12 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 2 unmodified lines | |||
| 221 | "org-client-secret", | 221 | "org-client-secret", |
| 222 | "org-1", | 222 | "org-1", |
| 223 | "openapi", | 223 | "openapi", |
| 224 | "dealcloud_api", | 224 | "example_api", |
| 225 | "org-1", | 225 | "org-1", |
| 226 | "oauth2:oauth2:client-secret", | 226 | "oauth2:oauth2:client-secret", |
| 227 | "secret", | 227 | "secret", |
| 228 | null, | 228 | null, |
| 229 | "dealcloud-client-secret-jd", | 229 | "example-client-secret-jd", |
| 230 | null, | 230 | null, |
| 231 | now, | 231 | now, |
| 232 | now, | 232 | now, |
| @@ -235,13 +235,13 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 2 unmodified lines | |||
| 235 | "org-connection", | 235 | "org-connection", |
| 236 | "org-1", | 236 | "org-1", |
| 237 | "openapi", | 237 | "openapi", |
| 238 | "dealcloud_api", | 238 | "example_api", |
| 239 | "org-1", | 239 | "org-1", |
| 240 | "oauth2:oauth2:connection", | 240 | "oauth2:oauth2:connection", |
| 241 | "connection", | 241 | "connection", |
| 242 | null, | 242 | null, |
| 243 | null, | 243 | null, |
| 244 | "openapi-oauth2-app-dealcloud_api", | 244 | "openapi-oauth2-app-example_api", |
| 245 | now, | 245 | now, |
| 246 | now, | 246 | now, |
| 247 | ); | 247 | ); |
| @@ -249,13 +249,13 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 1 unmodified line | |||
| 249 | "jd-connection", | 249 | "jd-connection", |
| 250 | "user-org:user-jd:org-1", | 250 | "user-org:user-jd:org-1", |
| 251 | "openapi", | 251 | "openapi", |
| 252 | "dealcloud_api", | 252 | "example_api", |
| 253 | "org-1", | 253 | "org-1", |
| 254 | "oauth2:oauth2:connection", | 254 | "oauth2:oauth2:connection", |
| 255 | "connection", | 255 | "connection", |
| 256 | null, | 256 | null, |
| 257 | null, | 257 | null, |
| 258 | "openapi-oauth2-app-dealcloud_api", | 258 | "openapi-oauth2-app-example_api", |
| 259 | now, | 259 | now, |
| 260 | now, | 260 | now, |
| 261 | ); | 261 | ); |
| @@ -271,20 +271,20 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 9 unmodified lines | |||
| 271 | .prepare( | 271 | .prepare( |
| 272 | "SELECT scope_id, slot_key, kind, secret_id, connection_id FROM `credential_binding` WHERE source_id = ? ORDER BY scope_id, slot_key", | 272 | "SELECT scope_id, slot_key, kind, secret_id, connection_id FROM `credential_binding` WHERE source_id = ? ORDER BY scope_id, slot_key", |
| 273 | ) | 273 | ) |
| 274 | .all("dealcloud_api"); | 274 | .all("example_api"); |
| 275 | expect(bindings).toEqual([ | 275 | expect(bindings).toEqual([ |
| 276 | { | 276 | { |
| 277 | scope_id: "org-1", | 277 | scope_id: "org-1", |
| 278 | slot_key: "oauth2:oauth2:client-id", | 278 | slot_key: "oauth2:oauth2:client-id", |
| 279 | kind: "secret", | 279 | kind: "secret", |
| 280 | secret_id: "dealcloud-client-id", | 280 | secret_id: "example-client-id", |
| 281 | connection_id: null, | 281 | connection_id: null, |
| 282 | }, | 282 | }, |
| 283 | { | 283 | { |
| 284 | scope_id: "org-1", | 284 | scope_id: "org-1", |
| 285 | slot_key: "oauth2:oauth2:client-secret", | 285 | slot_key: "oauth2:oauth2:client-secret", |
| 286 | kind: "secret", | 286 | kind: "secret", |
| 287 | secret_id: "dealcloud-client-secret", | 287 | secret_id: "example-client-secret", |
| 288 | connection_id: null, | 288 | connection_id: null, |
| 289 | }, | 289 | }, |
| 290 | { | 290 | { |
| @@ -292,20 +292,20 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 1 unmodified line | |||
| 292 | slot_key: "oauth2:oauth2:connection", | 292 | slot_key: "oauth2:oauth2:connection", |
| 293 | kind: "connection", | 293 | kind: "connection", |
| 294 | secret_id: null, | 294 | secret_id: null, |
| 295 | connection_id: "openapi-oauth2-app-dealcloud_api", | 295 | connection_id: "openapi-oauth2-app-example_api", |
| 296 | }, | 296 | }, |
| 297 | { | 297 | { |
| 298 | scope_id: "user-org:user-jd:org-1", | 298 | scope_id: "user-org:user-jd:org-1", |
| 299 | slot_key: "oauth2:oauth2:client-id", | 299 | slot_key: "oauth2:oauth2:client-id", |
| 300 | kind: "secret", | 300 | kind: "secret", |
| 301 | secret_id: "dealcloud-client-id-jd", | 301 | secret_id: "example-client-id-jd", |
| 302 | connection_id: null, | 302 | connection_id: null, |
| 303 | }, | 303 | }, |
| 304 | { | 304 | { |
| 305 | scope_id: "user-org:user-jd:org-1", | 305 | scope_id: "user-org:user-jd:org-1", |
| 306 | slot_key: "oauth2:oauth2:client-secret", | 306 | slot_key: "oauth2:oauth2:client-secret", |
| 307 | kind: "secret", | 307 | kind: "secret", |
| 308 | secret_id: "dealcloud-client-secret-jd", | 308 | secret_id: "example-client-secret-jd", |
| 309 | connection_id: null, | 309 | connection_id: null, |
| 310 | }, | 310 | }, |
| 311 | { | 311 | { |
| @@ -313,7 +313,7 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 1 unmodified line | |||
| 313 | slot_key: "oauth2:oauth2:connection", | 313 | slot_key: "oauth2:oauth2:connection", |
| 314 | kind: "connection", | 314 | kind: "connection", |
| 315 | secret_id: null, | 315 | secret_id: null, |
| 316 | connection_id: "openapi-oauth2-app-dealcloud_api", | 316 | connection_id: "openapi-oauth2-app-example_api", |
| 317 | }, | 317 | }, |
| 318 | ]); | 318 | ]); |
| 319 | }); | 319 | }); |
| @@ -48,6 +48,7 @@ const formatDescription = (sources: readonly Source[]): string => { · 47 unmodified lines | |||
| 48 | "- `tools.search()` returns paginated, ranked matches: `{ items, total, hasMore, nextOffset }`. Best-first. Use short intent phrases like `github issues`, `repo details`, or `create calendar event`.", | 48 | "- `tools.search()` returns paginated, ranked matches: `{ items, total, hasMore, nextOffset }`. Best-first. Use short intent phrases like `github issues`, `repo details`, or `create calendar event`.", |
| 49 | '- When you already know the namespace, narrow with `tools.search({ namespace: "github", query: "issues" })`.', | 49 | '- When you already know the namespace, narrow with `tools.search({ namespace: "github", query: "issues" })`.', |
| 50 | "- `tools.executor.sources.list()` returns the same paged shape: `{ items: [{ id, toolCount, ... }], total, hasMore, nextOffset }`.", | 50 | "- `tools.executor.sources.list()` returns the same paged shape: `{ items: [{ id, toolCount, ... }], total, hasMore, nextOffset }`.", |
| 51 | "- Tool calls return a value union: `{ ok: true, data }` for success or `{ ok: false, error: { code, message, status?, details?, retryable? } }` for expected tool/domain failures. Branch on `result.ok`.", | ||
| 51 | "- If `hasMore` is true and you didn't find what you need, fetch the next page: `tools.search({ query, offset: nextOffset, limit })`. Same `offset` parameter on `tools.executor.sources.list({ offset, limit })`.", | 52 | "- If `hasMore` is true and you didn't find what you need, fetch the next page: `tools.search({ query, offset: nextOffset, limit })`. Same `offset` parameter on `tools.executor.sources.list({ offset, limit })`.", |
| 52 | "- Always use the namespace prefix when calling tools: `tools.<namespace>.<tool>(args)`. Example: `tools.home_assistant_rest_api.states.getState(...)` — not `tools.states.getState(...)`.", | 53 | "- Always use the namespace prefix when calling tools: `tools.<namespace>.<tool>(args)`. Example: `tools.home_assistant_rest_api.states.getState(...)` — not `tools.states.getState(...)`.", |
| 53 | "- The `tools` object is a lazy proxy — `Object.keys(tools)` won't work. Use `tools.search()` or `tools.executor.sources.list()` instead.", | 54 | "- The `tools` object is a lazy proxy — `Object.keys(tools)` won't work. Use `tools.search()` or `tools.executor.sources.list()` instead.", |
| @@ -0,0 +1,113 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Data, Effect, Schema } from "effect"; | ||
| 3 | |||
| 4 | import { ElicitationResponse, createExecutor, definePlugin } from "@executor-js/sdk"; | ||
| 5 | import { makeTestConfig } from "@executor-js/sdk/testing"; | ||
| 6 | import { ExecutionToolError } from "./errors"; | ||
| 7 | import { makeExecutorToolInvoker } from "./tool-invoker"; | ||
| 8 | |||
| 9 | const EmptyInputSchema = Schema.toStandardSchemaV1( | ||
| 10 | Schema.toStandardJSONSchemaV1(Schema.Struct({})), | ||
| 11 | ); | ||
| 12 | |||
| 13 | const acceptAll = () => Effect.succeed(ElicitationResponse.make({ action: "accept" })); | ||
| 14 | |||
| 15 | // Plugin-internal tagged error whose `cause` carries internal diagnostics. | ||
| 16 | // The dispatcher must route this through the opaque-generic path so none of | ||
| 17 | // that context reaches the sandbox via Error.message. | ||
| 18 | class FakePluginInvocationError extends Data.TaggedError("PluginInvocationError")<{ | ||
| 19 | readonly message: string; | ||
| 20 | readonly cause: unknown; | ||
| 21 | }> {} | ||
| 22 | |||
| 23 | const leakyPlugin = definePlugin(() => ({ | ||
| 24 | id: "leaky-test" as const, | ||
| 25 | storage: () => ({}), | ||
| 26 | staticSources: () => [ | ||
| 27 | { | ||
| 28 | id: "leaky", | ||
| 29 | kind: "in-memory", | ||
| 30 | name: "Leaky", | ||
| 31 | tools: [ | ||
| 32 | { | ||
| 33 | name: "failsWithCause", | ||
| 34 | description: "", | ||
| 35 | inputSchema: EmptyInputSchema, | ||
| 36 | handler: () => | ||
| 37 | Effect.fail( | ||
| 38 | new FakePluginInvocationError({ | ||
| 39 | message: "Upstream request failed", | ||
| 40 | cause: { | ||
| 41 | _tag: "InternalTransportError", | ||
| 42 | request: { | ||
| 43 | method: "GET", | ||
| 44 | url: "https://internal.service.local/v1/resources?trace=trace-123", | ||
| 45 | headers: { "x-internal-routing": "private-cluster" }, | ||
| 46 | }, | ||
| 47 | stack: "Error: connect failed\n at plugin-transport.ts:42:11", | ||
| 48 | note: "internal diagnostic detail", | ||
| 49 | }, | ||
| 50 | }), | ||
| 51 | ), | ||
| 52 | }, | ||
| 53 | { | ||
| 54 | name: "throwsRawError", | ||
| 55 | description: "", | ||
| 56 | inputSchema: EmptyInputSchema, | ||
| 57 | handler: () => | ||
| 58 | Effect.fail( | ||
| 59 | Object.assign( | ||
| 60 | // oxlint-disable-next-line executor/no-error-constructor -- boundary: leak test deliberately fails with a raw Error + crafted stack to assert the dispatcher's opaque-generic redaction | ||
| 61 | new Error("Internal: secret 'sk_live_abcd' rotation failed"), | ||
| 62 | { | ||
| 63 | stack: | ||
| 64 | "Error: Internal: secret 'sk_live_abcd' rotation failed\n at /home/svc/.../secret-store.ts:88", | ||
| 65 | }, | ||
| 66 | ), | ||
| 67 | ), | ||
| 68 | }, | ||
| 69 | ], | ||
| 70 | }, | ||
| 71 | ], | ||
| 72 | })); | ||
| 73 | |||
| 74 | describe("internal-error leak audit (opaque defects)", () => { | ||
| 75 | it.effect("plugin tagged error: defect surfaces only as opaque generic + correlation id", () => | ||
| 76 | Effect.gen(function* () { | ||
| 77 | const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const })); | ||
| 78 | const invoker = makeExecutorToolInvoker(executor, { | ||
| 79 | invokeOptions: { onElicitation: acceptAll }, | ||
| 80 | }); | ||
| 81 | |||
| 82 | const err = yield* Effect.flip(invoker.invoke({ path: "leaky.failsWithCause", args: {} })); | ||
| 83 | expect(err).toBeInstanceOf(ExecutionToolError); | ||
| 84 | // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: leak test inspects the rendered message to assert it is the opaque generic | ||
| 85 | const msg = (err as { message: string }).message; | ||
| 86 | // Must be the canonical opaque shape: "Internal tool error [<hex>]" | ||
| 87 | expect(msg).toMatch(/^Internal tool error \[[0-9a-f]{8}\]$/); | ||
| 88 | // Crucially, no internal context leaks | ||
| 89 | expect(msg).not.toContain("trace-123"); | ||
| 90 | expect(msg).not.toContain("private-cluster"); | ||
| 91 | expect(msg).not.toContain("internal.service.local"); | ||
| 92 | expect(msg).not.toContain("InternalTransportError"); | ||
| 93 | expect(msg).not.toContain("Upstream request failed"); | ||
| 94 | }), | ||
| 95 | ); | ||
| 96 | |||
| 97 | it.effect("plain Error with stack: stack and message do NOT escape", () => | ||
| 98 | Effect.gen(function* () { | ||
| 99 | const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const })); | ||
| 100 | const invoker = makeExecutorToolInvoker(executor, { | ||
| 101 | invokeOptions: { onElicitation: acceptAll }, | ||
| 102 | }); | ||
| 103 | |||
| 104 | const err = yield* Effect.flip(invoker.invoke({ path: "leaky.throwsRawError", args: {} })); | ||
| 105 | // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: leak test inspects the rendered message to assert it is the opaque generic | ||
| 106 | const msg = (err as { message: string }).message; | ||
| 107 | expect(msg).toMatch(/^Internal tool error \[[0-9a-f]{8}\]$/); | ||
| 108 | expect(msg).not.toContain("secret-store.ts"); | ||
| 109 | expect(msg).not.toContain("at /home/"); | ||
| 110 | expect(msg).not.toContain("sk_live_abcd"); | ||
| 111 | }), | ||
| 112 | ); | ||
| 113 | }); | ||
| @@ -1,9 +1,11 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Effect, Fiber, Schema } from "effect"; | 2 | import { Effect, Fiber, Schema } from "effect"; |
| 3 | import * as ts from "typescript"; | ||
| 3 | 4 | ||
| 4 | import { | 5 | import { |
| 5 | ElicitationResponse, | 6 | ElicitationResponse, |
| 6 | FormElicitation, | 7 | FormElicitation, |
| 8 | ToolResult, | ||
| 7 | createExecutor, | 9 | createExecutor, |
| 8 | definePlugin, | 10 | definePlugin, |
| 9 | } from "@executor-js/sdk"; | 11 | } from "@executor-js/sdk"; |
| @@ -18,6 +20,10 @@ const RepoInputSchema = Schema.toStandardSchemaV1( · 8 unmodified lines | |||
| 18 | Schema.toStandardJSONSchemaV1(Schema.Struct({ owner: Schema.String, repo: Schema.String })), | 20 | Schema.toStandardJSONSchemaV1(Schema.Struct({ owner: Schema.String, repo: Schema.String })), |
| 19 | ); | 21 | ); |
| 20 | 22 | ||
| 23 | const RepoDetailsOutputSchema = Schema.toStandardSchemaV1( | ||
| 24 | Schema.toStandardJSONSchemaV1(Schema.Struct({ defaultBranch: Schema.String })), | ||
| 25 | ); | ||
| 26 | |||
| 21 | const ContactInputSchema = Schema.toStandardSchemaV1( | 27 | const ContactInputSchema = Schema.toStandardSchemaV1( |
| 22 | Schema.toStandardJSONSchemaV1(Schema.Struct({ email: Schema.String })), | 28 | Schema.toStandardJSONSchemaV1(Schema.Struct({ email: Schema.String })), |
| 23 | ); | 29 | ); |
| @@ -28,6 +34,58 @@ const EmptyInputSchema = Schema.toStandardSchemaV1( · 4 unmodified lines | |||
| 28 | 34 | ||
| 29 | const acceptAll = () => Effect.succeed(ElicitationResponse.make({ action: "accept" })); | 35 | const acceptAll = () => Effect.succeed(ElicitationResponse.make({ action: "accept" })); |
| 30 | 36 | ||
| 37 | type DescribedToolContract = { | ||
| 38 | readonly outputTypeScript: string; | ||
| 39 | readonly typeScriptDefinitions: Record<string, string>; | ||
| 40 | }; | ||
| 41 | |||
| 42 | const typeCheckDescribedInvocation = ( | ||
| 43 | described: DescribedToolContract, | ||
| 44 | runtimeResult: unknown, | ||
| 45 | consumerSource: string, | ||
| 46 | ): readonly string[] => { | ||
| 47 | const fileName = "described-tool-contract.ts"; | ||
| 48 | const source = [ | ||
| 49 | ...Object.entries(described.typeScriptDefinitions).map(([name, definition]) => { | ||
| 50 | return `type ${name} = ${definition};`; | ||
| 51 | }), | ||
| 52 | `type ToolOutput = ${described.outputTypeScript};`, | ||
| 53 | `const invokedResult: ToolOutput = ${JSON.stringify(runtimeResult)};`, | ||
| 54 | consumerSource, | ||
| 55 | ].join("\n"); | ||
| 56 | |||
| 57 | const options: ts.CompilerOptions = { | ||
| 58 | module: ts.ModuleKind.ESNext, | ||
| 59 | noEmit: true, | ||
| 60 | skipLibCheck: true, | ||
| 61 | strict: true, | ||
| 62 | target: ts.ScriptTarget.ES2022, | ||
| 63 | }; | ||
| 64 | const host = ts.createCompilerHost(options); | ||
| 65 | const originalGetSourceFile = host.getSourceFile.bind(host); | ||
| 66 | const originalReadFile = host.readFile.bind(host); | ||
| 67 | const originalFileExists = host.fileExists.bind(host); | ||
| 68 | |||
| 69 | host.getSourceFile = (candidate, languageVersion, onError, shouldCreateNewSourceFile) => { | ||
| 70 | if (candidate === fileName) { | ||
| 71 | return ts.createSourceFile(candidate, source, languageVersion, true); | ||
| 72 | } | ||
| 73 | return originalGetSourceFile(candidate, languageVersion, onError, shouldCreateNewSourceFile); | ||
| 74 | }; | ||
| 75 | host.readFile = (candidate) => (candidate === fileName ? source : originalReadFile(candidate)); | ||
| 76 | host.fileExists = (candidate) => candidate === fileName || originalFileExists(candidate); | ||
| 77 | |||
| 78 | const program = ts.createProgram([fileName], options, host); | ||
| 79 | return ts.getPreEmitDiagnostics(program).map((diagnostic) => { | ||
| 80 | const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"); | ||
| 81 | if (!diagnostic.file || diagnostic.start === undefined) { | ||
| 82 | return message; | ||
| 83 | } | ||
| 84 | const position = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); | ||
| 85 | return `${diagnostic.file.fileName}:${position.line + 1}:${position.character + 1} ${message}`; | ||
| 86 | }); | ||
| 87 | }; | ||
| 88 | |||
| 31 | // --------------------------------------------------------------------------- | 89 | // --------------------------------------------------------------------------- |
| 32 | // Test plugins — each one declares a namespace as a static source with N | 90 | // Test plugins — each one declares a namespace as a static source with N |
| 33 | // tools. Handlers return static data; the suite only cares about discovery | 91 | // tools. Handlers return static data; the suite only cares about discovery |
| @@ -53,6 +111,7 @@ const githubPlugin = definePlugin(() => ({ · 19 unmodified lines | |||
| 53 | name: "getRepositoryDetails", | 111 | name: "getRepositoryDetails", |
| 54 | description: "Get repository details including the default branch", | 112 | description: "Get repository details including the default branch", |
| 55 | inputSchema: RepoInputSchema, | 113 | inputSchema: RepoInputSchema, |
| 114 | outputSchema: RepoDetailsOutputSchema, | ||
| 56 | handler: () => Effect.succeed({ defaultBranch: "main" }), | 115 | handler: () => Effect.succeed({ defaultBranch: "main" }), |
| 57 | }, | 116 | }, |
| 58 | { | 117 | { |
| @@ -106,13 +165,78 @@ const errorPlugin = definePlugin(() => ({ · 47 unmodified lines | |||
| 106 | description: "Query rows", | 165 | description: "Query rows", |
| 107 | inputSchema: EmptyInputSchema, | 166 | inputSchema: EmptyInputSchema, |
| 108 | handler: () => | 167 | handler: () => |
| 109 | Effect.succeed({ | 168 | Effect.succeed( |
| 110 | data: null, | 169 | ToolResult.fail({ |
| 111 | error: { | ||
| 112 | message: 'Field with name "DisplayName" does not exist', | ||
| 113 | code: "invalid_query", | 170 | code: "invalid_query", |
| 114 | }, | 171 | message: 'Field with name "DisplayName" does not exist', |
| 115 | }), | 172 | }), |
| 173 | ), | ||
| 174 | }, | ||
| 175 | ], | ||
| 176 | }, | ||
| 177 | ], | ||
| 178 | })); | ||
| 179 | |||
| 180 | const structuredFailurePlugin = definePlugin(() => ({ | ||
| 181 | id: "structured-failure-test" as const, | ||
| 182 | storage: () => ({}), | ||
| 183 | staticSources: () => [ | ||
| 184 | { | ||
| 185 | id: "upstream", | ||
| 186 | kind: "in-memory", | ||
| 187 | name: "Upstream", | ||
| 188 | tools: [ | ||
| 189 | { | ||
| 190 | name: "nestedErrorBody", | ||
| 191 | description: "", | ||
| 192 | inputSchema: EmptyInputSchema, | ||
| 193 | handler: () => | ||
| 194 | Effect.succeed( | ||
| 195 | ToolResult.fail({ | ||
| 196 | code: "upstream_http_error", | ||
| 197 | status: 400, | ||
| 198 | message: 'The expression "foo" is not valid. Provide a valid expression.', | ||
| 199 | details: { | ||
| 200 | error: { | ||
| 201 | code: "invalidRequest", | ||
| 202 | message: 'The expression "foo" is not valid. Provide a valid expression.', | ||
| 203 | }, | ||
| 204 | }, | ||
| 205 | }), | ||
| 206 | ), | ||
| 207 | }, | ||
| 208 | { | ||
| 209 | name: "flatErrorBody", | ||
| 210 | description: "", | ||
| 211 | inputSchema: EmptyInputSchema, | ||
| 212 | handler: () => | ||
| 213 | Effect.succeed( | ||
| 214 | ToolResult.fail({ | ||
| 215 | code: "upstream_http_error", | ||
| 216 | status: 400, | ||
| 217 | message: "Field 'XYZ' does not exist", | ||
| 218 | details: { | ||
| 219 | errorCode: 400, | ||
| 220 | errorMessage: "Field 'XYZ' does not exist", | ||
| 221 | }, | ||
| 222 | }), | ||
| 223 | ), | ||
| 224 | }, | ||
| 225 | { | ||
| 226 | name: "errorsArrayBody", | ||
| 227 | description: "", | ||
| 228 | inputSchema: EmptyInputSchema, | ||
| 229 | handler: () => | ||
| 230 | Effect.succeed( | ||
| 231 | ToolResult.fail({ | ||
| 232 | code: "upstream_http_error", | ||
| 233 | status: 403, | ||
| 234 | message: "Insufficient scope", | ||
| 235 | details: { | ||
| 236 | errors: [{ status: "403", title: "Forbidden", detail: "Insufficient scope" }], | ||
| 237 | }, | ||
| 238 | }), | ||
| 239 | ), | ||
| 116 | }, | 240 | }, |
| 117 | ], | 241 | ], |
| 118 | }, | 242 | }, |
| @@ -342,8 +466,102 @@ describe("tool discovery", () => { · 223 unmodified lines | |||
| 342 | expect(described.name).toBe("listRepositoryIssues"); | 466 | expect(described.name).toBe("listRepositoryIssues"); |
| 343 | expect(described.description).toBe("List issues for a repository"); | 467 | expect(described.description).toBe("List issues for a repository"); |
| 344 | expect(described.inputTypeScript).toBe("{ owner: string; repo: string; }"); | 468 | expect(described.inputTypeScript).toBe("{ owner: string; repo: string; }"); |
| 345 | expect(described.outputTypeScript).toBeUndefined(); | 469 | expect(described.outputTypeScript).toBe( |
| 346 | expect(described.typeScriptDefinitions).toBeUndefined(); | 470 | "{ ok: true; data: unknown } | { ok: false; error: ToolError }", |
| 471 | ); | ||
| 472 | expect(described.typeScriptDefinitions).toEqual({ | ||
| 473 | ToolError: | ||
| 474 | "{ code: string; message: string; status?: number; details?: unknown; retryable?: boolean }", | ||
| 475 | }); | ||
| 476 | }), | ||
| 477 | ); | ||
| 478 | |||
| 479 | it.effect("describes a return type that accepts the sandbox invocation result", () => | ||
| 480 | Effect.gen(function* () { | ||
| 481 | const executor = yield* makeSearchExecutor(); | ||
| 482 | const engine = createExecutionEngine({ executor, codeExecutor }); | ||
| 483 | |||
| 484 | const execution = yield* engine.execute( | ||
| 485 | [ | ||
| 486 | 'const details = await tools.describe.tool({ path: "github.getRepositoryDetails" });', | ||
| 487 | "const result = await tools.github.getRepositoryDetails({ owner: 'executor', repo: 'executor' });", | ||
| 488 | "return {", | ||
| 489 | " outputTypeScript: details.outputTypeScript,", | ||
| 490 | " typeScriptDefinitions: details.typeScriptDefinitions,", | ||
| 491 | " result,", | ||
| 492 | "};", | ||
| 493 | ].join("\n"), | ||
| 494 | { onElicitation: acceptAll }, | ||
| 495 | ); | ||
| 496 | |||
| 497 | expect(execution.error).toBeUndefined(); | ||
| 498 | const observed = execution.result as DescribedToolContract & { readonly result: unknown }; | ||
| 499 | const diagnostics = typeCheckDescribedInvocation( | ||
| 500 | observed, | ||
| 501 | observed.result, | ||
| 502 | [ | ||
| 503 | "function readDefaultBranch(result: ToolOutput): string {", | ||
| 504 | " if (!result.ok) return result.error.message;", | ||
| 505 | " return result.data.defaultBranch;", | ||
| 506 | "}", | ||
| 507 | "readDefaultBranch(invokedResult);", | ||
| 508 | ].join("\n"), | ||
| 509 | ); | ||
| 510 | expect(diagnostics).toEqual([]); | ||
| 511 | }), | ||
| 512 | ); | ||
| 513 | |||
| 514 | it.effect( | ||
| 515 | "describes an error-as-value return type that accepts sandbox invocation failures", | ||
| 516 | () => | ||
| 517 | Effect.gen(function* () { | ||
| 518 | const executor = yield* createExecutor( | ||
| 519 | makeTestConfig({ plugins: [errorPlugin()] as const }), | ||
| 520 | ); | ||
| 521 | const engine = createExecutionEngine({ executor, codeExecutor }); | ||
| 522 | |||
| 523 | const execution = yield* engine.execute( | ||
| 524 | [ | ||
| 525 | 'const details = await tools.describe.tool({ path: "records.queryRows" });', | ||
| 526 | "const result = await tools.records.queryRows({});", | ||
| 527 | "return {", | ||
| 528 | " outputTypeScript: details.outputTypeScript,", | ||
| 529 | " typeScriptDefinitions: details.typeScriptDefinitions,", | ||
| 530 | " result,", | ||
| 531 | "};", | ||
| 532 | ].join("\n"), | ||
| 533 | { onElicitation: acceptAll }, | ||
| 534 | ); | ||
| 535 | |||
| 536 | expect(execution.error).toBeUndefined(); | ||
| 537 | const observed = execution.result as DescribedToolContract & { readonly result: unknown }; | ||
| 538 | const diagnostics = typeCheckDescribedInvocation( | ||
| 539 | observed, | ||
| 540 | observed.result, | ||
| 541 | [ | ||
| 542 | "function readToolResult(result: ToolOutput): unknown {", | ||
| 543 | " if (!result.ok) return result.error.message;", | ||
| 544 | " return result.data;", | ||
| 545 | "}", | ||
| 546 | "readToolResult(invokedResult);", | ||
| 547 | ].join("\n"), | ||
| 548 | ); | ||
| 549 | expect(diagnostics).toEqual([]); | ||
| 550 | }), | ||
| 551 | ); | ||
| 552 | |||
| 553 | it.effect("describes the ToolResult wrapper through the direct describe helper", () => | ||
| 554 | Effect.gen(function* () { | ||
| 555 | const executor = yield* makeSearchExecutor(); | ||
| 556 | const described = yield* describeTool(executor, "github.getRepositoryDetails"); | ||
| 557 | |||
| 558 | expect(described.outputTypeScript).toBe( | ||
| 559 | "{ ok: true; data: { defaultBranch: string; } } | { ok: false; error: ToolError }", | ||
| 560 | ); | ||
| 561 | expect(described.typeScriptDefinitions).toEqual({ | ||
| 562 | ToolError: | ||
| 563 | "{ code: string; message: string; status?: number; details?: unknown; retryable?: boolean }", | ||
| 564 | }); | ||
| 347 | }), | 565 | }), |
| 348 | ); | 566 | ); |
| 349 | 567 | ||
| @@ -405,20 +623,97 @@ describe("tool discovery", () => { · 55 unmodified lines | |||
| 405 | }), | 623 | }), |
| 406 | ); | 624 | ); |
| 407 | 625 | ||
| 408 | it.effect("converts message-bearing tool error results into execution errors", () => | 626 | it.effect("passes ToolResult.fail through to the sandbox as a value (no throw)", () => |
| 409 | Effect.gen(function* () { | 627 | Effect.gen(function* () { |
| 410 | const executor = yield* createExecutor(makeTestConfig({ plugins: [errorPlugin()] as const })); | 628 | const executor = yield* createExecutor(makeTestConfig({ plugins: [errorPlugin()] as const })); |
| 411 | const invoker = makeExecutorToolInvoker(executor, { | 629 | const invoker = makeExecutorToolInvoker(executor, { |
| 412 | invokeOptions: { onElicitation: acceptAll }, | 630 | invokeOptions: { onElicitation: acceptAll }, |
| 413 | }); | 631 | }); |
| 414 | 632 | ||
| 415 | const error = yield* Effect.flip(invoker.invoke({ path: "records.queryRows", args: {} })); | 633 | const result = yield* invoker.invoke({ path: "records.queryRows", args: {} }); |
| 416 | 634 | expect(result).toEqual({ | |
| 417 | expect(error).toEqual( | 635 | ok: false, |
| 418 | expect.objectContaining({ | 636 | error: { |
| 637 | code: "invalid_query", | ||
| 419 | message: 'Field with name "DisplayName" does not exist', | 638 | message: 'Field with name "DisplayName" does not exist', |
| 420 | }), | 639 | }, |
| 640 | }); | ||
| 641 | }), | ||
| 642 | ); | ||
| 643 | |||
| 644 | it.effect("preserves nested upstream error bodies through ToolResult.fail", () => | ||
| 645 | Effect.gen(function* () { | ||
| 646 | const executor = yield* createExecutor( | ||
| 647 | makeTestConfig({ plugins: [structuredFailurePlugin()] as const }), | ||
| 648 | ); | ||
| 649 | const invoker = makeExecutorToolInvoker(executor, { | ||
| 650 | invokeOptions: { onElicitation: acceptAll }, | ||
| 651 | }); | ||
| 652 | |||
| 653 | const result = yield* invoker.invoke({ path: "upstream.nestedErrorBody", args: {} }); | ||
| 654 | expect(result).toEqual({ | ||
| 655 | ok: false, | ||
| 656 | error: { | ||
| 657 | code: "upstream_http_error", | ||
| 658 | status: 400, | ||
| 659 | message: 'The expression "foo" is not valid. Provide a valid expression.', | ||
| 660 | details: { | ||
| 661 | error: { | ||
| 662 | code: "invalidRequest", | ||
| 663 | message: 'The expression "foo" is not valid. Provide a valid expression.', | ||
| 664 | }, | ||
| 665 | }, | ||
| 666 | }, | ||
| 667 | }); | ||
| 668 | }), | ||
| 669 | ); | ||
| 670 | |||
| 671 | it.effect("preserves flat upstream error bodies through ToolResult.fail", () => | ||
| 672 | Effect.gen(function* () { | ||
| 673 | const executor = yield* createExecutor( | ||
| 674 | makeTestConfig({ plugins: [structuredFailurePlugin()] as const }), | ||
| 675 | ); | ||
| 676 | const invoker = makeExecutorToolInvoker(executor, { | ||
| 677 | invokeOptions: { onElicitation: acceptAll }, | ||
| 678 | }); | ||
| 679 | |||
| 680 | const result = yield* invoker.invoke({ path: "upstream.flatErrorBody", args: {} }); | ||
| 681 | expect(result).toEqual({ | ||
| 682 | ok: false, | ||
| 683 | error: { | ||
| 684 | code: "upstream_http_error", | ||
| 685 | status: 400, | ||
| 686 | message: "Field 'XYZ' does not exist", | ||
| 687 | details: { | ||
| 688 | errorCode: 400, | ||
| 689 | errorMessage: "Field 'XYZ' does not exist", | ||
| 690 | }, | ||
| 691 | }, | ||
| 692 | }); | ||
| 693 | }), | ||
| 694 | ); | ||
| 695 | |||
| 696 | it.effect("preserves upstream errors arrays through ToolResult.fail", () => | ||
| 697 | Effect.gen(function* () { | ||
| 698 | const executor = yield* createExecutor( | ||
| 699 | makeTestConfig({ plugins: [structuredFailurePlugin()] as const }), | ||
| 421 | ); | 700 | ); |
| 701 | const invoker = makeExecutorToolInvoker(executor, { | ||
| 702 | invokeOptions: { onElicitation: acceptAll }, | ||
| 703 | }); | ||
| 704 | |||
| 705 | const result = yield* invoker.invoke({ path: "upstream.errorsArrayBody", args: {} }); | ||
| 706 | expect(result).toEqual({ | ||
| 707 | ok: false, | ||
| 708 | error: { | ||
| 709 | code: "upstream_http_error", | ||
| 710 | status: 403, | ||
| 711 | message: "Insufficient scope", | ||
| 712 | details: { | ||
| 713 | errors: [{ status: "403", title: "Forbidden", detail: "Insufficient scope" }], | ||
| 714 | }, | ||
| 715 | }, | ||
| 716 | }); | ||
| 422 | }), | 717 | }), |
| 423 | ); | 718 | ); |
| 424 | }); | 719 | }); |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { Effect, Match, Option, Predicate } from "effect"; | 1 | import { Effect, Predicate } from "effect"; |
| 2 | import * as Cause from "effect/Cause"; | 2 | import * as Cause from "effect/Cause"; |
| 3 | import type { | 3 | import type { |
| 4 | Executor, | 4 | Executor, |
| @@ -8,9 +8,32 @@ import type { · 3 unmodified lines | |||
| 8 | InvokeOptions, | 8 | InvokeOptions, |
| 9 | Source, | 9 | Source, |
| 10 | } from "@executor-js/sdk/core"; | 10 | } from "@executor-js/sdk/core"; |
| 11 | import { isToolResult } from "@executor-js/sdk/core"; | ||
| 11 | import type { SandboxToolInvoker } from "@executor-js/codemode-core"; | 12 | import type { SandboxToolInvoker } from "@executor-js/codemode-core"; |
| 12 | import { ExecutionToolError } from "./errors"; | 13 | import { ExecutionToolError } from "./errors"; |
| 13 | 14 | ||
| 15 | const OPAQUE_DEFECT_MESSAGE = "Internal tool error"; | ||
| 16 | const TOOL_ERROR_TYPESCRIPT = | ||
| 17 | "{ code: string; message: string; status?: number; details?: unknown; retryable?: boolean }"; | ||
| 18 | |||
| 19 | const wrapOutputTypeScript = (outputTypeScript?: string): string => | ||
| 20 | `{ ok: true; data: ${outputTypeScript ?? "unknown"} } | { ok: false; error: ToolError }`; | ||
| 21 | |||
| 22 | const withToolResultDefinitions = ( | ||
| 23 | definitions?: Record<string, string>, | ||
| 24 | ): Record<string, string> => ({ | ||
| 25 | ...(definitions ?? {}), | ||
| 26 | ToolError: TOOL_ERROR_TYPESCRIPT, | ||
| 27 | }); | ||
| 28 | |||
| 29 | const newCorrelationId = (): string => { | ||
| 30 | // 8-hex-char correlation id; enough entropy to disambiguate within a | ||
| 31 | // single deployment without leaking host process info. | ||
| 32 | return Math.floor(Math.random() * 0x1_0000_0000) | ||
| 33 | .toString(16) | ||
| 34 | .padStart(8, "0"); | ||
| 35 | }; | ||
| 36 | |||
| 14 | /** | 37 | /** |
| 15 | * Extract the source namespace from a tool path. Tool paths look like | 38 | * Extract the source namespace from a tool path. Tool paths look like |
| 16 | * "<sourceId>.<op>" or "<sourceId>.<group>.<op>" — we take the first | 39 | * "<sourceId>.<op>" or "<sourceId>.<group>.<op>" — we take the first |
| @@ -23,44 +46,6 @@ const extractSourceNamespace = (path: string): string => { · 6 unmodified lines | |||
| 23 | return idx === -1 ? path : path.slice(0, idx); | 46 | return idx === -1 ? path : path.slice(0, idx); |
| 24 | }; | 47 | }; |
| 25 | 48 | ||
| 26 | const hasStringMessage = (value: unknown): value is { readonly message: string } => | ||
| 27 | value !== null && | ||
| 28 | typeof value === "object" && | ||
| 29 | "message" in value && | ||
| 30 | typeof value.message === "string"; | ||
| 31 | |||
| 32 | const messageFromErrorLike = (value: unknown): string | undefined => { | ||
| 33 | if (hasStringMessage(value)) { | ||
| 34 | return value.message; | ||
| 35 | } | ||
| 36 | return undefined; | ||
| 37 | }; | ||
| 38 | |||
| 39 | const renderToolErrorMessage = (error: unknown): string => | ||
| 40 | messageFromErrorLike(error) ?? | ||
| 41 | (typeof error === "undefined" ? "Tool execution failed" : renderUnknownPrimitive(error)); | ||
| 42 | |||
| 43 | const renderUnknownPrimitive = (value: unknown): string => | ||
| 44 | Match.value(value).pipe( | ||
| 45 | Match.when(Match.string, (s) => s), | ||
| 46 | Match.whenOr(Match.number, Match.boolean, Match.bigint, Match.symbol, (x) => x.toString()), | ||
| 47 | Match.option, | ||
| 48 | Option.getOrElse(() => "Tool execution failed"), | ||
| 49 | ); | ||
| 50 | |||
| 51 | type ToolResultEnvelope = { | ||
| 52 | readonly error?: unknown; | ||
| 53 | readonly data?: unknown; | ||
| 54 | }; | ||
| 55 | |||
| 56 | const isToolResultEnvelope = (value: unknown): value is ToolResultEnvelope => | ||
| 57 | value !== null && typeof value === "object" && ("error" in value || "data" in value); | ||
| 58 | |||
| 59 | const hasToolResultError = ( | ||
| 60 | value: ToolResultEnvelope, | ||
| 61 | ): value is ToolResultEnvelope & { readonly error: unknown } => | ||
| 62 | value.error !== null && value.error !== undefined; | ||
| 63 | |||
| 64 | /** | 49 | /** |
| 65 | * Bridges QuickJS `tools.someSource.someOp(args)` calls into | 50 | * Bridges QuickJS `tools.someSource.someOp(args)` calls into |
| 66 | * `executor.tools.invoke(toolId, args)`. | 51 | * `executor.tools.invoke(toolId, args)`. |
| @@ -87,35 +72,46 @@ export const makeExecutorToolInvoker = ( · 20 unmodified lines | |||
| 87 | const result = yield* executor.tools.invoke(path as ToolId, args, options.invokeOptions).pipe( | 72 | const result = yield* executor.tools.invoke(path as ToolId, args, options.invokeOptions).pipe( |
| 88 | Effect.catchCause((cause): Effect.Effect<never, ExecutionToolError> => { | 73 | Effect.catchCause((cause): Effect.Effect<never, ExecutionToolError> => { |
| 89 | const err = cause.reasons.find(Cause.isFailReason)?.error; | 74 | const err = cause.reasons.find(Cause.isFailReason)?.error; |
| 90 | if (!isElicitationDeclinedError(err)) { | 75 | if (isElicitationDeclinedError(err)) { |
| 91 | return Effect.fail( | 76 | return Effect.fail( |
| 92 | new ExecutionToolError({ | 77 | new ExecutionToolError({ |
| 93 | message: renderToolErrorMessage(err), | 78 | message: `Tool "${err.toolId}" requires approval but the request was ${err.action === "cancel" ? "cancelled" : "declined"} by the user.`, |
| 94 | cause: err ?? cause, | 79 | cause: err, |
| 95 | }), | 80 | }), |
| 96 | ); | 81 | ); |
| 97 | } | 82 | } |
| 98 | return Effect.fail( | 83 | // Any other failure here is an infra/plugin defect. Emit an |
| 99 | new ExecutionToolError({ | 84 | // opaque generic with a correlation id so internal context (URLs |
| 100 | message: `Tool "${err.toolId}" requires approval but the request was ${err.action === "cancel" ? "cancelled" : "declined"} by the user.`, | 85 | // with tokens, DB connection strings, file paths in stacks) |
| 101 | cause: err, | 86 | // can't leak through Error.message into the sandbox. The full |
| 87 | // cause is logged with the same correlation id so operators can | ||
| 88 | // still trace the failure. | ||
| 89 | const correlationId = newCorrelationId(); | ||
| 90 | return Effect.logError("tool dispatch failed", cause).pipe( | ||
| 91 | Effect.annotateLogs({ | ||
| 92 | "executor.correlation_id": correlationId, | ||
| 93 | "mcp.tool.name": path, | ||
| 102 | }), | 94 | }), |
| 95 | Effect.flatMap(() => | ||
| 96 | Effect.fail( | ||
| 97 | new ExecutionToolError({ | ||
| 98 | message: `${OPAQUE_DEFECT_MESSAGE} [${correlationId}]`, | ||
| 99 | cause: err ?? cause, | ||
| 100 | }), | ||
| 101 | ), | ||
| 102 | ), | ||
| 103 | ); | 103 | ); |
| 104 | }), | 104 | }), |
| 105 | ); | 105 | ); |
| 106 | if (!isToolResultEnvelope(result)) { | 106 | |
| 107 | // Strict: plugins emit ToolResult<T>. Anything else is treated as a | ||
| 108 | // raw success value and wrapped — keeps the sandbox-facing contract | ||
| 109 | // uniform without forcing every tiny test plugin to import | ||
| 110 | // `ToolResult.ok`. | ||
| 111 | if (isToolResult(result)) { | ||
| 107 | return result; | 112 | return result; |
| 108 | } | 113 | } |
| 109 | if (hasToolResultError(result)) { | 114 | return { ok: true, data: result }; |
| 110 | return yield* new ExecutionToolError({ | ||
| 111 | message: renderToolErrorMessage(result.error), | ||
| 112 | cause: result.error, | ||
| 113 | }); | ||
| 114 | } | ||
| 115 | if ("data" in result) { | ||
| 116 | return result.data; | ||
| 117 | } | ||
| 118 | return result; | ||
| 119 | }), | 115 | }), |
| 120 | }); | 116 | }); |
| 121 | 117 | ||
| @@ -513,7 +509,7 @@ export const describeTool = Effect.fn("executor.tools.describe")(function* ( · 391 unmodified lines | |||
| 513 | name: schema.name ?? path, | 509 | name: schema.name ?? path, |
| 514 | description: schema.description, | 510 | description: schema.description, |
| 515 | inputTypeScript: schema.inputTypeScript, | 511 | inputTypeScript: schema.inputTypeScript, |
| 516 | outputTypeScript: schema.outputTypeScript, | 512 | outputTypeScript: wrapOutputTypeScript(schema.outputTypeScript), |
| 517 | typeScriptDefinitions: schema.typeScriptDefinitions, | 513 | typeScriptDefinitions: withToolResultDefinitions(schema.typeScriptDefinitions), |
| 518 | }; | 514 | }; |
| 519 | }); | 515 | }); |
| @@ -333,3 +333,10 @@ export { · 332 unmodified lines | |||
| 333 | 333 | ||
| 334 | // Wire-level HTTP error schemas usable by plugin HttpApiGroup definitions. | 334 | // Wire-level HTTP error schemas usable by plugin HttpApiGroup definitions. |
| 335 | export { InternalError } from "./api-errors"; | 335 | export { InternalError } from "./api-errors"; |
| 336 | |||
| 337 | // ToolResult — typed value-based discriminated union for tool outcomes. | ||
| 338 | // The `Tool` value namespace exposes `Tool.ok` / `Tool.fail` constructors; | ||
| 339 | // the `Tool` type alias from `./types` is a separate row projection. | ||
| 340 | // TypeScript permits the two to share a name because one is purely a | ||
| 341 | // value and the other purely a type. | ||
| 342 | export { ToolResult, isToolResult, type ToolError } from "./tool-result"; | ||
| @@ -0,0 +1,50 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { ToolResult, isToolResult } from "./tool-result"; | ||
| 3 | |||
| 4 | describe("ToolResult", () => { | ||
| 5 | it("ok wraps a value", () => { | ||
| 6 | const r = ToolResult.ok({ count: 3 }); | ||
| 7 | expect(r).toEqual({ ok: true, data: { count: 3 } }); | ||
| 8 | expect(isToolResult(r)).toBe(true); | ||
| 9 | }); | ||
| 10 | |||
| 11 | it("fail wraps a ToolError", () => { | ||
| 12 | const r = ToolResult.fail({ | ||
| 13 | code: "upstream_http_error", | ||
| 14 | status: 404, | ||
| 15 | message: "Not found", | ||
| 16 | details: { id: "x" }, | ||
| 17 | }); | ||
| 18 | expect(r).toEqual({ | ||
| 19 | ok: false, | ||
| 20 | error: { | ||
| 21 | code: "upstream_http_error", | ||
| 22 | status: 404, | ||
| 23 | message: "Not found", | ||
| 24 | details: { id: "x" }, | ||
| 25 | }, | ||
| 26 | }); | ||
| 27 | expect(isToolResult(r)).toBe(true); | ||
| 28 | }); | ||
| 29 | |||
| 30 | it("isToolResult rejects unrelated shapes", () => { | ||
| 31 | expect(isToolResult(null)).toBe(false); | ||
| 32 | expect(isToolResult({})).toBe(false); | ||
| 33 | expect(isToolResult({ ok: true })).toBe(false); | ||
| 34 | expect(isToolResult({ ok: false })).toBe(false); | ||
| 35 | expect(isToolResult({ ok: false, error: {} })).toBe(false); | ||
| 36 | expect(isToolResult({ ok: false, error: { code: 1, message: "x" } })).toBe(false); | ||
| 37 | expect(isToolResult({ ok: "yes", data: 1 })).toBe(false); | ||
| 38 | }); | ||
| 39 | |||
| 40 | it("isToolResult accepts both branches of the union", () => { | ||
| 41 | expect(isToolResult({ ok: true, data: 1 })).toBe(true); | ||
| 42 | expect(isToolResult({ ok: true, data: null })).toBe(true); | ||
| 43 | expect( | ||
| 44 | isToolResult({ | ||
| 45 | ok: false, | ||
| 46 | error: { code: "x", message: "y" }, | ||
| 47 | }), | ||
| 48 | ).toBe(true); | ||
| 49 | }); | ||
| 50 | }); | ||
| @@ -0,0 +1,37 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // ToolResult — typed value-based discriminated union returned by tool | ||
| 3 | // handlers and `invokeTool`. Domain success and expected failure both | ||
| 4 | // resolve through Effect's success channel; only true infra defects use | ||
| 5 | // the Effect failure channel. | ||
| 6 | // --------------------------------------------------------------------------- | ||
| 7 | |||
| 8 | import { Schema } from "effect"; | ||
| 9 | |||
| 10 | export const ToolErrorSchema = Schema.Struct({ | ||
| 11 | code: Schema.String, | ||
| 12 | message: Schema.String, | ||
| 13 | status: Schema.optional(Schema.Number), | ||
| 14 | details: Schema.optional(Schema.Unknown), | ||
| 15 | retryable: Schema.optional(Schema.Boolean), | ||
| 16 | }); | ||
| 17 | |||
| 18 | export type ToolError = typeof ToolErrorSchema.Type; | ||
| 19 | |||
| 20 | export type ToolResult<T> = | ||
| 21 | | { readonly ok: true; readonly data: T } | ||
| 22 | | { readonly ok: false; readonly error: ToolError }; | ||
| 23 | |||
| 24 | export const ToolResult = { | ||
| 25 | ok: <T>(data: T): ToolResult<T> => ({ ok: true, data }), | ||
| 26 | fail: <T = never>(error: ToolError): ToolResult<T> => ({ ok: false, error }), | ||
| 27 | } as const; | ||
| 28 | |||
| 29 | const ToolResultSchema = Schema.Union([ | ||
| 30 | Schema.Struct({ ok: Schema.Literal(true), data: Schema.Unknown }), | ||
| 31 | Schema.Struct({ ok: Schema.Literal(false), error: ToolErrorSchema }), | ||
| 32 | ]); | ||
| 33 | |||
| 34 | const isUnknownToolResult = Schema.is(ToolResultSchema); | ||
| 35 | |||
| 36 | export const isToolResult = (value: unknown): value is ToolResult<unknown> => | ||
| 37 | isUnknownToolResult(value); | ||
| @@ -116,7 +116,7 @@ describe("MCP host server — client with elicitation", () => { · 115 unmodified lines | |||
| 116 | }); | 116 | }); |
| 117 | }); | 117 | }); |
| 118 | 118 | ||
| 119 | it("execute tool resolves failed engine effects as MCP error results", async () => { | 119 | it("execute tool surfaces failed engine effects as an opaque generic with correlation id", async () => { |
| 120 | const engine = makeStubEngine({ | 120 | const engine = makeStubEngine({ |
| 121 | execute: () => Effect.fail(new TestExecutionError({ message: "Unexpected token ':'" })), | 121 | execute: () => Effect.fail(new TestExecutionError({ message: "Unexpected token ':'" })), |
| 122 | }); | 122 | }); |
| @@ -126,11 +126,11 @@ describe("MCP host server — client with elicitation", () => { · 3 unmodified lines | |||
| 126 | name: "execute", | 126 | name: "execute", |
| 127 | arguments: { code: "const x: any = 1;" }, | 127 | arguments: { code: "const x: any = 1;" }, |
| 128 | }); | 128 | }); |
| 129 | expect(textOf(result)).toBe("Error: Unexpected token ':'"); | 129 | const text = textOf(result); |
| 130 | expect(result.structuredContent).toEqual({ | 130 | expect(text).toMatch(/^Error: Internal tool error \[[0-9a-f]{8}\]$/); |
| 131 | status: "error", | 131 | expect(text).not.toContain("Unexpected token"); |
| 132 | error: "Unexpected token ':'", | 132 | const structured = (result.structuredContent as { readonly error?: string }).error ?? ""; |
| 133 | }); | 133 | expect(structured).toMatch(/^Internal tool error \[[0-9a-f]{8}\]$/); |
| 134 | expect(result.isError).toBe(true); | 134 | expect(result.isError).toBe(true); |
| 135 | }); | 135 | }); |
| 136 | }); | 136 | }); |
| @@ -146,11 +146,16 @@ describe("MCP host server — client with elicitation", () => { · 9 unmodified lines | |||
| 146 | name: "execute", | 146 | name: "execute", |
| 147 | arguments: { code: "run" }, | 147 | arguments: { code: "run" }, |
| 148 | }); | 148 | }); |
| 149 | expect(textOf(result)).toBe("Error: Tool execution failed"); | 149 | const text = textOf(result); |
| 150 | expect(result.structuredContent).toEqual({ | 150 | expect(text).toMatch(/^Error: Internal tool error \[[0-9a-f]{8}\]$/); |
| 151 | // Sensitive internal context must NOT leak through the MCP error path. | ||
| 152 | expect(text).not.toContain("secret internal detail"); | ||
| 153 | expect(result.structuredContent).toMatchObject({ | ||
| 151 | status: "error", | 154 | status: "error", |
| 152 | error: "Tool execution failed", | ||
| 153 | }); | 155 | }); |
| 156 | const structuredError = (result.structuredContent as { readonly error?: string }).error ?? ""; | ||
| 157 | expect(structuredError).toMatch(/^Internal tool error \[[0-9a-f]{8}\]$/); | ||
| 158 | expect(structuredError).not.toContain("secret internal detail"); | ||
| 154 | expect(result.isError).toBe(true); | 159 | expect(result.isError).toBe(true); |
| 155 | }); | 160 | }); |
| 156 | }); | 161 | }); |
| @@ -260,20 +260,28 @@ const toMcpPausedResult = (formatted: ReturnType<typeof formatPausedExecution>): · 259 unmodified lines | |||
| 260 | structuredContent: formatted.structured, | 260 | structuredContent: formatted.structured, |
| 261 | }); | 261 | }); |
| 262 | 262 | ||
| 263 | const formatFailureMessage = (value: unknown): string | null => { | 263 | // `execute` failures reaching the MCP host are infra defects — domain |
| 264 | if (typeof value === "object" && value !== null && "message" in value) { | 264 | // failures from tools are now expressed as `ToolResult` values (success |
| 265 | const message = (value as { readonly message?: unknown }).message; | 265 | // channel) and flow through `formatExecuteResult`. Emit an opaque |
| 266 | if (typeof message === "string" && message.length > 0) return message; | 266 | // generic plus a fresh correlation id and log the cause out-of-band so |
| 267 | } | 267 | // the model can't read internal context off `.message`. |
| 268 | if (typeof value === "string" && value.length > 0) return value; | 268 | const newCorrelationId = (): string => |
| 269 | return null; | 269 | Math.floor(Math.random() * 0x1_0000_0000) |
| 270 | }; | 270 | .toString(16) |
| 271 | .padStart(8, "0"); | ||
| 271 | 272 | ||
| 272 | const toMcpFailureResult = (cause: Cause.Cause<unknown>): McpToolResult => { | 273 | const toMcpFailureResult = (cause: Cause.Cause<unknown>): McpToolResult => { |
| 273 | const failure = cause.reasons.find(Cause.isFailReason); | 274 | const correlationId = newCorrelationId(); |
| 274 | const text = failure | 275 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort defect logging must tolerate non-serializable causes |
| 275 | ? (formatFailureMessage(failure.error) ?? "Tool execution failed") | 276 | try { |
| 276 | : "Tool execution failed"; | 277 | console.error( |
| 278 | `[executor:mcp] execute defect correlation_id=${correlationId}`, | ||
| 279 | Cause.pretty(cause), | ||
| 280 | ); | ||
| 281 | } catch { | ||
| 282 | /* ignore logger failures */ | ||
| 283 | } | ||
| 284 | const text = `Internal tool error [${correlationId}]`; | ||
| 277 | return { | 285 | return { |
| 278 | content: [{ type: "text", text: `Error: ${text}` }], | 286 | content: [{ type: "text", text: `Error: ${text}` }], |
| 279 | structuredContent: { status: "error", error: text }, | 287 | structuredContent: { status: "error", error: text }, |
| @@ -59,11 +59,11 @@ describe("stripTypeScript", () => { · 58 unmodified lines | |||
| 59 | // axiom://7bf76f79c5d807272781e9554040aab3 — typed annotation in | 59 | // axiom://7bf76f79c5d807272781e9554040aab3 — typed annotation in |
| 60 | // a function expression. | 60 | // a function expression. |
| 61 | const code = ` | 61 | const code = ` |
| 62 | const fetchDeals = async (sourceId: string): Promise<Array<{ id: string }>> => { | 62 | const fetchResources = async (sourceId: string): Promise<Array<{ id: string }>> => { |
| 63 | const result = await tools.executor.sources.list(); | 63 | const result = await tools.executor.sources.list(); |
| 64 | return result.items; | 64 | return result.items; |
| 65 | }; | 65 | }; |
| 66 | return fetchDeals('dealcloud'); | 66 | return fetchResources('example-source'); |
| 67 | `; | 67 | `; |
| 68 | const out = stripTypeScript(code); | 68 | const out = stripTypeScript(code); |
| 69 | expect(out).not.toContain(": string"); | 69 | expect(out).not.toContain(": string"); |
| @@ -4,6 +4,7 @@ import * as Cause from "effect/Cause"; · 3 unmodified lines | |||
| 4 | import * as Data from "effect/Data"; | 4 | import * as Data from "effect/Data"; |
| 5 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 6 | import type { SandboxToolInvoker } from "@executor-js/codemode-core"; | 6 | import type { SandboxToolInvoker } from "@executor-js/codemode-core"; |
| 7 | import { ExecutionToolError } from "@executor-js/execution"; | ||
| 7 | import { | 8 | import { |
| 8 | ToolDispatcher, | 9 | ToolDispatcher, |
| 9 | makeDynamicWorkerExecutor, | 10 | makeDynamicWorkerExecutor, |
| @@ -246,7 +247,7 @@ describe("makeDynamicWorkerExecutor", () => { · 236 unmodified lines | |||
| 246 | expect(result.result).toBe(7); | 247 | expect(result.result).toBe(7); |
| 247 | }); | 248 | }); |
| 248 | 249 | ||
| 249 | it("surfaces tool errors in execution result", async () => { | 250 | it("surfaces infra defects through the worker bridge as an opaque generic", async () => { |
| 250 | const executor = makeDynamicWorkerExecutor({ loader }); | 251 | const executor = makeDynamicWorkerExecutor({ loader }); |
| 251 | const invoker = failingInvoker("not authorized"); | 252 | const invoker = failingInvoker("not authorized"); |
| 252 | 253 | ||
| @@ -254,7 +255,28 @@ describe("makeDynamicWorkerExecutor", () => { · 1 unmodified line | |||
| 254 | executor.execute("async () => { return await tools.secret.read({}); }", invoker), | 255 | executor.execute("async () => { return await tools.secret.read({}); }", invoker), |
| 255 | ); | 256 | ); |
| 256 | 257 | ||
| 257 | expect(result.error).toBe("not authorized"); | 258 | expect(result.error).toBe("Internal tool error"); |
| 259 | }); | ||
| 260 | |||
| 261 | it("preserves public ExecutionToolError messages across the worker bridge", async () => { | ||
| 262 | const executor = makeDynamicWorkerExecutor({ loader }); | ||
| 263 | const invoker = { | ||
| 264 | invoke: () => | ||
| 265 | Effect.fail( | ||
| 266 | new ExecutionToolError({ | ||
| 267 | message: | ||
| 268 | "tools.search expects an object: { query?: string; namespace?: string; limit?: number; offset?: number }", | ||
| 269 | }), | ||
| 270 | ), | ||
| 271 | } satisfies SandboxToolInvoker; | ||
| 272 | |||
| 273 | const result = await Effect.runPromise( | ||
| 274 | executor.execute("async () => await tools.search('github')", invoker), | ||
| 275 | ); | ||
| 276 | |||
| 277 | expect(result.error).toBe( | ||
| 278 | "tools.search expects an object: { query?: string; namespace?: string; limit?: number; offset?: number }", | ||
| 279 | ); | ||
| 258 | }); | 280 | }); |
| 259 | 281 | ||
| 260 | it("does not expose host error stack details to sandbox error handlers", async () => { | 282 | it("does not expose host error stack details to sandbox error handlers", async () => { |
| @@ -284,11 +306,12 @@ describe("makeDynamicWorkerExecutor", () => { · 23 unmodified lines | |||
| 284 | ); | 306 | ); |
| 285 | 307 | ||
| 286 | expect(result.error).toBeUndefined(); | 308 | expect(result.error).toBeUndefined(); |
| 287 | expect(result.result).toMatchObject({ message: "not authorized" }); | 309 | expect(result.result).toMatchObject({ message: "Internal tool error" }); |
| 288 | expect((result.result as { stack?: string }).stack).not.toContain("secret host stack"); | 310 | expect((result.result as { stack?: string }).stack).not.toContain("secret host stack"); |
| 311 | expect((result.result as { message?: string }).message).not.toContain("not authorized"); | ||
| 289 | }); | 312 | }); |
| 290 | 313 | ||
| 291 | it("surfaces object-shaped tool errors in execution result", async () => { | 314 | it("collapses object-shaped tool defects to an opaque generic", async () => { |
| 292 | const executor = makeDynamicWorkerExecutor({ loader }); | 315 | const executor = makeDynamicWorkerExecutor({ loader }); |
| 293 | const invoker = { | 316 | const invoker = { |
| 294 | invoke: () => | 317 | invoke: () => |
| @@ -302,11 +325,11 @@ describe("makeDynamicWorkerExecutor", () => { · 7 unmodified lines | |||
| 302 | executor.execute("async () => { return await tools.secret.read({}); }", invoker), | 325 | executor.execute("async () => { return await tools.secret.read({}); }", invoker), |
| 303 | ); | 326 | ); |
| 304 | 327 | ||
| 305 | expect(result.error).toBe('{"code":"forbidden","detail":"missing team access"}'); | 328 | expect(result.error).toBe("Internal tool error"); |
| 306 | expect(result.result).toBeNull(); | 329 | expect(result.result).toBeNull(); |
| 307 | }); | 330 | }); |
| 308 | 331 | ||
| 309 | it("surfaces message-bearing object tool errors in execution result", async () => { | 332 | it("collapses message-bearing object tool defects to an opaque generic", async () => { |
| 310 | const executor = makeDynamicWorkerExecutor({ loader }); | 333 | const executor = makeDynamicWorkerExecutor({ loader }); |
| 311 | const invoker = { | 334 | const invoker = { |
| 312 | invoke: () => | 335 | invoke: () => |
| @@ -320,7 +343,8 @@ describe("makeDynamicWorkerExecutor", () => { · 7 unmodified lines | |||
| 320 | executor.execute("async () => { return await tools.records.query({}); }", invoker), | 343 | executor.execute("async () => { return await tools.records.query({}); }", invoker), |
| 321 | ); | 344 | ); |
| 322 | 345 | ||
| 323 | expect(result.error).toBe('Field with name "DisplayName" does not exist'); | 346 | expect(result.error).toBe("Internal tool error"); |
| 347 | expect(result.error).not.toContain("DisplayName"); | ||
| 324 | expect(result.result).toBeNull(); | 348 | expect(result.result).toBeNull(); |
| 325 | }); | 349 | }); |
| 326 | 350 | ||
| @@ -366,7 +390,7 @@ describe("makeDynamicWorkerExecutor", () => { · 39 unmodified lines | |||
| 366 | expect(result.error).toBe("Tool RPC payload contains a circular reference"); | 390 | expect(result.error).toBe("Tool RPC payload contains a circular reference"); |
| 367 | }); | 391 | }); |
| 368 | 392 | ||
| 369 | it("returns an execution error for circular tool results", async () => { | 393 | it("returns an opaque generic when a tool result can't be serialized", async () => { |
| 370 | const executor = makeDynamicWorkerExecutor({ loader }); | 394 | const executor = makeDynamicWorkerExecutor({ loader }); |
| 371 | const cyclic: Record<string, unknown> = {}; | 395 | const cyclic: Record<string, unknown> = {}; |
| 372 | cyclic.self = cyclic; | 396 | cyclic.self = cyclic; |
| @@ -377,7 +401,7 @@ describe("makeDynamicWorkerExecutor", () => { · 4 unmodified lines | |||
| 377 | ); | 401 | ); |
| 378 | 402 | ||
| 379 | expect(result.result).toBeNull(); | 403 | expect(result.result).toBeNull(); |
| 380 | expect(result.error).toBe("Tool RPC payload contains a circular reference"); | 404 | expect(result.error).toBe("Internal tool error"); |
| 381 | }); | 405 | }); |
| 382 | 406 | ||
| 383 | it("respects timeout", async () => { | 407 | it("respects timeout", async () => { |
| @@ -127,6 +127,14 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => · 126 unmodified lines | |||
| 127 | " for (const [k, v] of Object.entries(value)) out[k] = __decodeBinary(v, seen);", | 127 | " for (const [k, v] of Object.entries(value)) out[k] = __decodeBinary(v, seen);", |
| 128 | " return out;", | 128 | " return out;", |
| 129 | " };", | 129 | " };", |
| 130 | " const __publicToolErrorMessage = (error) => {", | ||
| 131 | " const values = [error && error.primary, ...(Array.isArray(error && error.failures) ? error.failures : [])];", | ||
| 132 | " for (const value of values) {", | ||
| 133 | " if (value && value.__type === 'Error' && value.name === 'ExecutionToolError' && typeof value.message === 'string') return value.message;", | ||
| 134 | " }", | ||
| 135 | " if (error && typeof error.message === 'string' && error.message.startsWith('Internal tool error')) return error.message;", | ||
| 136 | " return null;", | ||
| 137 | " };", | ||
| 130 | " const __makeToolsProxy = (path = []) => new Proxy(() => undefined, {", | 138 | " const __makeToolsProxy = (path = []) => new Proxy(() => undefined, {", |
| 131 | " get(_target, prop) {", | 139 | " get(_target, prop) {", |
| 132 | " if (prop === 'then' || typeof prop === 'symbol') return undefined;", | 140 | " if (prop === 'then' || typeof prop === 'symbol') return undefined;", |
| @@ -138,7 +146,7 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => · 5 unmodified lines | |||
| 138 | " return (async () => {", | 146 | " return (async () => {", |
| 139 | " const encoded = await __encodeBinary(args[0]);", | 147 | " const encoded = await __encodeBinary(args[0]);", |
| 140 | " const data = await __dispatcher.call(toolPath, encoded);", | 148 | " const data = await __dispatcher.call(toolPath, encoded);", |
| 141 | " if (!data.ok) throw new Error(data.error && data.error.message ? data.error.message : 'Tool execution failed');", | 149 | " if (!data.ok) throw new Error(__publicToolErrorMessage(data.error) || 'Internal tool error');", |
| 142 | " return __decodeBinary(data.result);", | 150 | " return __decodeBinary(data.result);", |
| 143 | " })();", | 151 | " })();", |
| 144 | " },", | 152 | " },", |
| @@ -121,6 +121,52 @@ describe("quickjs executor", () => { · 120 unmodified lines | |||
| 121 | }), | 121 | }), |
| 122 | ); | 122 | ); |
| 123 | 123 | ||
| 124 | it.effect("internal defects reach the sandbox as an opaque generic only", () => | ||
| 125 | Effect.gen(function* () { | ||
| 126 | // Plugin defect carrying sensitive context. The bridge's reject | ||
| 127 | // path must strip everything except the canonical | ||
| 128 | // "Internal tool error [<corrId>]" shape — or fall back to the | ||
| 129 | // bare generic if the upstream invoker hasn't already stamped | ||
| 130 | // the correlation id (this test exercises the latter path | ||
| 131 | // because it bypasses makeExecutorToolInvoker). | ||
| 132 | const invoker: SandboxToolInvoker = { | ||
| 133 | invoke: () => | ||
| 134 | Effect.fail( | ||
| 135 | Object.assign( | ||
| 136 | new Error("Authorization: Bearer SECRET_TOKEN_xyz failed against host 10.0.0.5"), | ||
| 137 | { | ||
| 138 | stack: "Error\n at /home/svc/executor/packages/plugins/foo:142:11", | ||
| 139 | }, | ||
| 140 | ) as never, | ||
| 141 | ), | ||
| 142 | }; | ||
| 143 | |||
| 144 | const result = yield* executor.execute( | ||
| 145 | ` | ||
| 146 | try { | ||
| 147 | await tools.leaky.call({}); | ||
| 148 | return "should not reach"; | ||
| 149 | } catch (e) { | ||
| 150 | return e.message; | ||
| 151 | } | ||
| 152 | `, | ||
| 153 | invoker, | ||
| 154 | ); | ||
| 155 | |||
| 156 | expect(result.error).toBeUndefined(); | ||
| 157 | const message = String(result.result); | ||
| 158 | // Either the canonical opaque generic with a correlation id, or | ||
| 159 | // the bare fallback. Neither must contain any sensitive context. | ||
| 160 | expect( | ||
| 161 | message === "Internal tool error" || /^Internal tool error \[[0-9a-f]{8}\]$/.test(message), | ||
| 162 | ).toBe(true); | ||
| 163 | expect(message).not.toContain("SECRET_TOKEN_xyz"); | ||
| 164 | expect(message).not.toContain("Authorization"); | ||
| 165 | expect(message).not.toContain("10.0.0.5"); | ||
| 166 | expect(message).not.toContain("packages/plugins"); | ||
| 167 | }), | ||
| 168 | ); | ||
| 169 | |||
| 124 | it.effect("handles unknown tool path", () => | 170 | it.effect("handles unknown tool path", () => |
| 125 | Effect.gen(function* () { | 171 | Effect.gen(function* () { |
| 126 | const invoker = makeTestInvoker({}); | 172 | const invoker = makeTestInvoker({}); |
| @@ -47,6 +47,30 @@ const EXECUTION_FILENAME = "executor-quickjs-runtime.js"; · 46 unmodified lines | |||
| 47 | const toError = (cause: unknown): Error => | 47 | const toError = (cause: unknown): Error => |
| 48 | cause instanceof Error ? cause : new Error(String(cause)); | 48 | cause instanceof Error ? cause : new Error(String(cause)); |
| 49 | 49 | ||
| 50 | // Defect surfaced to the sandbox when a tool dispatch reaches the | ||
| 51 | // reject path. Two sources reach here: | ||
| 52 | // - `ExecutionToolError` emitted by the execution package — message | ||
| 53 | // is already either the canonical | ||
| 54 | // `Internal tool error [<corrId>]` opaque generic (plugin defects | ||
| 55 | // passed through `makeExecutorToolInvoker`'s catchCause) or an | ||
| 56 | // intentional public string (validators in engine.ts: | ||
| 57 | // "tools.search expects an object: ..."). | ||
| 58 | // - Anything else — assume infra defect and emit a bare opaque | ||
| 59 | // generic. | ||
| 60 | // `_tag === "ExecutionToolError"` is the in-band signal that the | ||
| 61 | // message was generated by code we control and is safe to pass | ||
| 62 | // through; we never read `.cause` / `.stack`. | ||
| 63 | const sandboxDefectMessage = (cause: unknown): string => { | ||
| 64 | if (cause !== null && typeof cause === "object") { | ||
| 65 | const tagged = cause as { readonly _tag?: unknown; readonly message?: unknown }; | ||
| 66 | // oxlint-disable-next-line executor/no-manual-tag-check, executor/no-unknown-error-message -- boundary: QuickJS reject path receives an already-rendered ExecutionToolError; we pass its in-band public message through and otherwise emit the opaque generic | ||
| 67 | if (tagged._tag === "ExecutionToolError" && typeof tagged.message === "string") { | ||
| 68 | return tagged.message; | ||
| 69 | } | ||
| 70 | } | ||
| 71 | return "Internal tool error"; | ||
| 72 | }; | ||
| 73 | |||
| 50 | const toErrorMessage = (cause: unknown): string => { | 74 | const toErrorMessage = (cause: unknown): string => { |
| 51 | if (typeof cause === "object" && cause !== null) { | 75 | if (typeof cause === "object" && cause !== null) { |
| 52 | const message = | 76 | const message = |
| @@ -216,7 +240,17 @@ const createToolBridge = ( · 163 unmodified lines | |||
| 216 | return; | 240 | return; |
| 217 | } | 241 | } |
| 218 | 242 | ||
| 219 | const errorHandle = context.newError(toErrorMessage(cause)); | 243 | // The reject path is reserved for true infra defects. The |
| 244 | // upstream tool-invoker has already replaced the message with | ||
| 245 | // an opaque generic plus a correlation id, but defensively log | ||
| 246 | // the cause here and emit a stable generic if upstream changes. | ||
| 247 | const message = sandboxDefectMessage(cause); | ||
| 248 | try { | ||
| 249 | console.error("[executor:quickjs] tool dispatch defect", { path, cause }); | ||
| 250 | } catch { | ||
| 251 | /* ignore logger failures */ | ||
| 252 | } | ||
| 253 | const errorHandle = context.newError(message); | ||
| 220 | deferred.reject(errorHandle); | 254 | deferred.reject(errorHandle); |
| 221 | errorHandle.dispose(); | 255 | errorHandle.dispose(); |
| 222 | }, | 256 | }, |
| @@ -1,6 +1,6 @@ | |||
| 1 | // Reproduces the PR 706 bug class using Effect-native primitives only — | 1 | // Covers the Option JSON boundary using Effect-native primitives only: |
| 2 | // no JSON.parse, no JSON.stringify, no node:fs on our side. We split the | 2 | // no JSON.parse, no JSON.stringify, no node:fs on our side. We split the |
| 3 | // JSON boundary into two Effect schema steps: | 3 | // boundary into two Effect schema steps: |
| 4 | // | 4 | // |
| 5 | // 1. Schema.encodeEffect(Inner)(value) → encoded JS shape | 5 | // 1. Schema.encodeEffect(Inner)(value) → encoded JS shape |
| 6 | // 2. Schema.encodeEffect(UnknownFromJsonString) → JSON string | 6 | // 2. Schema.encodeEffect(UnknownFromJsonString) → JSON string |
| @@ -13,7 +13,7 @@ · 6 unmodified lines | |||
| 13 | // step 2's JSON-stringify (driven by Effect, not us) flattens the Option | 13 | // step 2's JSON-stringify (driven by Effect, not us) flattens the Option |
| 14 | // to {_id,_tag,value}, and step 5 rejects the shape. | 14 | // to {_id,_tag,value}, and step 5 rejects the shape. |
| 15 | // | 15 | // |
| 16 | // Run: vitest run packages/plugins/google-discovery/src/sdk/option-json-repro.test.ts | 16 | // Run: vitest run packages/plugins/google-discovery/src/sdk/option-json.test.ts |
| 17 | 17 | ||
| 18 | import { describe, expect, it } from "@effect/vitest"; | 18 | import { describe, expect, it } from "@effect/vitest"; |
| 19 | import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; | 19 | import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; |
| @@ -28,7 +28,7 @@ const fixed = { description: Option.some("hello") }; · 8 unmodified lines | |||
| 28 | const withTmpFile = <A, E>(fn: (path: string) => Effect.Effect<A, E, FileSystem.FileSystem>) => | 28 | const withTmpFile = <A, E>(fn: (path: string) => Effect.Effect<A, E, FileSystem.FileSystem>) => |
| 29 | Effect.gen(function* () { | 29 | Effect.gen(function* () { |
| 30 | const fs = yield* FileSystem.FileSystem; | 30 | const fs = yield* FileSystem.FileSystem; |
| 31 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "option-repro-" }); | 31 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "option-json-" }); |
| 32 | return yield* fn(`${dir}/binding.json`); | 32 | return yield* fn(`${dir}/binding.json`); |
| 33 | }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)); | 33 | }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)); |
| 34 | 34 | ||
| @@ -430,10 +430,10 @@ describe("Google Discovery plugin", () => { · 429 unmodified lines | |||
| 430 | "drive.files.get", | 430 | "drive.files.get", |
| 431 | { fileId: "123", fields: "id,name", prettyPrint: true }, | 431 | { fileId: "123", fields: "id,name", prettyPrint: true }, |
| 432 | autoApprove, | 432 | autoApprove, |
| 433 | )) as { data: unknown; error: unknown }; | 433 | )) as { readonly ok: true; readonly data: { status: number; data: unknown } }; |
| 434 | 434 | ||
| 435 | expect(invocation.error).toBeNull(); | 435 | expect(invocation.ok).toBe(true); |
| 436 | expect(invocation.data).toEqual({ id: "123", name: "Quarterly Plan" }); | 436 | expect(invocation.data.data).toEqual({ id: "123", name: "Quarterly Plan" }); |
| 437 | 437 | ||
| 438 | const apiRequest = handle.requests.find((request) => | 438 | const apiRequest = handle.requests.find((request) => |
| 439 | request.url.startsWith("/drive/v3/files/123"), | 439 | request.url.startsWith("/drive/v3/files/123"), |
| @@ -3,6 +3,7 @@ import { Effect, Option, Predicate, Schema } from "effect"; · 2 unmodified lines | |||
| 3 | import { | 3 | import { |
| 4 | ScopeId, | 4 | ScopeId, |
| 5 | SourceDetectionResult, | 5 | SourceDetectionResult, |
| 6 | ToolResult, | ||
| 6 | Usage, | 7 | Usage, |
| 7 | definePlugin, | 8 | definePlugin, |
| 8 | resolveSecretBackedMap, | 9 | resolveSecretBackedMap, |
| @@ -29,6 +30,69 @@ import type { · 20 unmodified lines | |||
| 29 | } from "./types"; | 30 | } from "./types"; |
| 30 | import { GoogleDiscoveryStoredSourceData as GoogleDiscoveryStoredSourceDataSchema } from "./types"; | 31 | import { GoogleDiscoveryStoredSourceData as GoogleDiscoveryStoredSourceDataSchema } from "./types"; |
| 31 | 32 | ||
| 33 | // --------------------------------------------------------------------------- | ||
| 34 | // Upstream-error message extraction | ||
| 35 | // --------------------------------------------------------------------------- | ||
| 36 | |||
| 37 | const GOOGLE_BODY_CAP = 1024; | ||
| 38 | const UpstreamMessageBody = Schema.Struct({ message: Schema.String }); | ||
| 39 | const UpstreamErrorMessageBody = Schema.Struct({ errorMessage: Schema.String }); | ||
| 40 | const UpstreamNestedErrorBody = Schema.Struct({ error: UpstreamMessageBody }); | ||
| 41 | const UpstreamErrorsArrayBody = Schema.Struct({ | ||
| 42 | errors: Schema.Array( | ||
| 43 | Schema.Struct({ | ||
| 44 | detail: Schema.optional(Schema.String), | ||
| 45 | message: Schema.optional(Schema.String), | ||
| 46 | title: Schema.optional(Schema.String), | ||
| 47 | }), | ||
| 48 | ), | ||
| 49 | }); | ||
| 50 | |||
| 51 | const decodeUpstreamMessageBody = Schema.decodeUnknownOption(UpstreamMessageBody); | ||
| 52 | const decodeUpstreamErrorMessageBody = Schema.decodeUnknownOption(UpstreamErrorMessageBody); | ||
| 53 | const decodeUpstreamNestedErrorBody = Schema.decodeUnknownOption(UpstreamNestedErrorBody); | ||
| 54 | const decodeUpstreamErrorsArrayBody = Schema.decodeUnknownOption(UpstreamErrorsArrayBody); | ||
| 55 | |||
| 56 | const googleClampedStringify = (value: unknown): string => { | ||
| 57 | let s: string; | ||
| 58 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: JSON.stringify may throw on cycles; fall back to String() so the upstream body can still be surfaced as ToolError.details fallback text | ||
| 59 | try { | ||
| 60 | s = JSON.stringify(value); | ||
| 61 | } catch { | ||
| 62 | s = String(value); | ||
| 63 | } | ||
| 64 | return s.length > GOOGLE_BODY_CAP ? `${s.slice(0, GOOGLE_BODY_CAP)}…` : s; | ||
| 65 | }; | ||
| 66 | |||
| 67 | const firstNonEmpty = (...values: readonly (string | undefined)[]): string | undefined => | ||
| 68 | values.find((value) => value !== undefined && value.length > 0); | ||
| 69 | |||
| 70 | const googleExtractUpstreamMessage = (body: unknown, status: number): string => { | ||
| 71 | if (typeof body === "string") { | ||
| 72 | return body.length > 0 ? body : `Upstream returned HTTP ${status}`; | ||
| 73 | } | ||
| 74 | const nested = Option.getOrUndefined(decodeUpstreamNestedErrorBody(body)); | ||
| 75 | const messageBody = Option.getOrUndefined(decodeUpstreamMessageBody(body)); | ||
| 76 | const errorMessageBody = Option.getOrUndefined(decodeUpstreamErrorMessageBody(body)); | ||
| 77 | const errorsBody = Option.getOrUndefined(decodeUpstreamErrorsArrayBody(body)); | ||
| 78 | const arrayMessage = errorsBody?.errors | ||
| 79 | .map(({ detail, message: upstreamMessage, title }) => | ||
| 80 | firstNonEmpty(detail, upstreamMessage, title), | ||
| 81 | ) | ||
| 82 | .find((message) => message !== undefined); | ||
| 83 | const message = firstNonEmpty( | ||
| 84 | nested?.error.message, | ||
| 85 | messageBody?.message, | ||
| 86 | errorMessageBody?.errorMessage, | ||
| 87 | arrayMessage, | ||
| 88 | ); | ||
| 89 | if (message !== undefined) return message; | ||
| 90 | if (body !== null && typeof body === "object") { | ||
| 91 | return googleClampedStringify(body); | ||
| 92 | } | ||
| 93 | return `Upstream returned HTTP ${status}`; | ||
| 94 | }; | ||
| 95 | |||
| 32 | // --------------------------------------------------------------------------- | 96 | // --------------------------------------------------------------------------- |
| 33 | // Public input / output shapes | 97 | // Public input / output shapes |
| 34 | // --------------------------------------------------------------------------- | 98 | // --------------------------------------------------------------------------- |
| @@ -381,11 +445,27 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 346 unmodified lines | |||
| 381 | extension: makeGoogleDiscoveryPluginExtension, | 445 | extension: makeGoogleDiscoveryPluginExtension, |
| 382 | 446 | ||
| 383 | invokeTool: ({ ctx, toolRow, args }) => | 447 | invokeTool: ({ ctx, toolRow, args }) => |
| 384 | invokeGoogleDiscoveryTool({ | 448 | Effect.gen(function* () { |
| 385 | ctx: ctx as PluginCtx<GoogleDiscoveryStore>, | 449 | const result = yield* invokeGoogleDiscoveryTool({ |
| 386 | toolId: toolRow.id, | 450 | ctx: ctx as PluginCtx<GoogleDiscoveryStore>, |
| 387 | toolScope: decodeString(toolRow.scope_id), | 451 | toolId: toolRow.id, |
| 388 | args, | 452 | toolScope: decodeString(toolRow.scope_id), |
| 453 | args, | ||
| 454 | }); | ||
| 455 | const ok = result.status >= 200 && result.status < 300; | ||
| 456 | if (!ok) { | ||
| 457 | return ToolResult.fail({ | ||
| 458 | code: "upstream_http_error", | ||
| 459 | status: result.status, | ||
| 460 | message: googleExtractUpstreamMessage(result.error, result.status), | ||
| 461 | details: result.error, | ||
| 462 | }); | ||
| 463 | } | ||
| 464 | return ToolResult.ok({ | ||
| 465 | status: result.status, | ||
| 466 | headers: result.headers, | ||
| 467 | data: result.data, | ||
| 468 | }); | ||
| 389 | }), | 469 | }), |
| 390 | 470 | ||
| 391 | resolveAnnotations: ({ ctx, sourceId, toolRows }) => | 471 | resolveAnnotations: ({ ctx, sourceId, toolRows }) => |
| @@ -1,5 +1,6 @@ | |||
| 1 | import { describe, it, expect } from "@effect/vitest"; | 1 | import { describe, it, expect } from "@effect/vitest"; |
| 2 | import { Effect, Predicate } from "effect"; | 2 | import { Effect, Predicate } from "effect"; |
| 3 | import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | ||
| 3 | 4 | ||
| 4 | import { | 5 | import { |
| 5 | ConnectionId, | 6 | ConnectionId, |
| @@ -13,7 +14,7 @@ import { · 7 unmodified lines | |||
| 13 | SecretId, | 14 | SecretId, |
| 14 | TokenMaterial, | 15 | TokenMaterial, |
| 15 | } from "@executor-js/sdk"; | 16 | } from "@executor-js/sdk"; |
| 16 | import { makeTestConfig } from "@executor-js/sdk/testing"; | 17 | import { makeTestConfig, serveTestHttpApp } from "@executor-js/sdk/testing"; |
| 17 | import { memorySecretsPlugin } from "@executor-js/sdk/testing"; | 18 | import { memorySecretsPlugin } from "@executor-js/sdk/testing"; |
| 18 | 19 | ||
| 19 | import { graphqlPlugin } from "./plugin"; | 20 | import { graphqlPlugin } from "./plugin"; |
| @@ -196,9 +197,8 @@ describe("graphqlPlugin real protocol server", () => { · 176 unmodified lines | |||
| 196 | }); | 197 | }); |
| 197 | 198 | ||
| 198 | expect(result).toEqual({ | 199 | expect(result).toEqual({ |
| 199 | status: 200, | 200 | ok: true, |
| 200 | data: { hello: "Hello Ada" }, | 201 | data: { hello: "Hello Ada" }, |
| 201 | errors: null, | ||
| 202 | }); | 202 | }); |
| 203 | 203 | ||
| 204 | const requests = yield* server.requests; | 204 | const requests = yield* server.requests; |
| @@ -209,6 +209,46 @@ describe("graphqlPlugin real protocol server", () => { · 4 unmodified lines | |||
| 209 | }), | 209 | }), |
| 210 | ); | 210 | ); |
| 211 | 211 | ||
| 212 | it.effect("surfaces non-2xx invocation responses as ToolResult.fail", () => | ||
| 213 | Effect.gen(function* () { | ||
| 214 | const server = yield* serveTestHttpApp((request) => | ||
| 215 | Effect.gen(function* () { | ||
| 216 | const webRequest = yield* HttpServerRequest.toWeb(request); | ||
| 217 | const body = yield* Effect.promise(() => webRequest.text()); | ||
| 218 | if (body.includes("__schema")) { | ||
| 219 | return HttpServerResponse.jsonUnsafe({ data: introspectionResult }); | ||
| 220 | } | ||
| 221 | return HttpServerResponse.text("temporary upstream outage", { | ||
| 222 | status: 503, | ||
| 223 | contentType: "text/plain", | ||
| 224 | }); | ||
| 225 | }), | ||
| 226 | ); | ||
| 227 | const executor = yield* createExecutor( | ||
| 228 | makeTestConfig({ plugins: [graphqlPlugin()] as const }), | ||
| 229 | ); | ||
| 230 | |||
| 231 | yield* executor.graphql.addSource({ | ||
| 232 | endpoint: server.url("/graphql"), | ||
| 233 | scope: TEST_SCOPE, | ||
| 234 | namespace: "http_error_graph", | ||
| 235 | }); | ||
| 236 | |||
| 237 | const result = yield* executor.tools.invoke("http_error_graph.query.hello", { | ||
| 238 | name: "Ada", | ||
| 239 | }); | ||
| 240 | |||
| 241 | expect(result).toMatchObject({ | ||
| 242 | ok: false, | ||
| 243 | error: { | ||
| 244 | code: "graphql_http_error", | ||
| 245 | status: 503, | ||
| 246 | message: "GraphQL request failed with HTTP 503", | ||
| 247 | }, | ||
| 248 | }); | ||
| 249 | }), | ||
| 250 | ); | ||
| 251 | |||
| 212 | it.effect("invokes OAuth-backed sources with a bearer token", () => | 252 | it.effect("invokes OAuth-backed sources with a bearer token", () => |
| 213 | Effect.gen(function* () { | 253 | Effect.gen(function* () { |
| 214 | const server = yield* serveGreetingServer; | 254 | const server = yield* serveGreetingServer; |
| @@ -251,9 +291,8 @@ describe("graphqlPlugin real protocol server", () => { · 36 unmodified lines | |||
| 251 | }); | 291 | }); |
| 252 | 292 | ||
| 253 | expect(result).toEqual({ | 293 | expect(result).toEqual({ |
| 254 | status: 200, | 294 | ok: true, |
| 255 | data: { hello: "Hello Ada" }, | 295 | data: { hello: "Hello Ada" }, |
| 256 | errors: null, | ||
| 257 | }); | 296 | }); |
| 258 | 297 | ||
| 259 | const requests = yield* server.requests; | 298 | const requests = yield* server.requests; |
| @@ -425,7 +464,7 @@ describe("graphqlPlugin", () => { · 165 unmodified lines | |||
| 425 | }, | 464 | }, |
| 426 | { onElicitation: "accept-all" }, | 465 | { onElicitation: "accept-all" }, |
| 427 | ); | 466 | ); |
| 428 | expect(result).toEqual({ toolCount: 2, namespace: "via_static" }); | 467 | expect(result).toEqual({ ok: true, data: { toolCount: 2, namespace: "via_static" } }); |
| 429 | expect(yield* executor.graphql.getSource("via_static", String(userScope))).toBeNull(); | 468 | expect(yield* executor.graphql.getSource("via_static", String(userScope))).toBeNull(); |
| 430 | expect((yield* executor.graphql.getSource("via_static", String(orgScope)))?.scope).toBe( | 469 | expect((yield* executor.graphql.getSource("via_static", String(orgScope)))?.scope).toBe( |
| 431 | orgScope, | 470 | orgScope, |
| @@ -729,7 +768,7 @@ describe("graphqlPlugin", () => { · 297 unmodified lines | |||
| 729 | }); | 768 | }); |
| 730 | 769 | ||
| 731 | expect(result).toMatchObject({ | 770 | expect(result).toMatchObject({ |
| 732 | status: 200, | 771 | ok: true, |
| 733 | data: { hello: "Hello Ada" }, | 772 | data: { hello: "Hello Ada" }, |
| 734 | }); | 773 | }); |
| 735 | const requests = yield* server.requests; | 774 | const requests = yield* server.requests; |
| @@ -843,7 +882,7 @@ describe("graphqlPlugin", () => { · 107 unmodified lines | |||
| 843 | }); | 882 | }); |
| 844 | 883 | ||
| 845 | expect(result).toMatchObject({ | 884 | expect(result).toMatchObject({ |
| 846 | status: 200, | 885 | ok: true, |
| 847 | data: { hello: "Hello Ada" }, | 886 | data: { hello: "Hello Ada" }, |
| 848 | }); | 887 | }); |
| 849 | const requests = yield* server.requests; | 888 | const requests = yield* server.requests; |
| @@ -915,7 +954,7 @@ describe("graphqlPlugin", () => { · 65 unmodified lines | |||
| 915 | }); | 954 | }); |
| 916 | 955 | ||
| 917 | expect(result).toMatchObject({ | 956 | expect(result).toMatchObject({ |
| 918 | status: 200, | 957 | ok: true, |
| 919 | data: { hello: "Hello Ada" }, | 958 | data: { hello: "Hello Ada" }, |
| 920 | }); | 959 | }); |
| 921 | const requests = yield* server.requests; | 960 | const requests = yield* server.requests; |
| @@ -12,6 +12,7 @@ import { · 11 unmodified lines | |||
| 12 | SecretId, | 12 | SecretId, |
| 13 | SourceDetectionResult, | 13 | SourceDetectionResult, |
| 14 | StorageError, | 14 | StorageError, |
| 15 | ToolResult, | ||
| 15 | type PluginCtx, | 16 | type PluginCtx, |
| 16 | type StorageFailure, | 17 | type StorageFailure, |
| 17 | type ToolAnnotations, | 18 | type ToolAnnotations, |
| @@ -65,6 +66,19 @@ import { · 47 unmodified lines | |||
| 65 | // Plugin config | 66 | // Plugin config |
| 66 | // --------------------------------------------------------------------------- | 67 | // --------------------------------------------------------------------------- |
| 67 | 68 | ||
| 69 | const GraphqlErrorBody = Schema.Struct({ message: Schema.String }); | ||
| 70 | const GraphqlErrorsBody = Schema.Array(Schema.Unknown); | ||
| 71 | const decodeGraphqlErrorBody = Schema.decodeUnknownOption(GraphqlErrorBody); | ||
| 72 | const decodeGraphqlErrorsBody = Schema.decodeUnknownOption(GraphqlErrorsBody); | ||
| 73 | |||
| 74 | const decodeGraphqlErrors = (errors: unknown): readonly unknown[] | undefined => | ||
| 75 | Option.getOrUndefined(decodeGraphqlErrorsBody(errors)); | ||
| 76 | |||
| 77 | const extractGraphqlErrorMessage = (errors: readonly unknown[]): string | undefined => | ||
| 78 | errors | ||
| 79 | .map((error) => Option.getOrUndefined(decodeGraphqlErrorBody(error))?.message) | ||
| 80 | .find((message) => message !== undefined && message.length > 0); | ||
| 81 | |||
| 68 | export type HeaderValue = HeaderValueValue; | 82 | export type HeaderValue = HeaderValueValue; |
| 69 | export type GraphqlCredentialValue = ConfiguredGraphqlCredentialValue; | 83 | export type GraphqlCredentialValue = ConfiguredGraphqlCredentialValue; |
| 70 | 84 | ||
| @@ -994,7 +1008,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 923 unmodified lines | |||
| 994 | }, | 1008 | }, |
| 995 | inputSchema: StaticAddSourceInputStandardSchema, | 1009 | inputSchema: StaticAddSourceInputStandardSchema, |
| 996 | outputSchema: StaticAddSourceOutputStandardSchema, | 1010 | outputSchema: StaticAddSourceOutputStandardSchema, |
| 997 | execute: (input) => self.addSource(input), | 1011 | execute: (input) => Effect.map(self.addSource(input), ToolResult.ok), |
| 998 | }), | 1012 | }), |
| 999 | ], | 1013 | ], |
| 1000 | }, | 1014 | }, |
| @@ -1057,7 +1071,28 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 56 unmodified lines | |||
| 1057 | httpClientLayer, | 1071 | httpClientLayer, |
| 1058 | ); | 1072 | ); |
| 1059 | 1073 | ||
| 1060 | return result; | 1074 | const errors = decodeGraphqlErrors(result.errors); |
| 1075 | if (errors !== undefined && errors.length > 0) { | ||
| 1076 | const firstMessage = extractGraphqlErrorMessage(errors); | ||
| 1077 | return ToolResult.fail({ | ||
| 1078 | code: "graphql_errors", | ||
| 1079 | message: firstMessage !== undefined ? firstMessage : "GraphQL request returned errors", | ||
| 1080 | details: { errors }, | ||
| 1081 | }); | ||
| 1082 | } | ||
| 1083 | if (result.status < 200 || result.status >= 300) { | ||
| 1084 | return ToolResult.fail({ | ||
| 1085 | code: "graphql_http_error", | ||
| 1086 | status: result.status, | ||
| 1087 | message: `GraphQL request failed with HTTP ${result.status}`, | ||
| 1088 | details: { | ||
| 1089 | status: result.status, | ||
| 1090 | data: result.data, | ||
| 1091 | errors: result.errors, | ||
| 1092 | }, | ||
| 1093 | }); | ||
| 1094 | } | ||
| 1095 | return ToolResult.ok(result.data); | ||
| 1061 | }), | 1096 | }), |
| 1062 | 1097 | ||
| 1063 | resolveAnnotations: ({ ctx, sourceId, toolRows }) => | 1098 | resolveAnnotations: ({ ctx, sourceId, toolRows }) => |
| @@ -55,6 +55,13 @@ import { MCP_OAUTH_CONNECTION_SLOT, type McpCredentialInput } from "../sdk/types · 54 unmodified lines | |||
| 55 | 55 | ||
| 56 | const ErrorMessage = Schema.Struct({ message: Schema.String }); | 56 | const ErrorMessage = Schema.Struct({ message: Schema.String }); |
| 57 | const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); | 57 | const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); |
| 58 | const STDIO_ENV_ESCAPE_REPLACEMENTS: Readonly<Record<string, string>> = { | ||
| 59 | "\\": "\\", | ||
| 60 | n: "\n", | ||
| 61 | r: "\r", | ||
| 62 | t: "\t", | ||
| 63 | '"': '"', | ||
| 64 | }; | ||
| 58 | 65 | ||
| 59 | const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => | 66 | const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => |
| 60 | Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), { | 67 | Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), { |
| @@ -525,22 +532,10 @@ export default function AddMcpSource(props: { · 464 unmodified lines | |||
| 525 | const inner = value.slice(1, -1); | 532 | const inner = value.slice(1, -1); |
| 526 | if (quote === "'") return inner; | 533 | if (quote === "'") return inner; |
| 527 | 534 | ||
| 528 | return inner.replace(/\\([\\nrt"])/g, (_, escaped: string) => { | 535 | return inner.replace( |
| 529 | switch (escaped) { | 536 | /\\([\\nrt"])/g, |
| 530 | case "\\": | 537 | (_, escaped: string) => STDIO_ENV_ESCAPE_REPLACEMENTS[escaped] ?? escaped, |
| 531 | return "\\"; | 538 | ); |
| 532 | case "n": | ||
| 533 | return "\n"; | ||
| 534 | case "r": | ||
| 535 | return "\r"; | ||
| 536 | case "t": | ||
| 537 | return "\t"; | ||
| 538 | case '"': | ||
| 539 | return '"'; | ||
| 540 | default: | ||
| 541 | return escaped; | ||
| 542 | } | ||
| 543 | }); | ||
| 544 | }; | 539 | }; |
| 545 | 540 | ||
| 546 | const parseStdioEnv = (raw: string): Record<string, string> | undefined => { | 541 | const parseStdioEnv = (raw: string): Record<string, string> | undefined => { |
| @@ -69,7 +69,8 @@ describe("MCP elicitation (end-to-end)", () => { · 68 unmodified lines | |||
| 69 | const result = yield* executor.tools.invoke(gatedEcho!.id, { value: "hello" }, options); | 69 | const result = yield* executor.tools.invoke(gatedEcho!.id, { value: "hello" }, options); |
| 70 | 70 | ||
| 71 | expect(result).toMatchObject({ | 71 | expect(result).toMatchObject({ |
| 72 | content: [{ type: "text", text: "approved:hello" }], | 72 | ok: true, |
| 73 | data: { content: [{ type: "text", text: "approved:hello" }] }, | ||
| 73 | }); | 74 | }); |
| 74 | // At least one elicitation should be the MCP server's form | 75 | // At least one elicitation should be the MCP server's form |
| 75 | expect(elicitationMessages.length).toBeGreaterThanOrEqual(1); | 76 | expect(elicitationMessages.length).toBeGreaterThanOrEqual(1); |
| @@ -95,7 +96,8 @@ describe("MCP elicitation (end-to-end)", () => { · 19 unmodified lines | |||
| 95 | ); | 96 | ); |
| 96 | 97 | ||
| 97 | expect(result).toMatchObject({ | 98 | expect(result).toMatchObject({ |
| 98 | content: [{ type: "text", text: "denied:nope" }], | 99 | ok: true, |
| 100 | data: { content: [{ type: "text", text: "denied:nope" }] }, | ||
| 99 | }); | 101 | }); |
| 100 | }), | 102 | }), |
| 101 | ); | 103 | ); |
| @@ -114,7 +116,32 @@ describe("MCP elicitation (end-to-end)", () => { · 12 unmodified lines | |||
| 114 | ); | 116 | ); |
| 115 | 117 | ||
| 116 | expect(result).toMatchObject({ | 118 | expect(result).toMatchObject({ |
| 117 | content: [{ type: "text", text: "plain" }], | 119 | ok: true, |
| 120 | data: { content: [{ type: "text", text: "plain" }] }, | ||
| 121 | }); | ||
| 122 | }), | ||
| 123 | ); | ||
| 124 | |||
| 125 | it.effect("successful tool invocation preserves structured MCP result fields", () => | ||
| 126 | Effect.gen(function* () { | ||
| 127 | const server = yield* serveElicitationTestServer; | ||
| 128 | const executor = yield* makeTestExecutor(server.url); | ||
| 129 | const tools = yield* executor.tools.list(); | ||
| 130 | const structuredEcho = tools.find((t) => t.name === "structured_echo")!; | ||
| 131 | |||
| 132 | const result = yield* executor.tools.invoke( | ||
| 133 | structuredEcho.id, | ||
| 134 | { value: "plain" }, | ||
| 135 | { onElicitation: "accept-all" }, | ||
| 136 | ); | ||
| 137 | |||
| 138 | expect(result).toMatchObject({ | ||
| 139 | ok: true, | ||
| 140 | data: { | ||
| 141 | content: [{ type: "text", text: "plain" }], | ||
| 142 | structuredContent: { value: "plain", upper: "PLAIN" }, | ||
| 143 | _meta: { trace: "kept" }, | ||
| 144 | }, | ||
| 118 | }); | 145 | }); |
| 119 | }), | 146 | }), |
| 120 | ); | 147 | ); |
| @@ -115,7 +115,8 @@ describe("per-user MCP auth isolation", () => { · 114 unmodified lines | |||
| 115 | { onElicitation: "accept-all" }, | 115 | { onElicitation: "accept-all" }, |
| 116 | ); | 116 | ); |
| 117 | expect(userAResult).toMatchObject({ | 117 | expect(userAResult).toMatchObject({ |
| 118 | content: [{ type: "text", text: "ok:from-user-a" }], | 118 | ok: true, |
| 119 | data: { content: [{ type: "text", text: "ok:from-user-a" }] }, | ||
| 119 | }); | 120 | }); |
| 120 | expect( | 121 | expect( |
| 121 | (yield* server.requests) | 122 | (yield* server.requests) |
| @@ -187,7 +188,8 @@ describe("per-user MCP auth isolation", () => { · 65 unmodified lines | |||
| 187 | { onElicitation: "accept-all" }, | 188 | { onElicitation: "accept-all" }, |
| 188 | ); | 189 | ); |
| 189 | expect(userAResult).toMatchObject({ | 190 | expect(userAResult).toMatchObject({ |
| 190 | content: [{ type: "text", text: "ok:user-a-header" }], | 191 | ok: true, |
| 192 | data: { content: [{ type: "text", text: "ok:user-a-header" }] }, | ||
| 191 | }); | 193 | }); |
| 192 | expect( | 194 | expect( |
| 193 | (yield* server.requests) | 195 | (yield* server.requests) |
| @@ -267,7 +269,8 @@ describe("per-user MCP auth isolation", () => { · 73 unmodified lines | |||
| 267 | ); | 269 | ); |
| 268 | 270 | ||
| 269 | expect(result).toMatchObject({ | 271 | expect(result).toMatchObject({ |
| 270 | content: [{ type: "text", text: "ok:org-header" }], | 272 | ok: true, |
| 273 | data: { content: [{ type: "text", text: "ok:org-header" }] }, | ||
| 271 | }); | 274 | }); |
| 272 | const invokeRequests = (yield* server.requests).slice(beforeInvoke); | 275 | const invokeRequests = (yield* server.requests).slice(beforeInvoke); |
| 273 | expect( | 276 | expect( |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | |||
| 8 | Predicate, | 8 | Predicate, |
| 9 | Result, | 9 | Result, |
| 10 | Scope, | 10 | Scope, |
| 11 | Schema, | ||
| 11 | ScopedCache, | 12 | ScopedCache, |
| 12 | } from "effect"; | 13 | } from "effect"; |
| 13 | import type { HttpClient } from "effect/unstable/http"; | 14 | import type { HttpClient } from "effect/unstable/http"; |
| @@ -22,6 +23,7 @@ import { · 8 unmodified lines | |||
| 22 | ScopeId, | 23 | ScopeId, |
| 23 | SecretId, | 24 | SecretId, |
| 24 | SourceDetectionResult, | 25 | SourceDetectionResult, |
| 26 | ToolResult, | ||
| 25 | definePlugin, | 27 | definePlugin, |
| 26 | resolveSecretBackedMap as resolveSharedSecretBackedMap, | 28 | resolveSecretBackedMap as resolveSharedSecretBackedMap, |
| 27 | type PluginCtx, | 29 | type PluginCtx, |
| @@ -193,6 +195,24 @@ const toBinding = (entry: McpToolManifestEntry): McpToolBinding => · 165 unmodified lines | |||
| 193 | }); | 195 | }); |
| 194 | 196 | ||
| 195 | const MCP_PLUGIN_ID = "mcp"; | 197 | const MCP_PLUGIN_ID = "mcp"; |
| 198 | const McpTextContent = Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }); | ||
| 199 | const McpToolCallEnvelope = Schema.Struct({ | ||
| 200 | isError: Schema.optional(Schema.Boolean), | ||
| 201 | content: Schema.optional(Schema.Array(Schema.Unknown)), | ||
| 202 | }); | ||
| 203 | |||
| 204 | const decodeMcpTextContent = Schema.decodeUnknownOption(McpTextContent); | ||
| 205 | const decodeMcpToolCallEnvelope = Schema.decodeUnknownOption(McpToolCallEnvelope); | ||
| 206 | |||
| 207 | const extractMcpErrorMessage = (content: unknown): string => { | ||
| 208 | if (Array.isArray(content)) { | ||
| 209 | for (const item of content) { | ||
| 210 | const decoded = Option.getOrUndefined(decodeMcpTextContent(item)); | ||
| 211 | if (decoded !== undefined && decoded.text.length > 0) return decoded.text; | ||
| 212 | } | ||
| 213 | } | ||
| 214 | return "MCP tool returned an error"; | ||
| 215 | }; | ||
| 196 | 216 | ||
| 197 | /** Match `token` as a separator-bounded run inside a URL hostname or path, | 217 | /** Match `token` as a separator-bounded run inside a URL hostname or path, |
| 198 | * used as a low-confidence detection hint when wire-shape detection fails. | 218 | * used as a low-confidence detection hint when wire-shape detection fails. |
| @@ -1726,7 +1746,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 1527 unmodified lines | |||
| 1726 | }); | 1746 | }); |
| 1727 | } | 1747 | } |
| 1728 | 1748 | ||
| 1729 | return yield* invokeMcpTool({ | 1749 | const raw = yield* invokeMcpTool({ |
| 1730 | toolId: toolRow.id, | 1750 | toolId: toolRow.id, |
| 1731 | toolName: entry.binding.toolName, | 1751 | toolName: entry.binding.toolName, |
| 1732 | args, | 1752 | args, |
| @@ -1764,6 +1784,16 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 31 unmodified lines | |||
| 1764 | pendingConnectors: runtime.pendingConnectors, | 1784 | pendingConnectors: runtime.pendingConnectors, |
| 1765 | elicit, | 1785 | elicit, |
| 1766 | }); | 1786 | }); |
| 1787 | |||
| 1788 | const envelope = Option.getOrUndefined(decodeMcpToolCallEnvelope(raw)); | ||
| 1789 | if (envelope?.isError === true) { | ||
| 1790 | return ToolResult.fail({ | ||
| 1791 | code: "mcp_tool_error", | ||
| 1792 | message: extractMcpErrorMessage(envelope.content), | ||
| 1793 | details: { content: envelope.content }, | ||
| 1794 | }); | ||
| 1795 | } | ||
| 1796 | return ToolResult.ok(raw); | ||
| 1767 | }).pipe( | 1797 | }).pipe( |
| 1768 | Effect.withSpan("mcp.plugin.invoke_tool", { | 1798 | Effect.withSpan("mcp.plugin.invoke_tool", { |
| 1769 | attributes: { | 1799 | attributes: { |
| @@ -375,6 +375,23 @@ export const makeElicitationMcpServer = () => { · 374 unmodified lines | |||
| 375 | }), | 375 | }), |
| 376 | ); | 376 | ); |
| 377 | 377 | ||
| 378 | server.registerTool( | ||
| 379 | "structured_echo", | ||
| 380 | { | ||
| 381 | description: "Returns text plus structured data", | ||
| 382 | inputSchema: { value: z.string() }, | ||
| 383 | outputSchema: { | ||
| 384 | value: z.string(), | ||
| 385 | upper: z.string(), | ||
| 386 | }, | ||
| 387 | }, | ||
| 388 | async ({ value }: { value: string }) => ({ | ||
| 389 | content: [{ type: "text" as const, text: value }], | ||
| 390 | structuredContent: { value, upper: value.toUpperCase() }, | ||
| 391 | _meta: { trace: "kept" }, | ||
| 392 | }), | ||
| 393 | ); | ||
| 394 | |||
| 378 | return server; | 395 | return server; |
| 379 | }; | 396 | }; |
| 380 | 397 | ||
| @@ -26,6 +26,7 @@ import { makeTestConfig, serveOAuthTestServer } from "@executor-js/sdk/testing"; · 25 unmodified lines | |||
| 26 | import { | 26 | import { |
| 27 | addOpenApiTestSource, | 27 | addOpenApiTestSource, |
| 28 | serveOpenApiHttpApiTestServer, | 28 | serveOpenApiHttpApiTestServer, |
| 29 | unwrapInvocation, | ||
| 29 | } from "@executor-js/plugin-openapi/testing"; | 30 | } from "@executor-js/plugin-openapi/testing"; |
| 30 | 31 | ||
| 31 | import { openApiPlugin } from "./plugin"; | 32 | import { openApiPlugin } from "./plugin"; |
| @@ -226,25 +227,21 @@ describe("OpenAPI client_credentials OAuth", () => { · 194 unmodified lines | |||
| 226 | ); | 227 | ); |
| 227 | // Invoking the tool injects the freshly-minted bearer via | 228 | // Invoking the tool injects the freshly-minted bearer via |
| 228 | // ctx.connections.accessToken. | 229 | // ctx.connections.accessToken. |
| 229 | const result = (yield* userExec.tools.invoke( | 230 | const result = unwrapInvocation( |
| 230 | "petstore.items.echoHeaders", | 231 | yield* userExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove), |
| 231 | {}, | 232 | ); |
| 232 | autoApprove, | ||
| 233 | )) as { | ||
| 234 | data: { authorization?: string } | null; | ||
| 235 | error: unknown; | ||
| 236 | }; | ||
| 237 | expect(result.error).toBeNull(); | 233 | expect(result.error).toBeNull(); |
| 238 | const bearer = result.data?.authorization?.replace(/^Bearer\s+/i, ""); | 234 | const data = result.data as EchoHeaders | null; |
| 235 | const bearer = data?.authorization?.replace(/^Bearer\s+/i, ""); | ||
| 239 | expect(bearer).toBeDefined(); | 236 | expect(bearer).toBeDefined(); |
| 240 | expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true); | 237 | expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true); |
| 241 | 238 | ||
| 242 | // The connection lives at the innermost (user) scope, which | 239 | // The connection lives at the innermost (user) scope, which |
| 243 | // preserves per-user credential resolution: if each user has | 240 | // preserves per-user credential resolution: if each user has |
| 244 | // their own `dealcloud_client_id`/`dealcloud_client_secret` | 241 | // their own OAuth client credentials shadowed at their user |
| 245 | // shadowed at their user scope, each user mints their own | 242 | // scope, each user mints their own token. A single shared |
| 246 | // token. A single shared connection slot still lets every caller | 243 | // connection slot still lets every caller reach the right |
| 247 | // reach the right physical row through scoped credential bindings. | 244 | // physical row through scoped credential bindings. |
| 248 | const userConnections = yield* userExec.connections.list(); | 245 | const userConnections = yield* userExec.connections.list(); |
| 249 | const connection = userConnections.find((c) => c.id === completedConnection.connectionId); | 246 | const connection = userConnections.find((c) => c.id === completedConnection.connectionId); |
| 250 | expect(connection).toBeDefined(); | 247 | expect(connection).toBeDefined(); |
| @@ -38,6 +38,7 @@ import { · 37 unmodified lines | |||
| 38 | addOpenApiTestSource, | 38 | addOpenApiTestSource, |
| 39 | makeOpenApiTestSourceConfig, | 39 | makeOpenApiTestSourceConfig, |
| 40 | serveOpenApiHttpApiTestServer, | 40 | serveOpenApiHttpApiTestServer, |
| 41 | unwrapInvocation, | ||
| 41 | } from "@executor-js/plugin-openapi/testing"; | 42 | } from "@executor-js/plugin-openapi/testing"; |
| 42 | 43 | ||
| 43 | import { openApiPlugin } from "./plugin"; | 44 | import { openApiPlugin } from "./plugin"; |
| @@ -277,25 +278,21 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 233 unmodified lines | |||
| 277 | // yields her token; bob's scope yields his. Same source, same | 278 | // yields her token; bob's scope yields his. Same source, same |
| 278 | // tool, different injected bearer. | 279 | // tool, different injected bearer. |
| 279 | // ------------------------------------------------------------- | 280 | // ------------------------------------------------------------- |
| 280 | const aliceResult = (yield* aliceExec.tools.invoke( | 281 | const aliceResult = unwrapInvocation( |
| 281 | "vercel.projects.list", | 282 | yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove), |
| 282 | {}, | 283 | ); |
| 283 | autoApprove, | ||
| 284 | )) as { | ||
| 285 | data: { authorization?: string; token?: string } | null; | ||
| 286 | error: unknown; | ||
| 287 | }; | ||
| 288 | expect(aliceResult.error).toBeNull(); | 284 | expect(aliceResult.error).toBeNull(); |
| 289 | expect(aliceResult.data?.authorization).toBe("Bearer alice-vercel-token"); | 285 | const aliceData = aliceResult.data as EchoHeaders | null; |
| 290 | expect(aliceResult.data?.token).toBe("alice-team"); | 286 | expect(aliceData?.authorization).toBe("Bearer alice-vercel-token"); |
| 287 | expect(aliceData?.token).toBe("alice-team"); | ||
| 291 | 288 | ||
| 292 | const bobResult = (yield* bobExec.tools.invoke("vercel.projects.list", {}, autoApprove)) as { | 289 | const bobResult = unwrapInvocation( |
| 293 | data: { authorization?: string; token?: string } | null; | 290 | yield* bobExec.tools.invoke("vercel.projects.list", {}, autoApprove), |
| 294 | error: unknown; | 291 | ); |
| 295 | }; | ||
| 296 | expect(bobResult.error).toBeNull(); | 292 | expect(bobResult.error).toBeNull(); |
| 297 | expect(bobResult.data?.authorization).toBe("Bearer bob-vercel-token"); | 293 | const bobData = bobResult.data as EchoHeaders | null; |
| 298 | expect(bobResult.data?.token).toBe("bob-team"); | 294 | expect(bobData?.authorization).toBe("Bearer bob-vercel-token"); |
| 295 | expect(bobData?.token).toBe("bob-team"); | ||
| 299 | 296 | ||
| 300 | // ------------------------------------------------------------- | 297 | // ------------------------------------------------------------- |
| 301 | // 5. Scope attribution: each user's token is pinned to their | 298 | // 5. Scope attribution: each user's token is pinned to their |
| @@ -450,20 +447,19 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 148 unmodified lines | |||
| 450 | }), | 447 | }), |
| 451 | ); | 448 | ); |
| 452 | 449 | ||
| 453 | const aliceResult = (yield* aliceExec.tools.invoke( | 450 | const aliceResult = unwrapInvocation( |
| 454 | "vercel.projects.list", | 451 | yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove), |
| 455 | {}, | 452 | ); |
| 456 | autoApprove, | ||
| 457 | )) as { data: { authorization?: string } | null; error: unknown }; | ||
| 458 | expect(aliceResult.error).toBeNull(); | 453 | expect(aliceResult.error).toBeNull(); |
| 459 | expect(aliceResult.data?.authorization).toBe("Bearer alice-vercel-token"); | 454 | expect((aliceResult.data as EchoHeaders | null)?.authorization).toBe( |
| 455 | "Bearer alice-vercel-token", | ||
| 456 | ); | ||
| 460 | 457 | ||
| 461 | const bobResult = (yield* bobExec.tools.invoke("vercel.projects.list", {}, autoApprove)) as { | 458 | const bobResult = unwrapInvocation( |
| 462 | data: { authorization?: string } | null; | 459 | yield* bobExec.tools.invoke("vercel.projects.list", {}, autoApprove), |
| 463 | error: unknown; | 460 | ); |
| 464 | }; | ||
| 465 | expect(bobResult.error).toBeNull(); | 461 | expect(bobResult.error).toBeNull(); |
| 466 | expect(bobResult.data?.authorization).toBe("Bearer bob-vercel-token"); | 462 | expect((bobResult.data as EchoHeaders | null)?.authorization).toBe("Bearer bob-vercel-token"); |
| 467 | }), | 463 | }), |
| 468 | ); | 464 | ); |
| 469 | 465 | ||
| @@ -558,13 +554,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 88 unmodified lines | |||
| 558 | }), | 554 | }), |
| 559 | ); | 555 | ); |
| 560 | 556 | ||
| 561 | const sharedResult = (yield* aliceExec.tools.invoke( | 557 | const sharedResult = unwrapInvocation( |
| 562 | "vercel.projects.list", | 558 | yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove), |
| 563 | {}, | 559 | ); |
| 564 | autoApprove, | ||
| 565 | )) as { data: { authorization?: string } | null; error: unknown }; | ||
| 566 | expect(sharedResult.error).toBeNull(); | 560 | expect(sharedResult.error).toBeNull(); |
| 567 | expect(sharedResult.data?.authorization).toBe("Bearer org-token"); | 561 | expect((sharedResult.data as EchoHeaders | null)?.authorization).toBe("Bearer org-token"); |
| 568 | 562 | ||
| 569 | yield* aliceExec.secrets.set( | 563 | yield* aliceExec.secrets.set( |
| 570 | SetSecretInput.make({ | 564 | SetSecretInput.make({ |
| @@ -587,13 +581,13 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 16 unmodified lines | |||
| 587 | }), | 581 | }), |
| 588 | ); | 582 | ); |
| 589 | 583 | ||
| 590 | const overrideResult = (yield* aliceExec.tools.invoke( | 584 | const overrideResult = unwrapInvocation( |
| 591 | "vercel.projects.list", | 585 | yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove), |
| 592 | {}, | 586 | ); |
| 593 | autoApprove, | ||
| 594 | )) as { data: { authorization?: string } | null; error: unknown }; | ||
| 595 | expect(overrideResult.error).toBeNull(); | 587 | expect(overrideResult.error).toBeNull(); |
| 596 | expect(overrideResult.data?.authorization).toBe("Bearer alice-token"); | 588 | expect((overrideResult.data as EchoHeaders | null)?.authorization).toBe( |
| 589 | "Bearer alice-token", | ||
| 590 | ); | ||
| 597 | 591 | ||
| 598 | yield* aliceExec.openapi.removeSourceBinding( | 592 | yield* aliceExec.openapi.removeSourceBinding( |
| 599 | "vercel", | 593 | "vercel", |
| @@ -602,13 +596,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 2 unmodified lines | |||
| 602 | String(aliceScope.id), | 596 | String(aliceScope.id), |
| 603 | ); | 597 | ); |
| 604 | 598 | ||
| 605 | const fallbackResult = (yield* aliceExec.tools.invoke( | 599 | const fallbackResult = unwrapInvocation( |
| 606 | "vercel.projects.list", | 600 | yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove), |
| 607 | {}, | 601 | ); |
| 608 | autoApprove, | ||
| 609 | )) as { data: { authorization?: string } | null; error: unknown }; | ||
| 610 | expect(fallbackResult.error).toBeNull(); | 602 | expect(fallbackResult.error).toBeNull(); |
| 611 | expect(fallbackResult.data?.authorization).toBe("Bearer org-token"); | 603 | expect((fallbackResult.data as EchoHeaders | null)?.authorization).toBe("Bearer org-token"); |
| 612 | 604 | ||
| 613 | yield* aliceExec.openapi.setSourceBinding( | 605 | yield* aliceExec.openapi.setSourceBinding( |
| 614 | OpenApiSourceBindingInput.make({ | 606 | OpenApiSourceBindingInput.make({ |
| @@ -812,12 +804,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 197 unmodified lines | |||
| 812 | }), | 804 | }), |
| 813 | ); | 805 | ); |
| 814 | 806 | ||
| 815 | const result = (yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove)) as { | 807 | const result = unwrapInvocation( |
| 816 | data: { authorization?: string } | null; | 808 | yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove), |
| 817 | error: unknown; | 809 | ); |
| 818 | }; | ||
| 819 | expect(result.error).toBeNull(); | 810 | expect(result.error).toBeNull(); |
| 820 | expect(result.data?.authorization).toBe("Bearer alice-token"); | 811 | expect((result.data as EchoHeaders | null)?.authorization).toBe("Bearer alice-token"); |
| 821 | }), | 812 | }), |
| 822 | ); | 813 | ); |
| 823 | 814 | ||
| @@ -914,13 +905,12 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 90 unmodified lines | |||
| 914 | }), | 905 | }), |
| 915 | ); | 906 | ); |
| 916 | 907 | ||
| 917 | const result = (yield* userExec.tools.invoke("vercel.projects.list", {}, autoApprove)) as { | 908 | const result = unwrapInvocation( |
| 918 | data: { authorization?: string } | null; | 909 | yield* userExec.tools.invoke("vercel.projects.list", {}, autoApprove), |
| 919 | error: unknown; | 910 | ); |
| 920 | }; | ||
| 921 | 911 | ||
| 922 | expect(result.error).toBeNull(); | 912 | expect(result.error).toBeNull(); |
| 923 | expect(result.data?.authorization).toBe("Bearer org-token"); | 913 | expect((result.data as EchoHeaders | null)?.authorization).toBe("Bearer org-token"); |
| 924 | }), | 914 | }), |
| 925 | ); | 915 | ); |
| 926 | 916 | ||
| @@ -1013,13 +1003,12 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 86 unmodified lines | |||
| 1013 | }), | 1003 | }), |
| 1014 | ); | 1004 | ); |
| 1015 | 1005 | ||
| 1016 | const result = (yield* userExec.tools.invoke("vercel.projects.list", {}, autoApprove)) as { | 1006 | const result = unwrapInvocation( |
| 1017 | data: { authorization?: string } | null; | 1007 | yield* userExec.tools.invoke("vercel.projects.list", {}, autoApprove), |
| 1018 | error: unknown; | 1008 | ); |
| 1019 | }; | ||
| 1020 | 1009 | ||
| 1021 | expect(result.error).toBeNull(); | 1010 | expect(result.error).toBeNull(); |
| 1022 | expect(result.data?.authorization).toBe("Bearer org-choice"); | 1011 | expect((result.data as EchoHeaders | null)?.authorization).toBe("Bearer org-choice"); |
| 1023 | }), | 1012 | }), |
| 1024 | ); | 1013 | ); |
| 1025 | }); | 1014 | }); |
| @@ -28,6 +28,7 @@ import { makeTestConfig, serveOAuthTestServer } from "@executor-js/sdk/testing"; · 27 unmodified lines | |||
| 28 | import { | 28 | import { |
| 29 | addOpenApiTestSource, | 29 | addOpenApiTestSource, |
| 30 | serveOpenApiHttpApiTestServer, | 30 | serveOpenApiHttpApiTestServer, |
| 31 | unwrapInvocation, | ||
| 31 | } from "@executor-js/plugin-openapi/testing"; | 32 | } from "@executor-js/plugin-openapi/testing"; |
| 32 | 33 | ||
| 33 | import { openApiPlugin } from "./plugin"; | 34 | import { openApiPlugin } from "./plugin"; |
| @@ -307,23 +308,25 @@ describe("OpenAPI multi-scope OAuth", () => { · 273 unmodified lines | |||
| 307 | // 4. Invoke through each exec — Authorization must carry that | 308 | // 4. Invoke through each exec — Authorization must carry that |
| 308 | // user's token. | 309 | // user's token. |
| 309 | // ------------------------------------------------------------- | 310 | // ------------------------------------------------------------- |
| 310 | const aliceResult = (yield* aliceExec.tools.invoke( | 311 | const aliceResult = unwrapInvocation( |
| 311 | "petstore.items.echoHeaders", | 312 | yield* aliceExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove), |
| 312 | {}, | 313 | ); |
| 313 | autoApprove, | ||
| 314 | )) as { data: { authorization?: string } | null; error: unknown }; | ||
| 315 | expect(aliceResult.error).toBeNull(); | 314 | expect(aliceResult.error).toBeNull(); |
| 316 | const aliceBearer = aliceResult.data?.authorization?.replace(/^Bearer\s+/i, ""); | 315 | const aliceBearer = (aliceResult.data as EchoHeaders | null)?.authorization?.replace( |
| 316 | /^Bearer\s+/i, | ||
| 317 | "", | ||
| 318 | ); | ||
| 317 | expect(aliceBearer).toBeDefined(); | 319 | expect(aliceBearer).toBeDefined(); |
| 318 | expect(yield* oauth.acceptsAccessToken(aliceBearer!)).toBe(true); | 320 | expect(yield* oauth.acceptsAccessToken(aliceBearer!)).toBe(true); |
| 319 | 321 | ||
| 320 | const bobResult = (yield* bobExec.tools.invoke( | 322 | const bobResult = unwrapInvocation( |
| 321 | "petstore.items.echoHeaders", | 323 | yield* bobExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove), |
| 322 | {}, | 324 | ); |
| 323 | autoApprove, | ||
| 324 | )) as { data: { authorization?: string } | null; error: unknown }; | ||
| 325 | expect(bobResult.error).toBeNull(); | 325 | expect(bobResult.error).toBeNull(); |
| 326 | const bobBearer = bobResult.data?.authorization?.replace(/^Bearer\s+/i, ""); | 326 | const bobBearer = (bobResult.data as EchoHeaders | null)?.authorization?.replace( |
| 327 | /^Bearer\s+/i, | ||
| 328 | "", | ||
| 329 | ); | ||
| 327 | expect(bobBearer).toBeDefined(); | 330 | expect(bobBearer).toBeDefined(); |
| 328 | expect(yield* oauth.acceptsAccessToken(bobBearer!)).toBe(true); | 331 | expect(yield* oauth.acceptsAccessToken(bobBearer!)).toBe(true); |
| 329 | expect(bobBearer).not.toBe(aliceBearer); | 332 | expect(bobBearer).not.toBe(aliceBearer); |
| @@ -610,23 +613,25 @@ describe("OpenAPI multi-scope OAuth", () => { · 280 unmodified lines | |||
| 610 | // (4) Each user's invocation resolves their OWN row and gets | 613 | // (4) Each user's invocation resolves their OWN row and gets |
| 611 | // their OWN token — not whatever the last signer happened to | 614 | // their OWN token — not whatever the last signer happened to |
| 612 | // mint. This is the core multi-user regression. | 615 | // mint. This is the core multi-user regression. |
| 613 | const aliceResult = (yield* aliceExec.tools.invoke( | 616 | const aliceResult = unwrapInvocation( |
| 614 | "petstore.items.echoHeaders", | 617 | yield* aliceExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove), |
| 615 | {}, | 618 | ); |
| 616 | autoApprove, | ||
| 617 | )) as { data: { authorization?: string } | null; error: unknown }; | ||
| 618 | expect(aliceResult.error).toBeNull(); | 619 | expect(aliceResult.error).toBeNull(); |
| 619 | const aliceBearer = aliceResult.data?.authorization?.replace(/^Bearer\s+/i, ""); | 620 | const aliceBearer = (aliceResult.data as EchoHeaders | null)?.authorization?.replace( |
| 621 | /^Bearer\s+/i, | ||
| 622 | "", | ||
| 623 | ); | ||
| 620 | expect(aliceBearer).toBeDefined(); | 624 | expect(aliceBearer).toBeDefined(); |
| 621 | expect(yield* oauth.acceptsAccessToken(aliceBearer!)).toBe(true); | 625 | expect(yield* oauth.acceptsAccessToken(aliceBearer!)).toBe(true); |
| 622 | 626 | ||
| 623 | const bobResult = (yield* bobExec.tools.invoke( | 627 | const bobResult = unwrapInvocation( |
| 624 | "petstore.items.echoHeaders", | 628 | yield* bobExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove), |
| 625 | {}, | 629 | ); |
| 626 | autoApprove, | ||
| 627 | )) as { data: { authorization?: string } | null; error: unknown }; | ||
| 628 | expect(bobResult.error).toBeNull(); | 630 | expect(bobResult.error).toBeNull(); |
| 629 | const bobBearer = bobResult.data?.authorization?.replace(/^Bearer\s+/i, ""); | 631 | const bobBearer = (bobResult.data as EchoHeaders | null)?.authorization?.replace( |
| 632 | /^Bearer\s+/i, | ||
| 633 | "", | ||
| 634 | ); | ||
| 630 | expect(bobBearer).toBeDefined(); | 635 | expect(bobBearer).toBeDefined(); |
| 631 | expect(yield* oauth.acceptsAccessToken(bobBearer!)).toBe(true); | 636 | expect(yield* oauth.acceptsAccessToken(bobBearer!)).toBe(true); |
| 632 | expect(bobBearer).not.toBe(aliceBearer); | 637 | expect(bobBearer).not.toBe(aliceBearer); |
| @@ -35,6 +35,7 @@ import { makeTestConfig, serveOAuthTestServer } from "@executor-js/sdk/testing"; · 34 unmodified lines | |||
| 35 | import { | 35 | import { |
| 36 | addOpenApiTestSource, | 36 | addOpenApiTestSource, |
| 37 | serveOpenApiHttpApiTestServer, | 37 | serveOpenApiHttpApiTestServer, |
| 38 | unwrapInvocation, | ||
| 38 | } from "@executor-js/plugin-openapi/testing"; | 39 | } from "@executor-js/plugin-openapi/testing"; |
| 39 | 40 | ||
| 40 | import { openApiPlugin } from "./plugin"; | 41 | import { openApiPlugin } from "./plugin"; |
| @@ -253,17 +254,16 @@ describe("OpenAPI oauth refresh", () => { · 212 unmodified lines | |||
| 253 | }); | 254 | }); |
| 254 | yield* bindOAuthConnection(executor, scopeId, "conn-refresh-ok", auth); | 255 | yield* bindOAuthConnection(executor, scopeId, "conn-refresh-ok", auth); |
| 255 | 256 | ||
| 256 | const result = (yield* executor.tools.invoke( | 257 | const result = unwrapInvocation( |
| 257 | "petstore.items.echoHeaders", | 258 | yield* executor.tools.invoke("petstore.items.echoHeaders", {}, autoApprove), |
| 258 | {}, | 259 | ); |
| 259 | autoApprove, | ||
| 260 | )) as { data: { authorization?: string } | null; error: unknown }; | ||
| 261 | 260 | ||
| 262 | expect(result.error).toBeNull(); | 261 | expect(result.error).toBeNull(); |
| 262 | const data = result.data as EchoHeaders | null; | ||
| 263 | // Proves the refresh landed: invoke carried the fresh token, | 263 | // Proves the refresh landed: invoke carried the fresh token, |
| 264 | // not the expired one we seeded. | 264 | // not the expired one we seeded. |
| 265 | expect(result.data?.authorization).not.toBe("Bearer expired-access-v1"); | 265 | expect(data?.authorization).not.toBe("Bearer expired-access-v1"); |
| 266 | const bearer = result.data?.authorization?.replace(/^Bearer\s+/i, ""); | 266 | const bearer = data?.authorization?.replace(/^Bearer\s+/i, ""); |
| 267 | expect(bearer).toBeDefined(); | 267 | expect(bearer).toBeDefined(); |
| 268 | expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true); | 268 | expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true); |
| 269 | const calls = refreshTokenRequests(yield* oauth.requests); | 269 | const calls = refreshTokenRequests(yield* oauth.requests); |
| @@ -313,12 +313,9 @@ describe("OpenAPI oauth refresh", () => { · 43 unmodified lines | |||
| 313 | ); | 313 | ); |
| 314 | 314 | ||
| 315 | for (const r of invokes) { | 315 | for (const r of invokes) { |
| 316 | const res = r as { | 316 | const res = unwrapInvocation(r); |
| 317 | data: { authorization?: string } | null; | ||
| 318 | error: unknown; | ||
| 319 | }; | ||
| 320 | expect(res.error).toBeNull(); | 317 | expect(res.error).toBeNull(); |
| 321 | const bearer = res.data?.authorization?.replace(/^Bearer\s+/i, ""); | 318 | const bearer = (res.data as EchoHeaders | null)?.authorization?.replace(/^Bearer\s+/i, ""); |
| 322 | expect(bearer).toBeDefined(); | 319 | expect(bearer).toBeDefined(); |
| 323 | expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true); | 320 | expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true); |
| 324 | } | 321 | } |
| @@ -25,6 +25,7 @@ import { · 24 unmodified lines | |||
| 25 | addOpenApiTestSource, | 25 | addOpenApiTestSource, |
| 26 | makeOpenApiHttpApiTestSourceConfig, | 26 | makeOpenApiHttpApiTestSourceConfig, |
| 27 | serveOpenApiHttpApiTestServer, | 27 | serveOpenApiHttpApiTestServer, |
| 28 | unwrapInvocation, | ||
| 28 | } from "../testing"; | 29 | } from "../testing"; |
| 29 | 30 | ||
| 30 | const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; | 31 | const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; |
| @@ -267,13 +268,15 @@ describe("OpenAPI Plugin", () => { · 236 unmodified lines | |||
| 267 | }), | 268 | }), |
| 268 | ); | 269 | ); |
| 269 | 270 | ||
| 270 | const result = (yield* executor.tools.invoke( | 271 | const preview = unwrapInvocation( |
| 271 | "executor.openapi.previewSpec", | 272 | yield* executor.tools.invoke( |
| 272 | { spec: testApiSpec() }, | 273 | "executor.openapi.previewSpec", |
| 273 | autoApprove, | 274 | { spec: testApiSpec() }, |
| 274 | )) as { operationCount: number }; | 275 | autoApprove, |
| 276 | ), | ||
| 277 | ).data as { operationCount: number }; | ||
| 275 | 278 | ||
| 276 | expect(result.operationCount).toBeGreaterThanOrEqual(2); | 279 | expect(preview.operationCount).toBeGreaterThanOrEqual(2); |
| 277 | }), | 280 | }), |
| 278 | ); | 281 | ); |
| 279 | 282 | ||
| @@ -316,11 +319,13 @@ describe("OpenAPI Plugin", () => { · 36 unmodified lines | |||
| 316 | }), | 319 | }), |
| 317 | ); | 320 | ); |
| 318 | 321 | ||
| 319 | const result = (yield* executor.tools.invoke( | 322 | const result = unwrapInvocation( |
| 320 | "executor.openapi.addSource", | 323 | yield* executor.tools.invoke( |
| 321 | testApiSourceConfig({ scope: String(orgScope), namespace: "runtime" }), | 324 | "executor.openapi.addSource", |
| 322 | autoApprove, | 325 | testApiSourceConfig({ scope: String(orgScope), namespace: "runtime" }), |
| 323 | )) as { sourceId: string; toolCount: number }; | 326 | autoApprove, |
| 327 | ), | ||
| 328 | ).data as { sourceId: string; toolCount: number }; | ||
| 324 | 329 | ||
| 325 | expect(result).toEqual({ sourceId: "runtime", toolCount: 4 }); | 330 | expect(result).toEqual({ sourceId: "runtime", toolCount: 4 }); |
| 326 | expect(yield* executor.openapi.getSource("runtime", String(userScope))).toBeNull(); | 331 | expect(yield* executor.openapi.getSource("runtime", String(userScope))).toBeNull(); |
| @@ -557,17 +562,12 @@ describe("OpenAPI Plugin", () => { · 230 unmodified lines | |||
| 557 | }, | 562 | }, |
| 558 | }); | 563 | }); |
| 559 | 564 | ||
| 560 | const result = (yield* executor.tools.invoke( | 565 | const result = unwrapInvocation( |
| 561 | "authed.items.echoHeaders", | 566 | yield* executor.tools.invoke("authed.items.echoHeaders", {}, autoApprove), |
| 562 | {}, | 567 | ); |
| 563 | autoApprove, | ||
| 564 | )) as { | ||
| 565 | data: { authorization?: string; "x-static"?: string } | null; | ||
| 566 | error: unknown; | ||
| 567 | }; | ||
| 568 | 568 | ||
| 569 | expect(result.error).toBeNull(); | 569 | expect(result.error).toBeNull(); |
| 570 | const data = result.data!; | 570 | const data = result.data as { authorization?: string; "x-static"?: string }; |
| 571 | expect(data.authorization).toBe("Bearer secret-value-123"); | 571 | expect(data.authorization).toBe("Bearer secret-value-123"); |
| 572 | expect(data["x-static"]).toBe("hello"); | 572 | expect(data["x-static"]).toBe("hello"); |
| 573 | }), | 573 | }), |
| @@ -751,10 +751,9 @@ describe("OpenAPI Plugin", () => { · 177 unmodified lines | |||
| 751 | namespace: "test", | 751 | namespace: "test", |
| 752 | }); | 752 | }); |
| 753 | 753 | ||
| 754 | const result = (yield* executor.tools.invoke("test.items.listItems", {}, autoApprove)) as { | 754 | const result = unwrapInvocation( |
| 755 | data: unknown; | 755 | yield* executor.tools.invoke("test.items.listItems", {}, autoApprove), |
| 756 | error: unknown; | 756 | ); |
| 757 | }; | ||
| 758 | expect(result.error).toBeNull(); | 757 | expect(result.error).toBeNull(); |
| 759 | expect(result.data).toEqual(ITEMS); | 758 | expect(result.data).toEqual(ITEMS); |
| 760 | }), | 759 | }), |
| @@ -780,11 +779,9 @@ describe("OpenAPI Plugin", () => { · 19 unmodified lines | |||
| 780 | namespace: "test", | 779 | namespace: "test", |
| 781 | }); | 780 | }); |
| 782 | 781 | ||
| 783 | const result = (yield* executor.tools.invoke( | 782 | const result = unwrapInvocation( |
| 784 | "test.items.getItem", | 783 | yield* executor.tools.invoke("test.items.getItem", { itemId: "2" }, autoApprove), |
| 785 | { itemId: "2" }, | 784 | ); |
| 786 | autoApprove, | ||
| 787 | )) as { data: unknown; error: unknown }; | ||
| 788 | expect(result.error).toBeNull(); | 785 | expect(result.error).toBeNull(); |
| 789 | expect(result.data).toEqual({ id: 2, name: "Gadget" }); | 786 | expect(result.data).toEqual({ id: 2, name: "Gadget" }); |
| 790 | }), | 787 | }), |
| @@ -810,16 +807,18 @@ describe("OpenAPI Plugin", () => { · 19 unmodified lines | |||
| 810 | namespace: "records", | 807 | namespace: "records", |
| 811 | }); | 808 | }); |
| 812 | 809 | ||
| 813 | const result = (yield* executor.tools.invoke( | 810 | const result = unwrapInvocation( |
| 814 | "records.items.queryRows", | 811 | yield* executor.tools.invoke( |
| 815 | { | 812 | "records.items.queryRows", |
| 816 | entryTypeId: "18538", | 813 | { |
| 817 | query: JSON.stringify([{ DisplayName: "Example" }]), | 814 | entryTypeId: "18538", |
| 818 | limit: 10, | 815 | query: JSON.stringify([{ DisplayName: "Example" }]), |
| 819 | skip: 0, | 816 | limit: 10, |
| 820 | }, | 817 | skip: 0, |
| 821 | autoApprove, | 818 | }, |
| 822 | )) as { data: unknown; error: unknown }; | 819 | autoApprove, |
| 820 | ), | ||
| 821 | ); | ||
| 823 | 822 | ||
| 824 | expect(result.data).toBeNull(); | 823 | expect(result.data).toBeNull(); |
| 825 | expect(result.error).toEqual( | 824 | expect(result.error).toEqual( |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | |||
| 7 | SecretId, | 7 | SecretId, |
| 8 | SourceDetectionResult, | 8 | SourceDetectionResult, |
| 9 | StorageError, | 9 | StorageError, |
| 10 | ToolResult, | ||
| 10 | definePlugin, | 11 | definePlugin, |
| 11 | tool, | 12 | tool, |
| 12 | resolveSecretBackedMap, | 13 | resolveSecretBackedMap, |
| @@ -57,6 +58,77 @@ import { · 44 unmodified lines | |||
| 57 | // Plugin config | 58 | // Plugin config |
| 58 | // --------------------------------------------------------------------------- | 59 | // --------------------------------------------------------------------------- |
| 59 | 60 | ||
| 61 | const STRINGIFIED_BODY_CAP = 1024; | ||
| 62 | const UpstreamMessageBody = Schema.Struct({ message: Schema.String }); | ||
| 63 | const UpstreamErrorMessageBody = Schema.Struct({ errorMessage: Schema.String }); | ||
| 64 | const UpstreamNestedErrorBody = Schema.Struct({ error: UpstreamMessageBody }); | ||
| 65 | const UpstreamErrorsArrayBody = Schema.Struct({ | ||
| 66 | errors: Schema.Array( | ||
| 67 | Schema.Struct({ | ||
| 68 | detail: Schema.optional(Schema.String), | ||
| 69 | message: Schema.optional(Schema.String), | ||
| 70 | title: Schema.optional(Schema.String), | ||
| 71 | }), | ||
| 72 | ), | ||
| 73 | }); | ||
| 74 | const UpstreamDescriptionBody = Schema.Struct({ | ||
| 75 | detail: Schema.optional(Schema.String), | ||
| 76 | title: Schema.optional(Schema.String), | ||
| 77 | description: Schema.optional(Schema.String), | ||
| 78 | }); | ||
| 79 | |||
| 80 | const decodeUpstreamMessageBody = Schema.decodeUnknownOption(UpstreamMessageBody); | ||
| 81 | const decodeUpstreamErrorMessageBody = Schema.decodeUnknownOption(UpstreamErrorMessageBody); | ||
| 82 | const decodeUpstreamNestedErrorBody = Schema.decodeUnknownOption(UpstreamNestedErrorBody); | ||
| 83 | const decodeUpstreamErrorsArrayBody = Schema.decodeUnknownOption(UpstreamErrorsArrayBody); | ||
| 84 | const decodeUpstreamDescriptionBody = Schema.decodeUnknownOption(UpstreamDescriptionBody); | ||
| 85 | |||
| 86 | const clampedStringify = (value: unknown): string => { | ||
| 87 | let s: string; | ||
| 88 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: JSON.stringify may throw on cycles; fall back to String() so the upstream body can still be surfaced as ToolError.details fallback text | ||
| 89 | try { | ||
| 90 | s = JSON.stringify(value); | ||
| 91 | } catch { | ||
| 92 | s = String(value); | ||
| 93 | } | ||
| 94 | return s.length > STRINGIFIED_BODY_CAP ? `${s.slice(0, STRINGIFIED_BODY_CAP)}…` : s; | ||
| 95 | }; | ||
| 96 | |||
| 97 | const firstNonEmpty = (...values: readonly (string | undefined)[]): string | undefined => | ||
| 98 | values.find((value) => value !== undefined && value.length > 0); | ||
| 99 | |||
| 100 | // Walk known upstream error-body shapes so ToolError.message stays concise | ||
| 101 | // while ToolError.details preserves the original body. | ||
| 102 | const extractUpstreamMessage = (body: unknown, status: number): string => { | ||
| 103 | if (typeof body === "string") { | ||
| 104 | return body.length > 0 ? body : `Upstream returned HTTP ${status}`; | ||
| 105 | } | ||
| 106 | const nested = Option.getOrUndefined(decodeUpstreamNestedErrorBody(body)); | ||
| 107 | const messageBody = Option.getOrUndefined(decodeUpstreamMessageBody(body)); | ||
| 108 | const errorMessageBody = Option.getOrUndefined(decodeUpstreamErrorMessageBody(body)); | ||
| 109 | const errorsBody = Option.getOrUndefined(decodeUpstreamErrorsArrayBody(body)); | ||
| 110 | const descriptionBody = Option.getOrUndefined(decodeUpstreamDescriptionBody(body)); | ||
| 111 | const arrayMessage = errorsBody?.errors | ||
| 112 | .map(({ detail, message: upstreamMessage, title }) => | ||
| 113 | firstNonEmpty(detail, upstreamMessage, title), | ||
| 114 | ) | ||
| 115 | .find((message) => message !== undefined); | ||
| 116 | const message = firstNonEmpty( | ||
| 117 | nested?.error.message, | ||
| 118 | messageBody?.message, | ||
| 119 | errorMessageBody?.errorMessage, | ||
| 120 | arrayMessage, | ||
| 121 | descriptionBody?.detail, | ||
| 122 | descriptionBody?.title, | ||
| 123 | descriptionBody?.description, | ||
| 124 | ); | ||
| 125 | if (message !== undefined) return message; | ||
| 126 | if (body !== null && typeof body === "object") { | ||
| 127 | return clampedStringify(body); | ||
| 128 | } | ||
| 129 | return `Upstream returned HTTP ${status}`; | ||
| 130 | }; | ||
| 131 | |||
| 60 | export type HeaderValue = HeaderValueValue; | 132 | export type HeaderValue = HeaderValueValue; |
| 61 | export type ConfiguredHeaderValue = ConfiguredHeaderValueValue; | 133 | export type ConfiguredHeaderValue = ConfiguredHeaderValueValue; |
| 62 | export type OpenApiHeaderInput = HeaderValue | ConfiguredHeaderValue; | 134 | export type OpenApiHeaderInput = HeaderValue | ConfiguredHeaderValue; |
| @@ -1193,7 +1265,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 1130 unmodified lines | |||
| 1193 | name: "previewSpec", | 1265 | name: "previewSpec", |
| 1194 | description: "Preview an OpenAPI document before adding it as a source", | 1266 | description: "Preview an OpenAPI document before adding it as a source", |
| 1195 | inputSchema: PreviewSpecInputStandardSchema, | 1267 | inputSchema: PreviewSpecInputStandardSchema, |
| 1196 | execute: (input) => self.previewSpec(input), | 1268 | execute: (input) => Effect.map(self.previewSpec(input), ToolResult.ok), |
| 1197 | }), | 1269 | }), |
| 1198 | tool({ | 1270 | tool({ |
| 1199 | name: "addSource", | 1271 | name: "addSource", |
| @@ -1204,7 +1276,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 4 unmodified lines | |||
| 1204 | }, | 1276 | }, |
| 1205 | inputSchema: AddSourceInputStandardSchema, | 1277 | inputSchema: AddSourceInputStandardSchema, |
| 1206 | outputSchema: AddSourceOutputStandardSchema, | 1278 | outputSchema: AddSourceOutputStandardSchema, |
| 1207 | execute: (input) => self.addSpec(input), | 1279 | execute: (input) => Effect.map(self.addSpec(input), ToolResult.ok), |
| 1208 | }), | 1280 | }), |
| 1209 | ], | 1281 | ], |
| 1210 | }, | 1282 | }, |
| @@ -1283,7 +1355,20 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 72 unmodified lines | |||
| 1283 | httpClientLayer, | 1355 | httpClientLayer, |
| 1284 | ); | 1356 | ); |
| 1285 | 1357 | ||
| 1286 | return result; | 1358 | const ok = result.status >= 200 && result.status < 300; |
| 1359 | if (!ok) { | ||
| 1360 | return ToolResult.fail({ | ||
| 1361 | code: "upstream_http_error", | ||
| 1362 | status: result.status, | ||
| 1363 | message: extractUpstreamMessage(result.error, result.status), | ||
| 1364 | details: result.error, | ||
| 1365 | }); | ||
| 1366 | } | ||
| 1367 | return ToolResult.ok({ | ||
| 1368 | status: result.status, | ||
| 1369 | headers: result.headers, | ||
| 1370 | data: result.data, | ||
| 1371 | }); | ||
| 1287 | }), | 1372 | }), |
| 1288 | 1373 | ||
| 1289 | resolveAnnotations: ({ ctx, sourceId, toolRows }) => | 1374 | resolveAnnotations: ({ ctx, sourceId, toolRows }) => |
| @@ -37,6 +37,7 @@ import { · 36 unmodified lines | |||
| 37 | makeOpenApiTestSourceConfig, | 37 | makeOpenApiTestSourceConfig, |
| 38 | type OpenApiTestServerShape, | 38 | type OpenApiTestServerShape, |
| 39 | serveOpenApiHttpApiTestServer, | 39 | serveOpenApiHttpApiTestServer, |
| 40 | unwrapInvocation, | ||
| 40 | } from "../testing"; | 41 | } from "../testing"; |
| 41 | 42 | ||
| 42 | import { openApiPlugin } from "./plugin"; | 43 | import { openApiPlugin } from "./plugin"; |
| @@ -303,10 +304,10 @@ describe("OpenAPI upstream failure modes", () => { · 260 unmodified lines | |||
| 303 | const { baseUrl } = yield* slowServer; | 304 | const { baseUrl } = yield* slowServer; |
| 304 | const executor = yield* buildExecutor(baseUrl); | 305 | const executor = yield* buildExecutor(baseUrl); |
| 305 | 306 | ||
| 306 | const result = yield* executor.tools.invoke("f.things.listThings", {}, autoApprove); | 307 | const result = unwrapInvocation( |
| 307 | // Empty array via .data envelope or directly — accept either shape. | 308 | yield* executor.tools.invoke("f.things.listThings", {}, autoApprove), |
| 308 | const data = (result as { data?: unknown }).data ?? result; | 309 | ); |
| 309 | expect(data).toEqual([]); | 310 | expect(result.data).toEqual([]); |
| 310 | }), | 311 | }), |
| 311 | ); | 312 | ); |
| 312 | }); | 313 | }); |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { Context, Data, Effect, Layer, Predicate, Ref, Schema, Scope } from "effect"; | 1 | import { Context, Data, Effect, Layer, Option, Predicate, Ref, Schema, Scope } from "effect"; |
| 2 | import { | 2 | import { |
| 3 | HttpClient, | 3 | HttpClient, |
| 4 | HttpRouter, | 4 | HttpRouter, |
| @@ -13,7 +13,7 @@ import { · 8 unmodified lines | |||
| 13 | OpenApi, | 13 | OpenApi, |
| 14 | } from "effect/unstable/httpapi"; | 14 | } from "effect/unstable/httpapi"; |
| 15 | import { OAuthTestServer, serveTestHttpServerLayer } from "@executor-js/sdk/testing"; | 15 | import { OAuthTestServer, serveTestHttpServerLayer } from "@executor-js/sdk/testing"; |
| 16 | import type { ScopeId } from "@executor-js/sdk/core"; | 16 | import { isToolResult, type ScopeId } from "@executor-js/sdk/core"; |
| 17 | import type { OpenApiPluginExtension, OpenApiSpecConfig } from "../sdk/plugin"; | 17 | import type { OpenApiPluginExtension, OpenApiSpecConfig } from "../sdk/plugin"; |
| 18 | 18 | ||
| 19 | export class OpenApiTestServerAddressError extends Data.TaggedError( | 19 | export class OpenApiTestServerAddressError extends Data.TaggedError( |
| @@ -618,3 +618,55 @@ export const TestLayers = { · 598 unmodified lines | |||
| 618 | echo: OpenApiEchoTestServer.layer, | 618 | echo: OpenApiEchoTestServer.layer, |
| 619 | echoWithOAuth: OpenApiEchoTestServer.layerWithOAuth, | 619 | echoWithOAuth: OpenApiEchoTestServer.layerWithOAuth, |
| 620 | }; | 620 | }; |
| 621 | |||
| 622 | const OpenApiTransportEnvelope = Schema.Struct({ | ||
| 623 | status: Schema.Number, | ||
| 624 | headers: Schema.Record(Schema.String, Schema.String), | ||
| 625 | data: Schema.Unknown, | ||
| 626 | }); | ||
| 627 | |||
| 628 | const decodeOpenApiTransportEnvelope = Schema.decodeUnknownOption(OpenApiTransportEnvelope); | ||
| 629 | |||
| 630 | export interface OpenApiInvocationResult<TData = Record<string, unknown> | unknown[] | null> { | ||
| 631 | readonly status: number | null; | ||
| 632 | readonly headers: Record<string, string> | null; | ||
| 633 | readonly data: TData; | ||
| 634 | readonly error: unknown; | ||
| 635 | } | ||
| 636 | |||
| 637 | export const unwrapInvocation = <TData = Record<string, unknown> | null>( | ||
| 638 | raw: unknown, | ||
| 639 | ): OpenApiInvocationResult<TData> => { | ||
| 640 | if (!isToolResult(raw)) { | ||
| 641 | return { | ||
| 642 | status: null, | ||
| 643 | headers: null, | ||
| 644 | data: raw as TData, | ||
| 645 | error: null, | ||
| 646 | }; | ||
| 647 | } | ||
| 648 | if (raw.ok) { | ||
| 649 | const inner = raw.data; | ||
| 650 | const wrapped = Option.getOrUndefined(decodeOpenApiTransportEnvelope(inner)); | ||
| 651 | if (wrapped !== undefined) { | ||
| 652 | return { | ||
| 653 | status: wrapped.status, | ||
| 654 | headers: wrapped.headers, | ||
| 655 | data: wrapped.data as TData, | ||
| 656 | error: null, | ||
| 657 | }; | ||
| 658 | } | ||
| 659 | return { | ||
| 660 | status: null, | ||
| 661 | headers: null, | ||
| 662 | data: inner as TData, | ||
| 663 | error: null, | ||
| 664 | }; | ||
| 665 | } | ||
| 666 | return { | ||
| 667 | status: raw.error.status ?? null, | ||
| 668 | headers: null, | ||
| 669 | data: null as TData, | ||
| 670 | error: raw.error.details ?? raw.error, | ||
| 671 | }; | ||
| 672 | }; | ||
| @@ -256,7 +256,7 @@ describe("WorkOS Vault secret provider", () => { · 255 unmodified lines | |||
| 256 | const client = makeFakeClient({ rejectReadNamesLongerThan: 80 }); | 256 | const client = makeFakeClient({ rejectReadNamesLongerThan: 80 }); |
| 257 | const executor = yield* makeExecutor(client); | 257 | const executor = yield* makeExecutor(client); |
| 258 | const longSecretId = SecretId.make( | 258 | const longSecretId = SecretId.make( |
| 259 | "openapi-oauth-dealcloud-api-oauth2-user-org-user-01kp6xm1zpvqvtpj77f0yv4eax.access_token", | 259 | "openapi-oauth-example-api-oauth2-user-org-user-01kp6xm1zpvqvtpj77f0yv4eax.access_token", |
| 260 | ); | 260 | ); |
| 261 | 261 | ||
| 262 | yield* executor.secrets.set( | 262 | yield* executor.secrets.set( |