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