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

Showing the changes to packages/plugins/openapi/src (8 of 35 files). Show all

Unified · Split

8 files
  1. packages/plugins/openapi/src/sdk/client-credentials-oauth.test.ts +10 −13
  2. packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts +49 −60
  3. packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts +29 −24
  4. packages/plugins/openapi/src/sdk/oauth-refresh.test.ts +9 −12
  5. packages/plugins/openapi/src/sdk/plugin.test.ts +38 −39
  6. packages/plugins/openapi/src/sdk/plugin.ts +88 −3
  7. packages/plugins/openapi/src/sdk/upstream-failures.test.ts +5 −4
  8. packages/plugins/openapi/src/testing/index.ts +54 −2
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+};