@stigmer/runner 3.11.0 → 3.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/discover-mcp-server.d.ts +7 -0
- package/dist/activities/discover-mcp-server.js +6 -4
- package/dist/activities/discover-mcp-server.js.map +1 -1
- package/dist/activities/emit-event.d.ts +14 -2
- package/dist/activities/emit-event.js +52 -17
- package/dist/activities/emit-event.js.map +1 -1
- package/dist/activities/execute-cursor/attachment-resolver.d.ts +11 -0
- package/dist/activities/execute-cursor/attachment-resolver.js +8 -0
- package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/blueprint-resolver.d.ts +1 -9
- package/dist/activities/execute-cursor/blueprint-resolver.js +0 -1
- package/dist/activities/execute-cursor/blueprint-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/error-classifier.d.ts +19 -0
- package/dist/activities/execute-cursor/error-classifier.js +34 -3
- package/dist/activities/execute-cursor/error-classifier.js.map +1 -1
- package/dist/activities/execute-cursor/index.d.ts +52 -12
- package/dist/activities/execute-cursor/index.js +141 -67
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/model-pricing-data.d.ts +2 -0
- package/dist/activities/execute-cursor/model-pricing-data.js +13 -3
- package/dist/activities/execute-cursor/model-pricing-data.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +48 -11
- package/dist/activities/execute-cursor/prompt-builder.js +57 -10
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/turn-recovery.d.ts +52 -0
- package/dist/activities/execute-cursor/turn-recovery.js +193 -0
- package/dist/activities/execute-cursor/turn-recovery.js.map +1 -0
- package/dist/activities/execute-deep-agent/attachment-injector.d.ts +11 -0
- package/dist/activities/execute-deep-agent/attachment-injector.js +7 -1
- package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +0 -15
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/mcp-gate.d.ts +0 -2
- package/dist/activities/execute-deep-agent/mcp-gate.js +0 -1
- package/dist/activities/execute-deep-agent/mcp-gate.js.map +1 -1
- package/dist/activities/execute-deep-agent/prompt-builder.d.ts +7 -8
- package/dist/activities/execute-deep-agent/prompt-builder.js +14 -7
- package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.d.ts +0 -10
- package/dist/activities/execute-deep-agent/setup.js +22 -49
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/shell-env.d.ts +5 -1
- package/dist/activities/execute-deep-agent/shell-env.js +7 -9
- package/dist/activities/execute-deep-agent/shell-env.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.js +7 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +17 -3
- package/dist/activities/execute-deep-agent/subagent-wiring.js +14 -2
- package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
- package/dist/activities/hydrate-workflow-execution.js +8 -5
- package/dist/activities/hydrate-workflow-execution.js.map +1 -1
- package/dist/activities/run-command.d.ts +5 -2
- package/dist/activities/run-command.js +22 -10
- package/dist/activities/run-command.js.map +1 -1
- package/dist/activities/run-env.d.ts +40 -0
- package/dist/activities/run-env.js +66 -0
- package/dist/activities/run-env.js.map +1 -0
- package/dist/bootstrap.js +5 -0
- package/dist/bootstrap.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +83 -26
- package/dist/client/stigmer-client.js +88 -24
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/config.d.ts +6 -5
- package/dist/config.js.map +1 -1
- package/dist/encryption/config.d.ts +41 -9
- package/dist/encryption/config.js +63 -23
- package/dist/encryption/config.js.map +1 -1
- package/dist/middleware/index.d.ts +3 -0
- package/dist/middleware/index.js +7 -0
- package/dist/middleware/index.js.map +1 -1
- package/dist/middleware/path-normalization.d.ts +57 -0
- package/dist/middleware/path-normalization.js +109 -0
- package/dist/middleware/path-normalization.js.map +1 -0
- package/dist/middleware/types.d.ts +8 -0
- package/dist/payload-codecs.d.ts +2 -1
- package/dist/payload-codecs.js +7 -3
- package/dist/payload-codecs.js.map +1 -1
- package/dist/runner-manager.js +17 -3
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.js +7 -3
- package/dist/runner.js.map +1 -1
- package/dist/shared/artifact-storage.d.ts +34 -1
- package/dist/shared/artifact-storage.js +53 -10
- package/dist/shared/artifact-storage.js.map +1 -1
- package/dist/shared/attachment-download-urls.d.ts +62 -0
- package/dist/shared/attachment-download-urls.js +79 -0
- package/dist/shared/attachment-download-urls.js.map +1 -0
- package/dist/shared/channel-attachment.d.ts +1 -1
- package/dist/shared/channel-attachment.js +1 -1
- package/dist/shared/checkpointer/http-saver.d.ts +4 -20
- package/dist/shared/checkpointer/http-saver.js +15 -57
- package/dist/shared/checkpointer/http-saver.js.map +1 -1
- package/dist/shared/conversation-attachment.d.ts +2 -3
- package/dist/shared/conversation-attachment.js +2 -3
- package/dist/shared/conversation-attachment.js.map +1 -1
- package/dist/shared/conversation-catchup.d.ts +9 -2
- package/dist/shared/conversation-catchup.js +39 -6
- package/dist/shared/conversation-catchup.js.map +1 -1
- package/dist/shared/grpc-retry.d.ts +5 -1
- package/dist/shared/grpc-retry.js +5 -1
- package/dist/shared/grpc-retry.js.map +1 -1
- package/dist/shared/http-retry.d.ts +51 -9
- package/dist/shared/http-retry.js +60 -9
- package/dist/shared/http-retry.js.map +1 -1
- package/dist/shared/mcp-manager.d.ts +8 -4
- package/dist/shared/mcp-manager.js +30 -4
- package/dist/shared/mcp-manager.js.map +1 -1
- package/dist/shared/mcp-schema-sanitizer.d.ts +70 -0
- package/dist/shared/mcp-schema-sanitizer.js +197 -0
- package/dist/shared/mcp-schema-sanitizer.js.map +1 -0
- package/dist/shared/mcp-transport-guard.d.ts +2 -2
- package/dist/shared/mcp-transport-guard.js +2 -2
- package/dist/shared/model-client.d.ts +6 -0
- package/dist/shared/model-client.js +14 -3
- package/dist/shared/model-client.js.map +1 -1
- package/dist/shared/model-pricing-data.d.ts +2 -0
- package/dist/shared/model-pricing-data.js +13 -3
- package/dist/shared/model-pricing-data.js.map +1 -1
- package/dist/shared/model-registry.js +3 -2
- package/dist/shared/model-registry.js.map +1 -1
- package/dist/shared/plan-mode-permissions.d.ts +7 -0
- package/dist/shared/plan-mode-permissions.js +7 -0
- package/dist/shared/plan-mode-permissions.js.map +1 -1
- package/dist/shared/registry-endpoint.d.ts +20 -0
- package/dist/shared/registry-endpoint.js +25 -0
- package/dist/shared/registry-endpoint.js.map +1 -1
- package/dist/shared/runner-credential-keys.d.ts +24 -0
- package/dist/shared/runner-credential-keys.js +47 -0
- package/dist/shared/runner-credential-keys.js.map +1 -0
- package/dist/shared/synthesized-attachment.d.ts +4 -4
- package/dist/shared/synthesized-attachment.js +4 -4
- package/dist/workflow-engine/resolve.d.ts +19 -1
- package/dist/workflow-engine/resolve.js +37 -2
- package/dist/workflow-engine/resolve.js.map +1 -1
- package/dist/workflows/connect-mcp-server.js +2 -0
- package/dist/workflows/connect-mcp-server.js.map +1 -1
- package/dist/workflows/types.d.ts +8 -0
- package/package.json +3 -4
- package/src/__test-utils__/__tests__/vitest-global-setup.test.ts +37 -0
- package/src/__test-utils__/fake-artifact-storage.ts +10 -1
- package/src/__test-utils__/vitest-global-setup.ts +50 -0
- package/src/__tests__/bootstrap.test.ts +43 -0
- package/src/__tests__/encryption-codec.test.ts +53 -0
- package/src/__tests__/preflight.test.ts +15 -0
- package/src/activities/__tests__/discover-mcp-server.test.ts +39 -1
- package/src/activities/__tests__/error-classifier.test.ts +94 -2
- package/src/activities/__tests__/run-command.test.ts +221 -0
- package/src/activities/discover-mcp-server.ts +16 -2
- package/src/activities/emit-event.ts +65 -21
- package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +63 -3
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +173 -31
- package/src/activities/execute-cursor/__tests__/model-pricing.test.ts +62 -1
- package/src/activities/execute-cursor/__tests__/turn-recovery.test.ts +160 -0
- package/src/activities/execute-cursor/attachment-resolver.ts +24 -0
- package/src/activities/execute-cursor/blueprint-resolver.ts +1 -10
- package/src/activities/execute-cursor/error-classifier.ts +40 -3
- package/src/activities/execute-cursor/index.ts +175 -74
- package/src/activities/execute-cursor/model-pricing-data.ts +22 -3
- package/src/activities/execute-cursor/prompt-builder.ts +89 -22
- package/src/activities/execute-cursor/turn-recovery.ts +208 -0
- package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +105 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +0 -2
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +0 -1
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +0 -1
- package/src/activities/execute-deep-agent/__tests__/mcp-gate.test.ts +2 -7
- package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +174 -0
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +71 -0
- package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +0 -1
- package/src/activities/execute-deep-agent/__tests__/shell-env.test.ts +24 -10
- package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +22 -17
- package/src/activities/execute-deep-agent/attachment-injector.ts +20 -1
- package/src/activities/execute-deep-agent/index.ts +0 -19
- package/src/activities/execute-deep-agent/mcp-gate.ts +0 -3
- package/src/activities/execute-deep-agent/prompt-builder.ts +27 -15
- package/src/activities/execute-deep-agent/setup.ts +22 -73
- package/src/activities/execute-deep-agent/shell-env.ts +8 -9
- package/src/activities/execute-deep-agent/subagent-transformer.ts +7 -1
- package/src/activities/execute-deep-agent/subagent-wiring.ts +26 -3
- package/src/activities/hydrate-workflow-execution.ts +8 -5
- package/src/activities/run-command.ts +25 -12
- package/src/activities/run-env.ts +79 -0
- package/src/bootstrap.ts +5 -0
- package/src/client/__tests__/stigmer-client.test.ts +104 -5
- package/src/client/stigmer-client.ts +128 -29
- package/src/config.ts +6 -5
- package/src/encryption/config.ts +87 -23
- package/src/middleware/__tests__/path-normalization.test.ts +140 -0
- package/src/middleware/index.ts +8 -0
- package/src/middleware/path-normalization.ts +125 -0
- package/src/middleware/types.ts +11 -0
- package/src/payload-codecs.ts +8 -2
- package/src/runner-manager.ts +21 -3
- package/src/runner.ts +7 -3
- package/src/shared/__tests__/artifact-storage.test.ts +154 -2
- package/src/shared/__tests__/attachment-download-urls.test.ts +70 -0
- package/src/shared/__tests__/channel-attachment.test.ts +2 -2
- package/src/shared/__tests__/conversation-catchup.test.ts +24 -0
- package/src/shared/__tests__/http-retry.test.ts +5 -4
- package/src/shared/__tests__/mcp-manager.test.ts +53 -13
- package/src/shared/__tests__/mcp-schema-sanitizer.test.ts +267 -0
- package/src/shared/__tests__/model-client.test.ts +46 -0
- package/src/shared/__tests__/model-pricing.test.ts +62 -2
- package/src/shared/__tests__/model-registry.test.ts +22 -0
- package/src/shared/__tests__/synthesized-attachment.test.ts +19 -41
- package/src/shared/artifact-storage.ts +86 -10
- package/src/shared/attachment-download-urls.ts +98 -0
- package/src/shared/channel-attachment.ts +1 -1
- package/src/shared/checkpointer/http-saver.ts +15 -60
- package/src/shared/conversation-attachment.ts +2 -3
- package/src/shared/conversation-catchup.ts +39 -6
- package/src/shared/grpc-retry.ts +5 -1
- package/src/shared/http-retry.ts +98 -9
- package/src/shared/mcp-manager.ts +35 -4
- package/src/shared/mcp-schema-sanitizer.ts +224 -0
- package/src/shared/mcp-transport-guard.ts +2 -2
- package/src/shared/model-client.ts +21 -3
- package/src/shared/model-pricing-data.ts +22 -3
- package/src/shared/model-registry.ts +7 -2
- package/src/shared/plan-mode-permissions.ts +7 -0
- package/src/shared/registry-endpoint.ts +27 -0
- package/src/shared/runner-credential-keys.ts +46 -0
- package/src/shared/synthesized-attachment.ts +4 -4
- package/src/workflow-engine/__tests__/tasks/emit-event.test.ts +127 -49
- package/src/workflow-engine/resolve.ts +48 -2
- package/src/workflows/__tests__/connect-mcp-server.test.ts +25 -0
- package/src/workflows/connect-mcp-server.ts +2 -0
- package/src/workflows/types.ts +8 -0
- package/dist/activities/execute-cursor/mcp-config.d.ts +0 -30
- package/dist/activities/execute-cursor/mcp-config.js +0 -39
- package/dist/activities/execute-cursor/mcp-config.js.map +0 -1
- package/dist/shared/datastore-attachment.d.ts +0 -99
- package/dist/shared/datastore-attachment.js +0 -184
- package/dist/shared/datastore-attachment.js.map +0 -1
- package/src/activities/execute-cursor/mcp-config.ts +0 -66
- package/src/activities/execute-deep-agent/__tests__/datastore-degradation.test.ts +0 -104
- package/src/shared/__tests__/datastore-attachment.test.ts +0 -293
- package/src/shared/datastore-attachment.ts +0 -209
|
@@ -49,6 +49,10 @@ describe("LocalArtifactStorage", () => {
|
|
|
49
49
|
expect(url).toBe("http://localhost:7235/artifacts/file.txt");
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
+
it("self-describes its URLs as local-serve (loopback reach, no expiry — issue #532)", () => {
|
|
53
|
+
expect(storage.downloadUrlKind).toBe("local-serve");
|
|
54
|
+
});
|
|
55
|
+
|
|
52
56
|
it("returns true for existing keys", async () => {
|
|
53
57
|
const key = "artifacts/exec-1/file.bin";
|
|
54
58
|
await storage.upload(key, Buffer.from("data"));
|
|
@@ -167,6 +171,11 @@ describe("ProxyArtifactStorage", () => {
|
|
|
167
171
|
globalThis.fetch = originalFetch;
|
|
168
172
|
});
|
|
169
173
|
|
|
174
|
+
it("self-describes its URLs as presigned (time-limited, remotely fetchable — issue #532)", () => {
|
|
175
|
+
const s = new ProxyArtifactStorage("https://proxy.example.com", "tok");
|
|
176
|
+
expect(s.downloadUrlKind).toBe("presigned");
|
|
177
|
+
});
|
|
178
|
+
|
|
170
179
|
it("uploads via presigned URL flow", async () => {
|
|
171
180
|
const calls: { url: string; method: string; body?: string }[] = [];
|
|
172
181
|
|
|
@@ -294,7 +303,13 @@ describe("ProxyArtifactStorage", () => {
|
|
|
294
303
|
return new Response("server error", { status: 500 });
|
|
295
304
|
}) as typeof fetch;
|
|
296
305
|
|
|
297
|
-
|
|
306
|
+
// 500 is retryable, so this persistent failure exhausts the budget —
|
|
307
|
+
// delayFn injected to skip the real ~7.75 s of backoff (#468). The
|
|
308
|
+
// degrade contract under test (budget exhaustion still throws the
|
|
309
|
+
// caller-visible error) is unchanged.
|
|
310
|
+
const storage = new ProxyArtifactStorage("https://proxy.example.com", "tok", {
|
|
311
|
+
delayFn: async () => {},
|
|
312
|
+
});
|
|
298
313
|
await expect(
|
|
299
314
|
storage.upload("key", Buffer.from("x")),
|
|
300
315
|
).rejects.toThrow("Presigned upload failed (HTTP 500)");
|
|
@@ -411,11 +426,148 @@ describe("ProxyArtifactStorage", () => {
|
|
|
411
426
|
|
|
412
427
|
it("exists throws on an unexpected object status (a fault, not an existence answer)", async () => {
|
|
413
428
|
mockProxyFetch(() => new Response("boom", { status: 500 }));
|
|
414
|
-
|
|
429
|
+
// Persistent 500 exhausts the retry budget; delayFn skips the real
|
|
430
|
+
// backoff (#468). The fault-not-an-answer contract is unchanged.
|
|
431
|
+
const storage = new ProxyArtifactStorage("https://proxy.example.com", "tok", {
|
|
432
|
+
delayFn: async () => {},
|
|
433
|
+
});
|
|
415
434
|
await expect(storage.exists("key")).rejects.toThrow(
|
|
416
435
|
/Artifact existence check failed \(HTTP 500\) for key 'key'/,
|
|
417
436
|
);
|
|
418
437
|
});
|
|
438
|
+
|
|
439
|
+
// The bounded-backoff adoption (stigmer/stigmer#468). Classification policy
|
|
440
|
+
// is covered by shared/__tests__/http-retry.test.ts; these cases pin the
|
|
441
|
+
// loop's behavior at this client's call sites: transient failures recover
|
|
442
|
+
// (each of these fails on pre-#468 code, which threw on the first error),
|
|
443
|
+
// deterministic failures never retry, and a hung request is aborted at the
|
|
444
|
+
// per-request bound instead of stalling forever.
|
|
445
|
+
describe("retry behavior (#468)", () => {
|
|
446
|
+
let recordedDelays: number[];
|
|
447
|
+
|
|
448
|
+
beforeEach(() => {
|
|
449
|
+
recordedDelays = [];
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
function makeStorage() {
|
|
453
|
+
return new ProxyArtifactStorage("https://proxy.example.com", "tok", {
|
|
454
|
+
delayFn: async (ms) => {
|
|
455
|
+
recordedDelays.push(ms);
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
it("upload recovers from a transient presign failure", async () => {
|
|
461
|
+
let presignCalls = 0;
|
|
462
|
+
const fetchSpy = vi.fn(async (input: string | URL | Request) => {
|
|
463
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
464
|
+
if (url.includes("presigned-upload-url")) {
|
|
465
|
+
presignCalls++;
|
|
466
|
+
if (presignCalls === 1) {
|
|
467
|
+
return new Response("bad gateway", { status: 502 });
|
|
468
|
+
}
|
|
469
|
+
return new Response(JSON.stringify({ url: "https://r2.example.com/put" }), { status: 200 });
|
|
470
|
+
}
|
|
471
|
+
return new Response("", { status: 200 });
|
|
472
|
+
});
|
|
473
|
+
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
|
474
|
+
|
|
475
|
+
const key = await makeStorage().upload("artifacts/e/f.txt", Buffer.from("x"));
|
|
476
|
+
|
|
477
|
+
expect(key).toBe("artifacts/e/f.txt");
|
|
478
|
+
expect(presignCalls).toBe(2);
|
|
479
|
+
expect(recordedDelays).toEqual([250]);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("upload recovers from a transient R2 PUT failure, re-sending identical bytes", async () => {
|
|
483
|
+
const putBodies: string[] = [];
|
|
484
|
+
let putCalls = 0;
|
|
485
|
+
globalThis.fetch = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
|
486
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
487
|
+
if (url.includes("presigned-upload-url")) {
|
|
488
|
+
return new Response(JSON.stringify({ url: "https://r2.example.com/put" }), { status: 200 });
|
|
489
|
+
}
|
|
490
|
+
putCalls++;
|
|
491
|
+
putBodies.push(String(init?.body));
|
|
492
|
+
if (putCalls === 1) {
|
|
493
|
+
return new Response("unavailable", { status: 503 });
|
|
494
|
+
}
|
|
495
|
+
return new Response("", { status: 200 });
|
|
496
|
+
}) as typeof fetch;
|
|
497
|
+
|
|
498
|
+
await makeStorage().upload("k", Buffer.from("same-bytes"));
|
|
499
|
+
|
|
500
|
+
expect(putCalls).toBe(2);
|
|
501
|
+
expect(putBodies[0]).toBe(putBodies[1]);
|
|
502
|
+
expect(recordedDelays).toEqual([250]);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("download recovers from a transient network failure on the R2 GET", async () => {
|
|
506
|
+
let getCalls = 0;
|
|
507
|
+
globalThis.fetch = vi.fn(async (input: string | URL | Request) => {
|
|
508
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
509
|
+
if (url.includes("presigned-download-url")) {
|
|
510
|
+
return new Response(JSON.stringify({ url: "https://r2.example.com/dl" }), { status: 200 });
|
|
511
|
+
}
|
|
512
|
+
getCalls++;
|
|
513
|
+
if (getCalls === 1) {
|
|
514
|
+
// undici's network-failure shape (retryable).
|
|
515
|
+
throw new TypeError("fetch failed");
|
|
516
|
+
}
|
|
517
|
+
return new Response(Buffer.from("payload"), { status: 200 });
|
|
518
|
+
}) as typeof fetch;
|
|
519
|
+
|
|
520
|
+
const got = await makeStorage().download("k");
|
|
521
|
+
|
|
522
|
+
expect(got.toString()).toBe("payload");
|
|
523
|
+
expect(getCalls).toBe(2);
|
|
524
|
+
expect(recordedDelays).toEqual([250]);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("never retries a deterministic presign refusal (403)", async () => {
|
|
528
|
+
const fetchSpy = vi.fn(async () => new Response("forbidden", { status: 403 }));
|
|
529
|
+
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
|
530
|
+
|
|
531
|
+
await expect(makeStorage().upload("k", Buffer.from("x"))).rejects.toThrow(
|
|
532
|
+
"Failed to get presigned upload URL (HTTP 403)",
|
|
533
|
+
);
|
|
534
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
535
|
+
expect(recordedDelays).toEqual([]);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("aborts a hung request at the per-request bound and retries it", async () => {
|
|
539
|
+
let calls = 0;
|
|
540
|
+
globalThis.fetch = vi.fn((input: string | URL | Request, init?: RequestInit) => {
|
|
541
|
+
calls++;
|
|
542
|
+
if (calls === 1) {
|
|
543
|
+
// Hang until the AbortSignal.timeout fires, then reject the way
|
|
544
|
+
// undici does — proving the bound converts a stall into a
|
|
545
|
+
// retryable attempt instead of waiting forever.
|
|
546
|
+
return new Promise<Response>((_, reject) => {
|
|
547
|
+
init?.signal?.addEventListener("abort", () =>
|
|
548
|
+
reject(init.signal!.reason as Error),
|
|
549
|
+
);
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
return Promise.resolve(
|
|
553
|
+
new Response(JSON.stringify({ url: "https://r2.example.com/dl" }), { status: 200 }),
|
|
554
|
+
);
|
|
555
|
+
}) as typeof fetch;
|
|
556
|
+
|
|
557
|
+
const storage = new ProxyArtifactStorage("https://proxy.example.com", "tok", {
|
|
558
|
+
requestTimeoutMs: 20,
|
|
559
|
+
delayFn: async (ms) => {
|
|
560
|
+
recordedDelays.push(ms);
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const url = await storage.getDownloadUrl("k");
|
|
565
|
+
|
|
566
|
+
expect(url).toBe("https://r2.example.com/dl");
|
|
567
|
+
expect(calls).toBe(2);
|
|
568
|
+
expect(recordedDelays).toEqual([250]);
|
|
569
|
+
});
|
|
570
|
+
});
|
|
419
571
|
});
|
|
420
572
|
|
|
421
573
|
// ── Factory ──────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the attachment download-URL hand-off policy (issue #532):
|
|
3
|
+
* the branch-independent mint rule, the non-fatal degrade, and the
|
|
4
|
+
* per-kind disclosure wording both harnesses embed.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
mintAttachmentDownloadUrl,
|
|
10
|
+
downloadUrlDisclosureLine,
|
|
11
|
+
} from "../attachment-download-urls.js";
|
|
12
|
+
import { makeInMemoryArtifactStorage } from "../../__test-utils__/fake-artifact-storage.js";
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
vi.restoreAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("mintAttachmentDownloadUrl", () => {
|
|
19
|
+
it("mints a URL for a storage key", async () => {
|
|
20
|
+
const { storage } = makeInMemoryArtifactStorage();
|
|
21
|
+
|
|
22
|
+
const url = await mintAttachmentDownloadUrl(storage, "attachments/01A/lease.pdf", "lease.pdf");
|
|
23
|
+
|
|
24
|
+
expect(url).toBe("mem://attachments/01A/lease.pdf");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns undefined when the attachment has no storage key", async () => {
|
|
28
|
+
const { storage } = makeInMemoryArtifactStorage();
|
|
29
|
+
|
|
30
|
+
const url = await mintAttachmentDownloadUrl(storage, "", "local.csv");
|
|
31
|
+
|
|
32
|
+
expect(url).toBeUndefined();
|
|
33
|
+
expect(storage.getDownloadUrl).not.toHaveBeenCalled();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns undefined when no storage is available", async () => {
|
|
37
|
+
const url = await mintAttachmentDownloadUrl(undefined, "attachments/01A/lease.pdf", "lease.pdf");
|
|
38
|
+
|
|
39
|
+
expect(url).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("degrades to undefined on a mint failure and logs the degrade (never throws)", async () => {
|
|
43
|
+
const { storage } = makeInMemoryArtifactStorage();
|
|
44
|
+
storage.getDownloadUrl.mockRejectedValueOnce(new Error("presign endpoint unreachable"));
|
|
45
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
46
|
+
|
|
47
|
+
const url = await mintAttachmentDownloadUrl(storage, "attachments/01A/lease.pdf", "lease.pdf");
|
|
48
|
+
|
|
49
|
+
expect(url).toBeUndefined();
|
|
50
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
51
|
+
expect(warn.mock.calls[0][0]).toContain("lease.pdf");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("downloadUrlDisclosureLine", () => {
|
|
56
|
+
it("promises time-limited single-object access for presigned URLs", () => {
|
|
57
|
+
const line = downloadUrlDisclosureLine("presigned");
|
|
58
|
+
|
|
59
|
+
expect(line).toContain("time-limited");
|
|
60
|
+
expect(line).toContain("single file");
|
|
61
|
+
expect(line).not.toContain("this machine");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("is honest about local-serve reach — same machine only, no expiry claim", () => {
|
|
65
|
+
const line = downloadUrlDisclosureLine("local-serve");
|
|
66
|
+
|
|
67
|
+
expect(line).toContain("reachable only from this machine");
|
|
68
|
+
expect(line).not.toContain("time-limited");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The channel messaging attachment (proactive-messaging DD-006 D7/D8):
|
|
3
3
|
* discovery with the never-throw failure posture, both connection
|
|
4
|
-
* shapes, the structural approval-freedom
|
|
5
|
-
*
|
|
4
|
+
* shapes, the structural approval-freedom of synthesized attachments,
|
|
5
|
+
* and the prompt section's filter/order/cap rules
|
|
6
6
|
* (DD-006 D6). The route is the cross-repo string, guarded here and in
|
|
7
7
|
* the mcp-server integration test (the TOOL_CALL_LIMIT precedent); the
|
|
8
8
|
* slug and roster are runner-internal and guarded here alone.
|
|
@@ -55,6 +55,30 @@ describe("formatConversationCatchupText", () => {
|
|
|
55
55
|
expect(framed).toContain("Continue from the customer's newest message.");
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
it("defines the send-status annotations — undelivered words are not settled history (cloud#347)", () => {
|
|
59
|
+
// The cloud composer marks lines the customer never got or may not
|
|
60
|
+
// have gotten yet; the preamble must define both annotations and
|
|
61
|
+
// carve them out of the don't-re-answer contract, or the agent would
|
|
62
|
+
// silently abandon whatever a failed teammate reply meant to convey.
|
|
63
|
+
expect(framed).toContain("(not delivered)");
|
|
64
|
+
expect(framed).toContain("never reached the customer");
|
|
65
|
+
expect(framed).toContain("(sending)");
|
|
66
|
+
expect(framed).toContain("still on their way");
|
|
67
|
+
expect(framed).toContain("weigh that when deciding what still needs saying");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("defines the send-outcome lines and the no-verbatim-resend contract (cloud#352, DD-009 Q-4)", () => {
|
|
71
|
+
// The agent's own failed sends reach it as `You (not delivered):`
|
|
72
|
+
// lines and `System:` delivery-failure notices. The owner-ruled
|
|
73
|
+
// behavior: unfinished business, re-said naturally — never a
|
|
74
|
+
// word-for-word resend (a multi-chunk send can partially land, and an
|
|
75
|
+
// exact repeat reads as a duplicate).
|
|
76
|
+
expect(framed).toContain("You (not delivered)");
|
|
77
|
+
expect(framed).toContain("was not delivered");
|
|
78
|
+
expect(framed).toContain("unfinished business");
|
|
79
|
+
expect(framed).toContain("never resend the failed text word-for-word");
|
|
80
|
+
});
|
|
81
|
+
|
|
58
82
|
it("asserts no takeover — a digest can exist with no human handoff at all (the A15/A20 honesty bar)", () => {
|
|
59
83
|
// The preamble may DESCRIBE what the digest can contain ("may include"),
|
|
60
84
|
// but must never state that a handoff happened on THIS conversation: a
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import { isRetryableHttpStatus, isRetryableFetchError } from "../http-retry.js";
|
|
3
3
|
|
|
4
|
-
// The bounded-backoff loop
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// The shared bounded-backoff loop (fetchWithRetry, same module) is exercised
|
|
5
|
+
// through its consumers' suites — http-saver.test.ts ("retry behavior"),
|
|
6
|
+
// artifact-storage.test.ts, registry-fetch tests — which pin the loop's
|
|
7
|
+
// behavior at real call sites rather than in the abstract. This file covers
|
|
8
|
+
// the classification policy in isolation — the grpc-retry.test.ts twin.
|
|
8
9
|
|
|
9
10
|
describe("isRetryableHttpStatus", () => {
|
|
10
11
|
it("returns true for 408 (request timeout)", () => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
|
2
2
|
import type { Connection } from "@langchain/mcp-adapters";
|
|
3
3
|
import { connectMcpServers, toMcpClientConfig } from "../mcp-manager.js";
|
|
4
|
+
import { RUNNER_CREDENTIAL_ENV_KEYS } from "../runner-credential-keys.js";
|
|
4
5
|
import type { ResolvedMcpServer } from "../mcp-resolver.js";
|
|
5
6
|
|
|
6
7
|
// The adapter client is mocked ONLY for the connectMcpServers tests below
|
|
@@ -112,25 +113,15 @@ describe("toMcpClientConfig", () => {
|
|
|
112
113
|
});
|
|
113
114
|
});
|
|
114
115
|
|
|
115
|
-
// Runner-internal credentials that must never reach an MCP stdio subprocess
|
|
116
|
-
// (oss#256). Names mirror what the runner actually reads: config.ts,
|
|
117
|
-
// fingerprint-secret.ts, model-client.ts, registry-endpoint.ts.
|
|
118
|
-
const RUNNER_CREDENTIAL_KEYS = [
|
|
119
|
-
"STIGMER_RUNNER_HITL_SECRET",
|
|
120
|
-
"STIGMER_TOKEN",
|
|
121
|
-
"STIGMER_AUTH_TOKEN",
|
|
122
|
-
"CURSOR_API_KEY",
|
|
123
|
-
"ANTHROPIC_API_KEY",
|
|
124
|
-
"OPENAI_API_KEY",
|
|
125
|
-
] as const;
|
|
126
|
-
|
|
127
116
|
describe("toMcpClientConfig — stdio env isolation (oss#256)", () => {
|
|
128
117
|
afterEach(() => {
|
|
129
118
|
vi.unstubAllEnvs();
|
|
130
119
|
});
|
|
131
120
|
|
|
132
121
|
it("gives a stdio server with no declared env none of the runner's variables", () => {
|
|
133
|
-
|
|
122
|
+
// The canaries are the runner's real credential names (oss#385's single
|
|
123
|
+
// source of truth), so a key added there is automatically covered here.
|
|
124
|
+
for (const key of RUNNER_CREDENTIAL_ENV_KEYS) {
|
|
134
125
|
vi.stubEnv(key, `leaked-${key}`);
|
|
135
126
|
}
|
|
136
127
|
vi.stubEnv("STIGMER_TEST_LEAK_SENTINEL", "canary");
|
|
@@ -249,3 +240,52 @@ describe("connectMcpServers — enabled_tools enforcement (issue #350)", () => {
|
|
|
249
240
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining("ghost"));
|
|
250
241
|
});
|
|
251
242
|
});
|
|
243
|
+
|
|
244
|
+
describe("connectMcpServers — schema sanitization (issue #420)", () => {
|
|
245
|
+
beforeEach(() => {
|
|
246
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
247
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
248
|
+
mockMcpClient.toolMap = {};
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
afterEach(() => {
|
|
252
|
+
vi.restoreAllMocks();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("drops unicode-invalid regex patterns from every discovered tool's schema and warns per tool", async () => {
|
|
256
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
257
|
+
const toxic = {
|
|
258
|
+
name: "toxic",
|
|
259
|
+
schema: {
|
|
260
|
+
type: "object",
|
|
261
|
+
// PayPal's issue-#420 shape: legal plain regex, invalid under /u.
|
|
262
|
+
properties: { url: { type: "string", pattern: "^https\\:\\/\\/" } },
|
|
263
|
+
},
|
|
264
|
+
} as any;
|
|
265
|
+
const clean = {
|
|
266
|
+
name: "clean",
|
|
267
|
+
schema: {
|
|
268
|
+
type: "object",
|
|
269
|
+
properties: { id: { type: "string", pattern: "^[a-z]+$" } },
|
|
270
|
+
},
|
|
271
|
+
} as any;
|
|
272
|
+
mockMcpClient.toolMap = { paypal: [toxic, clean] };
|
|
273
|
+
|
|
274
|
+
const result = await connectMcpServers([
|
|
275
|
+
makeServer({ slug: "paypal", connectionType: "http", url: "https://mcp.example.com/mcp" }),
|
|
276
|
+
]);
|
|
277
|
+
|
|
278
|
+
// Sanitization happens on the discovered tool objects themselves, so
|
|
279
|
+
// every downstream consumer (model binding, approval gate, sub-agent
|
|
280
|
+
// filter) sees the sanitized schema.
|
|
281
|
+
expect(result.serverToolMap.paypal[0].schema).toEqual({
|
|
282
|
+
type: "object",
|
|
283
|
+
properties: { url: { type: "string" } },
|
|
284
|
+
});
|
|
285
|
+
expect(result.serverToolMap.paypal[1].schema).toEqual(clean.schema);
|
|
286
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
287
|
+
expect(warn).toHaveBeenCalledWith(
|
|
288
|
+
expect.stringContaining("Server 'paypal' tool 'toxic'"),
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { loadMcpTools } from "@langchain/mcp-adapters";
|
|
3
|
+
import { DynamicStructuredTool } from "@langchain/core/tools";
|
|
4
|
+
import { sanitizeSchemaPatterns } from "../mcp-schema-sanitizer.js";
|
|
5
|
+
|
|
6
|
+
// PayPal's real-world specimen from issue #420: legal as a plain ECMAScript
|
|
7
|
+
// regex (Annex B identity escapes), invalid under the /u flag.
|
|
8
|
+
const TOXIC_PATTERN = "^https\\:\\/\\/";
|
|
9
|
+
|
|
10
|
+
describe("sanitizeSchemaPatterns — walker semantics", () => {
|
|
11
|
+
it("drops a unicode-invalid pattern and reports it", () => {
|
|
12
|
+
const schema = {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: {
|
|
15
|
+
url: { type: "string", pattern: TOXIC_PATTERN },
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const dropped = sanitizeSchemaPatterns(schema);
|
|
20
|
+
|
|
21
|
+
expect(schema.properties.url).toEqual({ type: "string" });
|
|
22
|
+
expect(dropped).toEqual([
|
|
23
|
+
{
|
|
24
|
+
location: "/properties/url/pattern",
|
|
25
|
+
pattern: TOXIC_PATTERN,
|
|
26
|
+
compilesWithoutUnicodeFlag: true,
|
|
27
|
+
},
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("flags patterns that are broken outright (invalid without /u too)", () => {
|
|
32
|
+
const schema = {
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: { name: { type: "string", pattern: "(unclosed" } },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const dropped = sanitizeSchemaPatterns(schema);
|
|
38
|
+
|
|
39
|
+
expect(schema.properties.name).toEqual({ type: "string" });
|
|
40
|
+
expect(dropped[0].compilesWithoutUnicodeFlag).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("leaves clean schemas byte-identical", () => {
|
|
44
|
+
const schema = {
|
|
45
|
+
type: "object",
|
|
46
|
+
properties: {
|
|
47
|
+
url: { type: "string", pattern: "^https://" },
|
|
48
|
+
tags: { type: "array", items: { type: "string", pattern: "^[a-z]+$" } },
|
|
49
|
+
},
|
|
50
|
+
patternProperties: { "^x-": { type: "string" } },
|
|
51
|
+
additionalProperties: false,
|
|
52
|
+
required: ["url"],
|
|
53
|
+
};
|
|
54
|
+
const before = JSON.parse(JSON.stringify(schema));
|
|
55
|
+
|
|
56
|
+
expect(sanitizeSchemaPatterns(schema)).toEqual([]);
|
|
57
|
+
expect(schema).toEqual(before);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("never touches a property literally named 'pattern'", () => {
|
|
61
|
+
// 'pattern' as a property NAME is data, not a keyword — but the pattern
|
|
62
|
+
// KEYWORD on that property's own schema is still sanitized.
|
|
63
|
+
const schema = {
|
|
64
|
+
type: "object",
|
|
65
|
+
properties: {
|
|
66
|
+
pattern: { type: "string", pattern: TOXIC_PATTERN },
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const dropped = sanitizeSchemaPatterns(schema);
|
|
71
|
+
|
|
72
|
+
expect(Object.keys(schema.properties)).toEqual(["pattern"]);
|
|
73
|
+
expect(schema.properties.pattern).toEqual({ type: "string" });
|
|
74
|
+
expect(dropped[0].location).toBe("/properties/pattern/pattern");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("leaves a non-string pattern value alone", () => {
|
|
78
|
+
const schema = { type: "object", properties: { n: { pattern: 123 } } };
|
|
79
|
+
expect(sanitizeSchemaPatterns(schema)).toEqual([]);
|
|
80
|
+
expect((schema.properties.n as { pattern: unknown }).pattern).toBe(123);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("reaches nested schema positions", () => {
|
|
84
|
+
const schema = {
|
|
85
|
+
type: "object",
|
|
86
|
+
properties: {
|
|
87
|
+
choice: {
|
|
88
|
+
anyOf: [
|
|
89
|
+
{ type: "string", pattern: TOXIC_PATTERN },
|
|
90
|
+
{ type: "number" },
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
tuple: {
|
|
94
|
+
type: "array",
|
|
95
|
+
items: [{ type: "string", pattern: TOXIC_PATTERN }],
|
|
96
|
+
},
|
|
97
|
+
names: { propertyNames: { pattern: TOXIC_PATTERN } },
|
|
98
|
+
},
|
|
99
|
+
$defs: {
|
|
100
|
+
link: { type: "string", pattern: TOXIC_PATTERN },
|
|
101
|
+
},
|
|
102
|
+
dependencies: {
|
|
103
|
+
// Array form is dependentRequired (names, not schemas) — untouched.
|
|
104
|
+
a: ["b"],
|
|
105
|
+
c: { properties: { d: { type: "string", pattern: TOXIC_PATTERN } } },
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const dropped = sanitizeSchemaPatterns(schema);
|
|
110
|
+
|
|
111
|
+
expect(dropped.map((d) => d.location).sort()).toEqual([
|
|
112
|
+
"/$defs/link/pattern",
|
|
113
|
+
"/dependencies/c/properties/d/pattern",
|
|
114
|
+
"/properties/choice/anyOf/0/pattern",
|
|
115
|
+
"/properties/names/propertyNames/pattern",
|
|
116
|
+
"/properties/tuple/items/0/pattern",
|
|
117
|
+
]);
|
|
118
|
+
expect(schema.dependencies.a).toEqual(["b"]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("terminates on a cyclic schema graph", () => {
|
|
122
|
+
const node: Record<string, unknown> = {
|
|
123
|
+
type: "object",
|
|
124
|
+
properties: { url: { type: "string", pattern: TOXIC_PATTERN } },
|
|
125
|
+
};
|
|
126
|
+
node.not = node;
|
|
127
|
+
|
|
128
|
+
const dropped = sanitizeSchemaPatterns(node);
|
|
129
|
+
expect(dropped).toHaveLength(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("ignores non-object input", () => {
|
|
133
|
+
expect(sanitizeSchemaPatterns(undefined)).toEqual([]);
|
|
134
|
+
expect(sanitizeSchemaPatterns("pattern")).toEqual([]);
|
|
135
|
+
expect(sanitizeSchemaPatterns([{ pattern: TOXIC_PATTERN }])).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("sanitizeSchemaPatterns — never-tighten invariant (patternProperties)", () => {
|
|
140
|
+
// Slash-free /u-invalid key so the expected report location needs no
|
|
141
|
+
// JSON-pointer escaping (the walker escapes `/` in map keys as `~1`).
|
|
142
|
+
const TOXIC_KEY = "^x\\:";
|
|
143
|
+
|
|
144
|
+
it("drops a broken patternProperties key and relaxes restrictive additionalProperties", () => {
|
|
145
|
+
const schema = {
|
|
146
|
+
type: "object",
|
|
147
|
+
patternProperties: {
|
|
148
|
+
[TOXIC_KEY]: { type: "string" },
|
|
149
|
+
"^clean-": { type: "number" },
|
|
150
|
+
},
|
|
151
|
+
additionalProperties: false,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const dropped = sanitizeSchemaPatterns(schema);
|
|
155
|
+
|
|
156
|
+
expect(dropped).toEqual([
|
|
157
|
+
{
|
|
158
|
+
location: `/patternProperties/${TOXIC_KEY}`,
|
|
159
|
+
pattern: TOXIC_KEY,
|
|
160
|
+
compilesWithoutUnicodeFlag: true,
|
|
161
|
+
},
|
|
162
|
+
]);
|
|
163
|
+
expect(schema.patternProperties).toEqual({ "^clean-": { type: "number" } });
|
|
164
|
+
// Keys the dropped pattern used to match would fall through to
|
|
165
|
+
// additionalProperties: false and be falsely REJECTED — the relaxation
|
|
166
|
+
// is what keeps sanitization strictly loosening.
|
|
167
|
+
expect("additionalProperties" in schema).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("removes an emptied patternProperties and relaxes a restrictive subschema additionalProperties", () => {
|
|
171
|
+
const schema = {
|
|
172
|
+
type: "object",
|
|
173
|
+
patternProperties: { [TOXIC_KEY]: { type: "string" } },
|
|
174
|
+
additionalProperties: { type: "number" },
|
|
175
|
+
unevaluatedProperties: false,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
sanitizeSchemaPatterns(schema);
|
|
179
|
+
|
|
180
|
+
expect("patternProperties" in schema).toBe(false);
|
|
181
|
+
expect("additionalProperties" in schema).toBe(false);
|
|
182
|
+
expect("unevaluatedProperties" in schema).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("keeps additionalProperties when no patternProperties entry was dropped", () => {
|
|
186
|
+
const schema = {
|
|
187
|
+
type: "object",
|
|
188
|
+
patternProperties: { "^clean-": { type: "string" } },
|
|
189
|
+
additionalProperties: false,
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
sanitizeSchemaPatterns(schema);
|
|
193
|
+
|
|
194
|
+
expect(schema.additionalProperties).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("proves the loosened schema through the real validator: previously matched keys stay accepted", async () => {
|
|
198
|
+
// End-to-end through @langchain/core's own validation path (the exact
|
|
199
|
+
// consumer that compiles patterns with /u): after sanitization, an input
|
|
200
|
+
// whose key only the dropped pattern used to match must validate — not
|
|
201
|
+
// fall through to additionalProperties: false.
|
|
202
|
+
const schema = {
|
|
203
|
+
type: "object" as const,
|
|
204
|
+
patternProperties: { "^x\\:": { type: "string" as const } },
|
|
205
|
+
additionalProperties: false,
|
|
206
|
+
};
|
|
207
|
+
sanitizeSchemaPatterns(schema);
|
|
208
|
+
|
|
209
|
+
// Default generics: the JSON-schema constructor arm types invoke input
|
|
210
|
+
// as ToolCall otherwise, rejecting the pattern-shaped key at compile time.
|
|
211
|
+
const tool: DynamicStructuredTool = new DynamicStructuredTool({
|
|
212
|
+
name: "probe",
|
|
213
|
+
description: "d",
|
|
214
|
+
schema,
|
|
215
|
+
func: async () => "ok",
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
await expect(tool.invoke({ "x:key": "value" })).resolves.toBe("ok");
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("sanitizeSchemaPatterns — issue #420 regression pin", () => {
|
|
223
|
+
// The issue's exact repro: a tool loaded through the real adapter with the
|
|
224
|
+
// PayPal pattern shape loads fine but fails EVERY invocation with
|
|
225
|
+
// "SyntaxError: Invalid regular expression: /^https\:\/\//u".
|
|
226
|
+
const toxicTool = {
|
|
227
|
+
name: "toxic",
|
|
228
|
+
description: "d",
|
|
229
|
+
inputSchema: {
|
|
230
|
+
type: "object",
|
|
231
|
+
properties: { url: { type: "string", pattern: TOXIC_PATTERN } },
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
const fakeClient = {
|
|
235
|
+
listTools: async () => ({ tools: [toxicTool] }),
|
|
236
|
+
callTool: async () => ({ content: [{ type: "text", text: "called" }] }),
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
it("negative control: without sanitization the tool is uncallable (the defect)", async () => {
|
|
240
|
+
const tools = await loadMcpTools("srv", fakeClient as never, {
|
|
241
|
+
throwOnLoadError: true,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
await expect(tools[0].invoke({ url: "https://x.com" })).rejects.toThrow(
|
|
245
|
+
/Invalid regular expression/,
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("after sanitization the same tool invokes successfully", async () => {
|
|
250
|
+
const tools = await loadMcpTools("srv", fakeClient as never, {
|
|
251
|
+
throwOnLoadError: true,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const dropped = sanitizeSchemaPatterns(tools[0].schema);
|
|
255
|
+
|
|
256
|
+
expect(dropped).toEqual([
|
|
257
|
+
{
|
|
258
|
+
location: "/properties/url/pattern",
|
|
259
|
+
pattern: TOXIC_PATTERN,
|
|
260
|
+
compilesWithoutUnicodeFlag: true,
|
|
261
|
+
},
|
|
262
|
+
]);
|
|
263
|
+
await expect(tools[0].invoke({ url: "https://x.com" })).resolves.toBe(
|
|
264
|
+
"called",
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
});
|