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
207207 expect(execution.structured).toMatchObject({
208208 status: "completed",
209209 result: {
210− message: "hello",
211− suffix: "world",
212− path: "/echo/hello",
210+ ok: true,
211+ data: {
212+ status: 200,
213+ data: {
214+ message: "hello",
215+ suffix: "world",
216+ path: "/echo/hello",
217+ },
218+ },
213219 },
214220 logs: [],
215221 });
@@ -315,7 +321,7 @@ describe("sources api (HTTP)", () => { · 99 unmodified lines
315321 expect(execution.isError).toBe(false);
316322 expect(execution.structured).toMatchObject({
317323 status: "completed",
318− result: { hello: "Hello Ada" },
324+ result: { ok: true, data: { hello: "Hello Ada" } },
319325 });
320326 const requests = yield* server.requests;
321327 expect(requests.some((request) => request.payload.query?.includes("__schema"))).toBe(true);
@@ -391,7 +397,8 @@ describe("sources api (HTTP)", () => { · 69 unmodified lines
391397 expect(execution.structured).toMatchObject({
392398 status: "completed",
393399 result: {
394− content: [{ type: "text", text: "cloud-mcp-ok" }],
400+ ok: true,
401+ data: { content: [{ type: "text", text: "cloud-mcp-ok" }] },
395402 },
396403 });
397404 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
159159 `);
160160
161161 db.prepare("INSERT INTO `openapi_source` (id, scope_id, oauth2) VALUES (?, ?, ?)").run(
162− "dealcloud_api",
162+ "example_api",
163163 "org-1",
164164 JSON.stringify({
165165 kind: "oauth2",
@@ -174,27 +174,27 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 8 unmodified lines
174174 "INSERT INTO `connection` (id, scope_id, provider, provider_state, scope, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
175175 );
176176 insertConnection.run(
177− "openapi-oauth2-app-dealcloud_api",
177+ "openapi-oauth2-app-example_api",
178178 "org-1",
179179 "oauth2",
180180 JSON.stringify({
181181 kind: "client-credentials",
182− tokenEndpoint: "https://resolve.dealcloud.com/oauth/token",
183− clientIdSecretId: "dealcloud-client-id",
184− clientSecretSecretId: "dealcloud-client-secret",
182+ tokenEndpoint: "https://auth.example.test/oauth/token",
183+ clientIdSecretId: "example-client-id",
184+ clientSecretSecretId: "example-client-secret",
185185 }),
186186 null,
187187 now,
188188 );
189189 insertConnection.run(
190− "openapi-oauth2-app-dealcloud_api",
190+ "openapi-oauth2-app-example_api",
191191 "user-org:user-jd:org-1",
192192 "openapi:oauth2",
193193 JSON.stringify({
194194 kind: "client-credentials",
195− tokenEndpoint: "https://resolve.dealcloud.com/oauth/token",
196− clientIdSecretId: "dealcloud-client-id-jd",
197− clientSecretSecretId: "dealcloud-client-secret-jd",
195+ tokenEndpoint: "https://auth.example.test/oauth/token",
196+ clientIdSecretId: "example-client-id-jd",
197+ clientSecretSecretId: "example-client-secret-jd",
198198 }),
199199 null,
200200 now,
@@ -207,12 +207,12 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 6 unmodified lines
207207 "org-client-id",
208208 "org-1",
209209 "openapi",
210− "dealcloud_api",
210+ "example_api",
211211 "org-1",
212212 "oauth2:oauth2:client-id",
213213 "secret",
214214 null,
215− "dealcloud-client-id-jd",
215+ "example-client-id-jd",
216216 null,
217217 now,
218218 now,
@@ -221,12 +221,12 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 2 unmodified lines
221221 "org-client-secret",
222222 "org-1",
223223 "openapi",
224− "dealcloud_api",
224+ "example_api",
225225 "org-1",
226226 "oauth2:oauth2:client-secret",
227227 "secret",
228228 null,
229− "dealcloud-client-secret-jd",
229+ "example-client-secret-jd",
230230 null,
231231 now,
232232 now,
@@ -235,13 +235,13 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 2 unmodified lines
235235 "org-connection",
236236 "org-1",
237237 "openapi",
238− "dealcloud_api",
238+ "example_api",
239239 "org-1",
240240 "oauth2:oauth2:connection",
241241 "connection",
242242 null,
243243 null,
244− "openapi-oauth2-app-dealcloud_api",
244+ "openapi-oauth2-app-example_api",
245245 now,
246246 now,
247247 );
@@ -249,13 +249,13 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 1 unmodified line
249249 "jd-connection",
250250 "user-org:user-jd:org-1",
251251 "openapi",
252− "dealcloud_api",
252+ "example_api",
253253 "org-1",
254254 "oauth2:oauth2:connection",
255255 "connection",
256256 null,
257257 null,
258− "openapi-oauth2-app-dealcloud_api",
258+ "openapi-oauth2-app-example_api",
259259 now,
260260 now,
261261 );
@@ -271,20 +271,20 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 9 unmodified lines
271271 .prepare(
272272 "SELECT scope_id, slot_key, kind, secret_id, connection_id FROM `credential_binding` WHERE source_id = ? ORDER BY scope_id, slot_key",
273273 )
274− .all("dealcloud_api");
274+ .all("example_api");
275275 expect(bindings).toEqual([
276276 {
277277 scope_id: "org-1",
278278 slot_key: "oauth2:oauth2:client-id",
279279 kind: "secret",
280− secret_id: "dealcloud-client-id",
280+ secret_id: "example-client-id",
281281 connection_id: null,
282282 },
283283 {
284284 scope_id: "org-1",
285285 slot_key: "oauth2:oauth2:client-secret",
286286 kind: "secret",
287− secret_id: "dealcloud-client-secret",
287+ secret_id: "example-client-secret",
288288 connection_id: null,
289289 },
290290 {
@@ -292,20 +292,20 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 1 unmodified line
292292 slot_key: "oauth2:oauth2:connection",
293293 kind: "connection",
294294 secret_id: null,
295− connection_id: "openapi-oauth2-app-dealcloud_api",
295+ connection_id: "openapi-oauth2-app-example_api",
296296 },
297297 {
298298 scope_id: "user-org:user-jd:org-1",
299299 slot_key: "oauth2:oauth2:client-id",
300300 kind: "secret",
301− secret_id: "dealcloud-client-id-jd",
301+ secret_id: "example-client-id-jd",
302302 connection_id: null,
303303 },
304304 {
305305 scope_id: "user-org:user-jd:org-1",
306306 slot_key: "oauth2:oauth2:client-secret",
307307 kind: "secret",
308− secret_id: "dealcloud-client-secret-jd",
308+ secret_id: "example-client-secret-jd",
309309 connection_id: null,
310310 },
311311 {
@@ -313,7 +313,7 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 1 unmodified line
313313 slot_key: "oauth2:oauth2:connection",
314314 kind: "connection",
315315 secret_id: null,
316− connection_id: "openapi-oauth2-app-dealcloud_api",
316+ connection_id: "openapi-oauth2-app-example_api",
317317 },
318318 ]);
319319 });
packages/core/execution/src/description.ts+1−0
@@ -48,6 +48,7 @@ const formatDescription = (sources: readonly Source[]): string => { · 47 unmodified lines
4848 "- `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`.",
4949 '- When you already know the namespace, narrow with `tools.search({ namespace: "github", query: "issues" })`.',
5050 "- `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`.",
5152 "- 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 })`.",
5253 "- Always use the namespace prefix when calling tools: `tools.<namespace>.<tool>(args)`. Example: `tools.home_assistant_rest_api.states.getState(...)` — not `tools.states.getState(...)`.",
5354 "- 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 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Data, Effect, Schema } from "effect";
3+
4+import { ElicitationResponse, createExecutor, definePlugin } from "@executor-js/sdk";
5+import { makeTestConfig } from "@executor-js/sdk/testing";
6+import { ExecutionToolError } from "./errors";
7+import { makeExecutorToolInvoker } from "./tool-invoker";
8+
9+const EmptyInputSchema = Schema.toStandardSchemaV1(
10+ Schema.toStandardJSONSchemaV1(Schema.Struct({})),
11+);
12+
13+const acceptAll = () => Effect.succeed(ElicitationResponse.make({ action: "accept" }));
14+
15+// Plugin-internal tagged error whose `cause` carries internal diagnostics.
16+// The dispatcher must route this through the opaque-generic path so none of
17+// that context reaches the sandbox via Error.message.
18+class FakePluginInvocationError extends Data.TaggedError("PluginInvocationError")<{
19+ readonly message: string;
20+ readonly cause: unknown;
21+}> {}
22+
23+const leakyPlugin = definePlugin(() => ({
24+ id: "leaky-test" as const,
25+ storage: () => ({}),
26+ staticSources: () => [
27+ {
28+ id: "leaky",
29+ kind: "in-memory",
30+ name: "Leaky",
31+ tools: [
32+ {
33+ name: "failsWithCause",
34+ description: "",
35+ inputSchema: EmptyInputSchema,
36+ handler: () =>
37+ Effect.fail(
38+ new FakePluginInvocationError({
39+ message: "Upstream request failed",
40+ cause: {
41+ _tag: "InternalTransportError",
42+ request: {
43+ method: "GET",
44+ url: "https://internal.service.local/v1/resources?trace=trace-123",
45+ headers: { "x-internal-routing": "private-cluster" },
46+ },
47+ stack: "Error: connect failed\n at plugin-transport.ts:42:11",
48+ note: "internal diagnostic detail",
49+ },
50+ }),
51+ ),
52+ },
53+ {
54+ name: "throwsRawError",
55+ description: "",
56+ inputSchema: EmptyInputSchema,
57+ handler: () =>
58+ Effect.fail(
59+ Object.assign(
60+ // oxlint-disable-next-line executor/no-error-constructor -- boundary: leak test deliberately fails with a raw Error + crafted stack to assert the dispatcher's opaque-generic redaction
61+ new Error("Internal: secret 'sk_live_abcd' rotation failed"),
62+ {
63+ stack:
64+ "Error: Internal: secret 'sk_live_abcd' rotation failed\n at /home/svc/.../secret-store.ts:88",
65+ },
66+ ),
67+ ),
68+ },
69+ ],
70+ },
71+ ],
72+}));
73+
74+describe("internal-error leak audit (opaque defects)", () => {
75+ it.effect("plugin tagged error: defect surfaces only as opaque generic + correlation id", () =>
76+ Effect.gen(function* () {
77+ const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));
78+ const invoker = makeExecutorToolInvoker(executor, {
79+ invokeOptions: { onElicitation: acceptAll },
80+ });
81+
82+ const err = yield* Effect.flip(invoker.invoke({ path: "leaky.failsWithCause", args: {} }));
83+ expect(err).toBeInstanceOf(ExecutionToolError);
84+ // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: leak test inspects the rendered message to assert it is the opaque generic
85+ const msg = (err as { message: string }).message;
86+ // Must be the canonical opaque shape: "Internal tool error [<hex>]"
87+ expect(msg).toMatch(/^Internal tool error \[[0-9a-f]{8}\]$/);
88+ // Crucially, no internal context leaks
89+ expect(msg).not.toContain("trace-123");
90+ expect(msg).not.toContain("private-cluster");
91+ expect(msg).not.toContain("internal.service.local");
92+ expect(msg).not.toContain("InternalTransportError");
93+ expect(msg).not.toContain("Upstream request failed");
94+ }),
95+ );
96+
97+ it.effect("plain Error with stack: stack and message do NOT escape", () =>
98+ Effect.gen(function* () {
99+ const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));
100+ const invoker = makeExecutorToolInvoker(executor, {
101+ invokeOptions: { onElicitation: acceptAll },
102+ });
103+
104+ const err = yield* Effect.flip(invoker.invoke({ path: "leaky.throwsRawError", args: {} }));
105+ // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: leak test inspects the rendered message to assert it is the opaque generic
106+ const msg = (err as { message: string }).message;
107+ expect(msg).toMatch(/^Internal tool error \[[0-9a-f]{8}\]$/);
108+ expect(msg).not.toContain("secret-store.ts");
109+ expect(msg).not.toContain("at /home/");
110+ expect(msg).not.toContain("sk_live_abcd");
111+ }),
112+ );
113+});
packages/core/execution/src/tool-invoker.test.ts+309−14
@@ -1,9 +1,11 @@
11 import { describe, expect, it } from "@effect/vitest";
22 import { Effect, Fiber, Schema } from "effect";
3+import * as ts from "typescript";
34
45 import {
56 ElicitationResponse,
67 FormElicitation,
8+ ToolResult,
79 createExecutor,
810 definePlugin,
911 } from "@executor-js/sdk";
@@ -18,6 +20,10 @@ const RepoInputSchema = Schema.toStandardSchemaV1( · 8 unmodified lines
1820 Schema.toStandardJSONSchemaV1(Schema.Struct({ owner: Schema.String, repo: Schema.String })),
1921 );
2022
23+const RepoDetailsOutputSchema = Schema.toStandardSchemaV1(
24+ Schema.toStandardJSONSchemaV1(Schema.Struct({ defaultBranch: Schema.String })),
25+);
26+
2127 const ContactInputSchema = Schema.toStandardSchemaV1(
2228 Schema.toStandardJSONSchemaV1(Schema.Struct({ email: Schema.String })),
2329 );
@@ -28,6 +34,58 @@ const EmptyInputSchema = Schema.toStandardSchemaV1( · 4 unmodified lines
2834
2935 const acceptAll = () => Effect.succeed(ElicitationResponse.make({ action: "accept" }));
3036
37+type DescribedToolContract = {
38+ readonly outputTypeScript: string;
39+ readonly typeScriptDefinitions: Record<string, string>;
40+};
41+
42+const typeCheckDescribedInvocation = (
43+ described: DescribedToolContract,
44+ runtimeResult: unknown,
45+ consumerSource: string,
46+): readonly string[] => {
47+ const fileName = "described-tool-contract.ts";
48+ const source = [
49+ ...Object.entries(described.typeScriptDefinitions).map(([name, definition]) => {
50+ return `type ${name} = ${definition};`;
51+ }),
52+ `type ToolOutput = ${described.outputTypeScript};`,
53+ `const invokedResult: ToolOutput = ${JSON.stringify(runtimeResult)};`,
54+ consumerSource,
55+ ].join("\n");
56+
57+ const options: ts.CompilerOptions = {
58+ module: ts.ModuleKind.ESNext,
59+ noEmit: true,
60+ skipLibCheck: true,
61+ strict: true,
62+ target: ts.ScriptTarget.ES2022,
63+ };
64+ const host = ts.createCompilerHost(options);
65+ const originalGetSourceFile = host.getSourceFile.bind(host);
66+ const originalReadFile = host.readFile.bind(host);
67+ const originalFileExists = host.fileExists.bind(host);
68+
69+ host.getSourceFile = (candidate, languageVersion, onError, shouldCreateNewSourceFile) => {
70+ if (candidate === fileName) {
71+ return ts.createSourceFile(candidate, source, languageVersion, true);
72+ }
73+ return originalGetSourceFile(candidate, languageVersion, onError, shouldCreateNewSourceFile);
74+ };
75+ host.readFile = (candidate) => (candidate === fileName ? source : originalReadFile(candidate));
76+ host.fileExists = (candidate) => candidate === fileName || originalFileExists(candidate);
77+
78+ const program = ts.createProgram([fileName], options, host);
79+ return ts.getPreEmitDiagnostics(program).map((diagnostic) => {
80+ const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
81+ if (!diagnostic.file || diagnostic.start === undefined) {
82+ return message;
83+ }
84+ const position = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
85+ return `${diagnostic.file.fileName}:${position.line + 1}:${position.character + 1} ${message}`;
86+ });
87+};
88+
3189 // ---------------------------------------------------------------------------
3290 // Test plugins — each one declares a namespace as a static source with N
3391 // tools. Handlers return static data; the suite only cares about discovery
@@ -53,6 +111,7 @@ const githubPlugin = definePlugin(() => ({ · 19 unmodified lines
53111 name: "getRepositoryDetails",
54112 description: "Get repository details including the default branch",
55113 inputSchema: RepoInputSchema,
114+ outputSchema: RepoDetailsOutputSchema,
56115 handler: () => Effect.succeed({ defaultBranch: "main" }),
57116 },
58117 {
@@ -106,13 +165,78 @@ const errorPlugin = definePlugin(() => ({ · 47 unmodified lines
106165 description: "Query rows",
107166 inputSchema: EmptyInputSchema,
108167 handler: () =>
109− Effect.succeed({
110− data: null,
111− error: {
112− message: 'Field with name "DisplayName" does not exist',
168+ Effect.succeed(
169+ ToolResult.fail({
113170 code: "invalid_query",
114− },
115− }),
171+ message: 'Field with name "DisplayName" does not exist',
172+ }),
173+ ),
174+ },
175+ ],
176+ },
177+ ],
178+}));
179+
180+const structuredFailurePlugin = definePlugin(() => ({
181+ id: "structured-failure-test" as const,
182+ storage: () => ({}),
183+ staticSources: () => [
184+ {
185+ id: "upstream",
186+ kind: "in-memory",
187+ name: "Upstream",
188+ tools: [
189+ {
190+ name: "nestedErrorBody",
191+ description: "",
192+ inputSchema: EmptyInputSchema,
193+ handler: () =>
194+ Effect.succeed(
195+ ToolResult.fail({
196+ code: "upstream_http_error",
197+ status: 400,
198+ message: 'The expression "foo" is not valid. Provide a valid expression.',
199+ details: {
200+ error: {
201+ code: "invalidRequest",
202+ message: 'The expression "foo" is not valid. Provide a valid expression.',
203+ },
204+ },
205+ }),
206+ ),
207+ },
208+ {
209+ name: "flatErrorBody",
210+ description: "",
211+ inputSchema: EmptyInputSchema,
212+ handler: () =>
213+ Effect.succeed(
214+ ToolResult.fail({
215+ code: "upstream_http_error",
216+ status: 400,
217+ message: "Field 'XYZ' does not exist",
218+ details: {
219+ errorCode: 400,
220+ errorMessage: "Field 'XYZ' does not exist",
221+ },
222+ }),
223+ ),
224+ },
225+ {
226+ name: "errorsArrayBody",
227+ description: "",
228+ inputSchema: EmptyInputSchema,
229+ handler: () =>
230+ Effect.succeed(
231+ ToolResult.fail({
232+ code: "upstream_http_error",
233+ status: 403,
234+ message: "Insufficient scope",
235+ details: {
236+ errors: [{ status: "403", title: "Forbidden", detail: "Insufficient scope" }],
237+ },
238+ }),
239+ ),
116240 },
117241 ],
118242 },
@@ -342,8 +466,102 @@ describe("tool discovery", () => { · 223 unmodified lines
342466 expect(described.name).toBe("listRepositoryIssues");
343467 expect(described.description).toBe("List issues for a repository");
344468 expect(described.inputTypeScript).toBe("{ owner: string; repo: string; }");
345− expect(described.outputTypeScript).toBeUndefined();
346− expect(described.typeScriptDefinitions).toBeUndefined();
469+ expect(described.outputTypeScript).toBe(
470+ "{ ok: true; data: unknown } | { ok: false; error: ToolError }",
471+ );
472+ expect(described.typeScriptDefinitions).toEqual({
473+ ToolError:
474+ "{ code: string; message: string; status?: number; details?: unknown; retryable?: boolean }",
475+ });
476+ }),
477+ );
478+
479+ it.effect("describes a return type that accepts the sandbox invocation result", () =>
480+ Effect.gen(function* () {
481+ const executor = yield* makeSearchExecutor();
482+ const engine = createExecutionEngine({ executor, codeExecutor });
483+
484+ const execution = yield* engine.execute(
485+ [
486+ 'const details = await tools.describe.tool({ path: "github.getRepositoryDetails" });',
487+ "const result = await tools.github.getRepositoryDetails({ owner: 'executor', repo: 'executor' });",
488+ "return {",
489+ " outputTypeScript: details.outputTypeScript,",
490+ " typeScriptDefinitions: details.typeScriptDefinitions,",
491+ " result,",
492+ "};",
493+ ].join("\n"),
494+ { onElicitation: acceptAll },
495+ );
496+
497+ expect(execution.error).toBeUndefined();
498+ const observed = execution.result as DescribedToolContract & { readonly result: unknown };
499+ const diagnostics = typeCheckDescribedInvocation(
500+ observed,
501+ observed.result,
502+ [
503+ "function readDefaultBranch(result: ToolOutput): string {",
504+ " if (!result.ok) return result.error.message;",
505+ " return result.data.defaultBranch;",
506+ "}",
507+ "readDefaultBranch(invokedResult);",
508+ ].join("\n"),
509+ );
510+ expect(diagnostics).toEqual([]);
511+ }),
512+ );
513+
514+ it.effect(
515+ "describes an error-as-value return type that accepts sandbox invocation failures",
516+ () =>
517+ Effect.gen(function* () {
518+ const executor = yield* createExecutor(
519+ makeTestConfig({ plugins: [errorPlugin()] as const }),
520+ );
521+ const engine = createExecutionEngine({ executor, codeExecutor });
522+
523+ const execution = yield* engine.execute(
524+ [
525+ 'const details = await tools.describe.tool({ path: "records.queryRows" });',
526+ "const result = await tools.records.queryRows({});",
527+ "return {",
528+ " outputTypeScript: details.outputTypeScript,",
529+ " typeScriptDefinitions: details.typeScriptDefinitions,",
530+ " result,",
531+ "};",
532+ ].join("\n"),
533+ { onElicitation: acceptAll },
534+ );
535+
536+ expect(execution.error).toBeUndefined();
537+ const observed = execution.result as DescribedToolContract & { readonly result: unknown };
538+ const diagnostics = typeCheckDescribedInvocation(
539+ observed,
540+ observed.result,
541+ [
542+ "function readToolResult(result: ToolOutput): unknown {",
543+ " if (!result.ok) return result.error.message;",
544+ " return result.data;",
545+ "}",
546+ "readToolResult(invokedResult);",
547+ ].join("\n"),
548+ );
549+ expect(diagnostics).toEqual([]);
550+ }),
551+ );
552+
553+ it.effect("describes the ToolResult wrapper through the direct describe helper", () =>
554+ Effect.gen(function* () {
555+ const executor = yield* makeSearchExecutor();
556+ const described = yield* describeTool(executor, "github.getRepositoryDetails");
557+
558+ expect(described.outputTypeScript).toBe(
559+ "{ ok: true; data: { defaultBranch: string; } } | { ok: false; error: ToolError }",
560+ );
561+ expect(described.typeScriptDefinitions).toEqual({
562+ ToolError:
563+ "{ code: string; message: string; status?: number; details?: unknown; retryable?: boolean }",
564+ });
347565 }),
348566 );
349567
@@ -405,20 +623,97 @@ describe("tool discovery", () => { · 55 unmodified lines
405623 }),
406624 );
407625
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)", () =>
409627 Effect.gen(function* () {
410628 const executor = yield* createExecutor(makeTestConfig({ plugins: [errorPlugin()] as const }));
411629 const invoker = makeExecutorToolInvoker(executor, {
412630 invokeOptions: { onElicitation: acceptAll },
413631 });
414632
415− const error = yield* Effect.flip(invoker.invoke({ path: "records.queryRows", args: {} }));
416−
417− expect(error).toEqual(
418− expect.objectContaining({
633+ const result = yield* invoker.invoke({ path: "records.queryRows", args: {} });
634+ expect(result).toEqual({
635+ ok: false,
636+ error: {
637+ code: "invalid_query",
419638 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 }),
421700 );
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+ });
422717 }),
423718 );
424719 });
packages/core/execution/src/tool-invoker.ts+55−59
@@ -1,4 +1,4 @@
1−import { Effect, Match, Option, Predicate } from "effect";
1+import { Effect, Predicate } from "effect";
22 import * as Cause from "effect/Cause";
33 import type {
44 Executor,
@@ -8,9 +8,32 @@ import type { · 3 unmodified lines
88 InvokeOptions,
99 Source,
1010 } from "@executor-js/sdk/core";
11+import { isToolResult } from "@executor-js/sdk/core";
1112 import type { SandboxToolInvoker } from "@executor-js/codemode-core";
1213 import { ExecutionToolError } from "./errors";
1314
15+const OPAQUE_DEFECT_MESSAGE = "Internal tool error";
16+const TOOL_ERROR_TYPESCRIPT =
17+ "{ code: string; message: string; status?: number; details?: unknown; retryable?: boolean }";
18+
19+const wrapOutputTypeScript = (outputTypeScript?: string): string =>
20+ `{ ok: true; data: ${outputTypeScript ?? "unknown"} } | { ok: false; error: ToolError }`;
21+
22+const withToolResultDefinitions = (
23+ definitions?: Record<string, string>,
24+): Record<string, string> => ({
25+ ...(definitions ?? {}),
26+ ToolError: TOOL_ERROR_TYPESCRIPT,
27+});
28+
29+const newCorrelationId = (): string => {
30+ // 8-hex-char correlation id; enough entropy to disambiguate within a
31+ // single deployment without leaking host process info.
32+ return Math.floor(Math.random() * 0x1_0000_0000)
33+ .toString(16)
34+ .padStart(8, "0");
35+};
36+
1437 /**
1538 * Extract the source namespace from a tool path. Tool paths look like
1639 * "<sourceId>.<op>" or "<sourceId>.<group>.<op>" — we take the first
@@ -23,44 +46,6 @@ const extractSourceNamespace = (path: string): string => { · 6 unmodified lines
2346 return idx === -1 ? path : path.slice(0, idx);
2447 };
2548
26−const hasStringMessage = (value: unknown): value is { readonly message: string } =>
27− value !== null &&
28− typeof value === "object" &&
29− "message" in value &&
30− typeof value.message === "string";
31−
32−const messageFromErrorLike = (value: unknown): string | undefined => {
33− if (hasStringMessage(value)) {
34− return value.message;
35− }
36− return undefined;
37−};
38−
39−const renderToolErrorMessage = (error: unknown): string =>
40− messageFromErrorLike(error) ??
41− (typeof error === "undefined" ? "Tool execution failed" : renderUnknownPrimitive(error));
42−
43−const renderUnknownPrimitive = (value: unknown): string =>
44− Match.value(value).pipe(
45− Match.when(Match.string, (s) => s),
46− Match.whenOr(Match.number, Match.boolean, Match.bigint, Match.symbol, (x) => x.toString()),
47− Match.option,
48− Option.getOrElse(() => "Tool execution failed"),
49− );
50−
51−type ToolResultEnvelope = {
52− readonly error?: unknown;
53− readonly data?: unknown;
54−};
55−
56−const isToolResultEnvelope = (value: unknown): value is ToolResultEnvelope =>
57− value !== null && typeof value === "object" && ("error" in value || "data" in value);
58−
59−const hasToolResultError = (
60− value: ToolResultEnvelope,
61−): value is ToolResultEnvelope & { readonly error: unknown } =>
62− value.error !== null && value.error !== undefined;
63−
6449 /**
6550 * Bridges QuickJS `tools.someSource.someOp(args)` calls into
6651 * `executor.tools.invoke(toolId, args)`.
@@ -87,35 +72,46 @@ export const makeExecutorToolInvoker = ( · 20 unmodified lines
8772 const result = yield* executor.tools.invoke(path as ToolId, args, options.invokeOptions).pipe(
8873 Effect.catchCause((cause): Effect.Effect<never, ExecutionToolError> => {
8974 const err = cause.reasons.find(Cause.isFailReason)?.error;
90− if (!isElicitationDeclinedError(err)) {
75+ if (isElicitationDeclinedError(err)) {
9176 return Effect.fail(
9277 new ExecutionToolError({
93− message: renderToolErrorMessage(err),
94− cause: err ?? cause,
78+ message: `Tool "${err.toolId}" requires approval but the request was ${err.action === "cancel" ? "cancelled" : "declined"} by the user.`,
79+ cause: err,
9580 }),
9681 );
9782 }
98− return Effect.fail(
99− new ExecutionToolError({
100− message: `Tool "${err.toolId}" requires approval but the request was ${err.action === "cancel" ? "cancelled" : "declined"} by the user.`,
101− cause: err,
83+ // Any other failure here is an infra/plugin defect. Emit an
84+ // opaque generic with a correlation id so internal context (URLs
85+ // with tokens, DB connection strings, file paths in stacks)
86+ // can't leak through Error.message into the sandbox. The full
87+ // cause is logged with the same correlation id so operators can
88+ // still trace the failure.
89+ const correlationId = newCorrelationId();
90+ return Effect.logError("tool dispatch failed", cause).pipe(
91+ Effect.annotateLogs({
92+ "executor.correlation_id": correlationId,
93+ "mcp.tool.name": path,
10294 }),
95+ Effect.flatMap(() =>
96+ Effect.fail(
97+ new ExecutionToolError({
98+ message: `${OPAQUE_DEFECT_MESSAGE} [${correlationId}]`,
99+ cause: err ?? cause,
100+ }),
101+ ),
102+ ),
103103 );
104104 }),
105105 );
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)) {
107112 return result;
108113 }
109− if (hasToolResultError(result)) {
110− return yield* new ExecutionToolError({
111− message: renderToolErrorMessage(result.error),
112− cause: result.error,
113− });
114− }
115− if ("data" in result) {
116− return result.data;
117− }
118− return result;
114+ return { ok: true, data: result };
119115 }),
120116 });
121117
@@ -513,7 +509,7 @@ export const describeTool = Effect.fn("executor.tools.describe")(function* ( · 391 unmodified lines
513509 name: schema.name ?? path,
514510 description: schema.description,
515511 inputTypeScript: schema.inputTypeScript,
516− outputTypeScript: schema.outputTypeScript,
517− typeScriptDefinitions: schema.typeScriptDefinitions,
512+ outputTypeScript: wrapOutputTypeScript(schema.outputTypeScript),
513+ typeScriptDefinitions: withToolResultDefinitions(schema.typeScriptDefinitions),
518514 };
519515 });
packages/core/sdk/src/index.ts+7−0
@@ -333,3 +333,10 @@ export { · 332 unmodified lines
333333
334334 // Wire-level HTTP error schemas usable by plugin HttpApiGroup definitions.
335335 export { InternalError } from "./api-errors";
336+
337+// ToolResult — typed value-based discriminated union for tool outcomes.
338+// The `Tool` value namespace exposes `Tool.ok` / `Tool.fail` constructors;
339+// the `Tool` type alias from `./types` is a separate row projection.
340+// TypeScript permits the two to share a name because one is purely a
341+// value and the other purely a type.
342+export { ToolResult, isToolResult, type ToolError } from "./tool-result";
packages/core/sdk/src/tool-result.test.ts+50−0
@@ -0,0 +1,50 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { ToolResult, isToolResult } from "./tool-result";
3+
4+describe("ToolResult", () => {
5+ it("ok wraps a value", () => {
6+ const r = ToolResult.ok({ count: 3 });
7+ expect(r).toEqual({ ok: true, data: { count: 3 } });
8+ expect(isToolResult(r)).toBe(true);
9+ });
10+
11+ it("fail wraps a ToolError", () => {
12+ const r = ToolResult.fail({
13+ code: "upstream_http_error",
14+ status: 404,
15+ message: "Not found",
16+ details: { id: "x" },
17+ });
18+ expect(r).toEqual({
19+ ok: false,
20+ error: {
21+ code: "upstream_http_error",
22+ status: 404,
23+ message: "Not found",
24+ details: { id: "x" },
25+ },
26+ });
27+ expect(isToolResult(r)).toBe(true);
28+ });
29+
30+ it("isToolResult rejects unrelated shapes", () => {
31+ expect(isToolResult(null)).toBe(false);
32+ expect(isToolResult({})).toBe(false);
33+ expect(isToolResult({ ok: true })).toBe(false);
34+ expect(isToolResult({ ok: false })).toBe(false);
35+ expect(isToolResult({ ok: false, error: {} })).toBe(false);
36+ expect(isToolResult({ ok: false, error: { code: 1, message: "x" } })).toBe(false);
37+ expect(isToolResult({ ok: "yes", data: 1 })).toBe(false);
38+ });
39+
40+ it("isToolResult accepts both branches of the union", () => {
41+ expect(isToolResult({ ok: true, data: 1 })).toBe(true);
42+ expect(isToolResult({ ok: true, data: null })).toBe(true);
43+ expect(
44+ isToolResult({
45+ ok: false,
46+ error: { code: "x", message: "y" },
47+ }),
48+ ).toBe(true);
49+ });
50+});
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+
8+import { Schema } from "effect";
9+
10+export const ToolErrorSchema = Schema.Struct({
11+ code: Schema.String,
12+ message: Schema.String,
13+ status: Schema.optional(Schema.Number),
14+ details: Schema.optional(Schema.Unknown),
15+ retryable: Schema.optional(Schema.Boolean),
16+});
17+
18+export type ToolError = typeof ToolErrorSchema.Type;
19+
20+export type ToolResult<T> =
21+ | { readonly ok: true; readonly data: T }
22+ | { readonly ok: false; readonly error: ToolError };
23+
24+export const ToolResult = {
25+ ok: <T>(data: T): ToolResult<T> => ({ ok: true, data }),
26+ fail: <T = never>(error: ToolError): ToolResult<T> => ({ ok: false, error }),
27+} as const;
28+
29+const ToolResultSchema = Schema.Union([
30+ Schema.Struct({ ok: Schema.Literal(true), data: Schema.Unknown }),
31+ Schema.Struct({ ok: Schema.Literal(false), error: ToolErrorSchema }),
32+]);
33+
34+const isUnknownToolResult = Schema.is(ToolResultSchema);
35+
36+export const isToolResult = (value: unknown): value is ToolResult<unknown> =>
37+ isUnknownToolResult(value);
packages/hosts/mcp/src/server.test.ts+14−9
@@ -116,7 +116,7 @@ describe("MCP host server — client with elicitation", () => { · 115 unmodified lines
116116 });
117117 });
118118
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 () => {
120120 const engine = makeStubEngine({
121121 execute: () => Effect.fail(new TestExecutionError({ message: "Unexpected token ':'" })),
122122 });
@@ -126,11 +126,11 @@ describe("MCP host server — client with elicitation", () => { · 3 unmodified lines
126126 name: "execute",
127127 arguments: { code: "const x: any = 1;" },
128128 });
129− expect(textOf(result)).toBe("Error: Unexpected token ':'");
130− expect(result.structuredContent).toEqual({
131− status: "error",
132− error: "Unexpected token ':'",
133− });
129+ const text = textOf(result);
130+ expect(text).toMatch(/^Error: Internal tool error \[[0-9a-f]{8}\]$/);
131+ expect(text).not.toContain("Unexpected token");
132+ const structured = (result.structuredContent as { readonly error?: string }).error ?? "";
133+ expect(structured).toMatch(/^Internal tool error \[[0-9a-f]{8}\]$/);
134134 expect(result.isError).toBe(true);
135135 });
136136 });
@@ -146,11 +146,16 @@ describe("MCP host server — client with elicitation", () => { · 9 unmodified lines
146146 name: "execute",
147147 arguments: { code: "run" },
148148 });
149− expect(textOf(result)).toBe("Error: Tool execution failed");
150− expect(result.structuredContent).toEqual({
149+ const text = textOf(result);
150+ expect(text).toMatch(/^Error: Internal tool error \[[0-9a-f]{8}\]$/);
151+ // Sensitive internal context must NOT leak through the MCP error path.
152+ expect(text).not.toContain("secret internal detail");
153+ expect(result.structuredContent).toMatchObject({
151154 status: "error",
152− error: "Tool execution failed",
153155 });
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");
154159 expect(result.isError).toBe(true);
155160 });
156161 });
packages/hosts/mcp/src/server.ts+20−12
@@ -260,20 +260,28 @@ const toMcpPausedResult = (formatted: ReturnType<typeof formatPausedExecution>): · 259 unmodified lines
260260 structuredContent: formatted.structured,
261261 });
262262
263−const formatFailureMessage = (value: unknown): string | null => {
264− if (typeof value === "object" && value !== null && "message" in value) {
265− const message = (value as { readonly message?: unknown }).message;
266− if (typeof message === "string" && message.length > 0) return message;
267− }
268− if (typeof value === "string" && value.length > 0) return value;
269− return null;
270−};
263+// `execute` failures reaching the MCP host are infra defects — domain
264+// failures from tools are now expressed as `ToolResult` values (success
265+// channel) and flow through `formatExecuteResult`. Emit an opaque
266+// generic plus a fresh correlation id and log the cause out-of-band so
267+// the model can't read internal context off `.message`.
268+const newCorrelationId = (): string =>
269+ Math.floor(Math.random() * 0x1_0000_0000)
270+ .toString(16)
271+ .padStart(8, "0");
271272
272273 const toMcpFailureResult = (cause: Cause.Cause<unknown>): McpToolResult => {
273− const failure = cause.reasons.find(Cause.isFailReason);
274− const text = failure
275− ? (formatFailureMessage(failure.error) ?? "Tool execution failed")
276− : "Tool execution failed";
274+ const correlationId = newCorrelationId();
275+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort defect logging must tolerate non-serializable causes
276+ try {
277+ console.error(
278+ `[executor:mcp] execute defect correlation_id=${correlationId}`,
279+ Cause.pretty(cause),
280+ );
281+ } catch {
282+ /* ignore logger failures */
283+ }
284+ const text = `Internal tool error [${correlationId}]`;
277285 return {
278286 content: [{ type: "text", text: `Error: ${text}` }],
279287 structuredContent: { status: "error", error: text },
packages/kernel/core/src/strip-types.test.ts+2−2
@@ -59,11 +59,11 @@ describe("stripTypeScript", () => { · 58 unmodified lines
5959 // axiom://7bf76f79c5d807272781e9554040aab3 — typed annotation in
6060 // a function expression.
6161 const code = `
62− const fetchDeals = async (sourceId: string): Promise<Array<{ id: string }>> => {
62+ const fetchResources = async (sourceId: string): Promise<Array<{ id: string }>> => {
6363 const result = await tools.executor.sources.list();
6464 return result.items;
6565 };
66− return fetchDeals('dealcloud');
66+ return fetchResources('example-source');
6767 `;
6868 const out = stripTypeScript(code);
6969 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
44 import * as Data from "effect/Data";
55 import * as Effect from "effect/Effect";
66 import type { SandboxToolInvoker } from "@executor-js/codemode-core";
7+import { ExecutionToolError } from "@executor-js/execution";
78 import {
89 ToolDispatcher,
910 makeDynamicWorkerExecutor,
@@ -246,7 +247,7 @@ describe("makeDynamicWorkerExecutor", () => { · 236 unmodified lines
246247 expect(result.result).toBe(7);
247248 });
248249
249− it("surfaces tool errors in execution result", async () => {
250+ it("surfaces infra defects through the worker bridge as an opaque generic", async () => {
250251 const executor = makeDynamicWorkerExecutor({ loader });
251252 const invoker = failingInvoker("not authorized");
252253
@@ -254,7 +255,28 @@ describe("makeDynamicWorkerExecutor", () => { · 1 unmodified line
254255 executor.execute("async () => { return await tools.secret.read({}); }", invoker),
255256 );
256257
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+ );
258280 });
259281
260282 it("does not expose host error stack details to sandbox error handlers", async () => {
@@ -284,11 +306,12 @@ describe("makeDynamicWorkerExecutor", () => { · 23 unmodified lines
284306 );
285307
286308 expect(result.error).toBeUndefined();
287− expect(result.result).toMatchObject({ message: "not authorized" });
309+ expect(result.result).toMatchObject({ message: "Internal tool error" });
288310 expect((result.result as { stack?: string }).stack).not.toContain("secret host stack");
311+ expect((result.result as { message?: string }).message).not.toContain("not authorized");
289312 });
290313
291− it("surfaces object-shaped tool errors in execution result", async () => {
314+ it("collapses object-shaped tool defects to an opaque generic", async () => {
292315 const executor = makeDynamicWorkerExecutor({ loader });
293316 const invoker = {
294317 invoke: () =>
@@ -302,11 +325,11 @@ describe("makeDynamicWorkerExecutor", () => { · 7 unmodified lines
302325 executor.execute("async () => { return await tools.secret.read({}); }", invoker),
303326 );
304327
305− expect(result.error).toBe('{"code":"forbidden","detail":"missing team access"}');
328+ expect(result.error).toBe("Internal tool error");
306329 expect(result.result).toBeNull();
307330 });
308331
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 () => {
310333 const executor = makeDynamicWorkerExecutor({ loader });
311334 const invoker = {
312335 invoke: () =>
@@ -320,7 +343,8 @@ describe("makeDynamicWorkerExecutor", () => { · 7 unmodified lines
320343 executor.execute("async () => { return await tools.records.query({}); }", invoker),
321344 );
322345
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");
324348 expect(result.result).toBeNull();
325349 });
326350
@@ -366,7 +390,7 @@ describe("makeDynamicWorkerExecutor", () => { · 39 unmodified lines
366390 expect(result.error).toBe("Tool RPC payload contains a circular reference");
367391 });
368392
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 () => {
370394 const executor = makeDynamicWorkerExecutor({ loader });
371395 const cyclic: Record<string, unknown> = {};
372396 cyclic.self = cyclic;
@@ -377,7 +401,7 @@ describe("makeDynamicWorkerExecutor", () => { · 4 unmodified lines
377401 );
378402
379403 expect(result.result).toBeNull();
380− expect(result.error).toBe("Tool RPC payload contains a circular reference");
404+ expect(result.error).toBe("Internal tool error");
381405 });
382406
383407 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
127127 " for (const [k, v] of Object.entries(value)) out[k] = __decodeBinary(v, seen);",
128128 " return out;",
129129 " };",
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+ " };",
130138 " const __makeToolsProxy = (path = []) => new Proxy(() => undefined, {",
131139 " get(_target, prop) {",
132140 " if (prop === 'then' || typeof prop === 'symbol') return undefined;",
@@ -138,7 +146,7 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => · 5 unmodified lines
138146 " return (async () => {",
139147 " const encoded = await __encodeBinary(args[0]);",
140148 " 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');",
142150 " return __decodeBinary(data.result);",
143151 " })();",
144152 " },",
packages/kernel/runtime-quickjs/src/index.test.ts+46−0
@@ -121,6 +121,52 @@ describe("quickjs executor", () => { · 120 unmodified lines
121121 }),
122122 );
123123
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+
124170 it.effect("handles unknown tool path", () =>
125171 Effect.gen(function* () {
126172 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
4747 const toError = (cause: unknown): Error =>
4848 cause instanceof Error ? cause : new Error(String(cause));
4949
50+// Defect surfaced to the sandbox when a tool dispatch reaches the
51+// reject path. Two sources reach here:
52+// - `ExecutionToolError` emitted by the execution package — message
53+// is already either the canonical
54+// `Internal tool error [<corrId>]` opaque generic (plugin defects
55+// passed through `makeExecutorToolInvoker`'s catchCause) or an
56+// intentional public string (validators in engine.ts:
57+// "tools.search expects an object: ...").
58+// - Anything else — assume infra defect and emit a bare opaque
59+// generic.
60+// `_tag === "ExecutionToolError"` is the in-band signal that the
61+// message was generated by code we control and is safe to pass
62+// through; we never read `.cause` / `.stack`.
63+const sandboxDefectMessage = (cause: unknown): string => {
64+ if (cause !== null && typeof cause === "object") {
65+ const tagged = cause as { readonly _tag?: unknown; readonly message?: unknown };
66+ // oxlint-disable-next-line executor/no-manual-tag-check, executor/no-unknown-error-message -- boundary: QuickJS reject path receives an already-rendered ExecutionToolError; we pass its in-band public message through and otherwise emit the opaque generic
67+ if (tagged._tag === "ExecutionToolError" && typeof tagged.message === "string") {
68+ return tagged.message;
69+ }
70+ }
71+ return "Internal tool error";
72+};
73+
5074 const toErrorMessage = (cause: unknown): string => {
5175 if (typeof cause === "object" && cause !== null) {
5276 const message =
@@ -216,7 +240,17 @@ const createToolBridge = ( · 163 unmodified lines
216240 return;
217241 }
218242
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);
220254 deferred.reject(errorHandle);
221255 errorHandle.dispose();
222256 },
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:
22 // 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:
44 //
55 // 1. Schema.encodeEffect(Inner)(value) → encoded JS shape
66 // 2. Schema.encodeEffect(UnknownFromJsonString) → JSON string
@@ -13,7 +13,7 @@ · 6 unmodified lines
1313 // step 2's JSON-stringify (driven by Effect, not us) flattens the Option
1414 // to {_id,_tag,value}, and step 5 rejects the shape.
1515 //
16−// Run: vitest run packages/plugins/google-discovery/src/sdk/option-json-repro.test.ts
16+// Run: vitest run packages/plugins/google-discovery/src/sdk/option-json.test.ts
1717
1818 import { describe, expect, it } from "@effect/vitest";
1919 import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";
@@ -28,7 +28,7 @@ const fixed = { description: Option.some("hello") }; · 8 unmodified lines
2828 const withTmpFile = <A, E>(fn: (path: string) => Effect.Effect<A, E, FileSystem.FileSystem>) =>
2929 Effect.gen(function* () {
3030 const fs = yield* FileSystem.FileSystem;
31− const dir = yield* fs.makeTempDirectoryScoped({ prefix: "option-repro-" });
31+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "option-json-" });
3232 return yield* fn(`${dir}/binding.json`);
3333 }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer));
3434
packages/plugins/google-discovery/src/sdk/plugin.test.ts+3−3
@@ -430,10 +430,10 @@ describe("Google Discovery plugin", () => { · 429 unmodified lines
430430 "drive.files.get",
431431 { fileId: "123", fields: "id,name", prettyPrint: true },
432432 autoApprove,
433− )) as { data: unknown; error: unknown };
433+ )) as { readonly ok: true; readonly data: { status: number; data: unknown } };
434434
435− expect(invocation.error).toBeNull();
436− expect(invocation.data).toEqual({ id: "123", name: "Quarterly Plan" });
435+ expect(invocation.ok).toBe(true);
436+ expect(invocation.data.data).toEqual({ id: "123", name: "Quarterly Plan" });
437437
438438 const apiRequest = handle.requests.find((request) =>
439439 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
33 import {
44 ScopeId,
55 SourceDetectionResult,
6+ ToolResult,
67 Usage,
78 definePlugin,
89 resolveSecretBackedMap,
@@ -29,6 +30,69 @@ import type { · 20 unmodified lines
2930 } from "./types";
3031 import { GoogleDiscoveryStoredSourceData as GoogleDiscoveryStoredSourceDataSchema } from "./types";
3132
33+// ---------------------------------------------------------------------------
34+// Upstream-error message extraction
35+// ---------------------------------------------------------------------------
36+
37+const GOOGLE_BODY_CAP = 1024;
38+const UpstreamMessageBody = Schema.Struct({ message: Schema.String });
39+const UpstreamErrorMessageBody = Schema.Struct({ errorMessage: Schema.String });
40+const UpstreamNestedErrorBody = Schema.Struct({ error: UpstreamMessageBody });
41+const UpstreamErrorsArrayBody = Schema.Struct({
42+ errors: Schema.Array(
43+ Schema.Struct({
44+ detail: Schema.optional(Schema.String),
45+ message: Schema.optional(Schema.String),
46+ title: Schema.optional(Schema.String),
47+ }),
48+ ),
49+});
50+
51+const decodeUpstreamMessageBody = Schema.decodeUnknownOption(UpstreamMessageBody);
52+const decodeUpstreamErrorMessageBody = Schema.decodeUnknownOption(UpstreamErrorMessageBody);
53+const decodeUpstreamNestedErrorBody = Schema.decodeUnknownOption(UpstreamNestedErrorBody);
54+const decodeUpstreamErrorsArrayBody = Schema.decodeUnknownOption(UpstreamErrorsArrayBody);
55+
56+const googleClampedStringify = (value: unknown): string => {
57+ let s: string;
58+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: JSON.stringify may throw on cycles; fall back to String() so the upstream body can still be surfaced as ToolError.details fallback text
59+ try {
60+ s = JSON.stringify(value);
61+ } catch {
62+ s = String(value);
63+ }
64+ return s.length > GOOGLE_BODY_CAP ? `${s.slice(0, GOOGLE_BODY_CAP)}…` : s;
65+};
66+
67+const firstNonEmpty = (...values: readonly (string | undefined)[]): string | undefined =>
68+ values.find((value) => value !== undefined && value.length > 0);
69+
70+const googleExtractUpstreamMessage = (body: unknown, status: number): string => {
71+ if (typeof body === "string") {
72+ return body.length > 0 ? body : `Upstream returned HTTP ${status}`;
73+ }
74+ const nested = Option.getOrUndefined(decodeUpstreamNestedErrorBody(body));
75+ const messageBody = Option.getOrUndefined(decodeUpstreamMessageBody(body));
76+ const errorMessageBody = Option.getOrUndefined(decodeUpstreamErrorMessageBody(body));
77+ const errorsBody = Option.getOrUndefined(decodeUpstreamErrorsArrayBody(body));
78+ const arrayMessage = errorsBody?.errors
79+ .map(({ detail, message: upstreamMessage, title }) =>
80+ firstNonEmpty(detail, upstreamMessage, title),
81+ )
82+ .find((message) => message !== undefined);
83+ const message = firstNonEmpty(
84+ nested?.error.message,
85+ messageBody?.message,
86+ errorMessageBody?.errorMessage,
87+ arrayMessage,
88+ );
89+ if (message !== undefined) return message;
90+ if (body !== null && typeof body === "object") {
91+ return googleClampedStringify(body);
92+ }
93+ return `Upstream returned HTTP ${status}`;
94+};
95+
3296 // ---------------------------------------------------------------------------
3397 // Public input / output shapes
3498 // ---------------------------------------------------------------------------
@@ -381,11 +445,27 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 346 unmodified lines
381445 extension: makeGoogleDiscoveryPluginExtension,
382446
383447 invokeTool: ({ ctx, toolRow, args }) =>
384− invokeGoogleDiscoveryTool({
385− ctx: ctx as PluginCtx<GoogleDiscoveryStore>,
386− toolId: toolRow.id,
387− toolScope: decodeString(toolRow.scope_id),
388− args,
448+ Effect.gen(function* () {
449+ const result = yield* invokeGoogleDiscoveryTool({
450+ ctx: ctx as PluginCtx<GoogleDiscoveryStore>,
451+ toolId: toolRow.id,
452+ toolScope: decodeString(toolRow.scope_id),
453+ args,
454+ });
455+ const ok = result.status >= 200 && result.status < 300;
456+ if (!ok) {
457+ return ToolResult.fail({
458+ code: "upstream_http_error",
459+ status: result.status,
460+ message: googleExtractUpstreamMessage(result.error, result.status),
461+ details: result.error,
462+ });
463+ }
464+ return ToolResult.ok({
465+ status: result.status,
466+ headers: result.headers,
467+ data: result.data,
468+ });
389469 }),
390470
391471 resolveAnnotations: ({ ctx, sourceId, toolRows }) =>
packages/plugins/graphql/src/sdk/plugin.test.ts+48−9
@@ -1,5 +1,6 @@
11 import { describe, it, expect } from "@effect/vitest";
22 import { Effect, Predicate } from "effect";
3+import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
34
45 import {
56 ConnectionId,
@@ -13,7 +14,7 @@ import { · 7 unmodified lines
1314 SecretId,
1415 TokenMaterial,
1516 } from "@executor-js/sdk";
16−import { makeTestConfig } from "@executor-js/sdk/testing";
17+import { makeTestConfig, serveTestHttpApp } from "@executor-js/sdk/testing";
1718 import { memorySecretsPlugin } from "@executor-js/sdk/testing";
1819
1920 import { graphqlPlugin } from "./plugin";
@@ -196,9 +197,8 @@ describe("graphqlPlugin real protocol server", () => { · 176 unmodified lines
196197 });
197198
198199 expect(result).toEqual({
199− status: 200,
200+ ok: true,
200201 data: { hello: "Hello Ada" },
201− errors: null,
202202 });
203203
204204 const requests = yield* server.requests;
@@ -209,6 +209,46 @@ describe("graphqlPlugin real protocol server", () => { · 4 unmodified lines
209209 }),
210210 );
211211
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+
212252 it.effect("invokes OAuth-backed sources with a bearer token", () =>
213253 Effect.gen(function* () {
214254 const server = yield* serveGreetingServer;
@@ -251,9 +291,8 @@ describe("graphqlPlugin real protocol server", () => { · 36 unmodified lines
251291 });
252292
253293 expect(result).toEqual({
254− status: 200,
294+ ok: true,
255295 data: { hello: "Hello Ada" },
256− errors: null,
257296 });
258297
259298 const requests = yield* server.requests;
@@ -425,7 +464,7 @@ describe("graphqlPlugin", () => { · 165 unmodified lines
425464 },
426465 { onElicitation: "accept-all" },
427466 );
428− expect(result).toEqual({ toolCount: 2, namespace: "via_static" });
467+ expect(result).toEqual({ ok: true, data: { toolCount: 2, namespace: "via_static" } });
429468 expect(yield* executor.graphql.getSource("via_static", String(userScope))).toBeNull();
430469 expect((yield* executor.graphql.getSource("via_static", String(orgScope)))?.scope).toBe(
431470 orgScope,
@@ -729,7 +768,7 @@ describe("graphqlPlugin", () => { · 297 unmodified lines
729768 });
730769
731770 expect(result).toMatchObject({
732− status: 200,
771+ ok: true,
733772 data: { hello: "Hello Ada" },
734773 });
735774 const requests = yield* server.requests;
@@ -843,7 +882,7 @@ describe("graphqlPlugin", () => { · 107 unmodified lines
843882 });
844883
845884 expect(result).toMatchObject({
846− status: 200,
885+ ok: true,
847886 data: { hello: "Hello Ada" },
848887 });
849888 const requests = yield* server.requests;
@@ -915,7 +954,7 @@ describe("graphqlPlugin", () => { · 65 unmodified lines
915954 });
916955
917956 expect(result).toMatchObject({
918− status: 200,
957+ ok: true,
919958 data: { hello: "Hello Ada" },
920959 });
921960 const requests = yield* server.requests;
packages/plugins/graphql/src/sdk/plugin.ts+37−2
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
1212 SecretId,
1313 SourceDetectionResult,
1414 StorageError,
15+ ToolResult,
1516 type PluginCtx,
1617 type StorageFailure,
1718 type ToolAnnotations,
@@ -65,6 +66,19 @@ import { · 47 unmodified lines
6566 // Plugin config
6667 // ---------------------------------------------------------------------------
6768
69+const GraphqlErrorBody = Schema.Struct({ message: Schema.String });
70+const GraphqlErrorsBody = Schema.Array(Schema.Unknown);
71+const decodeGraphqlErrorBody = Schema.decodeUnknownOption(GraphqlErrorBody);
72+const decodeGraphqlErrorsBody = Schema.decodeUnknownOption(GraphqlErrorsBody);
73+
74+const decodeGraphqlErrors = (errors: unknown): readonly unknown[] | undefined =>
75+ Option.getOrUndefined(decodeGraphqlErrorsBody(errors));
76+
77+const extractGraphqlErrorMessage = (errors: readonly unknown[]): string | undefined =>
78+ errors
79+ .map((error) => Option.getOrUndefined(decodeGraphqlErrorBody(error))?.message)
80+ .find((message) => message !== undefined && message.length > 0);
81+
6882 export type HeaderValue = HeaderValueValue;
6983 export type GraphqlCredentialValue = ConfiguredGraphqlCredentialValue;
7084
@@ -994,7 +1008,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 923 unmodified lines
9941008 },
9951009 inputSchema: StaticAddSourceInputStandardSchema,
9961010 outputSchema: StaticAddSourceOutputStandardSchema,
997− execute: (input) => self.addSource(input),
1011+ execute: (input) => Effect.map(self.addSource(input), ToolResult.ok),
9981012 }),
9991013 ],
10001014 },
@@ -1057,7 +1071,28 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 56 unmodified lines
10571071 httpClientLayer,
10581072 );
10591073
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);
10611096 }),
10621097
10631098 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
5555
5656 const ErrorMessage = Schema.Struct({ message: Schema.String });
5757 const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
58+const STDIO_ENV_ESCAPE_REPLACEMENTS: Readonly<Record<string, string>> = {
59+ "\\": "\\",
60+ n: "\n",
61+ r: "\r",
62+ t: "\t",
63+ '"': '"',
64+};
5865
5966 const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
6067 Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {
@@ -525,22 +532,10 @@ export default function AddMcpSource(props: { · 464 unmodified lines
525532 const inner = value.slice(1, -1);
526533 if (quote === "'") return inner;
527534
528− return inner.replace(/\\([\\nrt"])/g, (_, escaped: string) => {
529− switch (escaped) {
530− case "\\":
531− return "\\";
532− case "n":
533− return "\n";
534− case "r":
535− return "\r";
536− case "t":
537− return "\t";
538− case '"':
539− return '"';
540− default:
541− return escaped;
542− }
543− });
535+ return inner.replace(
536+ /\\([\\nrt"])/g,
537+ (_, escaped: string) => STDIO_ENV_ESCAPE_REPLACEMENTS[escaped] ?? escaped,
538+ );
544539 };
545540
546541 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
6969 const result = yield* executor.tools.invoke(gatedEcho!.id, { value: "hello" }, options);
7070
7171 expect(result).toMatchObject({
72− content: [{ type: "text", text: "approved:hello" }],
72+ ok: true,
73+ data: { content: [{ type: "text", text: "approved:hello" }] },
7374 });
7475 // At least one elicitation should be the MCP server's form
7576 expect(elicitationMessages.length).toBeGreaterThanOrEqual(1);
@@ -95,7 +96,8 @@ describe("MCP elicitation (end-to-end)", () => { · 19 unmodified lines
9596 );
9697
9798 expect(result).toMatchObject({
98− content: [{ type: "text", text: "denied:nope" }],
99+ ok: true,
100+ data: { content: [{ type: "text", text: "denied:nope" }] },
99101 });
100102 }),
101103 );
@@ -114,7 +116,32 @@ describe("MCP elicitation (end-to-end)", () => { · 12 unmodified lines
114116 );
115117
116118 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+ },
118145 });
119146 }),
120147 );
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
115115 { onElicitation: "accept-all" },
116116 );
117117 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" }] },
119120 });
120121 expect(
121122 (yield* server.requests)
@@ -187,7 +188,8 @@ describe("per-user MCP auth isolation", () => { · 65 unmodified lines
187188 { onElicitation: "accept-all" },
188189 );
189190 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" }] },
191193 });
192194 expect(
193195 (yield* server.requests)
@@ -267,7 +269,8 @@ describe("per-user MCP auth isolation", () => { · 73 unmodified lines
267269 );
268270
269271 expect(result).toMatchObject({
270− content: [{ type: "text", text: "ok:org-header" }],
272+ ok: true,
273+ data: { content: [{ type: "text", text: "ok:org-header" }] },
271274 });
272275 const invokeRequests = (yield* server.requests).slice(beforeInvoke);
273276 expect(
packages/plugins/mcp/src/sdk/plugin.ts+31−1
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
88 Predicate,
99 Result,
1010 Scope,
11+ Schema,
1112 ScopedCache,
1213 } from "effect";
1314 import type { HttpClient } from "effect/unstable/http";
@@ -22,6 +23,7 @@ import { · 8 unmodified lines
2223 ScopeId,
2324 SecretId,
2425 SourceDetectionResult,
26+ ToolResult,
2527 definePlugin,
2628 resolveSecretBackedMap as resolveSharedSecretBackedMap,
2729 type PluginCtx,
@@ -193,6 +195,24 @@ const toBinding = (entry: McpToolManifestEntry): McpToolBinding => · 165 unmodified lines
193195 });
194196
195197 const MCP_PLUGIN_ID = "mcp";
198+const McpTextContent = Schema.Struct({ type: Schema.Literal("text"), text: Schema.String });
199+const McpToolCallEnvelope = Schema.Struct({
200+ isError: Schema.optional(Schema.Boolean),
201+ content: Schema.optional(Schema.Array(Schema.Unknown)),
202+});
203+
204+const decodeMcpTextContent = Schema.decodeUnknownOption(McpTextContent);
205+const decodeMcpToolCallEnvelope = Schema.decodeUnknownOption(McpToolCallEnvelope);
206+
207+const extractMcpErrorMessage = (content: unknown): string => {
208+ if (Array.isArray(content)) {
209+ for (const item of content) {
210+ const decoded = Option.getOrUndefined(decodeMcpTextContent(item));
211+ if (decoded !== undefined && decoded.text.length > 0) return decoded.text;
212+ }
213+ }
214+ return "MCP tool returned an error";
215+};
196216
197217 /** Match `token` as a separator-bounded run inside a URL hostname or path,
198218 * 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
17261746 });
17271747 }
17281748
1729− return yield* invokeMcpTool({
1749+ const raw = yield* invokeMcpTool({
17301750 toolId: toolRow.id,
17311751 toolName: entry.binding.toolName,
17321752 args,
@@ -1764,6 +1784,16 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 31 unmodified lines
17641784 pendingConnectors: runtime.pendingConnectors,
17651785 elicit,
17661786 });
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);
17671797 }).pipe(
17681798 Effect.withSpan("mcp.plugin.invoke_tool", {
17691799 attributes: {
packages/plugins/mcp/src/testing/server.ts+17−0
@@ -375,6 +375,23 @@ export const makeElicitationMcpServer = () => { · 374 unmodified lines
375375 }),
376376 );
377377
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+
378395 return server;
379396 };
380397
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
2626 import {
2727 addOpenApiTestSource,
2828 serveOpenApiHttpApiTestServer,
29+ unwrapInvocation,
2930 } from "@executor-js/plugin-openapi/testing";
3031
3132 import { openApiPlugin } from "./plugin";
@@ -226,25 +227,21 @@ describe("OpenAPI client_credentials OAuth", () => { · 194 unmodified lines
226227 );
227228 // Invoking the tool injects the freshly-minted bearer via
228229 // ctx.connections.accessToken.
229− const result = (yield* userExec.tools.invoke(
230− "petstore.items.echoHeaders",
231− {},
232− autoApprove,
233− )) as {
234− data: { authorization?: string } | null;
235− error: unknown;
236− };
230+ const result = unwrapInvocation(
231+ yield* userExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove),
232+ );
237233 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, "");
239236 expect(bearer).toBeDefined();
240237 expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true);
241238
242239 // The connection lives at the innermost (user) scope, which
243240 // preserves per-user credential resolution: if each user has
244− // their own `dealcloud_client_id`/`dealcloud_client_secret`
245− // shadowed at their user scope, each user mints their own
246− // token. A single shared connection slot still lets every caller
247− // reach the right physical row through scoped credential bindings.
241+ // their own OAuth client credentials shadowed at their user
242+ // scope, each user mints their own token. A single shared
243+ // connection slot still lets every caller reach the right
244+ // physical row through scoped credential bindings.
248245 const userConnections = yield* userExec.connections.list();
249246 const connection = userConnections.find((c) => c.id === completedConnection.connectionId);
250247 expect(connection).toBeDefined();
packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts+49−60
@@ -38,6 +38,7 @@ import { · 37 unmodified lines
3838 addOpenApiTestSource,
3939 makeOpenApiTestSourceConfig,
4040 serveOpenApiHttpApiTestServer,
41+ unwrapInvocation,
4142 } from "@executor-js/plugin-openapi/testing";
4243
4344 import { openApiPlugin } from "./plugin";
@@ -277,25 +278,21 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 233 unmodified lines
277278 // yields her token; bob's scope yields his. Same source, same
278279 // tool, different injected bearer.
279280 // -------------------------------------------------------------
280− const aliceResult = (yield* aliceExec.tools.invoke(
281− "vercel.projects.list",
282− {},
283− autoApprove,
284− )) as {
285− data: { authorization?: string; token?: string } | null;
286− error: unknown;
287− };
281+ const aliceResult = unwrapInvocation(
282+ yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove),
283+ );
288284 expect(aliceResult.error).toBeNull();
289− expect(aliceResult.data?.authorization).toBe("Bearer alice-vercel-token");
290− expect(aliceResult.data?.token).toBe("alice-team");
285+ const aliceData = aliceResult.data as EchoHeaders | null;
286+ expect(aliceData?.authorization).toBe("Bearer alice-vercel-token");
287+ expect(aliceData?.token).toBe("alice-team");
291288
292− const bobResult = (yield* bobExec.tools.invoke("vercel.projects.list", {}, autoApprove)) as {
293− data: { authorization?: string; token?: string } | null;
294− error: unknown;
295− };
289+ const bobResult = unwrapInvocation(
290+ yield* bobExec.tools.invoke("vercel.projects.list", {}, autoApprove),
291+ );
296292 expect(bobResult.error).toBeNull();
297− expect(bobResult.data?.authorization).toBe("Bearer bob-vercel-token");
298− expect(bobResult.data?.token).toBe("bob-team");
293+ const bobData = bobResult.data as EchoHeaders | null;
294+ expect(bobData?.authorization).toBe("Bearer bob-vercel-token");
295+ expect(bobData?.token).toBe("bob-team");
299296
300297 // -------------------------------------------------------------
301298 // 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
450447 }),
451448 );
452449
453− const aliceResult = (yield* aliceExec.tools.invoke(
454− "vercel.projects.list",
455− {},
456− autoApprove,
457− )) as { data: { authorization?: string } | null; error: unknown };
450+ const aliceResult = unwrapInvocation(
451+ yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove),
452+ );
458453 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+ );
460457
461− const bobResult = (yield* bobExec.tools.invoke("vercel.projects.list", {}, autoApprove)) as {
462− data: { authorization?: string } | null;
463− error: unknown;
464− };
458+ const bobResult = unwrapInvocation(
459+ yield* bobExec.tools.invoke("vercel.projects.list", {}, autoApprove),
460+ );
465461 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");
467463 }),
468464 );
469465
@@ -558,13 +554,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 88 unmodified lines
558554 }),
559555 );
560556
561− const sharedResult = (yield* aliceExec.tools.invoke(
562− "vercel.projects.list",
563− {},
564− autoApprove,
565− )) as { data: { authorization?: string } | null; error: unknown };
557+ const sharedResult = unwrapInvocation(
558+ yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove),
559+ );
566560 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");
568562
569563 yield* aliceExec.secrets.set(
570564 SetSecretInput.make({
@@ -587,13 +581,13 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 16 unmodified lines
587581 }),
588582 );
589583
590− const overrideResult = (yield* aliceExec.tools.invoke(
591− "vercel.projects.list",
592− {},
593− autoApprove,
594− )) as { data: { authorization?: string } | null; error: unknown };
584+ const overrideResult = unwrapInvocation(
585+ yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove),
586+ );
595587 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+ );
597591
598592 yield* aliceExec.openapi.removeSourceBinding(
599593 "vercel",
@@ -602,13 +596,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 2 unmodified lines
602596 String(aliceScope.id),
603597 );
604598
605− const fallbackResult = (yield* aliceExec.tools.invoke(
606− "vercel.projects.list",
607− {},
608− autoApprove,
609− )) as { data: { authorization?: string } | null; error: unknown };
599+ const fallbackResult = unwrapInvocation(
600+ yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove),
601+ );
610602 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");
612604
613605 yield* aliceExec.openapi.setSourceBinding(
614606 OpenApiSourceBindingInput.make({
@@ -812,12 +804,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 197 unmodified lines
812804 }),
813805 );
814806
815− const result = (yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove)) as {
816− data: { authorization?: string } | null;
817− error: unknown;
818− };
807+ const result = unwrapInvocation(
808+ yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove),
809+ );
819810 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");
821812 }),
822813 );
823814
@@ -914,13 +905,12 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 90 unmodified lines
914905 }),
915906 );
916907
917− const result = (yield* userExec.tools.invoke("vercel.projects.list", {}, autoApprove)) as {
918− data: { authorization?: string } | null;
919− error: unknown;
920− };
908+ const result = unwrapInvocation(
909+ yield* userExec.tools.invoke("vercel.projects.list", {}, autoApprove),
910+ );
921911
922912 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");
924914 }),
925915 );
926916
@@ -1013,13 +1003,12 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 86 unmodified lines
10131003 }),
10141004 );
10151005
1016− const result = (yield* userExec.tools.invoke("vercel.projects.list", {}, autoApprove)) as {
1017− data: { authorization?: string } | null;
1018− error: unknown;
1019− };
1006+ const result = unwrapInvocation(
1007+ yield* userExec.tools.invoke("vercel.projects.list", {}, autoApprove),
1008+ );
10201009
10211010 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");
10231012 }),
10241013 );
10251014 });
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
2828 import {
2929 addOpenApiTestSource,
3030 serveOpenApiHttpApiTestServer,
31+ unwrapInvocation,
3132 } from "@executor-js/plugin-openapi/testing";
3233
3334 import { openApiPlugin } from "./plugin";
@@ -307,23 +308,25 @@ describe("OpenAPI multi-scope OAuth", () => { · 273 unmodified lines
307308 // 4. Invoke through each exec — Authorization must carry that
308309 // user's token.
309310 // -------------------------------------------------------------
310− const aliceResult = (yield* aliceExec.tools.invoke(
311− "petstore.items.echoHeaders",
312− {},
313− autoApprove,
314− )) as { data: { authorization?: string } | null; error: unknown };
311+ const aliceResult = unwrapInvocation(
312+ yield* aliceExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove),
313+ );
315314 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+ );
317319 expect(aliceBearer).toBeDefined();
318320 expect(yield* oauth.acceptsAccessToken(aliceBearer!)).toBe(true);
319321
320− const bobResult = (yield* bobExec.tools.invoke(
321− "petstore.items.echoHeaders",
322− {},
323− autoApprove,
324− )) as { data: { authorization?: string } | null; error: unknown };
322+ const bobResult = unwrapInvocation(
323+ yield* bobExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove),
324+ );
325325 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+ );
327330 expect(bobBearer).toBeDefined();
328331 expect(yield* oauth.acceptsAccessToken(bobBearer!)).toBe(true);
329332 expect(bobBearer).not.toBe(aliceBearer);
@@ -610,23 +613,25 @@ describe("OpenAPI multi-scope OAuth", () => { · 280 unmodified lines
610613 // (4) Each user's invocation resolves their OWN row and gets
611614 // their OWN token — not whatever the last signer happened to
612615 // mint. This is the core multi-user regression.
613− const aliceResult = (yield* aliceExec.tools.invoke(
614− "petstore.items.echoHeaders",
615− {},
616− autoApprove,
617− )) as { data: { authorization?: string } | null; error: unknown };
616+ const aliceResult = unwrapInvocation(
617+ yield* aliceExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove),
618+ );
618619 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+ );
620624 expect(aliceBearer).toBeDefined();
621625 expect(yield* oauth.acceptsAccessToken(aliceBearer!)).toBe(true);
622626
623− const bobResult = (yield* bobExec.tools.invoke(
624− "petstore.items.echoHeaders",
625− {},
626− autoApprove,
627− )) as { data: { authorization?: string } | null; error: unknown };
627+ const bobResult = unwrapInvocation(
628+ yield* bobExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove),
629+ );
628630 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+ );
630635 expect(bobBearer).toBeDefined();
631636 expect(yield* oauth.acceptsAccessToken(bobBearer!)).toBe(true);
632637 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
3535 import {
3636 addOpenApiTestSource,
3737 serveOpenApiHttpApiTestServer,
38+ unwrapInvocation,
3839 } from "@executor-js/plugin-openapi/testing";
3940
4041 import { openApiPlugin } from "./plugin";
@@ -253,17 +254,16 @@ describe("OpenAPI oauth refresh", () => { · 212 unmodified lines
253254 });
254255 yield* bindOAuthConnection(executor, scopeId, "conn-refresh-ok", auth);
255256
256− const result = (yield* executor.tools.invoke(
257− "petstore.items.echoHeaders",
258− {},
259− autoApprove,
260− )) as { data: { authorization?: string } | null; error: unknown };
257+ const result = unwrapInvocation(
258+ yield* executor.tools.invoke("petstore.items.echoHeaders", {}, autoApprove),
259+ );
261260
262261 expect(result.error).toBeNull();
262+ const data = result.data as EchoHeaders | null;
263263 // Proves the refresh landed: invoke carried the fresh token,
264264 // not the expired one we seeded.
265− expect(result.data?.authorization).not.toBe("Bearer expired-access-v1");
266− const bearer = result.data?.authorization?.replace(/^Bearer\s+/i, "");
265+ expect(data?.authorization).not.toBe("Bearer expired-access-v1");
266+ const bearer = data?.authorization?.replace(/^Bearer\s+/i, "");
267267 expect(bearer).toBeDefined();
268268 expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true);
269269 const calls = refreshTokenRequests(yield* oauth.requests);
@@ -313,12 +313,9 @@ describe("OpenAPI oauth refresh", () => { · 43 unmodified lines
313313 );
314314
315315 for (const r of invokes) {
316− const res = r as {
317− data: { authorization?: string } | null;
318− error: unknown;
319− };
316+ const res = unwrapInvocation(r);
320317 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, "");
322319 expect(bearer).toBeDefined();
323320 expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true);
324321 }
packages/plugins/openapi/src/sdk/plugin.test.ts+38−39
@@ -25,6 +25,7 @@ import { · 24 unmodified lines
2525 addOpenApiTestSource,
2626 makeOpenApiHttpApiTestSourceConfig,
2727 serveOpenApiHttpApiTestServer,
28+ unwrapInvocation,
2829 } from "../testing";
2930
3031 const autoApprove: InvokeOptions = { onElicitation: "accept-all" };
@@ -267,13 +268,15 @@ describe("OpenAPI Plugin", () => { · 236 unmodified lines
267268 }),
268269 );
269270
270− const result = (yield* executor.tools.invoke(
271− "executor.openapi.previewSpec",
272− { spec: testApiSpec() },
273− autoApprove,
274− )) as { operationCount: number };
271+ const preview = unwrapInvocation(
272+ yield* executor.tools.invoke(
273+ "executor.openapi.previewSpec",
274+ { spec: testApiSpec() },
275+ autoApprove,
276+ ),
277+ ).data as { operationCount: number };
275278
276− expect(result.operationCount).toBeGreaterThanOrEqual(2);
279+ expect(preview.operationCount).toBeGreaterThanOrEqual(2);
277280 }),
278281 );
279282
@@ -316,11 +319,13 @@ describe("OpenAPI Plugin", () => { · 36 unmodified lines
316319 }),
317320 );
318321
319− const result = (yield* executor.tools.invoke(
320− "executor.openapi.addSource",
321− testApiSourceConfig({ scope: String(orgScope), namespace: "runtime" }),
322− autoApprove,
323− )) as { sourceId: string; toolCount: number };
322+ const result = unwrapInvocation(
323+ yield* executor.tools.invoke(
324+ "executor.openapi.addSource",
325+ testApiSourceConfig({ scope: String(orgScope), namespace: "runtime" }),
326+ autoApprove,
327+ ),
328+ ).data as { sourceId: string; toolCount: number };
324329
325330 expect(result).toEqual({ sourceId: "runtime", toolCount: 4 });
326331 expect(yield* executor.openapi.getSource("runtime", String(userScope))).toBeNull();
@@ -557,17 +562,12 @@ describe("OpenAPI Plugin", () => { · 230 unmodified lines
557562 },
558563 });
559564
560− const result = (yield* executor.tools.invoke(
561− "authed.items.echoHeaders",
562− {},
563− autoApprove,
564− )) as {
565− data: { authorization?: string; "x-static"?: string } | null;
566− error: unknown;
567− };
565+ const result = unwrapInvocation(
566+ yield* executor.tools.invoke("authed.items.echoHeaders", {}, autoApprove),
567+ );
568568
569569 expect(result.error).toBeNull();
570− const data = result.data!;
570+ const data = result.data as { authorization?: string; "x-static"?: string };
571571 expect(data.authorization).toBe("Bearer secret-value-123");
572572 expect(data["x-static"]).toBe("hello");
573573 }),
@@ -751,10 +751,9 @@ describe("OpenAPI Plugin", () => { · 177 unmodified lines
751751 namespace: "test",
752752 });
753753
754− const result = (yield* executor.tools.invoke("test.items.listItems", {}, autoApprove)) as {
755− data: unknown;
756− error: unknown;
757− };
754+ const result = unwrapInvocation(
755+ yield* executor.tools.invoke("test.items.listItems", {}, autoApprove),
756+ );
758757 expect(result.error).toBeNull();
759758 expect(result.data).toEqual(ITEMS);
760759 }),
@@ -780,11 +779,9 @@ describe("OpenAPI Plugin", () => { · 19 unmodified lines
780779 namespace: "test",
781780 });
782781
783− const result = (yield* executor.tools.invoke(
784− "test.items.getItem",
785− { itemId: "2" },
786− autoApprove,
787− )) as { data: unknown; error: unknown };
782+ const result = unwrapInvocation(
783+ yield* executor.tools.invoke("test.items.getItem", { itemId: "2" }, autoApprove),
784+ );
788785 expect(result.error).toBeNull();
789786 expect(result.data).toEqual({ id: 2, name: "Gadget" });
790787 }),
@@ -810,16 +807,18 @@ describe("OpenAPI Plugin", () => { · 19 unmodified lines
810807 namespace: "records",
811808 });
812809
813− const result = (yield* executor.tools.invoke(
814− "records.items.queryRows",
815− {
816− entryTypeId: "18538",
817− query: JSON.stringify([{ DisplayName: "Example" }]),
818− limit: 10,
819− skip: 0,
820− },
821− autoApprove,
822− )) as { data: unknown; error: unknown };
810+ const result = unwrapInvocation(
811+ yield* executor.tools.invoke(
812+ "records.items.queryRows",
813+ {
814+ entryTypeId: "18538",
815+ query: JSON.stringify([{ DisplayName: "Example" }]),
816+ limit: 10,
817+ skip: 0,
818+ },
819+ autoApprove,
820+ ),
821+ );
823822
824823 expect(result.data).toBeNull();
825824 expect(result.error).toEqual(
packages/plugins/openapi/src/sdk/plugin.ts+88−3
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
77 SecretId,
88 SourceDetectionResult,
99 StorageError,
10+ ToolResult,
1011 definePlugin,
1112 tool,
1213 resolveSecretBackedMap,
@@ -57,6 +58,77 @@ import { · 44 unmodified lines
5758 // Plugin config
5859 // ---------------------------------------------------------------------------
5960
61+const STRINGIFIED_BODY_CAP = 1024;
62+const UpstreamMessageBody = Schema.Struct({ message: Schema.String });
63+const UpstreamErrorMessageBody = Schema.Struct({ errorMessage: Schema.String });
64+const UpstreamNestedErrorBody = Schema.Struct({ error: UpstreamMessageBody });
65+const UpstreamErrorsArrayBody = Schema.Struct({
66+ errors: Schema.Array(
67+ Schema.Struct({
68+ detail: Schema.optional(Schema.String),
69+ message: Schema.optional(Schema.String),
70+ title: Schema.optional(Schema.String),
71+ }),
72+ ),
73+});
74+const UpstreamDescriptionBody = Schema.Struct({
75+ detail: Schema.optional(Schema.String),
76+ title: Schema.optional(Schema.String),
77+ description: Schema.optional(Schema.String),
78+});
79+
80+const decodeUpstreamMessageBody = Schema.decodeUnknownOption(UpstreamMessageBody);
81+const decodeUpstreamErrorMessageBody = Schema.decodeUnknownOption(UpstreamErrorMessageBody);
82+const decodeUpstreamNestedErrorBody = Schema.decodeUnknownOption(UpstreamNestedErrorBody);
83+const decodeUpstreamErrorsArrayBody = Schema.decodeUnknownOption(UpstreamErrorsArrayBody);
84+const decodeUpstreamDescriptionBody = Schema.decodeUnknownOption(UpstreamDescriptionBody);
85+
86+const clampedStringify = (value: unknown): string => {
87+ let s: string;
88+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: JSON.stringify may throw on cycles; fall back to String() so the upstream body can still be surfaced as ToolError.details fallback text
89+ try {
90+ s = JSON.stringify(value);
91+ } catch {
92+ s = String(value);
93+ }
94+ return s.length > STRINGIFIED_BODY_CAP ? `${s.slice(0, STRINGIFIED_BODY_CAP)}…` : s;
95+};
96+
97+const firstNonEmpty = (...values: readonly (string | undefined)[]): string | undefined =>
98+ values.find((value) => value !== undefined && value.length > 0);
99+
100+// Walk known upstream error-body shapes so ToolError.message stays concise
101+// while ToolError.details preserves the original body.
102+const extractUpstreamMessage = (body: unknown, status: number): string => {
103+ if (typeof body === "string") {
104+ return body.length > 0 ? body : `Upstream returned HTTP ${status}`;
105+ }
106+ const nested = Option.getOrUndefined(decodeUpstreamNestedErrorBody(body));
107+ const messageBody = Option.getOrUndefined(decodeUpstreamMessageBody(body));
108+ const errorMessageBody = Option.getOrUndefined(decodeUpstreamErrorMessageBody(body));
109+ const errorsBody = Option.getOrUndefined(decodeUpstreamErrorsArrayBody(body));
110+ const descriptionBody = Option.getOrUndefined(decodeUpstreamDescriptionBody(body));
111+ const arrayMessage = errorsBody?.errors
112+ .map(({ detail, message: upstreamMessage, title }) =>
113+ firstNonEmpty(detail, upstreamMessage, title),
114+ )
115+ .find((message) => message !== undefined);
116+ const message = firstNonEmpty(
117+ nested?.error.message,
118+ messageBody?.message,
119+ errorMessageBody?.errorMessage,
120+ arrayMessage,
121+ descriptionBody?.detail,
122+ descriptionBody?.title,
123+ descriptionBody?.description,
124+ );
125+ if (message !== undefined) return message;
126+ if (body !== null && typeof body === "object") {
127+ return clampedStringify(body);
128+ }
129+ return `Upstream returned HTTP ${status}`;
130+};
131+
60132 export type HeaderValue = HeaderValueValue;
61133 export type ConfiguredHeaderValue = ConfiguredHeaderValueValue;
62134 export type OpenApiHeaderInput = HeaderValue | ConfiguredHeaderValue;
@@ -1193,7 +1265,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 1130 unmodified lines
11931265 name: "previewSpec",
11941266 description: "Preview an OpenAPI document before adding it as a source",
11951267 inputSchema: PreviewSpecInputStandardSchema,
1196− execute: (input) => self.previewSpec(input),
1268+ execute: (input) => Effect.map(self.previewSpec(input), ToolResult.ok),
11971269 }),
11981270 tool({
11991271 name: "addSource",
@@ -1204,7 +1276,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 4 unmodified lines
12041276 },
12051277 inputSchema: AddSourceInputStandardSchema,
12061278 outputSchema: AddSourceOutputStandardSchema,
1207− execute: (input) => self.addSpec(input),
1279+ execute: (input) => Effect.map(self.addSpec(input), ToolResult.ok),
12081280 }),
12091281 ],
12101282 },
@@ -1283,7 +1355,20 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 72 unmodified lines
12831355 httpClientLayer,
12841356 );
12851357
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+ });
12871372 }),
12881373
12891374 resolveAnnotations: ({ ctx, sourceId, toolRows }) =>
packages/plugins/openapi/src/sdk/upstream-failures.test.ts+5−4
@@ -37,6 +37,7 @@ import { · 36 unmodified lines
3737 makeOpenApiTestSourceConfig,
3838 type OpenApiTestServerShape,
3939 serveOpenApiHttpApiTestServer,
40+ unwrapInvocation,
4041 } from "../testing";
4142
4243 import { openApiPlugin } from "./plugin";
@@ -303,10 +304,10 @@ describe("OpenAPI upstream failure modes", () => { · 260 unmodified lines
303304 const { baseUrl } = yield* slowServer;
304305 const executor = yield* buildExecutor(baseUrl);
305306
306− const result = yield* executor.tools.invoke("f.things.listThings", {}, autoApprove);
307− // Empty array via .data envelope or directly — accept either shape.
308− const data = (result as { data?: unknown }).data ?? result;
309− expect(data).toEqual([]);
307+ const result = unwrapInvocation(
308+ yield* executor.tools.invoke("f.things.listThings", {}, autoApprove),
309+ );
310+ expect(result.data).toEqual([]);
310311 }),
311312 );
312313 });
packages/plugins/openapi/src/testing/index.ts+54−2
@@ -1,4 +1,4 @@
1−import { Context, Data, Effect, Layer, Predicate, Ref, Schema, Scope } from "effect";
1+import { Context, Data, Effect, Layer, Option, Predicate, Ref, Schema, Scope } from "effect";
22 import {
33 HttpClient,
44 HttpRouter,
@@ -13,7 +13,7 @@ import { · 8 unmodified lines
1313 OpenApi,
1414 } from "effect/unstable/httpapi";
1515 import { OAuthTestServer, serveTestHttpServerLayer } from "@executor-js/sdk/testing";
16−import type { ScopeId } from "@executor-js/sdk/core";
16+import { isToolResult, type ScopeId } from "@executor-js/sdk/core";
1717 import type { OpenApiPluginExtension, OpenApiSpecConfig } from "../sdk/plugin";
1818
1919 export class OpenApiTestServerAddressError extends Data.TaggedError(
@@ -618,3 +618,55 @@ export const TestLayers = { · 598 unmodified lines
618618 echo: OpenApiEchoTestServer.layer,
619619 echoWithOAuth: OpenApiEchoTestServer.layerWithOAuth,
620620 };
621+
622+const OpenApiTransportEnvelope = Schema.Struct({
623+ status: Schema.Number,
624+ headers: Schema.Record(Schema.String, Schema.String),
625+ data: Schema.Unknown,
626+});
627+
628+const decodeOpenApiTransportEnvelope = Schema.decodeUnknownOption(OpenApiTransportEnvelope);
629+
630+export interface OpenApiInvocationResult<TData = Record<string, unknown> | unknown[] | null> {
631+ readonly status: number | null;
632+ readonly headers: Record<string, string> | null;
633+ readonly data: TData;
634+ readonly error: unknown;
635+}
636+
637+export const unwrapInvocation = <TData = Record<string, unknown> | null>(
638+ raw: unknown,
639+): OpenApiInvocationResult<TData> => {
640+ if (!isToolResult(raw)) {
641+ return {
642+ status: null,
643+ headers: null,
644+ data: raw as TData,
645+ error: null,
646+ };
647+ }
648+ if (raw.ok) {
649+ const inner = raw.data;
650+ const wrapped = Option.getOrUndefined(decodeOpenApiTransportEnvelope(inner));
651+ if (wrapped !== undefined) {
652+ return {
653+ status: wrapped.status,
654+ headers: wrapped.headers,
655+ data: wrapped.data as TData,
656+ error: null,
657+ };
658+ }
659+ return {
660+ status: null,
661+ headers: null,
662+ data: inner as TData,
663+ error: null,
664+ };
665+ }
666+ return {
667+ status: raw.error.status ?? null,
668+ headers: null,
669+ data: null as TData,
670+ error: raw.error.details ?? raw.error,
671+ };
672+};
packages/plugins/workos-vault/src/sdk/secret-store.test.ts+1−1
@@ -256,7 +256,7 @@ describe("WorkOS Vault secret provider", () => { · 255 unmodified lines
256256 const client = makeFakeClient({ rejectReadNamesLongerThan: 80 });
257257 const executor = yield* makeExecutor(client);
258258 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",
260260 );
261261
262262 yield* executor.secrets.set(