History

Route tool defects opaquely; remove legacy envelope shim (#826)

* 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

Rhys Sullivan committed 5 months ago

Commit 278ca7c1 parent 8d986ff35 files · +1322 −340Browse files

Unified · Split

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