Skip to content

Commit 278ca7c

Browse files
authored
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
1 parent 8d986ff commit 278ca7c

35 files changed

Lines changed: 1322 additions & 340 deletions

apps/cloud/src/services/sources-api.node.test.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -207,9 +207,15 @@ describe("sources api (HTTP)", () => {
207207
expect(execution.structured).toMatchObject({
208208
status: "completed",
209209
result: {
210-
message: "hello",
211-
suffix: "world",
212-
path: "/echo/hello",
210+
ok: true,
211+
data: {
212+
status: 200,
213+
data: {
214+
message: "hello",
215+
suffix: "world",
216+
path: "/echo/hello",
217+
},
218+
},
213219
},
214220
logs: [],
215221
});
@@ -315,7 +321,7 @@ describe("sources api (HTTP)", () => {
315321
expect(execution.isError).toBe(false);
316322
expect(execution.structured).toMatchObject({
317323
status: "completed",
318-
result: { hello: "Hello Ada" },
324+
result: { ok: true, data: { hello: "Hello Ada" } },
319325
});
320326
const requests = yield* server.requests;
321327
expect(requests.some((request) => request.payload.query?.includes("__schema"))).toBe(true);
@@ -391,7 +397,8 @@ describe("sources api (HTTP)", () => {
391397
expect(execution.structured).toMatchObject({
392398
status: "completed",
393399
result: {
394-
content: [{ type: "text", text: "cloud-mcp-ok" }],
400+
ok: true,
401+
data: { content: [{ type: "text", text: "cloud-mcp-ok" }] },
395402
},
396403
});
397404
expect((yield* server.requests).length).toBeGreaterThanOrEqual(2);

apps/local/src/server/migrate-oauth-connections.test.ts

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => {
159159
`);
160160

161161
db.prepare("INSERT INTO `openapi_source` (id, scope_id, oauth2) VALUES (?, ?, ?)").run(
162-
"dealcloud_api",
162+
"example_api",
163163
"org-1",
164164
JSON.stringify({
165165
kind: "oauth2",
@@ -174,27 +174,27 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => {
174174
"INSERT INTO `connection` (id, scope_id, provider, provider_state, scope, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
175175
);
176176
insertConnection.run(
177-
"openapi-oauth2-app-dealcloud_api",
177+
"openapi-oauth2-app-example_api",
178178
"org-1",
179179
"oauth2",
180180
JSON.stringify({
181181
kind: "client-credentials",
182-
tokenEndpoint: "https://resolve.dealcloud.com/oauth/token",
183-
clientIdSecretId: "dealcloud-client-id",
184-
clientSecretSecretId: "dealcloud-client-secret",
182+
tokenEndpoint: "https://auth.example.test/oauth/token",
183+
clientIdSecretId: "example-client-id",
184+
clientSecretSecretId: "example-client-secret",
185185
}),
186186
null,
187187
now,
188188
);
189189
insertConnection.run(
190-
"openapi-oauth2-app-dealcloud_api",
190+
"openapi-oauth2-app-example_api",
191191
"user-org:user-jd:org-1",
192192
"openapi:oauth2",
193193
JSON.stringify({
194194
kind: "client-credentials",
195-
tokenEndpoint: "https://resolve.dealcloud.com/oauth/token",
196-
clientIdSecretId: "dealcloud-client-id-jd",
197-
clientSecretSecretId: "dealcloud-client-secret-jd",
195+
tokenEndpoint: "https://auth.example.test/oauth/token",
196+
clientIdSecretId: "example-client-id-jd",
197+
clientSecretSecretId: "example-client-secret-jd",
198198
}),
199199
null,
200200
now,
@@ -207,12 +207,12 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => {
207207
"org-client-id",
208208
"org-1",
209209
"openapi",
210-
"dealcloud_api",
210+
"example_api",
211211
"org-1",
212212
"oauth2:oauth2:client-id",
213213
"secret",
214214
null,
215-
"dealcloud-client-id-jd",
215+
"example-client-id-jd",
216216
null,
217217
now,
218218
now,
@@ -221,12 +221,12 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => {
221221
"org-client-secret",
222222
"org-1",
223223
"openapi",
224-
"dealcloud_api",
224+
"example_api",
225225
"org-1",
226226
"oauth2:oauth2:client-secret",
227227
"secret",
228228
null,
229-
"dealcloud-client-secret-jd",
229+
"example-client-secret-jd",
230230
null,
231231
now,
232232
now,
@@ -235,27 +235,27 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => {
235235
"org-connection",
236236
"org-1",
237237
"openapi",
238-
"dealcloud_api",
238+
"example_api",
239239
"org-1",
240240
"oauth2:oauth2:connection",
241241
"connection",
242242
null,
243243
null,
244-
"openapi-oauth2-app-dealcloud_api",
244+
"openapi-oauth2-app-example_api",
245245
now,
246246
now,
247247
);
248248
insertBinding.run(
249249
"jd-connection",
250250
"user-org:user-jd:org-1",
251251
"openapi",
252-
"dealcloud_api",
252+
"example_api",
253253
"org-1",
254254
"oauth2:oauth2:connection",
255255
"connection",
256256
null,
257257
null,
258-
"openapi-oauth2-app-dealcloud_api",
258+
"openapi-oauth2-app-example_api",
259259
now,
260260
now,
261261
);
@@ -271,49 +271,49 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => {
271271
.prepare(
272272
"SELECT scope_id, slot_key, kind, secret_id, connection_id FROM `credential_binding` WHERE source_id = ? ORDER BY scope_id, slot_key",
273273
)
274-
.all("dealcloud_api");
274+
.all("example_api");
275275
expect(bindings).toEqual([
276276
{
277277
scope_id: "org-1",
278278
slot_key: "oauth2:oauth2:client-id",
279279
kind: "secret",
280-
secret_id: "dealcloud-client-id",
280+
secret_id: "example-client-id",
281281
connection_id: null,
282282
},
283283
{
284284
scope_id: "org-1",
285285
slot_key: "oauth2:oauth2:client-secret",
286286
kind: "secret",
287-
secret_id: "dealcloud-client-secret",
287+
secret_id: "example-client-secret",
288288
connection_id: null,
289289
},
290290
{
291291
scope_id: "org-1",
292292
slot_key: "oauth2:oauth2:connection",
293293
kind: "connection",
294294
secret_id: null,
295-
connection_id: "openapi-oauth2-app-dealcloud_api",
295+
connection_id: "openapi-oauth2-app-example_api",
296296
},
297297
{
298298
scope_id: "user-org:user-jd:org-1",
299299
slot_key: "oauth2:oauth2:client-id",
300300
kind: "secret",
301-
secret_id: "dealcloud-client-id-jd",
301+
secret_id: "example-client-id-jd",
302302
connection_id: null,
303303
},
304304
{
305305
scope_id: "user-org:user-jd:org-1",
306306
slot_key: "oauth2:oauth2:client-secret",
307307
kind: "secret",
308-
secret_id: "dealcloud-client-secret-jd",
308+
secret_id: "example-client-secret-jd",
309309
connection_id: null,
310310
},
311311
{
312312
scope_id: "user-org:user-jd:org-1",
313313
slot_key: "oauth2:oauth2:connection",
314314
kind: "connection",
315315
secret_id: null,
316-
connection_id: "openapi-oauth2-app-dealcloud_api",
316+
connection_id: "openapi-oauth2-app-example_api",
317317
},
318318
]);
319319
});

packages/core/execution/src/description.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const formatDescription = (sources: readonly Source[]): string => {
4848
"- `tools.search()` returns paginated, ranked matches: `{ items, total, hasMore, nextOffset }`. Best-first. Use short intent phrases like `github issues`, `repo details`, or `create calendar event`.",
4949
'- When you already know the namespace, narrow with `tools.search({ namespace: "github", query: "issues" })`.',
5050
"- `tools.executor.sources.list()` returns the same paged shape: `{ items: [{ id, toolCount, ... }], total, hasMore, nextOffset }`.",
51+
"- Tool calls return a value union: `{ ok: true, data }` for success or `{ ok: false, error: { code, message, status?, details?, retryable? } }` for expected tool/domain failures. Branch on `result.ok`.",
5152
"- If `hasMore` is true and you didn't find what you need, fetch the next page: `tools.search({ query, offset: nextOffset, limit })`. Same `offset` parameter on `tools.executor.sources.list({ offset, limit })`.",
5253
"- Always use the namespace prefix when calling tools: `tools.<namespace>.<tool>(args)`. Example: `tools.home_assistant_rest_api.states.getState(...)` — not `tools.states.getState(...)`.",
5354
"- The `tools` object is a lazy proxy — `Object.keys(tools)` won't work. Use `tools.search()` or `tools.executor.sources.list()` instead.",
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import { Data, Effect, Schema } from "effect";
3+
4+
import { ElicitationResponse, createExecutor, definePlugin } from "@executor-js/sdk";
5+
import { makeTestConfig } from "@executor-js/sdk/testing";
6+
import { ExecutionToolError } from "./errors";
7+
import { makeExecutorToolInvoker } from "./tool-invoker";
8+
9+
const EmptyInputSchema = Schema.toStandardSchemaV1(
10+
Schema.toStandardJSONSchemaV1(Schema.Struct({})),
11+
);
12+
13+
const acceptAll = () => Effect.succeed(ElicitationResponse.make({ action: "accept" }));
14+
15+
// Plugin-internal tagged error whose `cause` carries internal diagnostics.
16+
// The dispatcher must route this through the opaque-generic path so none of
17+
// that context reaches the sandbox via Error.message.
18+
class FakePluginInvocationError extends Data.TaggedError("PluginInvocationError")<{
19+
readonly message: string;
20+
readonly cause: unknown;
21+
}> {}
22+
23+
const leakyPlugin = definePlugin(() => ({
24+
id: "leaky-test" as const,
25+
storage: () => ({}),
26+
staticSources: () => [
27+
{
28+
id: "leaky",
29+
kind: "in-memory",
30+
name: "Leaky",
31+
tools: [
32+
{
33+
name: "failsWithCause",
34+
description: "",
35+
inputSchema: EmptyInputSchema,
36+
handler: () =>
37+
Effect.fail(
38+
new FakePluginInvocationError({
39+
message: "Upstream request failed",
40+
cause: {
41+
_tag: "InternalTransportError",
42+
request: {
43+
method: "GET",
44+
url: "https://internal.service.local/v1/resources?trace=trace-123",
45+
headers: { "x-internal-routing": "private-cluster" },
46+
},
47+
stack: "Error: connect failed\n at plugin-transport.ts:42:11",
48+
note: "internal diagnostic detail",
49+
},
50+
}),
51+
),
52+
},
53+
{
54+
name: "throwsRawError",
55+
description: "",
56+
inputSchema: EmptyInputSchema,
57+
handler: () =>
58+
Effect.fail(
59+
Object.assign(
60+
// oxlint-disable-next-line executor/no-error-constructor -- boundary: leak test deliberately fails with a raw Error + crafted stack to assert the dispatcher's opaque-generic redaction
61+
new Error("Internal: secret 'sk_live_abcd' rotation failed"),
62+
{
63+
stack:
64+
"Error: Internal: secret 'sk_live_abcd' rotation failed\n at /home/svc/.../secret-store.ts:88",
65+
},
66+
),
67+
),
68+
},
69+
],
70+
},
71+
],
72+
}));
73+
74+
describe("internal-error leak audit (opaque defects)", () => {
75+
it.effect("plugin tagged error: defect surfaces only as opaque generic + correlation id", () =>
76+
Effect.gen(function* () {
77+
const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));
78+
const invoker = makeExecutorToolInvoker(executor, {
79+
invokeOptions: { onElicitation: acceptAll },
80+
});
81+
82+
const err = yield* Effect.flip(invoker.invoke({ path: "leaky.failsWithCause", args: {} }));
83+
expect(err).toBeInstanceOf(ExecutionToolError);
84+
// oxlint-disable-next-line executor/no-unknown-error-message -- boundary: leak test inspects the rendered message to assert it is the opaque generic
85+
const msg = (err as { message: string }).message;
86+
// Must be the canonical opaque shape: "Internal tool error [<hex>]"
87+
expect(msg).toMatch(/^Internal tool error \[[0-9a-f]{8}\]$/);
88+
// Crucially, no internal context leaks
89+
expect(msg).not.toContain("trace-123");
90+
expect(msg).not.toContain("private-cluster");
91+
expect(msg).not.toContain("internal.service.local");
92+
expect(msg).not.toContain("InternalTransportError");
93+
expect(msg).not.toContain("Upstream request failed");
94+
}),
95+
);
96+
97+
it.effect("plain Error with stack: stack and message do NOT escape", () =>
98+
Effect.gen(function* () {
99+
const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));
100+
const invoker = makeExecutorToolInvoker(executor, {
101+
invokeOptions: { onElicitation: acceptAll },
102+
});
103+
104+
const err = yield* Effect.flip(invoker.invoke({ path: "leaky.throwsRawError", args: {} }));
105+
// oxlint-disable-next-line executor/no-unknown-error-message -- boundary: leak test inspects the rendered message to assert it is the opaque generic
106+
const msg = (err as { message: string }).message;
107+
expect(msg).toMatch(/^Internal tool error \[[0-9a-f]{8}\]$/);
108+
expect(msg).not.toContain("secret-store.ts");
109+
expect(msg).not.toContain("at /home/");
110+
expect(msg).not.toContain("sk_live_abcd");
111+
}),
112+
);
113+
});

0 commit comments

Comments
 (0)