* 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 | 207 | expect(execution.structured).toMatchObject({ |
| 208 | 208 | status: "completed", |
| 209 | 209 | result: { |
| 210 | − message: "hello", | |
| 211 | − suffix: "world", | |
| 212 | − path: "/echo/hello", | |
| 210 | + ok: true, | |
| 211 | + data: { | |
| 212 | + status: 200, | |
| 213 | + data: { | |
| 214 | + message: "hello", | |
| 215 | + suffix: "world", | |
| 216 | + path: "/echo/hello", | |
| 217 | + }, | |
| 218 | + }, | |
| 213 | 219 | }, |
| 214 | 220 | logs: [], |
| 215 | 221 | }); |
| @@ -315,7 +321,7 @@ describe("sources api (HTTP)", () => { · 99 unmodified lines | ||
| 315 | 321 | expect(execution.isError).toBe(false); |
| 316 | 322 | expect(execution.structured).toMatchObject({ |
| 317 | 323 | status: "completed", |
| 318 | − result: { hello: "Hello Ada" }, | |
| 324 | + result: { ok: true, data: { hello: "Hello Ada" } }, | |
| 319 | 325 | }); |
| 320 | 326 | const requests = yield* server.requests; |
| 321 | 327 | expect(requests.some((request) => request.payload.query?.includes("__schema"))).toBe(true); |
| @@ -391,7 +397,8 @@ describe("sources api (HTTP)", () => { · 69 unmodified lines | ||
| 391 | 397 | expect(execution.structured).toMatchObject({ |
| 392 | 398 | status: "completed", |
| 393 | 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 | 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 | 161 | db.prepare("INSERT INTO `openapi_source` (id, scope_id, oauth2) VALUES (?, ?, ?)").run( |
| 162 | − "dealcloud_api", | |
| 162 | + "example_api", | |
| 163 | 163 | "org-1", |
| 164 | 164 | JSON.stringify({ |
| 165 | 165 | kind: "oauth2", |
| @@ -174,27 +174,27 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 8 unmodified lines | ||
| 174 | 174 | "INSERT INTO `connection` (id, scope_id, provider, provider_state, scope, updated_at) VALUES (?, ?, ?, ?, ?, ?)", |
| 175 | 175 | ); |
| 176 | 176 | insertConnection.run( |
| 177 | − "openapi-oauth2-app-dealcloud_api", | |
| 177 | + "openapi-oauth2-app-example_api", | |
| 178 | 178 | "org-1", |
| 179 | 179 | "oauth2", |
| 180 | 180 | JSON.stringify({ |
| 181 | 181 | kind: "client-credentials", |
| 182 | − tokenEndpoint: "https://resolve.dealcloud.com/oauth/token", | |
| 183 | − clientIdSecretId: "dealcloud-client-id", | |
| 184 | − clientSecretSecretId: "dealcloud-client-secret", | |
| 182 | + tokenEndpoint: "https://auth.example.test/oauth/token", | |
| 183 | + clientIdSecretId: "example-client-id", | |
| 184 | + clientSecretSecretId: "example-client-secret", | |
| 185 | 185 | }), |
| 186 | 186 | null, |
| 187 | 187 | now, |
| 188 | 188 | ); |
| 189 | 189 | insertConnection.run( |
| 190 | − "openapi-oauth2-app-dealcloud_api", | |
| 190 | + "openapi-oauth2-app-example_api", | |
| 191 | 191 | "user-org:user-jd:org-1", |
| 192 | 192 | "openapi:oauth2", |
| 193 | 193 | JSON.stringify({ |
| 194 | 194 | kind: "client-credentials", |
| 195 | − tokenEndpoint: "https://resolve.dealcloud.com/oauth/token", | |
| 196 | − clientIdSecretId: "dealcloud-client-id-jd", | |
| 197 | − clientSecretSecretId: "dealcloud-client-secret-jd", | |
| 195 | + tokenEndpoint: "https://auth.example.test/oauth/token", | |
| 196 | + clientIdSecretId: "example-client-id-jd", | |
| 197 | + clientSecretSecretId: "example-client-secret-jd", | |
| 198 | 198 | }), |
| 199 | 199 | null, |
| 200 | 200 | now, |
| @@ -207,12 +207,12 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 6 unmodified lines | ||
| 207 | 207 | "org-client-id", |
| 208 | 208 | "org-1", |
| 209 | 209 | "openapi", |
| 210 | − "dealcloud_api", | |
| 210 | + "example_api", | |
| 211 | 211 | "org-1", |
| 212 | 212 | "oauth2:oauth2:client-id", |
| 213 | 213 | "secret", |
| 214 | 214 | null, |
| 215 | − "dealcloud-client-id-jd", | |
| 215 | + "example-client-id-jd", | |
| 216 | 216 | null, |
| 217 | 217 | now, |
| 218 | 218 | now, |
| @@ -221,12 +221,12 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 2 unmodified lines | ||
| 221 | 221 | "org-client-secret", |
| 222 | 222 | "org-1", |
| 223 | 223 | "openapi", |
| 224 | − "dealcloud_api", | |
| 224 | + "example_api", | |
| 225 | 225 | "org-1", |
| 226 | 226 | "oauth2:oauth2:client-secret", |
| 227 | 227 | "secret", |
| 228 | 228 | null, |
| 229 | − "dealcloud-client-secret-jd", | |
| 229 | + "example-client-secret-jd", | |
| 230 | 230 | null, |
| 231 | 231 | now, |
| 232 | 232 | now, |
| @@ -235,13 +235,13 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 2 unmodified lines | ||
| 235 | 235 | "org-connection", |
| 236 | 236 | "org-1", |
| 237 | 237 | "openapi", |
| 238 | − "dealcloud_api", | |
| 238 | + "example_api", | |
| 239 | 239 | "org-1", |
| 240 | 240 | "oauth2:oauth2:connection", |
| 241 | 241 | "connection", |
| 242 | 242 | null, |
| 243 | 243 | null, |
| 244 | − "openapi-oauth2-app-dealcloud_api", | |
| 244 | + "openapi-oauth2-app-example_api", | |
| 245 | 245 | now, |
| 246 | 246 | now, |
| 247 | 247 | ); |
| @@ -249,13 +249,13 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 1 unmodified line | ||
| 249 | 249 | "jd-connection", |
| 250 | 250 | "user-org:user-jd:org-1", |
| 251 | 251 | "openapi", |
| 252 | − "dealcloud_api", | |
| 252 | + "example_api", | |
| 253 | 253 | "org-1", |
| 254 | 254 | "oauth2:oauth2:connection", |
| 255 | 255 | "connection", |
| 256 | 256 | null, |
| 257 | 257 | null, |
| 258 | − "openapi-oauth2-app-dealcloud_api", | |
| 258 | + "openapi-oauth2-app-example_api", | |
| 259 | 259 | now, |
| 260 | 260 | now, |
| 261 | 261 | ); |
| @@ -271,20 +271,20 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 9 unmodified lines | ||
| 271 | 271 | .prepare( |
| 272 | 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 | 275 | expect(bindings).toEqual([ |
| 276 | 276 | { |
| 277 | 277 | scope_id: "org-1", |
| 278 | 278 | slot_key: "oauth2:oauth2:client-id", |
| 279 | 279 | kind: "secret", |
| 280 | − secret_id: "dealcloud-client-id", | |
| 280 | + secret_id: "example-client-id", | |
| 281 | 281 | connection_id: null, |
| 282 | 282 | }, |
| 283 | 283 | { |
| 284 | 284 | scope_id: "org-1", |
| 285 | 285 | slot_key: "oauth2:oauth2:client-secret", |
| 286 | 286 | kind: "secret", |
| 287 | − secret_id: "dealcloud-client-secret", | |
| 287 | + secret_id: "example-client-secret", | |
| 288 | 288 | connection_id: null, |
| 289 | 289 | }, |
| 290 | 290 | { |
| @@ -292,20 +292,20 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 1 unmodified line | ||
| 292 | 292 | slot_key: "oauth2:oauth2:connection", |
| 293 | 293 | kind: "connection", |
| 294 | 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 | 298 | scope_id: "user-org:user-jd:org-1", |
| 299 | 299 | slot_key: "oauth2:oauth2:client-id", |
| 300 | 300 | kind: "secret", |
| 301 | − secret_id: "dealcloud-client-id-jd", | |
| 301 | + secret_id: "example-client-id-jd", | |
| 302 | 302 | connection_id: null, |
| 303 | 303 | }, |
| 304 | 304 | { |
| 305 | 305 | scope_id: "user-org:user-jd:org-1", |
| 306 | 306 | slot_key: "oauth2:oauth2:client-secret", |
| 307 | 307 | kind: "secret", |
| 308 | − secret_id: "dealcloud-client-secret-jd", | |
| 308 | + secret_id: "example-client-secret-jd", | |
| 309 | 309 | connection_id: null, |
| 310 | 310 | }, |
| 311 | 311 | { |
| @@ -313,7 +313,7 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 1 unmodified line | ||
| 313 | 313 | slot_key: "oauth2:oauth2:connection", |
| 314 | 314 | kind: "connection", |
| 315 | 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 | 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 | 49 | '- When you already know the namespace, narrow with `tools.search({ namespace: "github", query: "issues" })`.', |
| 50 | 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 | 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 | 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 | 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 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | import { Effect, Fiber, Schema } from "effect"; |
| 3 | +import * as ts from "typescript"; | |
| 3 | 4 | |
| 4 | 5 | import { |
| 5 | 6 | ElicitationResponse, |
| 6 | 7 | FormElicitation, |
| 8 | + ToolResult, | |
| 7 | 9 | createExecutor, |
| 8 | 10 | definePlugin, |
| 9 | 11 | } from "@executor-js/sdk"; |
| @@ -18,6 +20,10 @@ const RepoInputSchema = Schema.toStandardSchemaV1( · 8 unmodified lines | ||
| 18 | 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 | 27 | const ContactInputSchema = Schema.toStandardSchemaV1( |
| 22 | 28 | Schema.toStandardJSONSchemaV1(Schema.Struct({ email: Schema.String })), |
| 23 | 29 | ); |
| @@ -28,6 +34,58 @@ const EmptyInputSchema = Schema.toStandardSchemaV1( · 4 unmodified lines | ||
| 28 | 34 | |
| 29 | 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 | 90 | // Test plugins — each one declares a namespace as a static source with N |
| 33 | 91 | // tools. Handlers return static data; the suite only cares about discovery |
| @@ -53,6 +111,7 @@ const githubPlugin = definePlugin(() => ({ · 19 unmodified lines | ||
| 53 | 111 | name: "getRepositoryDetails", |
| 54 | 112 | description: "Get repository details including the default branch", |
| 55 | 113 | inputSchema: RepoInputSchema, |
| 114 | + outputSchema: RepoDetailsOutputSchema, | |
| 56 | 115 | handler: () => Effect.succeed({ defaultBranch: "main" }), |
| 57 | 116 | }, |
| 58 | 117 | { |
| @@ -106,13 +165,78 @@ const errorPlugin = definePlugin(() => ({ · 47 unmodified lines | ||
| 106 | 165 | description: "Query rows", |
| 107 | 166 | inputSchema: EmptyInputSchema, |
| 108 | 167 | handler: () => |
| 109 | − Effect.succeed({ | |
| 110 | − data: null, | |
| 111 | − error: { | |
| 112 | − message: 'Field with name "DisplayName" does not exist', | |
| 168 | + Effect.succeed( | |
| 169 | + ToolResult.fail({ | |
| 113 | 170 | code: "invalid_query", |
| 114 | − }, | |
| 115 | − }), | |
| 171 | + message: 'Field with name "DisplayName" does not exist', | |
| 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 | 466 | expect(described.name).toBe("listRepositoryIssues"); |
| 343 | 467 | expect(described.description).toBe("List issues for a repository"); |
| 344 | 468 | expect(described.inputTypeScript).toBe("{ owner: string; repo: string; }"); |
| 345 | − expect(described.outputTypeScript).toBeUndefined(); | |
| 346 | − expect(described.typeScriptDefinitions).toBeUndefined(); | |
| 469 | + expect(described.outputTypeScript).toBe( | |
| 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 | 627 | Effect.gen(function* () { |
| 410 | 628 | const executor = yield* createExecutor(makeTestConfig({ plugins: [errorPlugin()] as const })); |
| 411 | 629 | const invoker = makeExecutorToolInvoker(executor, { |
| 412 | 630 | invokeOptions: { onElicitation: acceptAll }, |
| 413 | 631 | }); |
| 414 | 632 | |
| 415 | − const error = yield* Effect.flip(invoker.invoke({ path: "records.queryRows", args: {} })); | |
| 416 | − | |
| 417 | − expect(error).toEqual( | |
| 418 | − expect.objectContaining({ | |
| 633 | + const result = yield* invoker.invoke({ path: "records.queryRows", args: {} }); | |
| 634 | + expect(result).toEqual({ | |
| 635 | + ok: false, | |
| 636 | + error: { | |
| 637 | + code: "invalid_query", | |
| 419 | 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 | 2 | import * as Cause from "effect/Cause"; |
| 3 | 3 | import type { |
| 4 | 4 | Executor, |
| @@ -8,9 +8,32 @@ import type { · 3 unmodified lines | ||
| 8 | 8 | InvokeOptions, |
| 9 | 9 | Source, |
| 10 | 10 | } from "@executor-js/sdk/core"; |
| 11 | +import { isToolResult } from "@executor-js/sdk/core"; | |
| 11 | 12 | import type { SandboxToolInvoker } from "@executor-js/codemode-core"; |
| 12 | 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 | 38 | * Extract the source namespace from a tool path. Tool paths look like |
| 16 | 39 | * "<sourceId>.<op>" or "<sourceId>.<group>.<op>" — we take the first |
| @@ -23,44 +46,6 @@ const extractSourceNamespace = (path: string): string => { · 6 unmodified lines | ||
| 23 | 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 | 50 | * Bridges QuickJS `tools.someSource.someOp(args)` calls into |
| 66 | 51 | * `executor.tools.invoke(toolId, args)`. |
| @@ -87,35 +72,46 @@ export const makeExecutorToolInvoker = ( · 20 unmodified lines | ||
| 87 | 72 | const result = yield* executor.tools.invoke(path as ToolId, args, options.invokeOptions).pipe( |
| 88 | 73 | Effect.catchCause((cause): Effect.Effect<never, ExecutionToolError> => { |
| 89 | 74 | const err = cause.reasons.find(Cause.isFailReason)?.error; |
| 90 | − if (!isElicitationDeclinedError(err)) { | |
| 75 | + if (isElicitationDeclinedError(err)) { | |
| 91 | 76 | return Effect.fail( |
| 92 | 77 | new ExecutionToolError({ |
| 93 | − message: renderToolErrorMessage(err), | |
| 94 | − cause: err ?? cause, | |
| 78 | + message: `Tool "${err.toolId}" requires approval but the request was ${err.action === "cancel" ? "cancelled" : "declined"} by the user.`, | |
| 79 | + cause: err, | |
| 95 | 80 | }), |
| 96 | 81 | ); |
| 97 | 82 | } |
| 98 | − return Effect.fail( | |
| 99 | − new ExecutionToolError({ | |
| 100 | − message: `Tool "${err.toolId}" requires approval but the request was ${err.action === "cancel" ? "cancelled" : "declined"} by the user.`, | |
| 101 | − cause: err, | |
| 83 | + // Any other failure here is an infra/plugin defect. Emit an | |
| 84 | + // opaque generic with a correlation id so internal context (URLs | |
| 85 | + // with tokens, DB connection strings, file paths in stacks) | |
| 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 | 112 | return result; |
| 108 | 113 | } |
| 109 | − if (hasToolResultError(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; | |
| 114 | + return { ok: true, data: result }; | |
| 119 | 115 | }), |
| 120 | 116 | }); |
| 121 | 117 | |
| @@ -513,7 +509,7 @@ export const describeTool = Effect.fn("executor.tools.describe")(function* ( · 391 unmodified lines | ||
| 513 | 509 | name: schema.name ?? path, |
| 514 | 510 | description: schema.description, |
| 515 | 511 | inputTypeScript: schema.inputTypeScript, |
| 516 | − outputTypeScript: schema.outputTypeScript, | |
| 517 | − typeScriptDefinitions: schema.typeScriptDefinitions, | |
| 512 | + outputTypeScript: wrapOutputTypeScript(schema.outputTypeScript), | |
| 513 | + typeScriptDefinitions: withToolResultDefinitions(schema.typeScriptDefinitions), | |
| 518 | 514 | }; |
| 519 | 515 | }); |
| @@ -333,3 +333,10 @@ export { · 332 unmodified lines | ||
| 333 | 333 | |
| 334 | 334 | // Wire-level HTTP error schemas usable by plugin HttpApiGroup definitions. |
| 335 | 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 | 120 | const engine = makeStubEngine({ |
| 121 | 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 | 126 | name: "execute", |
| 127 | 127 | arguments: { code: "const x: any = 1;" }, |
| 128 | 128 | }); |
| 129 | − expect(textOf(result)).toBe("Error: Unexpected token ':'"); | |
| 130 | − expect(result.structuredContent).toEqual({ | |
| 131 | − status: "error", | |
| 132 | − error: "Unexpected token ':'", | |
| 133 | − }); | |
| 129 | + const text = textOf(result); | |
| 130 | + expect(text).toMatch(/^Error: Internal tool error \[[0-9a-f]{8}\]$/); | |
| 131 | + expect(text).not.toContain("Unexpected token"); | |
| 132 | + const structured = (result.structuredContent as { readonly error?: string }).error ?? ""; | |
| 133 | + expect(structured).toMatch(/^Internal tool error \[[0-9a-f]{8}\]$/); | |
| 134 | 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 | 146 | name: "execute", |
| 147 | 147 | arguments: { code: "run" }, |
| 148 | 148 | }); |
| 149 | − expect(textOf(result)).toBe("Error: Tool execution failed"); | |
| 150 | − expect(result.structuredContent).toEqual({ | |
| 149 | + const text = textOf(result); | |
| 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 | 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 | 159 | expect(result.isError).toBe(true); |
| 155 | 160 | }); |
| 156 | 161 | }); |
| @@ -260,20 +260,28 @@ const toMcpPausedResult = (formatted: ReturnType<typeof formatPausedExecution>): · 259 unmodified lines | ||
| 260 | 260 | structuredContent: formatted.structured, |
| 261 | 261 | }); |
| 262 | 262 | |
| 263 | −const formatFailureMessage = (value: unknown): string | null => { | |
| 264 | − if (typeof value === "object" && value !== null && "message" in value) { | |
| 265 | − const message = (value as { readonly message?: unknown }).message; | |
| 266 | − if (typeof message === "string" && message.length > 0) return message; | |
| 267 | − } | |
| 268 | − if (typeof value === "string" && value.length > 0) return value; | |
| 269 | − return null; | |
| 270 | −}; | |
| 263 | +// `execute` failures reaching the MCP host are infra defects — domain | |
| 264 | +// failures from tools are now expressed as `ToolResult` values (success | |
| 265 | +// channel) and flow through `formatExecuteResult`. Emit an opaque | |
| 266 | +// generic plus a fresh correlation id and log the cause out-of-band so | |
| 267 | +// the model can't read internal context off `.message`. | |
| 268 | +const newCorrelationId = (): string => | |
| 269 | + Math.floor(Math.random() * 0x1_0000_0000) | |
| 270 | + .toString(16) | |
| 271 | + .padStart(8, "0"); | |
| 271 | 272 | |
| 272 | 273 | const toMcpFailureResult = (cause: Cause.Cause<unknown>): McpToolResult => { |
| 273 | − const failure = cause.reasons.find(Cause.isFailReason); | |
| 274 | − const text = failure | |
| 275 | − ? (formatFailureMessage(failure.error) ?? "Tool execution failed") | |
| 276 | − : "Tool execution failed"; | |
| 274 | + const correlationId = newCorrelationId(); | |
| 275 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort defect logging must tolerate non-serializable causes | |
| 276 | + try { | |
| 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 | 285 | return { |
| 278 | 286 | content: [{ type: "text", text: `Error: ${text}` }], |
| 279 | 287 | structuredContent: { status: "error", error: text }, |
| @@ -59,11 +59,11 @@ describe("stripTypeScript", () => { · 58 unmodified lines | ||
| 59 | 59 | // axiom://7bf76f79c5d807272781e9554040aab3 — typed annotation in |
| 60 | 60 | // a function expression. |
| 61 | 61 | const code = ` |
| 62 | − const fetchDeals = async (sourceId: string): Promise<Array<{ id: string }>> => { | |
| 62 | + const fetchResources = async (sourceId: string): Promise<Array<{ id: string }>> => { | |
| 63 | 63 | const result = await tools.executor.sources.list(); |
| 64 | 64 | return result.items; |
| 65 | 65 | }; |
| 66 | − return fetchDeals('dealcloud'); | |
| 66 | + return fetchResources('example-source'); | |
| 67 | 67 | `; |
| 68 | 68 | const out = stripTypeScript(code); |
| 69 | 69 | expect(out).not.toContain(": string"); |
| @@ -4,6 +4,7 @@ import * as Cause from "effect/Cause"; · 3 unmodified lines | ||
| 4 | 4 | import * as Data from "effect/Data"; |
| 5 | 5 | import * as Effect from "effect/Effect"; |
| 6 | 6 | import type { SandboxToolInvoker } from "@executor-js/codemode-core"; |
| 7 | +import { ExecutionToolError } from "@executor-js/execution"; | |
| 7 | 8 | import { |
| 8 | 9 | ToolDispatcher, |
| 9 | 10 | makeDynamicWorkerExecutor, |
| @@ -246,7 +247,7 @@ describe("makeDynamicWorkerExecutor", () => { · 236 unmodified lines | ||
| 246 | 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 | 251 | const executor = makeDynamicWorkerExecutor({ loader }); |
| 251 | 252 | const invoker = failingInvoker("not authorized"); |
| 252 | 253 | |
| @@ -254,7 +255,28 @@ describe("makeDynamicWorkerExecutor", () => { · 1 unmodified line | ||
| 254 | 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 | 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 | 308 | expect(result.error).toBeUndefined(); |
| 287 | − expect(result.result).toMatchObject({ message: "not authorized" }); | |
| 309 | + expect(result.result).toMatchObject({ message: "Internal tool error" }); | |
| 288 | 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 | 315 | const executor = makeDynamicWorkerExecutor({ loader }); |
| 293 | 316 | const invoker = { |
| 294 | 317 | invoke: () => |
| @@ -302,11 +325,11 @@ describe("makeDynamicWorkerExecutor", () => { · 7 unmodified lines | ||
| 302 | 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 | 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 | 333 | const executor = makeDynamicWorkerExecutor({ loader }); |
| 311 | 334 | const invoker = { |
| 312 | 335 | invoke: () => |
| @@ -320,7 +343,8 @@ describe("makeDynamicWorkerExecutor", () => { · 7 unmodified lines | ||
| 320 | 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 | 348 | expect(result.result).toBeNull(); |
| 325 | 349 | }); |
| 326 | 350 | |
| @@ -366,7 +390,7 @@ describe("makeDynamicWorkerExecutor", () => { · 39 unmodified lines | ||
| 366 | 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 | 394 | const executor = makeDynamicWorkerExecutor({ loader }); |
| 371 | 395 | const cyclic: Record<string, unknown> = {}; |
| 372 | 396 | cyclic.self = cyclic; |
| @@ -377,7 +401,7 @@ describe("makeDynamicWorkerExecutor", () => { · 4 unmodified lines | ||
| 377 | 401 | ); |
| 378 | 402 | |
| 379 | 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 | 407 | it("respects timeout", async () => { |
| @@ -127,6 +127,14 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => · 126 unmodified lines | ||
| 127 | 127 | " for (const [k, v] of Object.entries(value)) out[k] = __decodeBinary(v, seen);", |
| 128 | 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 | 138 | " const __makeToolsProxy = (path = []) => new Proxy(() => undefined, {", |
| 131 | 139 | " get(_target, prop) {", |
| 132 | 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 | 146 | " return (async () => {", |
| 139 | 147 | " const encoded = await __encodeBinary(args[0]);", |
| 140 | 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 | 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 | 170 | it.effect("handles unknown tool path", () => |
| 125 | 171 | Effect.gen(function* () { |
| 126 | 172 | const invoker = makeTestInvoker({}); |
| @@ -47,6 +47,30 @@ const EXECUTION_FILENAME = "executor-quickjs-runtime.js"; · 46 unmodified lines | ||
| 47 | 47 | const toError = (cause: unknown): Error => |
| 48 | 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 | 74 | const toErrorMessage = (cause: unknown): string => { |
| 51 | 75 | if (typeof cause === "object" && cause !== null) { |
| 52 | 76 | const message = |
| @@ -216,7 +240,17 @@ const createToolBridge = ( · 163 unmodified lines | ||
| 216 | 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 | 254 | deferred.reject(errorHandle); |
| 221 | 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 | 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 | 5 | // 1. Schema.encodeEffect(Inner)(value) → encoded JS shape |
| 6 | 6 | // 2. Schema.encodeEffect(UnknownFromJsonString) → JSON string |
| @@ -13,7 +13,7 @@ · 6 unmodified lines | ||
| 13 | 13 | // step 2's JSON-stringify (driven by Effect, not us) flattens the Option |
| 14 | 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 | 18 | import { describe, expect, it } from "@effect/vitest"; |
| 19 | 19 | import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; |
| @@ -28,7 +28,7 @@ const fixed = { description: Option.some("hello") }; · 8 unmodified lines | ||
| 28 | 28 | const withTmpFile = <A, E>(fn: (path: string) => Effect.Effect<A, E, FileSystem.FileSystem>) => |
| 29 | 29 | Effect.gen(function* () { |
| 30 | 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 | 32 | return yield* fn(`${dir}/binding.json`); |
| 33 | 33 | }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)); |
| 34 | 34 | |
| @@ -430,10 +430,10 @@ describe("Google Discovery plugin", () => { · 429 unmodified lines | ||
| 430 | 430 | "drive.files.get", |
| 431 | 431 | { fileId: "123", fields: "id,name", prettyPrint: true }, |
| 432 | 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(); | |
| 436 | − expect(invocation.data).toEqual({ id: "123", name: "Quarterly Plan" }); | |
| 435 | + expect(invocation.ok).toBe(true); | |
| 436 | + expect(invocation.data.data).toEqual({ id: "123", name: "Quarterly Plan" }); | |
| 437 | 437 | |
| 438 | 438 | const apiRequest = handle.requests.find((request) => |
| 439 | 439 | request.url.startsWith("/drive/v3/files/123"), |
| @@ -3,6 +3,7 @@ import { Effect, Option, Predicate, Schema } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | import { |
| 4 | 4 | ScopeId, |
| 5 | 5 | SourceDetectionResult, |
| 6 | + ToolResult, | |
| 6 | 7 | Usage, |
| 7 | 8 | definePlugin, |
| 8 | 9 | resolveSecretBackedMap, |
| @@ -29,6 +30,69 @@ import type { · 20 unmodified lines | ||
| 29 | 30 | } from "./types"; |
| 30 | 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 | 97 | // Public input / output shapes |
| 34 | 98 | // --------------------------------------------------------------------------- |
| @@ -381,11 +445,27 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 346 unmodified lines | ||
| 381 | 445 | extension: makeGoogleDiscoveryPluginExtension, |
| 382 | 446 | |
| 383 | 447 | invokeTool: ({ ctx, toolRow, args }) => |
| 384 | − invokeGoogleDiscoveryTool({ | |
| 385 | − ctx: ctx as PluginCtx<GoogleDiscoveryStore>, | |
| 386 | − toolId: toolRow.id, | |
| 387 | − toolScope: decodeString(toolRow.scope_id), | |
| 388 | − args, | |
| 448 | + Effect.gen(function* () { | |
| 449 | + const result = yield* invokeGoogleDiscoveryTool({ | |
| 450 | + ctx: ctx as PluginCtx<GoogleDiscoveryStore>, | |
| 451 | + toolId: toolRow.id, | |
| 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 | 471 | resolveAnnotations: ({ ctx, sourceId, toolRows }) => |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { describe, it, expect } from "@effect/vitest"; |
| 2 | 2 | import { Effect, Predicate } from "effect"; |
| 3 | +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | |
| 3 | 4 | |
| 4 | 5 | import { |
| 5 | 6 | ConnectionId, |
| @@ -13,7 +14,7 @@ import { · 7 unmodified lines | ||
| 13 | 14 | SecretId, |
| 14 | 15 | TokenMaterial, |
| 15 | 16 | } from "@executor-js/sdk"; |
| 16 | −import { makeTestConfig } from "@executor-js/sdk/testing"; | |
| 17 | +import { makeTestConfig, serveTestHttpApp } from "@executor-js/sdk/testing"; | |
| 17 | 18 | import { memorySecretsPlugin } from "@executor-js/sdk/testing"; |
| 18 | 19 | |
| 19 | 20 | import { graphqlPlugin } from "./plugin"; |
| @@ -196,9 +197,8 @@ describe("graphqlPlugin real protocol server", () => { · 176 unmodified lines | ||
| 196 | 197 | }); |
| 197 | 198 | |
| 198 | 199 | expect(result).toEqual({ |
| 199 | − status: 200, | |
| 200 | + ok: true, | |
| 200 | 201 | data: { hello: "Hello Ada" }, |
| 201 | − errors: null, | |
| 202 | 202 | }); |
| 203 | 203 | |
| 204 | 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 | 252 | it.effect("invokes OAuth-backed sources with a bearer token", () => |
| 213 | 253 | Effect.gen(function* () { |
| 214 | 254 | const server = yield* serveGreetingServer; |
| @@ -251,9 +291,8 @@ describe("graphqlPlugin real protocol server", () => { · 36 unmodified lines | ||
| 251 | 291 | }); |
| 252 | 292 | |
| 253 | 293 | expect(result).toEqual({ |
| 254 | − status: 200, | |
| 294 | + ok: true, | |
| 255 | 295 | data: { hello: "Hello Ada" }, |
| 256 | − errors: null, | |
| 257 | 296 | }); |
| 258 | 297 | |
| 259 | 298 | const requests = yield* server.requests; |
| @@ -425,7 +464,7 @@ describe("graphqlPlugin", () => { · 165 unmodified lines | ||
| 425 | 464 | }, |
| 426 | 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 | 468 | expect(yield* executor.graphql.getSource("via_static", String(userScope))).toBeNull(); |
| 430 | 469 | expect((yield* executor.graphql.getSource("via_static", String(orgScope)))?.scope).toBe( |
| 431 | 470 | orgScope, |
| @@ -729,7 +768,7 @@ describe("graphqlPlugin", () => { · 297 unmodified lines | ||
| 729 | 768 | }); |
| 730 | 769 | |
| 731 | 770 | expect(result).toMatchObject({ |
| 732 | − status: 200, | |
| 771 | + ok: true, | |
| 733 | 772 | data: { hello: "Hello Ada" }, |
| 734 | 773 | }); |
| 735 | 774 | const requests = yield* server.requests; |
| @@ -843,7 +882,7 @@ describe("graphqlPlugin", () => { · 107 unmodified lines | ||
| 843 | 882 | }); |
| 844 | 883 | |
| 845 | 884 | expect(result).toMatchObject({ |
| 846 | − status: 200, | |
| 885 | + ok: true, | |
| 847 | 886 | data: { hello: "Hello Ada" }, |
| 848 | 887 | }); |
| 849 | 888 | const requests = yield* server.requests; |
| @@ -915,7 +954,7 @@ describe("graphqlPlugin", () => { · 65 unmodified lines | ||
| 915 | 954 | }); |
| 916 | 955 | |
| 917 | 956 | expect(result).toMatchObject({ |
| 918 | − status: 200, | |
| 957 | + ok: true, | |
| 919 | 958 | data: { hello: "Hello Ada" }, |
| 920 | 959 | }); |
| 921 | 960 | const requests = yield* server.requests; |
| @@ -12,6 +12,7 @@ import { · 11 unmodified lines | ||
| 12 | 12 | SecretId, |
| 13 | 13 | SourceDetectionResult, |
| 14 | 14 | StorageError, |
| 15 | + ToolResult, | |
| 15 | 16 | type PluginCtx, |
| 16 | 17 | type StorageFailure, |
| 17 | 18 | type ToolAnnotations, |
| @@ -65,6 +66,19 @@ import { · 47 unmodified lines | ||
| 65 | 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 | 82 | export type HeaderValue = HeaderValueValue; |
| 69 | 83 | export type GraphqlCredentialValue = ConfiguredGraphqlCredentialValue; |
| 70 | 84 | |
| @@ -994,7 +1008,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 923 unmodified lines | ||
| 994 | 1008 | }, |
| 995 | 1009 | inputSchema: StaticAddSourceInputStandardSchema, |
| 996 | 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 | 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 | 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 | 56 | const ErrorMessage = Schema.Struct({ message: Schema.String }); |
| 57 | 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 | 66 | const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => |
| 60 | 67 | Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), { |
| @@ -525,22 +532,10 @@ export default function AddMcpSource(props: { · 464 unmodified lines | ||
| 525 | 532 | const inner = value.slice(1, -1); |
| 526 | 533 | if (quote === "'") return inner; |
| 527 | 534 | |
| 528 | − return inner.replace(/\\([\\nrt"])/g, (_, escaped: string) => { | |
| 529 | − switch (escaped) { | |
| 530 | − case "\\": | |
| 531 | − return "\\"; | |
| 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 | − }); | |
| 535 | + return inner.replace( | |
| 536 | + /\\([\\nrt"])/g, | |
| 537 | + (_, escaped: string) => STDIO_ENV_ESCAPE_REPLACEMENTS[escaped] ?? escaped, | |
| 538 | + ); | |
| 544 | 539 | }; |
| 545 | 540 | |
| 546 | 541 | const parseStdioEnv = (raw: string): Record<string, string> | undefined => { |
| @@ -69,7 +69,8 @@ describe("MCP elicitation (end-to-end)", () => { · 68 unmodified lines | ||
| 69 | 69 | const result = yield* executor.tools.invoke(gatedEcho!.id, { value: "hello" }, options); |
| 70 | 70 | |
| 71 | 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 | 75 | // At least one elicitation should be the MCP server's form |
| 75 | 76 | expect(elicitationMessages.length).toBeGreaterThanOrEqual(1); |
| @@ -95,7 +96,8 @@ describe("MCP elicitation (end-to-end)", () => { · 19 unmodified lines | ||
| 95 | 96 | ); |
| 96 | 97 | |
| 97 | 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 | 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 | 115 | { onElicitation: "accept-all" }, |
| 116 | 116 | ); |
| 117 | 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 | 121 | expect( |
| 121 | 122 | (yield* server.requests) |
| @@ -187,7 +188,8 @@ describe("per-user MCP auth isolation", () => { · 65 unmodified lines | ||
| 187 | 188 | { onElicitation: "accept-all" }, |
| 188 | 189 | ); |
| 189 | 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 | 194 | expect( |
| 193 | 195 | (yield* server.requests) |
| @@ -267,7 +269,8 @@ describe("per-user MCP auth isolation", () => { · 73 unmodified lines | ||
| 267 | 269 | ); |
| 268 | 270 | |
| 269 | 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 | 275 | const invokeRequests = (yield* server.requests).slice(beforeInvoke); |
| 273 | 276 | expect( |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | Predicate, |
| 9 | 9 | Result, |
| 10 | 10 | Scope, |
| 11 | + Schema, | |
| 11 | 12 | ScopedCache, |
| 12 | 13 | } from "effect"; |
| 13 | 14 | import type { HttpClient } from "effect/unstable/http"; |
| @@ -22,6 +23,7 @@ import { · 8 unmodified lines | ||
| 22 | 23 | ScopeId, |
| 23 | 24 | SecretId, |
| 24 | 25 | SourceDetectionResult, |
| 26 | + ToolResult, | |
| 25 | 27 | definePlugin, |
| 26 | 28 | resolveSecretBackedMap as resolveSharedSecretBackedMap, |
| 27 | 29 | type PluginCtx, |
| @@ -193,6 +195,24 @@ const toBinding = (entry: McpToolManifestEntry): McpToolBinding => · 165 unmodified lines | ||
| 193 | 195 | }); |
| 194 | 196 | |
| 195 | 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 | 217 | /** Match `token` as a separator-bounded run inside a URL hostname or path, |
| 198 | 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 | 1750 | toolId: toolRow.id, |
| 1731 | 1751 | toolName: entry.binding.toolName, |
| 1732 | 1752 | args, |
| @@ -1764,6 +1784,16 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 31 unmodified lines | ||
| 1764 | 1784 | pendingConnectors: runtime.pendingConnectors, |
| 1765 | 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 | 1797 | }).pipe( |
| 1768 | 1798 | Effect.withSpan("mcp.plugin.invoke_tool", { |
| 1769 | 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 | 395 | return server; |
| 379 | 396 | }; |
| 380 | 397 | |
| @@ -26,6 +26,7 @@ import { makeTestConfig, serveOAuthTestServer } from "@executor-js/sdk/testing"; · 25 unmodified lines | ||
| 26 | 26 | import { |
| 27 | 27 | addOpenApiTestSource, |
| 28 | 28 | serveOpenApiHttpApiTestServer, |
| 29 | + unwrapInvocation, | |
| 29 | 30 | } from "@executor-js/plugin-openapi/testing"; |
| 30 | 31 | |
| 31 | 32 | import { openApiPlugin } from "./plugin"; |
| @@ -226,25 +227,21 @@ describe("OpenAPI client_credentials OAuth", () => { · 194 unmodified lines | ||
| 226 | 227 | ); |
| 227 | 228 | // Invoking the tool injects the freshly-minted bearer via |
| 228 | 229 | // ctx.connections.accessToken. |
| 229 | − const result = (yield* userExec.tools.invoke( | |
| 230 | − "petstore.items.echoHeaders", | |
| 231 | − {}, | |
| 232 | − autoApprove, | |
| 233 | − )) as { | |
| 234 | − data: { authorization?: string } | null; | |
| 235 | − error: unknown; | |
| 236 | − }; | |
| 230 | + const result = unwrapInvocation( | |
| 231 | + yield* userExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove), | |
| 232 | + ); | |
| 237 | 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 | 236 | expect(bearer).toBeDefined(); |
| 240 | 237 | expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true); |
| 241 | 238 | |
| 242 | 239 | // The connection lives at the innermost (user) scope, which |
| 243 | 240 | // preserves per-user credential resolution: if each user has |
| 244 | − // their own `dealcloud_client_id`/`dealcloud_client_secret` | |
| 245 | − // shadowed at their user scope, each user mints their own | |
| 246 | − // token. A single shared connection slot still lets every caller | |
| 247 | − // reach the right physical row through scoped credential bindings. | |
| 241 | + // their own OAuth client credentials shadowed at their user | |
| 242 | + // scope, each user mints their own token. A single shared | |
| 243 | + // connection slot still lets every caller reach the right | |
| 244 | + // physical row through scoped credential bindings. | |
| 248 | 245 | const userConnections = yield* userExec.connections.list(); |
| 249 | 246 | const connection = userConnections.find((c) => c.id === completedConnection.connectionId); |
| 250 | 247 | expect(connection).toBeDefined(); |
| @@ -38,6 +38,7 @@ import { · 37 unmodified lines | ||
| 38 | 38 | addOpenApiTestSource, |
| 39 | 39 | makeOpenApiTestSourceConfig, |
| 40 | 40 | serveOpenApiHttpApiTestServer, |
| 41 | + unwrapInvocation, | |
| 41 | 42 | } from "@executor-js/plugin-openapi/testing"; |
| 42 | 43 | |
| 43 | 44 | import { openApiPlugin } from "./plugin"; |
| @@ -277,25 +278,21 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 233 unmodified lines | ||
| 277 | 278 | // yields her token; bob's scope yields his. Same source, same |
| 278 | 279 | // tool, different injected bearer. |
| 279 | 280 | // ------------------------------------------------------------- |
| 280 | − const aliceResult = (yield* aliceExec.tools.invoke( | |
| 281 | − "vercel.projects.list", | |
| 282 | − {}, | |
| 283 | − autoApprove, | |
| 284 | − )) as { | |
| 285 | − data: { authorization?: string; token?: string } | null; | |
| 286 | − error: unknown; | |
| 287 | − }; | |
| 281 | + const aliceResult = unwrapInvocation( | |
| 282 | + yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove), | |
| 283 | + ); | |
| 288 | 284 | expect(aliceResult.error).toBeNull(); |
| 289 | − expect(aliceResult.data?.authorization).toBe("Bearer alice-vercel-token"); | |
| 290 | − expect(aliceResult.data?.token).toBe("alice-team"); | |
| 285 | + const aliceData = aliceResult.data as EchoHeaders | null; | |
| 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 { | |
| 293 | − data: { authorization?: string; token?: string } | null; | |
| 294 | − error: unknown; | |
| 295 | − }; | |
| 289 | + const bobResult = unwrapInvocation( | |
| 290 | + yield* bobExec.tools.invoke("vercel.projects.list", {}, autoApprove), | |
| 291 | + ); | |
| 296 | 292 | expect(bobResult.error).toBeNull(); |
| 297 | − expect(bobResult.data?.authorization).toBe("Bearer bob-vercel-token"); | |
| 298 | − expect(bobResult.data?.token).toBe("bob-team"); | |
| 293 | + const bobData = bobResult.data as EchoHeaders | null; | |
| 294 | + expect(bobData?.authorization).toBe("Bearer bob-vercel-token"); | |
| 295 | + expect(bobData?.token).toBe("bob-team"); | |
| 299 | 296 | |
| 300 | 297 | // ------------------------------------------------------------- |
| 301 | 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( | |
| 454 | − "vercel.projects.list", | |
| 455 | − {}, | |
| 456 | − autoApprove, | |
| 457 | − )) as { data: { authorization?: string } | null; error: unknown }; | |
| 450 | + const aliceResult = unwrapInvocation( | |
| 451 | + yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove), | |
| 452 | + ); | |
| 458 | 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 { | |
| 462 | − data: { authorization?: string } | null; | |
| 463 | − error: unknown; | |
| 464 | − }; | |
| 458 | + const bobResult = unwrapInvocation( | |
| 459 | + yield* bobExec.tools.invoke("vercel.projects.list", {}, autoApprove), | |
| 460 | + ); | |
| 465 | 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( | |
| 562 | − "vercel.projects.list", | |
| 563 | − {}, | |
| 564 | − autoApprove, | |
| 565 | − )) as { data: { authorization?: string } | null; error: unknown }; | |
| 557 | + const sharedResult = unwrapInvocation( | |
| 558 | + yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove), | |
| 559 | + ); | |
| 566 | 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 | 563 | yield* aliceExec.secrets.set( |
| 570 | 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( | |
| 591 | − "vercel.projects.list", | |
| 592 | − {}, | |
| 593 | − autoApprove, | |
| 594 | − )) as { data: { authorization?: string } | null; error: unknown }; | |
| 584 | + const overrideResult = unwrapInvocation( | |
| 585 | + yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove), | |
| 586 | + ); | |
| 595 | 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 | 592 | yield* aliceExec.openapi.removeSourceBinding( |
| 599 | 593 | "vercel", |
| @@ -602,13 +596,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 2 unmodified lines | ||
| 602 | 596 | String(aliceScope.id), |
| 603 | 597 | ); |
| 604 | 598 | |
| 605 | − const fallbackResult = (yield* aliceExec.tools.invoke( | |
| 606 | − "vercel.projects.list", | |
| 607 | − {}, | |
| 608 | − autoApprove, | |
| 609 | − )) as { data: { authorization?: string } | null; error: unknown }; | |
| 599 | + const fallbackResult = unwrapInvocation( | |
| 600 | + yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove), | |
| 601 | + ); | |
| 610 | 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 | 605 | yield* aliceExec.openapi.setSourceBinding( |
| 614 | 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 { | |
| 816 | − data: { authorization?: string } | null; | |
| 817 | − error: unknown; | |
| 818 | − }; | |
| 807 | + const result = unwrapInvocation( | |
| 808 | + yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove), | |
| 809 | + ); | |
| 819 | 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 { | |
| 918 | − data: { authorization?: string } | null; | |
| 919 | − error: unknown; | |
| 920 | − }; | |
| 908 | + const result = unwrapInvocation( | |
| 909 | + yield* userExec.tools.invoke("vercel.projects.list", {}, autoApprove), | |
| 910 | + ); | |
| 921 | 911 | |
| 922 | 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 { | |
| 1017 | − data: { authorization?: string } | null; | |
| 1018 | − error: unknown; | |
| 1019 | − }; | |
| 1006 | + const result = unwrapInvocation( | |
| 1007 | + yield* userExec.tools.invoke("vercel.projects.list", {}, autoApprove), | |
| 1008 | + ); | |
| 1020 | 1009 | |
| 1021 | 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 | 28 | import { |
| 29 | 29 | addOpenApiTestSource, |
| 30 | 30 | serveOpenApiHttpApiTestServer, |
| 31 | + unwrapInvocation, | |
| 31 | 32 | } from "@executor-js/plugin-openapi/testing"; |
| 32 | 33 | |
| 33 | 34 | import { openApiPlugin } from "./plugin"; |
| @@ -307,23 +308,25 @@ describe("OpenAPI multi-scope OAuth", () => { · 273 unmodified lines | ||
| 307 | 308 | // 4. Invoke through each exec — Authorization must carry that |
| 308 | 309 | // user's token. |
| 309 | 310 | // ------------------------------------------------------------- |
| 310 | − const aliceResult = (yield* aliceExec.tools.invoke( | |
| 311 | − "petstore.items.echoHeaders", | |
| 312 | − {}, | |
| 313 | − autoApprove, | |
| 314 | − )) as { data: { authorization?: string } | null; error: unknown }; | |
| 311 | + const aliceResult = unwrapInvocation( | |
| 312 | + yield* aliceExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove), | |
| 313 | + ); | |
| 315 | 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 | 319 | expect(aliceBearer).toBeDefined(); |
| 318 | 320 | expect(yield* oauth.acceptsAccessToken(aliceBearer!)).toBe(true); |
| 319 | 321 | |
| 320 | − const bobResult = (yield* bobExec.tools.invoke( | |
| 321 | − "petstore.items.echoHeaders", | |
| 322 | − {}, | |
| 323 | − autoApprove, | |
| 324 | − )) as { data: { authorization?: string } | null; error: unknown }; | |
| 322 | + const bobResult = unwrapInvocation( | |
| 323 | + yield* bobExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove), | |
| 324 | + ); | |
| 325 | 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 | 330 | expect(bobBearer).toBeDefined(); |
| 328 | 331 | expect(yield* oauth.acceptsAccessToken(bobBearer!)).toBe(true); |
| 329 | 332 | expect(bobBearer).not.toBe(aliceBearer); |
| @@ -610,23 +613,25 @@ describe("OpenAPI multi-scope OAuth", () => { · 280 unmodified lines | ||
| 610 | 613 | // (4) Each user's invocation resolves their OWN row and gets |
| 611 | 614 | // their OWN token — not whatever the last signer happened to |
| 612 | 615 | // mint. This is the core multi-user regression. |
| 613 | − const aliceResult = (yield* aliceExec.tools.invoke( | |
| 614 | − "petstore.items.echoHeaders", | |
| 615 | − {}, | |
| 616 | − autoApprove, | |
| 617 | − )) as { data: { authorization?: string } | null; error: unknown }; | |
| 616 | + const aliceResult = unwrapInvocation( | |
| 617 | + yield* aliceExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove), | |
| 618 | + ); | |
| 618 | 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 | 624 | expect(aliceBearer).toBeDefined(); |
| 621 | 625 | expect(yield* oauth.acceptsAccessToken(aliceBearer!)).toBe(true); |
| 622 | 626 | |
| 623 | − const bobResult = (yield* bobExec.tools.invoke( | |
| 624 | − "petstore.items.echoHeaders", | |
| 625 | − {}, | |
| 626 | − autoApprove, | |
| 627 | − )) as { data: { authorization?: string } | null; error: unknown }; | |
| 627 | + const bobResult = unwrapInvocation( | |
| 628 | + yield* bobExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove), | |
| 629 | + ); | |
| 628 | 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 | 635 | expect(bobBearer).toBeDefined(); |
| 631 | 636 | expect(yield* oauth.acceptsAccessToken(bobBearer!)).toBe(true); |
| 632 | 637 | expect(bobBearer).not.toBe(aliceBearer); |
| @@ -35,6 +35,7 @@ import { makeTestConfig, serveOAuthTestServer } from "@executor-js/sdk/testing"; · 34 unmodified lines | ||
| 35 | 35 | import { |
| 36 | 36 | addOpenApiTestSource, |
| 37 | 37 | serveOpenApiHttpApiTestServer, |
| 38 | + unwrapInvocation, | |
| 38 | 39 | } from "@executor-js/plugin-openapi/testing"; |
| 39 | 40 | |
| 40 | 41 | import { openApiPlugin } from "./plugin"; |
| @@ -253,17 +254,16 @@ describe("OpenAPI oauth refresh", () => { · 212 unmodified lines | ||
| 253 | 254 | }); |
| 254 | 255 | yield* bindOAuthConnection(executor, scopeId, "conn-refresh-ok", auth); |
| 255 | 256 | |
| 256 | − const result = (yield* executor.tools.invoke( | |
| 257 | − "petstore.items.echoHeaders", | |
| 258 | − {}, | |
| 259 | − autoApprove, | |
| 260 | − )) as { data: { authorization?: string } | null; error: unknown }; | |
| 257 | + const result = unwrapInvocation( | |
| 258 | + yield* executor.tools.invoke("petstore.items.echoHeaders", {}, autoApprove), | |
| 259 | + ); | |
| 261 | 260 | |
| 262 | 261 | expect(result.error).toBeNull(); |
| 262 | + const data = result.data as EchoHeaders | null; | |
| 263 | 263 | // Proves the refresh landed: invoke carried the fresh token, |
| 264 | 264 | // not the expired one we seeded. |
| 265 | − expect(result.data?.authorization).not.toBe("Bearer expired-access-v1"); | |
| 266 | − const bearer = result.data?.authorization?.replace(/^Bearer\s+/i, ""); | |
| 265 | + expect(data?.authorization).not.toBe("Bearer expired-access-v1"); | |
| 266 | + const bearer = data?.authorization?.replace(/^Bearer\s+/i, ""); | |
| 267 | 267 | expect(bearer).toBeDefined(); |
| 268 | 268 | expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true); |
| 269 | 269 | const calls = refreshTokenRequests(yield* oauth.requests); |
| @@ -313,12 +313,9 @@ describe("OpenAPI oauth refresh", () => { · 43 unmodified lines | ||
| 313 | 313 | ); |
| 314 | 314 | |
| 315 | 315 | for (const r of invokes) { |
| 316 | − const res = r as { | |
| 317 | − data: { authorization?: string } | null; | |
| 318 | − error: unknown; | |
| 319 | − }; | |
| 316 | + const res = unwrapInvocation(r); | |
| 320 | 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 | 319 | expect(bearer).toBeDefined(); |
| 323 | 320 | expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true); |
| 324 | 321 | } |
| @@ -25,6 +25,7 @@ import { · 24 unmodified lines | ||
| 25 | 25 | addOpenApiTestSource, |
| 26 | 26 | makeOpenApiHttpApiTestSourceConfig, |
| 27 | 27 | serveOpenApiHttpApiTestServer, |
| 28 | + unwrapInvocation, | |
| 28 | 29 | } from "../testing"; |
| 29 | 30 | |
| 30 | 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 | − "executor.openapi.previewSpec", | |
| 272 | − { spec: testApiSpec() }, | |
| 273 | − autoApprove, | |
| 274 | − )) as { operationCount: number }; | |
| 271 | + const preview = unwrapInvocation( | |
| 272 | + yield* executor.tools.invoke( | |
| 273 | + "executor.openapi.previewSpec", | |
| 274 | + { spec: testApiSpec() }, | |
| 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( | |
| 320 | − "executor.openapi.addSource", | |
| 321 | − testApiSourceConfig({ scope: String(orgScope), namespace: "runtime" }), | |
| 322 | − autoApprove, | |
| 323 | − )) as { sourceId: string; toolCount: number }; | |
| 322 | + const result = unwrapInvocation( | |
| 323 | + yield* executor.tools.invoke( | |
| 324 | + "executor.openapi.addSource", | |
| 325 | + testApiSourceConfig({ scope: String(orgScope), namespace: "runtime" }), | |
| 326 | + autoApprove, | |
| 327 | + ), | |
| 328 | + ).data as { sourceId: string; toolCount: number }; | |
| 324 | 329 | |
| 325 | 330 | expect(result).toEqual({ sourceId: "runtime", toolCount: 4 }); |
| 326 | 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( | |
| 561 | − "authed.items.echoHeaders", | |
| 562 | − {}, | |
| 563 | − autoApprove, | |
| 564 | − )) as { | |
| 565 | − data: { authorization?: string; "x-static"?: string } | null; | |
| 566 | − error: unknown; | |
| 567 | − }; | |
| 565 | + const result = unwrapInvocation( | |
| 566 | + yield* executor.tools.invoke("authed.items.echoHeaders", {}, autoApprove), | |
| 567 | + ); | |
| 568 | 568 | |
| 569 | 569 | expect(result.error).toBeNull(); |
| 570 | − const data = result.data!; | |
| 570 | + const data = result.data as { authorization?: string; "x-static"?: string }; | |
| 571 | 571 | expect(data.authorization).toBe("Bearer secret-value-123"); |
| 572 | 572 | expect(data["x-static"]).toBe("hello"); |
| 573 | 573 | }), |
| @@ -751,10 +751,9 @@ describe("OpenAPI Plugin", () => { · 177 unmodified lines | ||
| 751 | 751 | namespace: "test", |
| 752 | 752 | }); |
| 753 | 753 | |
| 754 | − const result = (yield* executor.tools.invoke("test.items.listItems", {}, autoApprove)) as { | |
| 755 | − data: unknown; | |
| 756 | − error: unknown; | |
| 757 | − }; | |
| 754 | + const result = unwrapInvocation( | |
| 755 | + yield* executor.tools.invoke("test.items.listItems", {}, autoApprove), | |
| 756 | + ); | |
| 758 | 757 | expect(result.error).toBeNull(); |
| 759 | 758 | expect(result.data).toEqual(ITEMS); |
| 760 | 759 | }), |
| @@ -780,11 +779,9 @@ describe("OpenAPI Plugin", () => { · 19 unmodified lines | ||
| 780 | 779 | namespace: "test", |
| 781 | 780 | }); |
| 782 | 781 | |
| 783 | − const result = (yield* executor.tools.invoke( | |
| 784 | − "test.items.getItem", | |
| 785 | − { itemId: "2" }, | |
| 786 | − autoApprove, | |
| 787 | − )) as { data: unknown; error: unknown }; | |
| 782 | + const result = unwrapInvocation( | |
| 783 | + yield* executor.tools.invoke("test.items.getItem", { itemId: "2" }, autoApprove), | |
| 784 | + ); | |
| 788 | 785 | expect(result.error).toBeNull(); |
| 789 | 786 | expect(result.data).toEqual({ id: 2, name: "Gadget" }); |
| 790 | 787 | }), |
| @@ -810,16 +807,18 @@ describe("OpenAPI Plugin", () => { · 19 unmodified lines | ||
| 810 | 807 | namespace: "records", |
| 811 | 808 | }); |
| 812 | 809 | |
| 813 | − const result = (yield* executor.tools.invoke( | |
| 814 | − "records.items.queryRows", | |
| 815 | − { | |
| 816 | − entryTypeId: "18538", | |
| 817 | − query: JSON.stringify([{ DisplayName: "Example" }]), | |
| 818 | − limit: 10, | |
| 819 | − skip: 0, | |
| 820 | − }, | |
| 821 | − autoApprove, | |
| 822 | − )) as { data: unknown; error: unknown }; | |
| 810 | + const result = unwrapInvocation( | |
| 811 | + yield* executor.tools.invoke( | |
| 812 | + "records.items.queryRows", | |
| 813 | + { | |
| 814 | + entryTypeId: "18538", | |
| 815 | + query: JSON.stringify([{ DisplayName: "Example" }]), | |
| 816 | + limit: 10, | |
| 817 | + skip: 0, | |
| 818 | + }, | |
| 819 | + autoApprove, | |
| 820 | + ), | |
| 821 | + ); | |
| 823 | 822 | |
| 824 | 823 | expect(result.data).toBeNull(); |
| 825 | 824 | expect(result.error).toEqual( |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | ||
| 7 | 7 | SecretId, |
| 8 | 8 | SourceDetectionResult, |
| 9 | 9 | StorageError, |
| 10 | + ToolResult, | |
| 10 | 11 | definePlugin, |
| 11 | 12 | tool, |
| 12 | 13 | resolveSecretBackedMap, |
| @@ -57,6 +58,77 @@ import { · 44 unmodified lines | ||
| 57 | 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 | 132 | export type HeaderValue = HeaderValueValue; |
| 61 | 133 | export type ConfiguredHeaderValue = ConfiguredHeaderValueValue; |
| 62 | 134 | export type OpenApiHeaderInput = HeaderValue | ConfiguredHeaderValue; |
| @@ -1193,7 +1265,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 1130 unmodified lines | ||
| 1193 | 1265 | name: "previewSpec", |
| 1194 | 1266 | description: "Preview an OpenAPI document before adding it as a source", |
| 1195 | 1267 | inputSchema: PreviewSpecInputStandardSchema, |
| 1196 | − execute: (input) => self.previewSpec(input), | |
| 1268 | + execute: (input) => Effect.map(self.previewSpec(input), ToolResult.ok), | |
| 1197 | 1269 | }), |
| 1198 | 1270 | tool({ |
| 1199 | 1271 | name: "addSource", |
| @@ -1204,7 +1276,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 4 unmodified lines | ||
| 1204 | 1276 | }, |
| 1205 | 1277 | inputSchema: AddSourceInputStandardSchema, |
| 1206 | 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 | 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 | 1374 | resolveAnnotations: ({ ctx, sourceId, toolRows }) => |
| @@ -37,6 +37,7 @@ import { · 36 unmodified lines | ||
| 37 | 37 | makeOpenApiTestSourceConfig, |
| 38 | 38 | type OpenApiTestServerShape, |
| 39 | 39 | serveOpenApiHttpApiTestServer, |
| 40 | + unwrapInvocation, | |
| 40 | 41 | } from "../testing"; |
| 41 | 42 | |
| 42 | 43 | import { openApiPlugin } from "./plugin"; |
| @@ -303,10 +304,10 @@ describe("OpenAPI upstream failure modes", () => { · 260 unmodified lines | ||
| 303 | 304 | const { baseUrl } = yield* slowServer; |
| 304 | 305 | const executor = yield* buildExecutor(baseUrl); |
| 305 | 306 | |
| 306 | − const result = yield* executor.tools.invoke("f.things.listThings", {}, autoApprove); | |
| 307 | − // Empty array via .data envelope or directly — accept either shape. | |
| 308 | − const data = (result as { data?: unknown }).data ?? result; | |
| 309 | − expect(data).toEqual([]); | |
| 307 | + const result = unwrapInvocation( | |
| 308 | + yield* executor.tools.invoke("f.things.listThings", {}, autoApprove), | |
| 309 | + ); | |
| 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 | 2 | import { |
| 3 | 3 | HttpClient, |
| 4 | 4 | HttpRouter, |
| @@ -13,7 +13,7 @@ import { · 8 unmodified lines | ||
| 13 | 13 | OpenApi, |
| 14 | 14 | } from "effect/unstable/httpapi"; |
| 15 | 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 | 17 | import type { OpenApiPluginExtension, OpenApiSpecConfig } from "../sdk/plugin"; |
| 18 | 18 | |
| 19 | 19 | export class OpenApiTestServerAddressError extends Data.TaggedError( |
| @@ -618,3 +618,55 @@ export const TestLayers = { · 598 unmodified lines | ||
| 618 | 618 | echo: OpenApiEchoTestServer.layer, |
| 619 | 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 | 256 | const client = makeFakeClient({ rejectReadNamesLongerThan: 80 }); |
| 257 | 257 | const executor = yield* makeExecutor(client); |
| 258 | 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 | 262 | yield* executor.secrets.set( |