@stigmer/runner 3.11.0 → 3.11.1-dev.20260812192248

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.
Files changed (177) hide show
  1. package/README.md +2 -0
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/discover-mcp-server.d.ts +7 -0
  4. package/dist/activities/discover-mcp-server.js +6 -4
  5. package/dist/activities/discover-mcp-server.js.map +1 -1
  6. package/dist/activities/emit-event.d.ts +14 -2
  7. package/dist/activities/emit-event.js +52 -17
  8. package/dist/activities/emit-event.js.map +1 -1
  9. package/dist/activities/execute-cursor/error-classifier.d.ts +19 -0
  10. package/dist/activities/execute-cursor/error-classifier.js +34 -3
  11. package/dist/activities/execute-cursor/error-classifier.js.map +1 -1
  12. package/dist/activities/execute-cursor/index.d.ts +46 -10
  13. package/dist/activities/execute-cursor/index.js +126 -36
  14. package/dist/activities/execute-cursor/index.js.map +1 -1
  15. package/dist/activities/execute-cursor/model-pricing-data.d.ts +2 -0
  16. package/dist/activities/execute-cursor/model-pricing-data.js +13 -3
  17. package/dist/activities/execute-cursor/model-pricing-data.js.map +1 -1
  18. package/dist/activities/execute-cursor/prompt-builder.d.ts +33 -0
  19. package/dist/activities/execute-cursor/prompt-builder.js +37 -0
  20. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  21. package/dist/activities/execute-cursor/turn-recovery.d.ts +52 -0
  22. package/dist/activities/execute-cursor/turn-recovery.js +193 -0
  23. package/dist/activities/execute-cursor/turn-recovery.js.map +1 -0
  24. package/dist/activities/execute-deep-agent/setup.js +16 -7
  25. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  26. package/dist/activities/execute-deep-agent/shell-env.d.ts +5 -1
  27. package/dist/activities/execute-deep-agent/shell-env.js +7 -9
  28. package/dist/activities/execute-deep-agent/shell-env.js.map +1 -1
  29. package/dist/activities/execute-deep-agent/subagent-transformer.js +7 -1
  30. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  31. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +17 -3
  32. package/dist/activities/execute-deep-agent/subagent-wiring.js +14 -2
  33. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  34. package/dist/activities/hydrate-workflow-execution.js +8 -5
  35. package/dist/activities/hydrate-workflow-execution.js.map +1 -1
  36. package/dist/activities/run-command.d.ts +5 -2
  37. package/dist/activities/run-command.js +22 -10
  38. package/dist/activities/run-command.js.map +1 -1
  39. package/dist/activities/run-env.d.ts +40 -0
  40. package/dist/activities/run-env.js +66 -0
  41. package/dist/activities/run-env.js.map +1 -0
  42. package/dist/bootstrap.js +5 -0
  43. package/dist/bootstrap.js.map +1 -1
  44. package/dist/client/stigmer-client.d.ts +83 -26
  45. package/dist/client/stigmer-client.js +88 -24
  46. package/dist/client/stigmer-client.js.map +1 -1
  47. package/dist/encryption/config.d.ts +41 -9
  48. package/dist/encryption/config.js +63 -23
  49. package/dist/encryption/config.js.map +1 -1
  50. package/dist/middleware/index.d.ts +3 -0
  51. package/dist/middleware/index.js +7 -0
  52. package/dist/middleware/index.js.map +1 -1
  53. package/dist/middleware/path-normalization.d.ts +57 -0
  54. package/dist/middleware/path-normalization.js +109 -0
  55. package/dist/middleware/path-normalization.js.map +1 -0
  56. package/dist/middleware/types.d.ts +8 -0
  57. package/dist/payload-codecs.d.ts +2 -1
  58. package/dist/payload-codecs.js +7 -3
  59. package/dist/payload-codecs.js.map +1 -1
  60. package/dist/runner-manager.js +17 -3
  61. package/dist/runner-manager.js.map +1 -1
  62. package/dist/runner.js +7 -3
  63. package/dist/runner.js.map +1 -1
  64. package/dist/shared/artifact-storage.d.ts +23 -1
  65. package/dist/shared/artifact-storage.js +51 -10
  66. package/dist/shared/artifact-storage.js.map +1 -1
  67. package/dist/shared/checkpointer/http-saver.d.ts +4 -20
  68. package/dist/shared/checkpointer/http-saver.js +15 -57
  69. package/dist/shared/checkpointer/http-saver.js.map +1 -1
  70. package/dist/shared/conversation-catchup.d.ts +9 -2
  71. package/dist/shared/conversation-catchup.js +39 -6
  72. package/dist/shared/conversation-catchup.js.map +1 -1
  73. package/dist/shared/grpc-retry.d.ts +5 -1
  74. package/dist/shared/grpc-retry.js +5 -1
  75. package/dist/shared/grpc-retry.js.map +1 -1
  76. package/dist/shared/http-retry.d.ts +51 -9
  77. package/dist/shared/http-retry.js +60 -9
  78. package/dist/shared/http-retry.js.map +1 -1
  79. package/dist/shared/mcp-manager.d.ts +8 -4
  80. package/dist/shared/mcp-manager.js +30 -4
  81. package/dist/shared/mcp-manager.js.map +1 -1
  82. package/dist/shared/mcp-schema-sanitizer.d.ts +70 -0
  83. package/dist/shared/mcp-schema-sanitizer.js +197 -0
  84. package/dist/shared/mcp-schema-sanitizer.js.map +1 -0
  85. package/dist/shared/model-client.d.ts +6 -0
  86. package/dist/shared/model-client.js +14 -3
  87. package/dist/shared/model-client.js.map +1 -1
  88. package/dist/shared/model-pricing-data.d.ts +2 -0
  89. package/dist/shared/model-pricing-data.js +13 -3
  90. package/dist/shared/model-pricing-data.js.map +1 -1
  91. package/dist/shared/model-registry.js +3 -2
  92. package/dist/shared/model-registry.js.map +1 -1
  93. package/dist/shared/plan-mode-permissions.d.ts +7 -0
  94. package/dist/shared/plan-mode-permissions.js +7 -0
  95. package/dist/shared/plan-mode-permissions.js.map +1 -1
  96. package/dist/shared/registry-endpoint.d.ts +20 -0
  97. package/dist/shared/registry-endpoint.js +25 -0
  98. package/dist/shared/registry-endpoint.js.map +1 -1
  99. package/dist/shared/runner-credential-keys.d.ts +24 -0
  100. package/dist/shared/runner-credential-keys.js +47 -0
  101. package/dist/shared/runner-credential-keys.js.map +1 -0
  102. package/dist/workflow-engine/resolve.d.ts +19 -1
  103. package/dist/workflow-engine/resolve.js +37 -2
  104. package/dist/workflow-engine/resolve.js.map +1 -1
  105. package/dist/workflows/connect-mcp-server.js +2 -0
  106. package/dist/workflows/connect-mcp-server.js.map +1 -1
  107. package/dist/workflows/types.d.ts +8 -0
  108. package/package.json +3 -4
  109. package/src/__test-utils__/__tests__/vitest-global-setup.test.ts +37 -0
  110. package/src/__test-utils__/vitest-global-setup.ts +50 -0
  111. package/src/__tests__/bootstrap.test.ts +43 -0
  112. package/src/__tests__/encryption-codec.test.ts +53 -0
  113. package/src/__tests__/preflight.test.ts +15 -0
  114. package/src/activities/__tests__/discover-mcp-server.test.ts +39 -1
  115. package/src/activities/__tests__/error-classifier.test.ts +94 -2
  116. package/src/activities/__tests__/run-command.test.ts +221 -0
  117. package/src/activities/discover-mcp-server.ts +16 -2
  118. package/src/activities/emit-event.ts +65 -21
  119. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +111 -2
  120. package/src/activities/execute-cursor/__tests__/model-pricing.test.ts +62 -1
  121. package/src/activities/execute-cursor/__tests__/turn-recovery.test.ts +160 -0
  122. package/src/activities/execute-cursor/error-classifier.ts +40 -3
  123. package/src/activities/execute-cursor/index.ts +154 -38
  124. package/src/activities/execute-cursor/model-pricing-data.ts +22 -3
  125. package/src/activities/execute-cursor/prompt-builder.ts +51 -0
  126. package/src/activities/execute-cursor/turn-recovery.ts +208 -0
  127. package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +174 -0
  128. package/src/activities/execute-deep-agent/__tests__/shell-env.test.ts +24 -10
  129. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +22 -17
  130. package/src/activities/execute-deep-agent/setup.ts +16 -8
  131. package/src/activities/execute-deep-agent/shell-env.ts +8 -9
  132. package/src/activities/execute-deep-agent/subagent-transformer.ts +7 -1
  133. package/src/activities/execute-deep-agent/subagent-wiring.ts +26 -3
  134. package/src/activities/hydrate-workflow-execution.ts +8 -5
  135. package/src/activities/run-command.ts +25 -12
  136. package/src/activities/run-env.ts +79 -0
  137. package/src/bootstrap.ts +5 -0
  138. package/src/client/__tests__/stigmer-client.test.ts +104 -5
  139. package/src/client/stigmer-client.ts +128 -29
  140. package/src/encryption/config.ts +87 -23
  141. package/src/middleware/__tests__/path-normalization.test.ts +140 -0
  142. package/src/middleware/index.ts +8 -0
  143. package/src/middleware/path-normalization.ts +125 -0
  144. package/src/middleware/types.ts +11 -0
  145. package/src/payload-codecs.ts +8 -2
  146. package/src/runner-manager.ts +21 -3
  147. package/src/runner.ts +7 -3
  148. package/src/shared/__tests__/artifact-storage.test.ts +145 -2
  149. package/src/shared/__tests__/conversation-catchup.test.ts +24 -0
  150. package/src/shared/__tests__/http-retry.test.ts +5 -4
  151. package/src/shared/__tests__/mcp-manager.test.ts +53 -13
  152. package/src/shared/__tests__/mcp-schema-sanitizer.test.ts +267 -0
  153. package/src/shared/__tests__/model-client.test.ts +46 -0
  154. package/src/shared/__tests__/model-pricing.test.ts +62 -2
  155. package/src/shared/__tests__/model-registry.test.ts +22 -0
  156. package/src/shared/artifact-storage.ts +75 -10
  157. package/src/shared/checkpointer/http-saver.ts +15 -60
  158. package/src/shared/conversation-catchup.ts +39 -6
  159. package/src/shared/grpc-retry.ts +5 -1
  160. package/src/shared/http-retry.ts +98 -9
  161. package/src/shared/mcp-manager.ts +35 -4
  162. package/src/shared/mcp-schema-sanitizer.ts +224 -0
  163. package/src/shared/model-client.ts +21 -3
  164. package/src/shared/model-pricing-data.ts +22 -3
  165. package/src/shared/model-registry.ts +7 -2
  166. package/src/shared/plan-mode-permissions.ts +7 -0
  167. package/src/shared/registry-endpoint.ts +27 -0
  168. package/src/shared/runner-credential-keys.ts +46 -0
  169. package/src/workflow-engine/__tests__/tasks/emit-event.test.ts +127 -49
  170. package/src/workflow-engine/resolve.ts +48 -2
  171. package/src/workflows/__tests__/connect-mcp-server.test.ts +25 -0
  172. package/src/workflows/connect-mcp-server.ts +2 -0
  173. package/src/workflows/types.ts +8 -0
  174. package/dist/activities/execute-cursor/mcp-config.d.ts +0 -30
  175. package/dist/activities/execute-cursor/mcp-config.js +0 -39
  176. package/dist/activities/execute-cursor/mcp-config.js.map +0 -1
  177. package/src/activities/execute-cursor/mcp-config.ts +0 -66
