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
26import {26import {
27 addOpenApiTestSource,27 addOpenApiTestSource,
28 serveOpenApiHttpApiTestServer,28 serveOpenApiHttpApiTestServer,
29 unwrapInvocation,
29} from "@executor-js/plugin-openapi/testing";30} from "@executor-js/plugin-openapi/testing";
30 31
31import { openApiPlugin } from "./plugin";32import { openApiPlugin } from "./plugin";
@@ -226,25 +227,21 @@ describe("OpenAPI client_credentials OAuth", () => { · 194 unmodified lines
226 );227 );
227 // Invoking the tool injects the freshly-minted bearer via228 // Invoking the tool injects the freshly-minted bearer via
228 // ctx.connections.accessToken.229 // ctx.connections.accessToken.
229 const result = (yield* userExec.tools.invoke(230 const result = unwrapInvocation(
230 "petstore.items.echoHeaders",231 yield* userExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove),
231 {},232 );
232 autoApprove,
233 )) as {
234 data: { authorization?: string } | null;
235 error: unknown;
236 };
237 expect(result.error).toBeNull();233 expect(result.error).toBeNull();
238 const bearer = result.data?.authorization?.replace(/^Bearer\s+/i, "");234 const data = result.data as EchoHeaders | null;
235 const bearer = data?.authorization?.replace(/^Bearer\s+/i, "");
239 expect(bearer).toBeDefined();236 expect(bearer).toBeDefined();
240 expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true);237 expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true);
241 238
242 // The connection lives at the innermost (user) scope, which239 // The connection lives at the innermost (user) scope, which
243 // preserves per-user credential resolution: if each user has240 // preserves per-user credential resolution: if each user has
244 // their own `dealcloud_client_id`/`dealcloud_client_secret`241 // their own OAuth client credentials shadowed at their user
245 // shadowed at their user scope, each user mints their own242 // scope, each user mints their own token. A single shared
246 // token. A single shared connection slot still lets every caller243 // connection slot still lets every caller reach the right
247 // reach the right physical row through scoped credential bindings.244 // physical row through scoped credential bindings.
248 const userConnections = yield* userExec.connections.list();245 const userConnections = yield* userExec.connections.list();
249 const connection = userConnections.find((c) => c.id === completedConnection.connectionId);246 const connection = userConnections.find((c) => c.id === completedConnection.connectionId);
250 expect(connection).toBeDefined();247 expect(connection).toBeDefined();
packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts+49−60
@@ -38,6 +38,7 @@ import { · 37 unmodified lines
38 addOpenApiTestSource,38 addOpenApiTestSource,
39 makeOpenApiTestSourceConfig,39 makeOpenApiTestSourceConfig,
40 serveOpenApiHttpApiTestServer,40 serveOpenApiHttpApiTestServer,
41 unwrapInvocation,
41} from "@executor-js/plugin-openapi/testing";42} from "@executor-js/plugin-openapi/testing";
42 43
43import { openApiPlugin } from "./plugin";44import { openApiPlugin } from "./plugin";
@@ -277,25 +278,21 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 233 unmodified lines
277 // yields her token; bob's scope yields his. Same source, same278 // yields her token; bob's scope yields his. Same source, same
278 // tool, different injected bearer.279 // tool, different injected bearer.
279 // -------------------------------------------------------------280 // -------------------------------------------------------------
280 const aliceResult = (yield* aliceExec.tools.invoke(281 const aliceResult = unwrapInvocation(
281 "vercel.projects.list",282 yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove),
282 {},283 );
283 autoApprove,
284 )) as {
285 data: { authorization?: string; token?: string } | null;
286 error: unknown;
287 };
288 expect(aliceResult.error).toBeNull();284 expect(aliceResult.error).toBeNull();
289 expect(aliceResult.data?.authorization).toBe("Bearer alice-vercel-token");285 const aliceData = aliceResult.data as EchoHeaders | null;
290 expect(aliceResult.data?.token).toBe("alice-team");286 expect(aliceData?.authorization).toBe("Bearer alice-vercel-token");
287 expect(aliceData?.token).toBe("alice-team");
291 288
292 const bobResult = (yield* bobExec.tools.invoke("vercel.projects.list", {}, autoApprove)) as {289 const bobResult = unwrapInvocation(
293 data: { authorization?: string; token?: string } | null;290 yield* bobExec.tools.invoke("vercel.projects.list", {}, autoApprove),
294 error: unknown;291 );
295 };
296 expect(bobResult.error).toBeNull();292 expect(bobResult.error).toBeNull();
297 expect(bobResult.data?.authorization).toBe("Bearer bob-vercel-token");293 const bobData = bobResult.data as EchoHeaders | null;
298 expect(bobResult.data?.token).toBe("bob-team");294 expect(bobData?.authorization).toBe("Bearer bob-vercel-token");
295 expect(bobData?.token).toBe("bob-team");
299 296
300 // -------------------------------------------------------------297 // -------------------------------------------------------------
301 // 5. Scope attribution: each user's token is pinned to their298 // 5. Scope attribution: each user's token is pinned to their
@@ -450,20 +447,19 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 148 unmodified lines
450 }),447 }),
451 );448 );
452 449
453 const aliceResult = (yield* aliceExec.tools.invoke(450 const aliceResult = unwrapInvocation(
454 "vercel.projects.list",451 yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove),
455 {},452 );
456 autoApprove,
457 )) as { data: { authorization?: string } | null; error: unknown };
458 expect(aliceResult.error).toBeNull();453 expect(aliceResult.error).toBeNull();
459 expect(aliceResult.data?.authorization).toBe("Bearer alice-vercel-token");454 expect((aliceResult.data as EchoHeaders | null)?.authorization).toBe(
455 "Bearer alice-vercel-token",
456 );
460 457
461 const bobResult = (yield* bobExec.tools.invoke("vercel.projects.list", {}, autoApprove)) as {458 const bobResult = unwrapInvocation(
462 data: { authorization?: string } | null;459 yield* bobExec.tools.invoke("vercel.projects.list", {}, autoApprove),
463 error: unknown;460 );
464 };
465 expect(bobResult.error).toBeNull();461 expect(bobResult.error).toBeNull();
466 expect(bobResult.data?.authorization).toBe("Bearer bob-vercel-token");462 expect((bobResult.data as EchoHeaders | null)?.authorization).toBe("Bearer bob-vercel-token");
467 }),463 }),
468 );464 );
469 465
@@ -558,13 +554,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 88 unmodified lines
558 }),554 }),
559 );555 );
560 556
561 const sharedResult = (yield* aliceExec.tools.invoke(557 const sharedResult = unwrapInvocation(
562 "vercel.projects.list",558 yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove),
563 {},559 );
564 autoApprove,
565 )) as { data: { authorization?: string } | null; error: unknown };
566 expect(sharedResult.error).toBeNull();560 expect(sharedResult.error).toBeNull();
567 expect(sharedResult.data?.authorization).toBe("Bearer org-token");561 expect((sharedResult.data as EchoHeaders | null)?.authorization).toBe("Bearer org-token");
568 562
569 yield* aliceExec.secrets.set(563 yield* aliceExec.secrets.set(
570 SetSecretInput.make({564 SetSecretInput.make({
@@ -587,13 +581,13 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 16 unmodified lines
587 }),581 }),
588 );582 );
589 583
590 const overrideResult = (yield* aliceExec.tools.invoke(584 const overrideResult = unwrapInvocation(
591 "vercel.projects.list",585 yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove),
592 {},586 );
593 autoApprove,
594 )) as { data: { authorization?: string } | null; error: unknown };
595 expect(overrideResult.error).toBeNull();587 expect(overrideResult.error).toBeNull();
596 expect(overrideResult.data?.authorization).toBe("Bearer alice-token");588 expect((overrideResult.data as EchoHeaders | null)?.authorization).toBe(
589 "Bearer alice-token",
590 );
597 591
598 yield* aliceExec.openapi.removeSourceBinding(592 yield* aliceExec.openapi.removeSourceBinding(
599 "vercel",593 "vercel",
@@ -602,13 +596,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 2 unmodified lines
602 String(aliceScope.id),596 String(aliceScope.id),
603 );597 );
604 598
605 const fallbackResult = (yield* aliceExec.tools.invoke(599 const fallbackResult = unwrapInvocation(
606 "vercel.projects.list",600 yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove),
607 {},601 );
608 autoApprove,
609 )) as { data: { authorization?: string } | null; error: unknown };
610 expect(fallbackResult.error).toBeNull();602 expect(fallbackResult.error).toBeNull();
611 expect(fallbackResult.data?.authorization).toBe("Bearer org-token");603 expect((fallbackResult.data as EchoHeaders | null)?.authorization).toBe("Bearer org-token");
612 604
613 yield* aliceExec.openapi.setSourceBinding(605 yield* aliceExec.openapi.setSourceBinding(
614 OpenApiSourceBindingInput.make({606 OpenApiSourceBindingInput.make({
@@ -812,12 +804,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 197 unmodified lines
812 }),804 }),
813 );805 );
814 806
815 const result = (yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove)) as {807 const result = unwrapInvocation(
816 data: { authorization?: string } | null;808 yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove),
817 error: unknown;809 );
818 };
819 expect(result.error).toBeNull();810 expect(result.error).toBeNull();
820 expect(result.data?.authorization).toBe("Bearer alice-token");811 expect((result.data as EchoHeaders | null)?.authorization).toBe("Bearer alice-token");
821 }),812 }),
822 );813 );
823 814
@@ -914,13 +905,12 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 90 unmodified lines
914 }),905 }),
915 );906 );
916 907
917 const result = (yield* userExec.tools.invoke("vercel.projects.list", {}, autoApprove)) as {908 const result = unwrapInvocation(
918 data: { authorization?: string } | null;909 yield* userExec.tools.invoke("vercel.projects.list", {}, autoApprove),
919 error: unknown;910 );
920 };
921 911
922 expect(result.error).toBeNull();912 expect(result.error).toBeNull();
923 expect(result.data?.authorization).toBe("Bearer org-token");913 expect((result.data as EchoHeaders | null)?.authorization).toBe("Bearer org-token");
924 }),914 }),
925 );915 );
926 916
@@ -1013,13 +1003,12 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { · 86 unmodified lines
1013 }),1003 }),
1014 );1004 );
1015 1005
1016 const result = (yield* userExec.tools.invoke("vercel.projects.list", {}, autoApprove)) as {1006 const result = unwrapInvocation(
1017 data: { authorization?: string } | null;1007 yield* userExec.tools.invoke("vercel.projects.list", {}, autoApprove),
1018 error: unknown;1008 );
1019 };
1020 1009
1021 expect(result.error).toBeNull();1010 expect(result.error).toBeNull();
1022 expect(result.data?.authorization).toBe("Bearer org-choice");1011 expect((result.data as EchoHeaders | null)?.authorization).toBe("Bearer org-choice");
1023 }),1012 }),
1024 );1013 );
1025});1014});
packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts+29−24
@@ -28,6 +28,7 @@ import { makeTestConfig, serveOAuthTestServer } from "@executor-js/sdk/testing"; · 27 unmodified lines
28import {28import {
29 addOpenApiTestSource,29 addOpenApiTestSource,
30 serveOpenApiHttpApiTestServer,30 serveOpenApiHttpApiTestServer,
31 unwrapInvocation,
31} from "@executor-js/plugin-openapi/testing";32} from "@executor-js/plugin-openapi/testing";
32 33
33import { openApiPlugin } from "./plugin";34import { openApiPlugin } from "./plugin";
@@ -307,23 +308,25 @@ describe("OpenAPI multi-scope OAuth", () => { · 273 unmodified lines
307 // 4. Invoke through each exec — Authorization must carry that308 // 4. Invoke through each exec — Authorization must carry that
308 // user's token.309 // user's token.
309 // -------------------------------------------------------------310 // -------------------------------------------------------------
310 const aliceResult = (yield* aliceExec.tools.invoke(311 const aliceResult = unwrapInvocation(
311 "petstore.items.echoHeaders",312 yield* aliceExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove),
312 {},313 );
313 autoApprove,
314 )) as { data: { authorization?: string } | null; error: unknown };
315 expect(aliceResult.error).toBeNull();314 expect(aliceResult.error).toBeNull();
316 const aliceBearer = aliceResult.data?.authorization?.replace(/^Bearer\s+/i, "");315 const aliceBearer = (aliceResult.data as EchoHeaders | null)?.authorization?.replace(
316 /^Bearer\s+/i,
317 "",
318 );
317 expect(aliceBearer).toBeDefined();319 expect(aliceBearer).toBeDefined();
318 expect(yield* oauth.acceptsAccessToken(aliceBearer!)).toBe(true);320 expect(yield* oauth.acceptsAccessToken(aliceBearer!)).toBe(true);
319 321
320 const bobResult = (yield* bobExec.tools.invoke(322 const bobResult = unwrapInvocation(
321 "petstore.items.echoHeaders",323 yield* bobExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove),
322 {},324 );
323 autoApprove,
324 )) as { data: { authorization?: string } | null; error: unknown };
325 expect(bobResult.error).toBeNull();325 expect(bobResult.error).toBeNull();
326 const bobBearer = bobResult.data?.authorization?.replace(/^Bearer\s+/i, "");326 const bobBearer = (bobResult.data as EchoHeaders | null)?.authorization?.replace(
327 /^Bearer\s+/i,
328 "",
329 );
327 expect(bobBearer).toBeDefined();330 expect(bobBearer).toBeDefined();
328 expect(yield* oauth.acceptsAccessToken(bobBearer!)).toBe(true);331 expect(yield* oauth.acceptsAccessToken(bobBearer!)).toBe(true);
329 expect(bobBearer).not.toBe(aliceBearer);332 expect(bobBearer).not.toBe(aliceBearer);
@@ -610,23 +613,25 @@ describe("OpenAPI multi-scope OAuth", () => { · 280 unmodified lines
610 // (4) Each user's invocation resolves their OWN row and gets613 // (4) Each user's invocation resolves their OWN row and gets
611 // their OWN token — not whatever the last signer happened to614 // their OWN token — not whatever the last signer happened to
612 // mint. This is the core multi-user regression.615 // mint. This is the core multi-user regression.
613 const aliceResult = (yield* aliceExec.tools.invoke(616 const aliceResult = unwrapInvocation(
614 "petstore.items.echoHeaders",617 yield* aliceExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove),
615 {},618 );
616 autoApprove,
617 )) as { data: { authorization?: string } | null; error: unknown };
618 expect(aliceResult.error).toBeNull();619 expect(aliceResult.error).toBeNull();
619 const aliceBearer = aliceResult.data?.authorization?.replace(/^Bearer\s+/i, "");620 const aliceBearer = (aliceResult.data as EchoHeaders | null)?.authorization?.replace(
621 /^Bearer\s+/i,
622 "",
623 );
620 expect(aliceBearer).toBeDefined();624 expect(aliceBearer).toBeDefined();
621 expect(yield* oauth.acceptsAccessToken(aliceBearer!)).toBe(true);625 expect(yield* oauth.acceptsAccessToken(aliceBearer!)).toBe(true);
622 626
623 const bobResult = (yield* bobExec.tools.invoke(627 const bobResult = unwrapInvocation(
624 "petstore.items.echoHeaders",628 yield* bobExec.tools.invoke("petstore.items.echoHeaders", {}, autoApprove),
625 {},629 );
626 autoApprove,
627 )) as { data: { authorization?: string } | null; error: unknown };
628 expect(bobResult.error).toBeNull();630 expect(bobResult.error).toBeNull();
629 const bobBearer = bobResult.data?.authorization?.replace(/^Bearer\s+/i, "");631 const bobBearer = (bobResult.data as EchoHeaders | null)?.authorization?.replace(
632 /^Bearer\s+/i,
633 "",
634 );
630 expect(bobBearer).toBeDefined();635 expect(bobBearer).toBeDefined();
631 expect(yield* oauth.acceptsAccessToken(bobBearer!)).toBe(true);636 expect(yield* oauth.acceptsAccessToken(bobBearer!)).toBe(true);
632 expect(bobBearer).not.toBe(aliceBearer);637 expect(bobBearer).not.toBe(aliceBearer);
packages/plugins/openapi/src/sdk/oauth-refresh.test.ts+9−12
@@ -35,6 +35,7 @@ import { makeTestConfig, serveOAuthTestServer } from "@executor-js/sdk/testing"; · 34 unmodified lines
35import {35import {
36 addOpenApiTestSource,36 addOpenApiTestSource,
37 serveOpenApiHttpApiTestServer,37 serveOpenApiHttpApiTestServer,
38 unwrapInvocation,
38} from "@executor-js/plugin-openapi/testing";39} from "@executor-js/plugin-openapi/testing";
39 40
40import { openApiPlugin } from "./plugin";41import { openApiPlugin } from "./plugin";
@@ -253,17 +254,16 @@ describe("OpenAPI oauth refresh", () => { · 212 unmodified lines
253 });254 });
254 yield* bindOAuthConnection(executor, scopeId, "conn-refresh-ok", auth);255 yield* bindOAuthConnection(executor, scopeId, "conn-refresh-ok", auth);
255 256
256 const result = (yield* executor.tools.invoke(257 const result = unwrapInvocation(
257 "petstore.items.echoHeaders",258 yield* executor.tools.invoke("petstore.items.echoHeaders", {}, autoApprove),
258 {},259 );
259 autoApprove,
260 )) as { data: { authorization?: string } | null; error: unknown };
261 260
262 expect(result.error).toBeNull();261 expect(result.error).toBeNull();
262 const data = result.data as EchoHeaders | null;
263 // Proves the refresh landed: invoke carried the fresh token,263 // Proves the refresh landed: invoke carried the fresh token,
264 // not the expired one we seeded.264 // not the expired one we seeded.
265 expect(result.data?.authorization).not.toBe("Bearer expired-access-v1");265 expect(data?.authorization).not.toBe("Bearer expired-access-v1");
266 const bearer = result.data?.authorization?.replace(/^Bearer\s+/i, "");266 const bearer = data?.authorization?.replace(/^Bearer\s+/i, "");
267 expect(bearer).toBeDefined();267 expect(bearer).toBeDefined();
268 expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true);268 expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true);
269 const calls = refreshTokenRequests(yield* oauth.requests);269 const calls = refreshTokenRequests(yield* oauth.requests);
@@ -313,12 +313,9 @@ describe("OpenAPI oauth refresh", () => { · 43 unmodified lines
313 );313 );
314 314
315 for (const r of invokes) {315 for (const r of invokes) {
316 const res = r as {316 const res = unwrapInvocation(r);
317 data: { authorization?: string } | null;
318 error: unknown;
319 };
320 expect(res.error).toBeNull();317 expect(res.error).toBeNull();
321 const bearer = res.data?.authorization?.replace(/^Bearer\s+/i, "");318 const bearer = (res.data as EchoHeaders | null)?.authorization?.replace(/^Bearer\s+/i, "");
322 expect(bearer).toBeDefined();319 expect(bearer).toBeDefined();
323 expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true);320 expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true);
324 }321 }
packages/plugins/openapi/src/sdk/plugin.test.ts+38−39
@@ -25,6 +25,7 @@ import { · 24 unmodified lines
25 addOpenApiTestSource,25 addOpenApiTestSource,
26 makeOpenApiHttpApiTestSourceConfig,26 makeOpenApiHttpApiTestSourceConfig,
27 serveOpenApiHttpApiTestServer,27 serveOpenApiHttpApiTestServer,
28 unwrapInvocation,
28} from "../testing";29} from "../testing";
29 30
30const autoApprove: InvokeOptions = { onElicitation: "accept-all" };31const autoApprove: InvokeOptions = { onElicitation: "accept-all" };
@@ -267,13 +268,15 @@ describe("OpenAPI Plugin", () => { · 236 unmodified lines
267 }),268 }),
268 );269 );
269 270
270 const result = (yield* executor.tools.invoke(271 const preview = unwrapInvocation(
271 "executor.openapi.previewSpec",272 yield* executor.tools.invoke(
272 { spec: testApiSpec() },273 "executor.openapi.previewSpec",
273 autoApprove,274 { spec: testApiSpec() },
274 )) as { operationCount: number };275 autoApprove,
276 ),
277 ).data as { operationCount: number };
275 278
276 expect(result.operationCount).toBeGreaterThanOrEqual(2);279 expect(preview.operationCount).toBeGreaterThanOrEqual(2);
277 }),280 }),
278 );281 );
279 282
@@ -316,11 +319,13 @@ describe("OpenAPI Plugin", () => { · 36 unmodified lines
316 }),319 }),
317 );320 );
318 321
319 const result = (yield* executor.tools.invoke(322 const result = unwrapInvocation(
320 "executor.openapi.addSource",323 yield* executor.tools.invoke(
321 testApiSourceConfig({ scope: String(orgScope), namespace: "runtime" }),324 "executor.openapi.addSource",
322 autoApprove,325 testApiSourceConfig({ scope: String(orgScope), namespace: "runtime" }),
323 )) as { sourceId: string; toolCount: number };326 autoApprove,
327 ),
328 ).data as { sourceId: string; toolCount: number };
324 329
325 expect(result).toEqual({ sourceId: "runtime", toolCount: 4 });330 expect(result).toEqual({ sourceId: "runtime", toolCount: 4 });
326 expect(yield* executor.openapi.getSource("runtime", String(userScope))).toBeNull();331 expect(yield* executor.openapi.getSource("runtime", String(userScope))).toBeNull();
@@ -557,17 +562,12 @@ describe("OpenAPI Plugin", () => { · 230 unmodified lines
557 },562 },
558 });563 });
559 564
560 const result = (yield* executor.tools.invoke(565 const result = unwrapInvocation(
561 "authed.items.echoHeaders",566 yield* executor.tools.invoke("authed.items.echoHeaders", {}, autoApprove),
562 {},567 );
563 autoApprove,
564 )) as {
565 data: { authorization?: string; "x-static"?: string } | null;
566 error: unknown;
567 };
568 568
569 expect(result.error).toBeNull();569 expect(result.error).toBeNull();
570 const data = result.data!;570 const data = result.data as { authorization?: string; "x-static"?: string };
571 expect(data.authorization).toBe("Bearer secret-value-123");571 expect(data.authorization).toBe("Bearer secret-value-123");
572 expect(data["x-static"]).toBe("hello");572 expect(data["x-static"]).toBe("hello");
573 }),573 }),
@@ -751,10 +751,9 @@ describe("OpenAPI Plugin", () => { · 177 unmodified lines
751 namespace: "test",751 namespace: "test",
752 });752 });
753 753
754 const result = (yield* executor.tools.invoke("test.items.listItems", {}, autoApprove)) as {754 const result = unwrapInvocation(
755 data: unknown;755 yield* executor.tools.invoke("test.items.listItems", {}, autoApprove),
756 error: unknown;756 );
757 };
758 expect(result.error).toBeNull();757 expect(result.error).toBeNull();
759 expect(result.data).toEqual(ITEMS);758 expect(result.data).toEqual(ITEMS);
760 }),759 }),
@@ -780,11 +779,9 @@ describe("OpenAPI Plugin", () => { · 19 unmodified lines
780 namespace: "test",779 namespace: "test",
781 });780 });
782 781
783 const result = (yield* executor.tools.invoke(782 const result = unwrapInvocation(
784 "test.items.getItem",783 yield* executor.tools.invoke("test.items.getItem", { itemId: "2" }, autoApprove),
785 { itemId: "2" },784 );
786 autoApprove,
787 )) as { data: unknown; error: unknown };
788 expect(result.error).toBeNull();785 expect(result.error).toBeNull();
789 expect(result.data).toEqual({ id: 2, name: "Gadget" });786 expect(result.data).toEqual({ id: 2, name: "Gadget" });
790 }),787 }),
@@ -810,16 +807,18 @@ describe("OpenAPI Plugin", () => { · 19 unmodified lines
810 namespace: "records",807 namespace: "records",
811 });808 });
812 809
813 const result = (yield* executor.tools.invoke(810 const result = unwrapInvocation(
814 "records.items.queryRows",811 yield* executor.tools.invoke(
815 {812 "records.items.queryRows",
816 entryTypeId: "18538",813 {
817 query: JSON.stringify([{ DisplayName: "Example" }]),814 entryTypeId: "18538",
818 limit: 10,815 query: JSON.stringify([{ DisplayName: "Example" }]),
819 skip: 0,816 limit: 10,
820 },817 skip: 0,
821 autoApprove,818 },
822 )) as { data: unknown; error: unknown };819 autoApprove,
820 ),
821 );
823 822
824 expect(result.data).toBeNull();823 expect(result.data).toBeNull();
825 expect(result.error).toEqual(824 expect(result.error).toEqual(
packages/plugins/openapi/src/sdk/plugin.ts+88−3
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
7 SecretId,7 SecretId,
8 SourceDetectionResult,8 SourceDetectionResult,
9 StorageError,9 StorageError,
10 ToolResult,
10 definePlugin,11 definePlugin,
11 tool,12 tool,
12 resolveSecretBackedMap,13 resolveSecretBackedMap,
@@ -57,6 +58,77 @@ import { · 44 unmodified lines
57// Plugin config58// Plugin config
58// ---------------------------------------------------------------------------59// ---------------------------------------------------------------------------
59 60
61const STRINGIFIED_BODY_CAP = 1024;
62const UpstreamMessageBody = Schema.Struct({ message: Schema.String });
63const UpstreamErrorMessageBody = Schema.Struct({ errorMessage: Schema.String });
64const UpstreamNestedErrorBody = Schema.Struct({ error: UpstreamMessageBody });
65const UpstreamErrorsArrayBody = Schema.Struct({
66 errors: Schema.Array(
67 Schema.Struct({
68 detail: Schema.optional(Schema.String),
69 message: Schema.optional(Schema.String),
70 title: Schema.optional(Schema.String),
71 }),
72 ),
73});
74const UpstreamDescriptionBody = Schema.Struct({
75 detail: Schema.optional(Schema.String),
76 title: Schema.optional(Schema.String),
77 description: Schema.optional(Schema.String),
78});
79
80const decodeUpstreamMessageBody = Schema.decodeUnknownOption(UpstreamMessageBody);
81const decodeUpstreamErrorMessageBody = Schema.decodeUnknownOption(UpstreamErrorMessageBody);
82const decodeUpstreamNestedErrorBody = Schema.decodeUnknownOption(UpstreamNestedErrorBody);
83const decodeUpstreamErrorsArrayBody = Schema.decodeUnknownOption(UpstreamErrorsArrayBody);
84const decodeUpstreamDescriptionBody = Schema.decodeUnknownOption(UpstreamDescriptionBody);
85
86const clampedStringify = (value: unknown): string => {
87 let s: string;
88 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: JSON.stringify may throw on cycles; fall back to String() so the upstream body can still be surfaced as ToolError.details fallback text
89 try {
90 s = JSON.stringify(value);
91 } catch {
92 s = String(value);
93 }
94 return s.length > STRINGIFIED_BODY_CAP ? `${s.slice(0, STRINGIFIED_BODY_CAP)}…` : s;
95};
96
97const firstNonEmpty = (...values: readonly (string | undefined)[]): string | undefined =>
98 values.find((value) => value !== undefined && value.length > 0);
99
100// Walk known upstream error-body shapes so ToolError.message stays concise
101// while ToolError.details preserves the original body.
102const extractUpstreamMessage = (body: unknown, status: number): string => {
103 if (typeof body === "string") {
104 return body.length > 0 ? body : `Upstream returned HTTP ${status}`;
105 }
106 const nested = Option.getOrUndefined(decodeUpstreamNestedErrorBody(body));
107 const messageBody = Option.getOrUndefined(decodeUpstreamMessageBody(body));
108 const errorMessageBody = Option.getOrUndefined(decodeUpstreamErrorMessageBody(body));
109 const errorsBody = Option.getOrUndefined(decodeUpstreamErrorsArrayBody(body));
110 const descriptionBody = Option.getOrUndefined(decodeUpstreamDescriptionBody(body));
111 const arrayMessage = errorsBody?.errors
112 .map(({ detail, message: upstreamMessage, title }) =>
113 firstNonEmpty(detail, upstreamMessage, title),
114 )
115 .find((message) => message !== undefined);
116 const message = firstNonEmpty(
117 nested?.error.message,
118 messageBody?.message,
119 errorMessageBody?.errorMessage,
120 arrayMessage,
121 descriptionBody?.detail,
122 descriptionBody?.title,
123 descriptionBody?.description,
124 );
125 if (message !== undefined) return message;
126 if (body !== null && typeof body === "object") {
127 return clampedStringify(body);
128 }
129 return `Upstream returned HTTP ${status}`;
130};
131
60export type HeaderValue = HeaderValueValue;132export type HeaderValue = HeaderValueValue;
61export type ConfiguredHeaderValue = ConfiguredHeaderValueValue;133export type ConfiguredHeaderValue = ConfiguredHeaderValueValue;
62export type OpenApiHeaderInput = HeaderValue | ConfiguredHeaderValue;134export type OpenApiHeaderInput = HeaderValue | ConfiguredHeaderValue;
@@ -1193,7 +1265,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 1130 unmodified lines
1193 name: "previewSpec",1265 name: "previewSpec",
1194 description: "Preview an OpenAPI document before adding it as a source",1266 description: "Preview an OpenAPI document before adding it as a source",
1195 inputSchema: PreviewSpecInputStandardSchema,1267 inputSchema: PreviewSpecInputStandardSchema,
1196 execute: (input) => self.previewSpec(input),1268 execute: (input) => Effect.map(self.previewSpec(input), ToolResult.ok),
1197 }),1269 }),
1198 tool({1270 tool({
1199 name: "addSource",1271 name: "addSource",
@@ -1204,7 +1276,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 4 unmodified lines
1204 },1276 },
1205 inputSchema: AddSourceInputStandardSchema,1277 inputSchema: AddSourceInputStandardSchema,
1206 outputSchema: AddSourceOutputStandardSchema,1278 outputSchema: AddSourceOutputStandardSchema,
1207 execute: (input) => self.addSpec(input),1279 execute: (input) => Effect.map(self.addSpec(input), ToolResult.ok),
1208 }),1280 }),
1209 ],1281 ],
1210 },1282 },
@@ -1283,7 +1355,20 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 72 unmodified lines
1283 httpClientLayer,1355 httpClientLayer,
1284 );1356 );
1285 1357
1286 return result;1358 const ok = result.status >= 200 && result.status < 300;
1359 if (!ok) {
1360 return ToolResult.fail({
1361 code: "upstream_http_error",
1362 status: result.status,
1363 message: extractUpstreamMessage(result.error, result.status),
1364 details: result.error,
1365 });
1366 }
1367 return ToolResult.ok({
1368 status: result.status,
1369 headers: result.headers,
1370 data: result.data,
1371 });
1287 }),1372 }),
1288 1373
1289 resolveAnnotations: ({ ctx, sourceId, toolRows }) =>1374 resolveAnnotations: ({ ctx, sourceId, toolRows }) =>
packages/plugins/openapi/src/sdk/upstream-failures.test.ts+5−4
@@ -37,6 +37,7 @@ import { · 36 unmodified lines
37 makeOpenApiTestSourceConfig,37 makeOpenApiTestSourceConfig,
38 type OpenApiTestServerShape,38 type OpenApiTestServerShape,
39 serveOpenApiHttpApiTestServer,39 serveOpenApiHttpApiTestServer,
40 unwrapInvocation,
40} from "../testing";41} from "../testing";
41 42
42import { openApiPlugin } from "./plugin";43import { openApiPlugin } from "./plugin";
@@ -303,10 +304,10 @@ describe("OpenAPI upstream failure modes", () => { · 260 unmodified lines
303 const { baseUrl } = yield* slowServer;304 const { baseUrl } = yield* slowServer;
304 const executor = yield* buildExecutor(baseUrl);305 const executor = yield* buildExecutor(baseUrl);
305 306
306 const result = yield* executor.tools.invoke("f.things.listThings", {}, autoApprove);307 const result = unwrapInvocation(
307 // Empty array via .data envelope or directly — accept either shape.308 yield* executor.tools.invoke("f.things.listThings", {}, autoApprove),
308 const data = (result as { data?: unknown }).data ?? result;309 );
309 expect(data).toEqual([]);310 expect(result.data).toEqual([]);
310 }),311 }),
311 );312 );
312});313});
packages/plugins/openapi/src/testing/index.ts+54−2
@@ -1,4 +1,4 @@
1import { Context, Data, Effect, Layer, Predicate, Ref, Schema, Scope } from "effect";1import { Context, Data, Effect, Layer, Option, Predicate, Ref, Schema, Scope } from "effect";
2import {2import {
3 HttpClient,3 HttpClient,
4 HttpRouter,4 HttpRouter,
@@ -13,7 +13,7 @@ import { · 8 unmodified lines
13 OpenApi,13 OpenApi,
14} from "effect/unstable/httpapi";14} from "effect/unstable/httpapi";
15import { OAuthTestServer, serveTestHttpServerLayer } from "@executor-js/sdk/testing";15import { OAuthTestServer, serveTestHttpServerLayer } from "@executor-js/sdk/testing";
16import type { ScopeId } from "@executor-js/sdk/core";16import { isToolResult, type ScopeId } from "@executor-js/sdk/core";
17import type { OpenApiPluginExtension, OpenApiSpecConfig } from "../sdk/plugin";17import type { OpenApiPluginExtension, OpenApiSpecConfig } from "../sdk/plugin";
18 18
19export class OpenApiTestServerAddressError extends Data.TaggedError(19export class OpenApiTestServerAddressError extends Data.TaggedError(
@@ -618,3 +618,55 @@ export const TestLayers = { · 598 unmodified lines
618 echo: OpenApiEchoTestServer.layer,618 echo: OpenApiEchoTestServer.layer,
619 echoWithOAuth: OpenApiEchoTestServer.layerWithOAuth,619 echoWithOAuth: OpenApiEchoTestServer.layerWithOAuth,
620};620};
621
622const OpenApiTransportEnvelope = Schema.Struct({
623 status: Schema.Number,
624 headers: Schema.Record(Schema.String, Schema.String),
625 data: Schema.Unknown,
626});
627
628const decodeOpenApiTransportEnvelope = Schema.decodeUnknownOption(OpenApiTransportEnvelope);
629
630export interface OpenApiInvocationResult<TData = Record<string, unknown> | unknown[] | null> {
631 readonly status: number | null;
632 readonly headers: Record<string, string> | null;
633 readonly data: TData;
634 readonly error: unknown;
635}
636
637export const unwrapInvocation = <TData = Record<string, unknown> | null>(
638 raw: unknown,
639): OpenApiInvocationResult<TData> => {
640 if (!isToolResult(raw)) {
641 return {
642 status: null,
643 headers: null,
644 data: raw as TData,
645 error: null,
646 };
647 }
648 if (raw.ok) {
649 const inner = raw.data;
650 const wrapped = Option.getOrUndefined(decodeOpenApiTransportEnvelope(inner));
651 if (wrapped !== undefined) {
652 return {
653 status: wrapped.status,
654 headers: wrapped.headers,
655 data: wrapped.data as TData,
656 error: null,
657 };
658 }
659 return {
660 status: null,
661 headers: null,
662 data: inner as TData,
663 error: null,
664 };
665 }
666 return {
667 status: raw.error.status ?? null,
668 headers: null,
669 data: null as TData,
670 error: raw.error.details ?? raw.error,
671 };
672};