* 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/kernel/runtime-quickjs/src (2 of 35 files). Show all
Unified · Split
| @@ -121,6 +121,52 @@ describe("quickjs executor", () => { · 120 unmodified lines | |||
| 121 | }), | 121 | }), |
| 122 | ); | 122 | ); |
| 123 | 123 | ||
| 124 | it.effect("internal defects reach the sandbox as an opaque generic only", () => | ||
| 125 | Effect.gen(function* () { | ||
| 126 | // Plugin defect carrying sensitive context. The bridge's reject | ||
| 127 | // path must strip everything except the canonical | ||
| 128 | // "Internal tool error [<corrId>]" shape — or fall back to the | ||
| 129 | // bare generic if the upstream invoker hasn't already stamped | ||
| 130 | // the correlation id (this test exercises the latter path | ||
| 131 | // because it bypasses makeExecutorToolInvoker). | ||
| 132 | const invoker: SandboxToolInvoker = { | ||
| 133 | invoke: () => | ||
| 134 | Effect.fail( | ||
| 135 | Object.assign( | ||
| 136 | new Error("Authorization: Bearer SECRET_TOKEN_xyz failed against host 10.0.0.5"), | ||
| 137 | { | ||
| 138 | stack: "Error\n at /home/svc/executor/packages/plugins/foo:142:11", | ||
| 139 | }, | ||
| 140 | ) as never, | ||
| 141 | ), | ||
| 142 | }; | ||
| 143 | |||
| 144 | const result = yield* executor.execute( | ||
| 145 | ` | ||
| 146 | try { | ||
| 147 | await tools.leaky.call({}); | ||
| 148 | return "should not reach"; | ||
| 149 | } catch (e) { | ||
| 150 | return e.message; | ||
| 151 | } | ||
| 152 | `, | ||
| 153 | invoker, | ||
| 154 | ); | ||
| 155 | |||
| 156 | expect(result.error).toBeUndefined(); | ||
| 157 | const message = String(result.result); | ||
| 158 | // Either the canonical opaque generic with a correlation id, or | ||
| 159 | // the bare fallback. Neither must contain any sensitive context. | ||
| 160 | expect( | ||
| 161 | message === "Internal tool error" || /^Internal tool error \[[0-9a-f]{8}\]$/.test(message), | ||
| 162 | ).toBe(true); | ||
| 163 | expect(message).not.toContain("SECRET_TOKEN_xyz"); | ||
| 164 | expect(message).not.toContain("Authorization"); | ||
| 165 | expect(message).not.toContain("10.0.0.5"); | ||
| 166 | expect(message).not.toContain("packages/plugins"); | ||
| 167 | }), | ||
| 168 | ); | ||
| 169 | |||
| 124 | it.effect("handles unknown tool path", () => | 170 | it.effect("handles unknown tool path", () => |
| 125 | Effect.gen(function* () { | 171 | Effect.gen(function* () { |
| 126 | const invoker = makeTestInvoker({}); | 172 | const invoker = makeTestInvoker({}); |
| @@ -47,6 +47,30 @@ const EXECUTION_FILENAME = "executor-quickjs-runtime.js"; · 46 unmodified lines | |||
| 47 | const toError = (cause: unknown): Error => | 47 | const toError = (cause: unknown): Error => |
| 48 | cause instanceof Error ? cause : new Error(String(cause)); | 48 | cause instanceof Error ? cause : new Error(String(cause)); |
| 49 | 49 | ||
| 50 | // Defect surfaced to the sandbox when a tool dispatch reaches the | ||
| 51 | // reject path. Two sources reach here: | ||
| 52 | // - `ExecutionToolError` emitted by the execution package — message | ||
| 53 | // is already either the canonical | ||
| 54 | // `Internal tool error [<corrId>]` opaque generic (plugin defects | ||
| 55 | // passed through `makeExecutorToolInvoker`'s catchCause) or an | ||
| 56 | // intentional public string (validators in engine.ts: | ||
| 57 | // "tools.search expects an object: ..."). | ||
| 58 | // - Anything else — assume infra defect and emit a bare opaque | ||
| 59 | // generic. | ||
| 60 | // `_tag === "ExecutionToolError"` is the in-band signal that the | ||
| 61 | // message was generated by code we control and is safe to pass | ||
| 62 | // through; we never read `.cause` / `.stack`. | ||
| 63 | const sandboxDefectMessage = (cause: unknown): string => { | ||
| 64 | if (cause !== null && typeof cause === "object") { | ||
| 65 | const tagged = cause as { readonly _tag?: unknown; readonly message?: unknown }; | ||
| 66 | // oxlint-disable-next-line executor/no-manual-tag-check, executor/no-unknown-error-message -- boundary: QuickJS reject path receives an already-rendered ExecutionToolError; we pass its in-band public message through and otherwise emit the opaque generic | ||
| 67 | if (tagged._tag === "ExecutionToolError" && typeof tagged.message === "string") { | ||
| 68 | return tagged.message; | ||
| 69 | } | ||
| 70 | } | ||
| 71 | return "Internal tool error"; | ||
| 72 | }; | ||
| 73 | |||
| 50 | const toErrorMessage = (cause: unknown): string => { | 74 | const toErrorMessage = (cause: unknown): string => { |
| 51 | if (typeof cause === "object" && cause !== null) { | 75 | if (typeof cause === "object" && cause !== null) { |
| 52 | const message = | 76 | const message = |
| @@ -216,7 +240,17 @@ const createToolBridge = ( · 163 unmodified lines | |||
| 216 | return; | 240 | return; |
| 217 | } | 241 | } |
| 218 | 242 | ||
| 219 | const errorHandle = context.newError(toErrorMessage(cause)); | 243 | // The reject path is reserved for true infra defects. The |
| 244 | // upstream tool-invoker has already replaced the message with | ||
| 245 | // an opaque generic plus a correlation id, but defensively log | ||
| 246 | // the cause here and emit a stable generic if upstream changes. | ||
| 247 | const message = sandboxDefectMessage(cause); | ||
| 248 | try { | ||
| 249 | console.error("[executor:quickjs] tool dispatch defect", { path, cause }); | ||
| 250 | } catch { | ||
| 251 | /* ignore logger failures */ | ||
| 252 | } | ||
| 253 | const errorHandle = context.newError(message); | ||
| 220 | deferred.reject(errorHandle); | 254 | deferred.reject(errorHandle); |
| 221 | errorHandle.dispose(); | 255 | errorHandle.dispose(); |
| 222 | }, | 256 | }, |