@@ -294,7 +294,13 @@ describe("ProxyArtifactStorage", () => {
294
294
  return new Response("server error", { status: 500 });
295
295
  }) as typeof fetch;
296
296
 
297
- const storage = new ProxyArtifactStorage("https://proxy.example.com", "tok");
297
+ // 500 is retryable, so this persistent failure exhausts the budget —
298
+ // delayFn injected to skip the real ~7.75 s of backoff (#468). The
299
+ // degrade contract under test (budget exhaustion still throws the
300
+ // caller-visible error) is unchanged.
301
+ const storage = new ProxyArtifactStorage("https://proxy.example.com", "tok", {
302
+ delayFn: async () => {},
303
+ });
298
304
  await expect(
299
305
  storage.upload("key", Buffer.from("x")),
300
306
  ).rejects.toThrow("Presigned upload failed (HTTP 500)");
@@ -411,11 +417,148 @@ describe("ProxyArtifactStorage", () => {
411
417
 
412
418
  it("exists throws on an unexpected object status (a fault, not an existence answer)", async () => {
413
419
  mockProxyFetch(() => new Response("boom", { status: 500 }));
414
- const storage = new ProxyArtifactStorage("https://proxy.example.com", "tok");
420
+ // Persistent 500 exhausts the retry budget; delayFn skips the real
421
+ // backoff (#468). The fault-not-an-answer contract is unchanged.
422
+ const storage = new ProxyArtifactStorage("https://proxy.example.com", "tok", {
423
+ delayFn: async () => {},
424
+ });
415
425
  await expect(storage.exists("key")).rejects.toThrow(
416
426
  /Artifact existence check failed \(HTTP 500\) for key 'key'/,
417
427
  );
418
428
  });
429
+
430
+ // The bounded-backoff adoption (stigmer/stigmer#468). Classification policy
431
+ // is covered by shared/__tests__/http-retry.test.ts; these cases pin the
432
+ // loop's behavior at this client's call sites: transient failures recover
433
+ // (each of these fails on pre-#468 code, which threw on the first error),
434
+ // deterministic failures never retry, and a hung request is aborted at the
435
+ // per-request bound instead of stalling forever.
436
+ describe("retry behavior (#468)", () => {
437
+ let recordedDelays: number[];
438
+
439
+ beforeEach(() => {
440
+ recordedDelays = [];
441
+ });
442
+
443
+ function makeStorage() {
444
+ return new ProxyArtifactStorage("https://proxy.example.com", "tok", {
445
+ delayFn: async (ms) => {
446
+ recordedDelays.push(ms);
447
+ },
448
+ });
449
+ }
450
+
451
+ it("upload recovers from a transient presign failure", async () => {
452
+ let presignCalls = 0;
453
+ const fetchSpy = vi.fn(async (input: string | URL | Request) => {
454
+ const url = typeof input === "string" ? input : input.toString();
455
+ if (url.includes("presigned-upload-url")) {
456
+ presignCalls++;
457
+ if (presignCalls === 1) {
458
+ return new Response("bad gateway", { status: 502 });
459
+ }
460
+ return new Response(JSON.stringify({ url: "https://r2.example.com/put" }), { status: 200 });
461
+ }
462
+ return new Response("", { status: 200 });
463
+ });
464
+ globalThis.fetch = fetchSpy as unknown as typeof fetch;
465
+
466
+ const key = await makeStorage().upload("artifacts/e/f.txt", Buffer.from("x"));
467
+
468
+ expect(key).toBe("artifacts/e/f.txt");
469
+ expect(presignCalls).toBe(2);
470
+ expect(recordedDelays).toEqual([250]);
471
+ });
472
+
473
+ it("upload recovers from a transient R2 PUT failure, re-sending identical bytes", async () => {
474
+ const putBodies: string[] = [];
475
+ let putCalls = 0;
476
+ globalThis.fetch = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
477
+ const url = typeof input === "string" ? input : input.toString();
478
+ if (url.includes("presigned-upload-url")) {
479
+ return new Response(JSON.stringify({ url: "https://r2.example.com/put" }), { status: 200 });
480
+ }
481
+ putCalls++;
482
+ putBodies.push(String(init?.body));
483
+ if (putCalls === 1) {
484
+ return new Response("unavailable", { status: 503 });
485
+ }
486
+ return new Response("", { status: 200 });
487
+ }) as typeof fetch;
488
+
489
+ await makeStorage().upload("k", Buffer.from("same-bytes"));
490
+
491
+ expect(putCalls).toBe(2);
492
+ expect(putBodies[0]).toBe(putBodies[1]);
493
+ expect(recordedDelays).toEqual([250]);
494
+ });
495
+
496
+ it("download recovers from a transient network failure on the R2 GET", async () => {
497
+ let getCalls = 0;
498
+ globalThis.fetch = vi.fn(async (input: string | URL | Request) => {
499
+ const url = typeof input === "string" ? input : input.toString();
500
+ if (url.includes("presigned-download-url")) {
501
+ return new Response(JSON.stringify({ url: "https://r2.example.com/dl" }), { status: 200 });
502
+ }
503
+ getCalls++;
504
+ if (getCalls === 1) {
505
+ // undici's network-failure shape (retryable).
506
+ throw new TypeError("fetch failed");
507
+ }
508
+ return new Response(Buffer.from("payload"), { status: 200 });
509
+ }) as typeof fetch;
510
+
511
+ const got = await makeStorage().download("k");
512
+
513
+ expect(got.toString()).toBe("payload");
514
+ expect(getCalls).toBe(2);
515
+ expect(recordedDelays).toEqual([250]);
516
+ });
517
+
518
+ it("never retries a deterministic presign refusal (403)", async () => {
519
+ const fetchSpy = vi.fn(async () => new Response("forbidden", { status: 403 }));
520
+ globalThis.fetch = fetchSpy as unknown as typeof fetch;
521
+
522
+ await expect(makeStorage().upload("k", Buffer.from("x"))).rejects.toThrow(
523
+ "Failed to get presigned upload URL (HTTP 403)",
524
+ );
525
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
526
+ expect(recordedDelays).toEqual([]);
527
+ });
528
+
529
+ it("aborts a hung request at the per-request bound and retries it", async () => {
530
+ let calls = 0;
531
+ globalThis.fetch = vi.fn((input: string | URL | Request, init?: RequestInit) => {
532
+ calls++;
533
+ if (calls === 1) {
534
+ // Hang until the AbortSignal.timeout fires, then reject the way
535
+ // undici does — proving the bound converts a stall into a
536
+ // retryable attempt instead of waiting forever.
537
+ return new Promise<Response>((_, reject) => {
538
+ init?.signal?.addEventListener("abort", () =>
539
+ reject(init.signal!.reason as Error),
540
+ );
541
+ });
542
+ }
543
+ return Promise.resolve(
544
+ new Response(JSON.stringify({ url: "https://r2.example.com/dl" }), { status: 200 }),
545
+ );
546
+ }) as typeof fetch;
547
+
548
+ const storage = new ProxyArtifactStorage("https://proxy.example.com", "tok", {
549
+ requestTimeoutMs: 20,
550
+ delayFn: async (ms) => {
551
+ recordedDelays.push(ms);
552
+ },
553
+ });
554
+
555
+ const url = await storage.getDownloadUrl("k");
556
+
557
+ expect(url).toBe("https://r2.example.com/dl");
558
+ expect(calls).toBe(2);
559
+ expect(recordedDelays).toEqual([250]);
560
+ });
561
+ });
419
562
  });
420
563
 
421
564
  // ── Factory ──────────────────────────────────────────────────────────
@@ -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 that consumes these classifiers lives in
5
- // checkpointer/http-saver.ts (fetchWithRetry) and is exercised by
6
- // http-saver.test.ts ("retry behavior"). This file covers the classification
7
- // policy in isolation the grpc-retry.test.ts twin.
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
- for (const key of RUNNER_CREDENTIAL_KEYS) {
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
+ });
@@ -56,6 +56,7 @@ describe("buildChatModel", () => {
56
56
  delete process.env.STIGMER_ANTHROPIC_BACKEND;
57
57
  delete process.env.STIGMER_OPENAI_BACKEND;
58
58
  delete process.env.CLOUD_ML_REGION;
59
+ delete process.env.STIGMER_LLM_REQUEST_TIMEOUT_MS;
59
60
  });
60
61
 
61
62
  afterEach(() => {
@@ -215,6 +216,51 @@ describe("buildChatModel", () => {
215
216
  },
216
217
  });
217
218
  });
219
+
220
+ // The env default resolves INSIDE buildChatModel (#468): before this,
221
+ // each caller parsed STIGMER_LLM_REQUEST_TIMEOUT_MS itself, and every
222
+ // caller except the deep-agent main path — including its own sub-agent
223
+ // modelFactory — dropped the bound.
224
+ it("defaults to STIGMER_LLM_REQUEST_TIMEOUT_MS when the caller passes no timeout", async () => {
225
+ process.env.STIGMER_LLM_REQUEST_TIMEOUT_MS = "7000";
226
+ mockRegistryResponse([
227
+ { id: "claude-haiku-4.5", apiModelId: "claude-haiku-4-5-20251001", provider: "anthropic" },
228
+ ]);
229
+
230
+ await buildChatModel({ modelName: "claude-haiku-4.5" });
231
+
232
+ expect(lastAnthropicArgs()).toMatchObject({
233
+ clientOptions: { timeout: 7000 },
234
+ maxRetries: 0,
235
+ });
236
+ });
237
+
238
+ it("lets an explicit caller timeout win over the env default", async () => {
239
+ process.env.STIGMER_LLM_REQUEST_TIMEOUT_MS = "7000";
240
+ mockRegistryResponse([
241
+ { id: "claude-haiku-4.5", apiModelId: "claude-haiku-4-5-20251001", provider: "anthropic" },
242
+ ]);
243
+
244
+ await buildChatModel({ modelName: "claude-haiku-4.5", timeoutMs: 5000 });
245
+
246
+ expect(lastAnthropicArgs()).toMatchObject({ clientOptions: { timeout: 5000 } });
247
+ });
248
+
249
+ it.each(["0", "-1", "not-a-number", ""])(
250
+ "normalizes a non-positive env value (%j) to no bound",
251
+ async (envValue) => {
252
+ process.env.STIGMER_LLM_REQUEST_TIMEOUT_MS = envValue;
253
+ mockRegistryResponse([
254
+ { id: "claude-haiku-4.5", apiModelId: "claude-haiku-4-5-20251001", provider: "anthropic" },
255
+ ]);
256
+
257
+ await buildChatModel({ modelName: "claude-haiku-4.5" });
258
+
259
+ const args = lastAnthropicArgs();
260
+ expect(args).not.toHaveProperty("clientOptions");
261
+ expect(args).not.toHaveProperty("maxRetries");
262
+ },
263
+ );
218
264
  });
219
265
 
220
266
  // ─── Provider backends (STIGMER_ANTHROPIC_BACKEND) ─────────────────────────