@stigmer/runner 3.9.0 → 3.11.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.
Files changed (270) hide show
  1. package/README.md +12 -1
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/call-llm.js +9 -10
  4. package/dist/activities/call-llm.js.map +1 -1
  5. package/dist/activities/classify-tool-approvals.d.ts +2 -1
  6. package/dist/activities/classify-tool-approvals.js +28 -2
  7. package/dist/activities/classify-tool-approvals.js.map +1 -1
  8. package/dist/activities/discover-mcp-server.d.ts +32 -0
  9. package/dist/activities/discover-mcp-server.js +162 -27
  10. package/dist/activities/discover-mcp-server.js.map +1 -1
  11. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +8 -0
  12. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +1 -1
  13. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
  14. package/dist/activities/execute-cursor/approval-state.d.ts +28 -2
  15. package/dist/activities/execute-cursor/approval-state.js +7 -1
  16. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  17. package/dist/activities/execute-cursor/attachment-resolver.d.ts +14 -0
  18. package/dist/activities/execute-cursor/attachment-resolver.js +25 -4
  19. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  20. package/dist/activities/execute-cursor/blueprint-resolver.d.ts +1 -9
  21. package/dist/activities/execute-cursor/blueprint-resolver.js +6 -22
  22. package/dist/activities/execute-cursor/blueprint-resolver.js.map +1 -1
  23. package/dist/activities/execute-cursor/env-resolver.js +3 -1
  24. package/dist/activities/execute-cursor/env-resolver.js.map +1 -1
  25. package/dist/activities/execute-cursor/error-classifier.d.ts +40 -3
  26. package/dist/activities/execute-cursor/error-classifier.js +81 -3
  27. package/dist/activities/execute-cursor/error-classifier.js.map +1 -1
  28. package/dist/activities/execute-cursor/extract-structured-output.d.ts +29 -0
  29. package/dist/activities/execute-cursor/extract-structured-output.js +58 -0
  30. package/dist/activities/execute-cursor/extract-structured-output.js.map +1 -0
  31. package/dist/activities/execute-cursor/hook-script.d.ts +14 -3
  32. package/dist/activities/execute-cursor/hook-script.js +72 -10
  33. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  34. package/dist/activities/execute-cursor/index.d.ts +5 -1
  35. package/dist/activities/execute-cursor/index.js +59 -58
  36. package/dist/activities/execute-cursor/index.js.map +1 -1
  37. package/dist/activities/execute-cursor/mcp-resolver.d.ts +24 -1
  38. package/dist/activities/execute-cursor/mcp-resolver.js +5 -2
  39. package/dist/activities/execute-cursor/mcp-resolver.js.map +1 -1
  40. package/dist/activities/execute-cursor/prompt-builder.d.ts +18 -4
  41. package/dist/activities/execute-cursor/prompt-builder.js +12 -7
  42. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  43. package/dist/activities/execute-cursor/turn-stream.js +4 -1
  44. package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
  45. package/dist/activities/execute-deep-agent/attachment-injector.d.ts +18 -1
  46. package/dist/activities/execute-deep-agent/attachment-injector.js +68 -23
  47. package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
  48. package/dist/activities/execute-deep-agent/environment.js +3 -1
  49. package/dist/activities/execute-deep-agent/environment.js.map +1 -1
  50. package/dist/activities/execute-deep-agent/index.js +15 -0
  51. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  52. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +7 -7
  53. package/dist/activities/execute-deep-agent/prompt-builder.js +8 -2
  54. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  55. package/dist/activities/execute-deep-agent/setup.d.ts +10 -0
  56. package/dist/activities/execute-deep-agent/setup.js +65 -26
  57. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  58. package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +18 -1
  59. package/dist/activities/execute-deep-agent/subagent-transformer.js +8 -1
  60. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  61. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +11 -4
  62. package/dist/activities/execute-deep-agent/subagent-wiring.js +13 -4
  63. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  64. package/dist/activities/hydrate-workflow-execution.js +3 -1
  65. package/dist/activities/hydrate-workflow-execution.js.map +1 -1
  66. package/dist/activities/workflow-event-activities.d.ts +28 -10
  67. package/dist/activities/workflow-event-activities.js +87 -58
  68. package/dist/activities/workflow-event-activities.js.map +1 -1
  69. package/dist/claimcheck/payload-codec.js +21 -1
  70. package/dist/claimcheck/payload-codec.js.map +1 -1
  71. package/dist/client/stigmer-client.d.ts +9 -4
  72. package/dist/client/stigmer-client.js +28 -15
  73. package/dist/client/stigmer-client.js.map +1 -1
  74. package/dist/encryption/config.d.ts +32 -0
  75. package/dist/encryption/config.js +68 -0
  76. package/dist/encryption/config.js.map +1 -0
  77. package/dist/encryption/index.d.ts +3 -0
  78. package/dist/encryption/index.js +3 -0
  79. package/dist/encryption/index.js.map +1 -0
  80. package/dist/encryption/payload-codec.d.ts +41 -0
  81. package/dist/encryption/payload-codec.js +130 -0
  82. package/dist/encryption/payload-codec.js.map +1 -0
  83. package/dist/payload-codecs.d.ts +16 -0
  84. package/dist/payload-codecs.js +38 -0
  85. package/dist/payload-codecs.js.map +1 -0
  86. package/dist/preflight.d.ts +31 -0
  87. package/dist/preflight.js +43 -0
  88. package/dist/preflight.js.map +1 -1
  89. package/dist/runner-manager.js +5 -15
  90. package/dist/runner-manager.js.map +1 -1
  91. package/dist/runner.js +5 -16
  92. package/dist/runner.js.map +1 -1
  93. package/dist/shared/approval-policy.d.ts +9 -3
  94. package/dist/shared/approval-policy.js +15 -6
  95. package/dist/shared/approval-policy.js.map +1 -1
  96. package/dist/shared/attachment-naming.d.ts +53 -0
  97. package/dist/shared/attachment-naming.js +59 -0
  98. package/dist/shared/attachment-naming.js.map +1 -0
  99. package/dist/shared/attachment-vision.d.ts +43 -2
  100. package/dist/shared/attachment-vision.js +72 -6
  101. package/dist/shared/attachment-vision.js.map +1 -1
  102. package/dist/shared/caller-identity.d.ts +23 -2
  103. package/dist/shared/caller-identity.js +36 -5
  104. package/dist/shared/caller-identity.js.map +1 -1
  105. package/dist/shared/channel-attachment.js +1 -0
  106. package/dist/shared/channel-attachment.js.map +1 -1
  107. package/dist/shared/checkpointer/http-saver.d.ts +42 -1
  108. package/dist/shared/checkpointer/http-saver.js +96 -8
  109. package/dist/shared/checkpointer/http-saver.js.map +1 -1
  110. package/dist/shared/conversation-attachment.js +1 -0
  111. package/dist/shared/conversation-attachment.js.map +1 -1
  112. package/dist/shared/datastore-attachment.d.ts +50 -7
  113. package/dist/shared/datastore-attachment.js +93 -11
  114. package/dist/shared/datastore-attachment.js.map +1 -1
  115. package/dist/shared/http-retry.d.ts +43 -0
  116. package/dist/shared/http-retry.js +50 -0
  117. package/dist/shared/http-retry.js.map +1 -0
  118. package/dist/shared/llm-backend.d.ts +275 -0
  119. package/dist/shared/llm-backend.js +425 -0
  120. package/dist/shared/llm-backend.js.map +1 -0
  121. package/dist/shared/llm-proxy.d.ts +8 -0
  122. package/dist/shared/llm-proxy.js +15 -0
  123. package/dist/shared/llm-proxy.js.map +1 -1
  124. package/dist/shared/mcp-enabled-tools.d.ts +57 -0
  125. package/dist/shared/mcp-enabled-tools.js +86 -0
  126. package/dist/shared/mcp-enabled-tools.js.map +1 -0
  127. package/dist/shared/mcp-manager.d.ts +17 -2
  128. package/dist/shared/mcp-manager.js +37 -25
  129. package/dist/shared/mcp-manager.js.map +1 -1
  130. package/dist/shared/mcp-resolver.d.ts +39 -2
  131. package/dist/shared/mcp-resolver.js +38 -2
  132. package/dist/shared/mcp-resolver.js.map +1 -1
  133. package/dist/shared/model-client.d.ts +12 -5
  134. package/dist/shared/model-client.js +138 -18
  135. package/dist/shared/model-client.js.map +1 -1
  136. package/dist/shared/model-error.js +198 -5
  137. package/dist/shared/model-error.js.map +1 -1
  138. package/dist/shared/model-registry.d.ts +20 -2
  139. package/dist/shared/model-registry.js +37 -2
  140. package/dist/shared/model-registry.js.map +1 -1
  141. package/dist/shared/plan-mode-permissions.d.ts +26 -0
  142. package/dist/shared/plan-mode-permissions.js +28 -0
  143. package/dist/shared/plan-mode-permissions.js.map +1 -0
  144. package/dist/worker.d.ts +2 -1
  145. package/dist/worker.js +2 -4
  146. package/dist/worker.js.map +1 -1
  147. package/dist/workflow-engine/types.d.ts +18 -0
  148. package/dist/workflow-engine/types.js.map +1 -1
  149. package/dist/workflows/call-agent-orchestrator.d.ts +9 -0
  150. package/dist/workflows/call-agent-orchestrator.js +1 -0
  151. package/dist/workflows/call-agent-orchestrator.js.map +1 -1
  152. package/dist/workflows/connect-mcp-server.js +7 -0
  153. package/dist/workflows/connect-mcp-server.js.map +1 -1
  154. package/dist/workflows/engine-core.js +23 -2
  155. package/dist/workflows/engine-core.js.map +1 -1
  156. package/dist/workflows/execute-from-execution.d.ts +1 -1
  157. package/dist/workflows/execute-from-execution.js +11 -1
  158. package/dist/workflows/execute-from-execution.js.map +1 -1
  159. package/package.json +8 -2
  160. package/src/__tests__/claimcheck-codec.test.ts +36 -0
  161. package/src/__tests__/encryption-codec.test.ts +234 -0
  162. package/src/__tests__/fixtures/encrypted-payload-fixture.json +15 -0
  163. package/src/__tests__/history-encryption-e2e.test.ts +243 -0
  164. package/src/__tests__/preflight.test.ts +50 -2
  165. package/src/activities/__tests__/call-llm.test.ts +75 -0
  166. package/src/activities/__tests__/classify-tool-approvals.test.ts +117 -1
  167. package/src/activities/__tests__/discover-mcp-server.hang.test.ts +103 -0
  168. package/src/activities/__tests__/discover-mcp-server.test.ts +203 -0
  169. package/src/activities/__tests__/workflow-event-activities.test.ts +107 -8
  170. package/src/activities/call-llm.ts +9 -16
  171. package/src/activities/classify-tool-approvals.ts +34 -4
  172. package/src/activities/discover-mcp-server.ts +190 -32
  173. package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +9 -0
  174. package/src/activities/execute-cursor/__tests__/approval-gate.test.ts +14 -0
  175. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +92 -0
  176. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +40 -14
  177. package/src/activities/execute-cursor/__tests__/error-classifier-extraction.test.ts +208 -0
  178. package/src/activities/execute-cursor/__tests__/extract-structured-output.test.ts +120 -0
  179. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +93 -0
  180. package/src/activities/execute-cursor/__tests__/mcp-resolver.test.ts +125 -0
  181. package/src/activities/execute-cursor/__tests__/prompt-builder-delegation.test.ts +1 -1
  182. package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +13 -0
  183. package/src/activities/execute-cursor/approval-state.ts +30 -1
  184. package/src/activities/execute-cursor/attachment-resolver.ts +38 -3
  185. package/src/activities/execute-cursor/blueprint-resolver.ts +7 -27
  186. package/src/activities/execute-cursor/env-resolver.ts +3 -1
  187. package/src/activities/execute-cursor/error-classifier.ts +91 -4
  188. package/src/activities/execute-cursor/extract-structured-output.ts +72 -0
  189. package/src/activities/execute-cursor/hook-script.ts +74 -10
  190. package/src/activities/execute-cursor/index.ts +63 -72
  191. package/src/activities/execute-cursor/mcp-resolver.ts +36 -2
  192. package/src/activities/execute-cursor/prompt-builder.ts +34 -9
  193. package/src/activities/execute-cursor/turn-stream.ts +5 -2
  194. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +132 -8
  195. package/src/activities/execute-deep-agent/__tests__/datastore-degradation.test.ts +104 -0
  196. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +2 -0
  197. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
  198. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
  199. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +34 -5
  200. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
  201. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +173 -0
  202. package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +12 -7
  203. package/src/activities/execute-deep-agent/attachment-injector.ts +94 -30
  204. package/src/activities/execute-deep-agent/environment.ts +3 -1
  205. package/src/activities/execute-deep-agent/index.ts +20 -0
  206. package/src/activities/execute-deep-agent/prompt-builder.ts +20 -10
  207. package/src/activities/execute-deep-agent/setup.ts +86 -31
  208. package/src/activities/execute-deep-agent/subagent-transformer.ts +23 -1
  209. package/src/activities/execute-deep-agent/subagent-wiring.ts +14 -4
  210. package/src/activities/hydrate-workflow-execution.ts +3 -1
  211. package/src/activities/workflow-event-activities.ts +96 -69
  212. package/src/claimcheck/payload-codec.ts +33 -1
  213. package/src/client/__tests__/stigmer-client.test.ts +8 -8
  214. package/src/client/stigmer-client.ts +32 -18
  215. package/src/encryption/config.ts +91 -0
  216. package/src/encryption/index.ts +3 -0
  217. package/src/encryption/payload-codec.ts +152 -0
  218. package/src/payload-codecs.ts +56 -0
  219. package/src/preflight.ts +45 -0
  220. package/src/runner-manager.ts +6 -24
  221. package/src/runner.ts +6 -25
  222. package/src/shared/__tests__/approval-policy.test.ts +82 -39
  223. package/src/shared/__tests__/attachment-naming.test.ts +159 -0
  224. package/src/shared/__tests__/attachment-vision.test.ts +97 -0
  225. package/src/shared/__tests__/bedrock-adapter.test.ts +213 -0
  226. package/src/shared/__tests__/bedrock-seam.test.ts +390 -0
  227. package/src/shared/__tests__/caller-identity.test.ts +25 -0
  228. package/src/shared/__tests__/channel-attachment.test.ts +1 -1
  229. package/src/shared/__tests__/connect-backfill.test.ts +1 -0
  230. package/src/shared/__tests__/conversation-attachment.test.ts +1 -1
  231. package/src/shared/__tests__/datastore-attachment.test.ts +129 -1
  232. package/src/shared/__tests__/foundry-adapter.test.ts +276 -0
  233. package/src/shared/__tests__/foundry-seam.test.ts +482 -0
  234. package/src/shared/__tests__/http-retry.test.ts +67 -0
  235. package/src/shared/__tests__/llm-backend.test.ts +616 -0
  236. package/src/shared/__tests__/mcp-enabled-tools.test.ts +86 -0
  237. package/src/shared/__tests__/mcp-manager.test.ts +170 -2
  238. package/src/shared/__tests__/mcp-resolver.test.ts +146 -3
  239. package/src/shared/__tests__/model-client.test.ts +154 -0
  240. package/src/shared/__tests__/model-error.test.ts +289 -1
  241. package/src/shared/__tests__/model-registry.test.ts +71 -0
  242. package/src/shared/__tests__/synthesized-attachment.test.ts +1 -0
  243. package/src/shared/__tests__/vertex-adapter.test.ts +169 -0
  244. package/src/shared/__tests__/vertex-seam.test.ts +295 -0
  245. package/src/shared/approval-policy.ts +14 -7
  246. package/src/shared/attachment-naming.ts +78 -0
  247. package/src/shared/attachment-vision.ts +92 -9
  248. package/src/shared/caller-identity.ts +40 -5
  249. package/src/shared/channel-attachment.ts +1 -0
  250. package/src/shared/checkpointer/__tests__/http-saver.test.ts +196 -1
  251. package/src/shared/checkpointer/http-saver.ts +117 -9
  252. package/src/shared/conversation-attachment.ts +1 -0
  253. package/src/shared/datastore-attachment.ts +106 -11
  254. package/src/shared/http-retry.ts +50 -0
  255. package/src/shared/llm-backend.ts +544 -0
  256. package/src/shared/llm-proxy.ts +15 -0
  257. package/src/shared/mcp-enabled-tools.ts +105 -0
  258. package/src/shared/mcp-manager.ts +43 -26
  259. package/src/shared/mcp-resolver.ts +73 -2
  260. package/src/shared/model-client.ts +161 -19
  261. package/src/shared/model-error.ts +222 -4
  262. package/src/shared/model-registry.ts +50 -2
  263. package/src/shared/plan-mode-permissions.ts +30 -0
  264. package/src/worker.ts +4 -5
  265. package/src/workflow-engine/types.ts +18 -0
  266. package/src/workflows/__tests__/execute-serverless-workflow.test.ts +68 -2
  267. package/src/workflows/call-agent-orchestrator.ts +10 -0
  268. package/src/workflows/connect-mcp-server.ts +7 -0
  269. package/src/workflows/engine-core.ts +23 -2
  270. package/src/workflows/execute-from-execution.ts +12 -2
@@ -0,0 +1,243 @@
1
+ /**
2
+ * History-encryption tripwire (stigmer-cloud#227).
3
+ *
4
+ * Runs a secret-bearing workflow through a real Temporal server with the
5
+ * encryption codec installed, then scans the RAW workflow history and
6
+ * asserts the secret bytes appear in no payload. This is the test that
7
+ * proves the issue's claim is closed: without the codec, the secret
8
+ * appears in the hydrate activity result, in every per-task activity
9
+ * input (the engine passes the full env map), and in local-activity
10
+ * markers from expression evaluation.
11
+ *
12
+ * Also pins the cross-language completion contract: the
13
+ * execute-from-execution workflow must complete with a data-less result
14
+ * (void), because the Java parent awaits it as Void and Temporal Java's
15
+ * converter has no Void special-case — a data-bearing encrypted result
16
+ * would fail its converter lookup.
17
+ *
18
+ * Follows the golden-e2e pattern: tests skip gracefully when the
19
+ * Temporal test server cannot start.
20
+ */
21
+
22
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
23
+ import { randomBytes } from "node:crypto";
24
+ import { join, dirname } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { loadWorkflowFromYaml } from "../workflow-engine/loader.js";
27
+ import { evaluateExpressionBatch } from "../workflow-engine/expression.js";
28
+ import { EncryptionPayloadCodec } from "../encryption/payload-codec.js";
29
+ import type { ExecuteServerlessWorkflowInput } from "../workflows/execute-serverless-workflow.js";
30
+
31
+ const __dirname = dirname(fileURLToPath(import.meta.url));
32
+ const WORKFLOWS_PATH = join(__dirname, "../workflows/index.ts");
33
+ const TASK_QUEUE = "history-encryption-e2e";
34
+
35
+ const SECRET = "sup3r-s3cret-t0ken-do-not-persist";
36
+
37
+ const SECRET_BEARING_YAML = `
38
+ document:
39
+ dsl: '1.0.0'
40
+ namespace: tripwire
41
+ name: secret-bearing
42
+ version: '1.0.0'
43
+ description: Exercises every env-to-history crossing with a secret value
44
+ do:
45
+ # Expression evaluation: the secret flows through an EvaluateExpressions
46
+ # local activity whose result is recorded as a history marker.
47
+ - stampToken:
48
+ set:
49
+ authHeader: \${ "Bearer " + $env.API_TOKEN }
50
+ # Per-task activity: CallHttp receives the interpolated config AND the
51
+ # full runtime env map as activity input.
52
+ - callApi:
53
+ call: http
54
+ with:
55
+ method: GET
56
+ endpoint:
57
+ uri: https://example.com/data
58
+ headers:
59
+ Authorization: \${ $context.authHeader }
60
+ - done:
61
+ set:
62
+ finished: true
63
+ `;
64
+
65
+ type TestWorkflowEnvironment = import("@temporalio/testing").TestWorkflowEnvironment;
66
+ type Worker = import("@temporalio/worker").Worker;
67
+
68
+ let env: TestWorkflowEnvironment | null = null;
69
+ let worker: Worker | null = null;
70
+ let workerRunPromise: Promise<void> | null = null;
71
+ let envReady = false;
72
+
73
+ function createMockActivities() {
74
+ return {
75
+ HydrateWorkflowExecution: async (): Promise<ExecuteServerlessWorkflowInput> => ({
76
+ model: loadWorkflowFromYaml(SECRET_BEARING_YAML),
77
+ workflow_input: null,
78
+ env: { API_TOKEN: SECRET },
79
+ metadata: { execution_id: "tripwire-exec", org_id: "tripwire-org" },
80
+ }),
81
+ EvaluateExpressions: async (
82
+ expressions: Record<string, string>,
83
+ input: unknown,
84
+ stateVars: Record<string, unknown>,
85
+ ): Promise<Record<string, unknown>> => {
86
+ return evaluateExpressionBatch(expressions, input, stateVars);
87
+ },
88
+ CallHttp: async (): Promise<unknown> => ({ ok: true }),
89
+ ResetEventSequence: async (): Promise<number> => 0,
90
+ EmitWorkflowEvents: async (): Promise<void> => {},
91
+ LoadRecoveryContext: async (): Promise<unknown[]> => [],
92
+ PromoteTaskOutput: async (): Promise<void> => {},
93
+ };
94
+ }
95
+
96
+ /** Recursively collects every byte field in the raw history proto. */
97
+ function collectByteFields(value: unknown, out: Uint8Array[]): void {
98
+ if (value instanceof Uint8Array) {
99
+ out.push(value);
100
+ return;
101
+ }
102
+ if (Array.isArray(value)) {
103
+ for (const item of value) collectByteFields(item, out);
104
+ return;
105
+ }
106
+ if (value && typeof value === "object") {
107
+ for (const item of Object.values(value)) collectByteFields(item, out);
108
+ }
109
+ }
110
+
111
+ describe("History encryption tripwire — Temporal TestWorkflowEnvironment", () => {
112
+ beforeAll(async () => {
113
+ try {
114
+ const { TestWorkflowEnvironment: TWE } = await import("@temporalio/testing");
115
+ const { Worker: W } = await import("@temporalio/worker");
116
+
117
+ env = await TWE.createLocal();
118
+ worker = await W.create({
119
+ connection: env.nativeConnection,
120
+ taskQueue: TASK_QUEUE,
121
+ workflowsPath: WORKFLOWS_PATH,
122
+ activities: createMockActivities(),
123
+ dataConverter: {
124
+ payloadCodecs: [
125
+ new EncryptionPayloadCodec({
126
+ primary: { keyId: "tripwire-key", key: randomBytes(32) },
127
+ }),
128
+ ],
129
+ },
130
+ });
131
+ workerRunPromise = worker.run();
132
+ envReady = true;
133
+ } catch (err: unknown) {
134
+ const msg = err instanceof Error ? err.message : String(err);
135
+ console.warn(`Temporal test server unavailable (tests will be skipped): ${msg}`);
136
+ envReady = false;
137
+ }
138
+ }, 60_000);
139
+
140
+ afterAll(async () => {
141
+ if (worker) {
142
+ worker.shutdown();
143
+ await workerRunPromise?.catch(() => {});
144
+ }
145
+ if (env) await env.teardown();
146
+ }, 30_000);
147
+
148
+ it("records no plaintext secret bytes anywhere in workflow history", async () => {
149
+ if (!envReady || !env) return;
150
+
151
+ const workflowId = `tripwire-${Date.now()}`;
152
+ // The starting client has NO codec, mirroring the Java/Go
153
+ // orchestrators: the slim input is plaintext and the void result must
154
+ // be readable without a key.
155
+ const result = await env.client.workflow.execute(
156
+ "stigmer/workflow/execute-from-execution",
157
+ {
158
+ taskQueue: TASK_QUEUE,
159
+ workflowId,
160
+ args: [
161
+ {
162
+ execution_id: "tripwire-exec",
163
+ workflow_instance_id: "",
164
+ workflow_id: "tripwire-wf",
165
+ org_id: "tripwire-org",
166
+ },
167
+ ],
168
+ workflowExecutionTimeout: "30s",
169
+ },
170
+ );
171
+ expect(result).toBeUndefined();
172
+
173
+ // Fetch the RAW history (no codec on this read — we want the bytes
174
+ // exactly as Temporal persisted them).
175
+ const secretBytes = Buffer.from(SECRET);
176
+ const byteFields: Uint8Array[] = [];
177
+ let encryptedPayloadCount = 0;
178
+ let completedResultHasData = false;
179
+
180
+ let nextPageToken: Uint8Array | undefined;
181
+ do {
182
+ const response = await env.client.workflowService.getWorkflowExecutionHistory({
183
+ namespace: "default",
184
+ execution: { workflowId },
185
+ nextPageToken,
186
+ });
187
+ for (const event of response.history?.events ?? []) {
188
+ collectByteFields(event, byteFields);
189
+
190
+ const completed = event.workflowExecutionCompletedEventAttributes;
191
+ if (completed?.result?.payloads?.some((p) => p.data && p.data.length > 0)) {
192
+ completedResultHasData = true;
193
+ }
194
+ }
195
+ encryptedPayloadCount += countEncryptedEncodings(response.history?.events ?? []);
196
+ nextPageToken =
197
+ response.nextPageToken && response.nextPageToken.length > 0
198
+ ? response.nextPageToken
199
+ : undefined;
200
+ } while (nextPageToken);
201
+
202
+ // The tripwire itself: the secret must not appear in any byte field
203
+ // of any history event — payload data, marker details, anywhere.
204
+ expect(byteFields.length).toBeGreaterThan(0);
205
+ for (const bytes of byteFields) {
206
+ expect(Buffer.from(bytes).includes(secretBytes)).toBe(false);
207
+ }
208
+
209
+ // Sanity: encryption was actually active (otherwise this test would
210
+ // pass vacuously against a broken codec setup).
211
+ expect(encryptedPayloadCount).toBeGreaterThan(0);
212
+
213
+ // Cross-language completion contract: void result, no data payload.
214
+ expect(completedResultHasData).toBe(false);
215
+ }, 60_000);
216
+ });
217
+
218
+ function countEncryptedEncodings(events: unknown[]): number {
219
+ let count = 0;
220
+ const encodings: Uint8Array[] = [];
221
+ for (const event of events) {
222
+ collectEncodingMetadata(event, encodings);
223
+ }
224
+ for (const encoding of encodings) {
225
+ if (Buffer.from(encoding).toString("utf-8") === "binary/encrypted") count++;
226
+ }
227
+ return count;
228
+ }
229
+
230
+ function collectEncodingMetadata(value: unknown, out: Uint8Array[]): void {
231
+ if (!value || typeof value !== "object") return;
232
+ if (Array.isArray(value)) {
233
+ for (const item of value) collectEncodingMetadata(item, out);
234
+ return;
235
+ }
236
+ const record = value as Record<string, unknown>;
237
+ const metadata = record["metadata"];
238
+ if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) {
239
+ const encoding = (metadata as Record<string, unknown>)["encoding"];
240
+ if (encoding instanceof Uint8Array) out.push(encoding);
241
+ }
242
+ for (const item of Object.values(record)) collectEncodingMetadata(item, out);
243
+ }
@@ -11,8 +11,12 @@
11
11
  * the desktop Run dialog via the IPC handshake.
12
12
  */
13
13
 
14
- import { describe, expect, it } from "vitest";
15
- import { isNodeSqliteAvailable, preflightNodeRuntime } from "../preflight.js";
14
+ import { afterEach, describe, expect, it, vi } from "vitest";
15
+ import {
16
+ isNodeSqliteAvailable,
17
+ preflightNodeRuntime,
18
+ assertLlmBackendsPreflight,
19
+ } from "../preflight.js";
16
20
 
17
21
  describe("preflightNodeRuntime", () => {
18
22
  it("passes (returns null) when node:sqlite is available", () => {
@@ -39,3 +43,47 @@ describe("preflightNodeRuntime", () => {
39
43
  expect(preflightNodeRuntime() === null).toBe(isNodeSqliteAvailable());
40
44
  });
41
45
  });
46
+
47
+ describe("assertLlmBackendsPreflight", () => {
48
+ // This is the gate both runner factories run at construction — the throw
49
+ // is what a misconfigured deployment sees instead of accepting work. The
50
+ // per-case parse/prereq tables live in shared/__tests__/llm-backend.test.ts;
51
+ // here we pin the wrapper's contract: throw on fatal, warn on precedence,
52
+ // silent when clean, and options-proxy treated like the env proxy.
53
+ afterEach(() => {
54
+ vi.unstubAllEnvs();
55
+ vi.restoreAllMocks();
56
+ });
57
+
58
+ /** Pin the vars this gate reads so ambient shell env cannot leak in. */
59
+ function stubBackendEnv(vars: Record<string, string>) {
60
+ vi.stubEnv("STIGMER_PROXY_ENDPOINT", "");
61
+ vi.stubEnv("STIGMER_ANTHROPIC_BACKEND", "");
62
+ vi.stubEnv("STIGMER_OPENAI_BACKEND", "");
63
+ vi.stubEnv("CLOUD_ML_REGION", "");
64
+ for (const [key, value] of Object.entries(vars)) {
65
+ vi.stubEnv(key, value);
66
+ }
67
+ }
68
+
69
+ it("passes silently for an unconfigured deployment", () => {
70
+ stubBackendEnv({});
71
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
72
+ expect(() => assertLlmBackendsPreflight(null)).not.toThrow();
73
+ expect(warn).not.toHaveBeenCalled();
74
+ });
75
+
76
+ it("throws the actionable message for a misconfigured backend", () => {
77
+ stubBackendEnv({ STIGMER_ANTHROPIC_BACKEND: "vertex" });
78
+ expect(() => assertLlmBackendsPreflight(null)).toThrow(/CLOUD_ML_REGION/);
79
+ });
80
+
81
+ it("warns instead of throwing when an options-supplied proxy makes the var inert", () => {
82
+ stubBackendEnv({ STIGMER_ANTHROPIC_BACKEND: "vertex" });
83
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
84
+ expect(() => assertLlmBackendsPreflight("https://api.stigmer.ai")).not.toThrow();
85
+ expect(warn).toHaveBeenCalledWith(
86
+ expect.stringContaining("STIGMER_ANTHROPIC_BACKEND=vertex is ignored"),
87
+ );
88
+ });
89
+ });
@@ -5,6 +5,45 @@ import { _resetRegistryCache } from "../../shared/model-registry.js";
5
5
  const originalFetch = globalThis.fetch;
6
6
  const originalEnv = { ...process.env };
7
7
 
8
+ const { vertexCreateRequests } = vi.hoisted(() => ({
9
+ vertexCreateRequests: [] as Array<Record<string, unknown>>,
10
+ }));
11
+
12
+ // Mocked at the SDK class (the vertex-adapter.test.ts pattern) so no real
13
+ // google-auth credential resolution ever runs inside a unit test — locally
14
+ // it fails fast, but in CI it can probe the GCE metadata server. ChatAnthropic
15
+ // consumes the client's parsed event stream, so the streaming arm returns a
16
+ // plain async generator of Anthropic stream events.
17
+ vi.mock("@anthropic-ai/vertex-sdk", () => ({
18
+ AnthropicVertex: class {
19
+ readonly messages = {
20
+ create: async (request: Record<string, unknown>) => {
21
+ vertexCreateRequests.push(request);
22
+ if (request.stream === true) {
23
+ return (async function* () {
24
+ yield { type: "message_start", message: { id: "msg_vertex_call_llm", type: "message", role: "assistant", model: request.model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 11, output_tokens: 1 } } };
25
+ yield { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } };
26
+ yield { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Vertex direct." } };
27
+ yield { type: "content_block_stop", index: 0 };
28
+ yield { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 4 } };
29
+ yield { type: "message_stop" };
30
+ })();
31
+ }
32
+ return {
33
+ id: "msg_vertex_call_llm",
34
+ type: "message",
35
+ role: "assistant",
36
+ model: request.model,
37
+ content: [{ type: "text", text: "Vertex direct." }],
38
+ stop_reason: "end_turn",
39
+ stop_sequence: null,
40
+ usage: { input_tokens: 11, output_tokens: 4 },
41
+ };
42
+ },
43
+ };
44
+ },
45
+ }));
46
+
8
47
  describe("callLlmAction", () => {
9
48
  let mockFetch: ReturnType<typeof vi.fn>;
10
49
 
@@ -16,6 +55,12 @@ describe("callLlmAction", () => {
16
55
  delete process.env.STIGMER_TOKEN;
17
56
  delete process.env.OPENAI_API_KEY;
18
57
  delete process.env.ANTHROPIC_API_KEY;
58
+ // Deterministic regardless of the developer's shell: a leaked
59
+ // STIGMER_ANTHROPIC_BACKEND=vertex would reroute every direct-mode test.
60
+ delete process.env.STIGMER_ANTHROPIC_BACKEND;
61
+ delete process.env.STIGMER_OPENAI_BACKEND;
62
+ delete process.env.CLOUD_ML_REGION;
63
+ vertexCreateRequests.length = 0;
19
64
  });
20
65
 
21
66
  afterEach(() => {
@@ -234,6 +279,36 @@ describe("callLlmAction", () => {
234
279
  });
235
280
  });
236
281
 
282
+ describe("direct mode — Anthropic on the vertex backend", () => {
283
+ it("serves the call with no ANTHROPIC_API_KEY anywhere (ADC is the credential path)", async () => {
284
+ // The regression pin for the backend-blind key check: a correctly
285
+ // configured Vertex deployment holds no Anthropic key, and llm_call
286
+ // tasks must not fail with LLM_MISSING_API_KEY before construction.
287
+ process.env.STIGMER_ANTHROPIC_BACKEND = "vertex";
288
+ process.env.CLOUD_ML_REGION = "global";
289
+ mockFetchWithRegistry(() => {
290
+ throw new Error("unexpected fetch: vertex traffic must go through the SDK client");
291
+ });
292
+
293
+ const result = await callLlmAction(baseAnthropicConfig, {}, "exec-1");
294
+
295
+ expect(result.result).toBe("Vertex direct.");
296
+ expect(result.provider).toBe("anthropic");
297
+ expect(result.input_tokens).toBe(11);
298
+ expect(result.output_tokens).toBe(4);
299
+ expect(vertexCreateRequests.length).toBeGreaterThan(0);
300
+ });
301
+
302
+ it("keeps the LLM_MISSING_API_KEY contract on the public backend, now naming the backend remedy", async () => {
303
+ const err = await callLlmAction(baseAnthropicConfig, {}, "exec-1").catch((e: unknown) => e as Error & { type?: string });
304
+
305
+ expect(err).toBeInstanceOf(Error);
306
+ expect((err as { type?: string }).type).toBe("LLM_MISSING_API_KEY");
307
+ expect((err as Error).message).toContain("ANTHROPIC_API_KEY");
308
+ expect((err as Error).message).toContain("STIGMER_ANTHROPIC_BACKEND=vertex");
309
+ });
310
+ });
311
+
237
312
  describe("proxy mode", () => {
238
313
  it("routes through proxy when endpoint is set", async () => {
239
314
  process.env.STIGMER_PROXY_ENDPOINT = "https://proxy.stigmer.ai";
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import type { ClassifyToolApprovalsInput, ToolDescriptor } from "../classify-tool-approvals.js";
3
3
 
4
4
  vi.mock("../../idle-watchdog.js", () => ({
@@ -597,6 +597,122 @@ describe("ClassifyToolApprovals activity", () => {
597
597
  expect(headers).not.toHaveProperty("X-Stigmer-Mcp-Server-Id");
598
598
  });
599
599
  });
600
+
601
+ describe("direct mode (no proxy)", () => {
602
+ // The regression suite for the stigmerBackendEndpoint-as-LLM-proxy bug:
603
+ // an unproxied runner must call the provider directly with the real key,
604
+ // and must fail closed with one clear message when it has none.
605
+ const directOptions = { proxyEndpoint: null, stigmerToken: null, primaryModel: "gpt-4.1" };
606
+
607
+ beforeEach(() => {
608
+ // Deterministic regardless of the developer's shell: blank reads as
609
+ // missing, and backend/proxy vars must not leak in from outside.
610
+ vi.stubEnv("OPENAI_API_KEY", "");
611
+ vi.stubEnv("ANTHROPIC_API_KEY", "");
612
+ vi.stubEnv("STIGMER_ANTHROPIC_BACKEND", "");
613
+ vi.stubEnv("STIGMER_OPENAI_BACKEND", "");
614
+ vi.stubEnv("STIGMER_PROXY_ENDPOINT", "");
615
+ });
616
+
617
+ afterEach(() => {
618
+ vi.unstubAllEnvs();
619
+ });
620
+
621
+ it("fails closed without any credential path: every tool gated, no client constructed", async () => {
622
+ const { ChatAnthropic } = await import("@langchain/anthropic");
623
+ const { ChatOpenAI } = await import("@langchain/openai");
624
+ const { classifyTools } = await import("../classify-tool-approvals.js");
625
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
626
+
627
+ const tools: ToolDescriptor[] = [
628
+ { name: "search_code", description: "Search code" },
629
+ { name: "delete_file", description: "Delete a file" },
630
+ ];
631
+
632
+ const result = await classifyTools(
633
+ { tools, serverName: "github", serverDescription: "GitHub", mcpServerId: null },
634
+ directOptions,
635
+ );
636
+
637
+ expect(result).toEqual([
638
+ { tool_name: "search_code", requires_approval: true, message: "Execute search_code" },
639
+ { tool_name: "delete_file", requires_approval: true, message: "Execute delete_file" },
640
+ ]);
641
+ expect(ChatOpenAI).not.toHaveBeenCalled();
642
+ expect(ChatAnthropic).not.toHaveBeenCalled();
643
+ expect(mockInvoke).not.toHaveBeenCalled();
644
+ // The message must name the model and provider (classification's
645
+ // provider follows the economy model, not the operator's key) and
646
+ // carry the predicate's remedy text.
647
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("'gpt-4o-mini' (openai)"));
648
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("OPENAI_API_KEY"));
649
+ warn.mockRestore();
650
+ });
651
+
652
+ it("uses the provider key directly — no proxy baseURL, no Authorization header", async () => {
653
+ vi.stubEnv("OPENAI_API_KEY", "sk-direct-test");
654
+ const { ChatOpenAI } = await import("@langchain/openai");
655
+ const { classifyTools } = await import("../classify-tool-approvals.js");
656
+
657
+ mockInvoke.mockResolvedValueOnce({
658
+ approvals: [{ tool_name: "t", requires_approval: false, message: "" }],
659
+ });
660
+
661
+ const result = await classifyTools(
662
+ { tools: [{ name: "t", description: "d" }], serverName: "s", serverDescription: "", mcpServerId: null },
663
+ directOptions,
664
+ );
665
+
666
+ expect(result).toEqual([]);
667
+ const args = vi.mocked(ChatOpenAI).mock.calls.at(-1)?.[0] as Record<string, unknown>;
668
+ expect(args.apiKey).toBe("sk-direct-test");
669
+ // The bug this suite pins: the gRPC control-plane endpoint must never
670
+ // reappear as a baseURL/Authorization transport override.
671
+ expect(args).not.toHaveProperty("configuration");
672
+ });
673
+
674
+ it("routes an Anthropic economy model directly through ChatAnthropic with the real key", async () => {
675
+ vi.stubEnv("ANTHROPIC_API_KEY", "sk-ant-direct");
676
+ const { ChatAnthropic } = await import("@langchain/anthropic");
677
+ const { getSummarizationModel } = await import("../../shared/model-registry.js");
678
+ vi.mocked(getSummarizationModel).mockResolvedValueOnce("claude-haiku-4.5");
679
+ const { classifyTools } = await import("../classify-tool-approvals.js");
680
+
681
+ mockInvoke.mockResolvedValueOnce({
682
+ approvals: [{ tool_name: "t", requires_approval: false, message: "" }],
683
+ });
684
+
685
+ await classifyTools(
686
+ { tools: [{ name: "t", description: "d" }], serverName: "s", serverDescription: "", mcpServerId: null },
687
+ directOptions,
688
+ );
689
+
690
+ const args = vi.mocked(ChatAnthropic).mock.calls.at(-1)?.[0] as Record<string, unknown>;
691
+ expect(args.apiKey).toBe("sk-ant-direct");
692
+ expect(args).not.toHaveProperty("clientOptions");
693
+ });
694
+
695
+ it("defers an un-inferable economy model to the per-batch fail-closed path", async () => {
696
+ // Real scenario: the registry-empty fallback returns the primary model
697
+ // verbatim (model-registry.ts), whatever its name. The pre-check must
698
+ // not guess a provider — the batch path owns the precise error.
699
+ const { getSummarizationModel } = await import("../../shared/model-registry.js");
700
+ vi.mocked(getSummarizationModel).mockResolvedValueOnce("mystery-model");
701
+ const { classifyTools } = await import("../classify-tool-approvals.js");
702
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
703
+
704
+ const result = await classifyTools(
705
+ { tools: [{ name: "t", description: "d" }], serverName: "s", serverDescription: "", mcpServerId: null },
706
+ directOptions,
707
+ );
708
+
709
+ expect(result).toEqual([
710
+ { tool_name: "t", requires_approval: true, message: "Execute t" },
711
+ ]);
712
+ expect(error).toHaveBeenCalled();
713
+ error.mockRestore();
714
+ });
715
+ });
600
716
  });
601
717
 
602
718
  // ─────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Regression pin for issue #239's hang mechanism, against the REAL MCP client
3
+ * stack (no @langchain/mcp-adapters mock — that's why this lives in its own
4
+ * file, apart from discover-mcp-server.test.ts's mocked suite).
5
+ *
6
+ * The monday.com failure shape: the endpoint answers the streamable-HTTP
7
+ * initialize POST with a 4xx, mcp-adapters automatically falls back to SSE at
8
+ * the same URL, the endpoint accepts the stream and never sends the legacy
9
+ * `endpoint` event — and the MCP SDK's SSEClientTransport has no timer of its
10
+ * own, so initialization hangs forever. Before the transport-aware init bound,
11
+ * that hang rode until Temporal killed the activity opaquely; this test pins
12
+ * that discovery now fails fast (in fake time) with an error that names the
13
+ * endpoint.
14
+ */
15
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
16
+ import { createServer, type Server } from "node:http";
17
+
18
+ vi.mock("../../idle-watchdog.js", () => ({
19
+ activityStarted: vi.fn(),
20
+ activityFinished: vi.fn(),
21
+ }));
22
+
23
+ // classifyHttpOAuthFailure re-probes the endpoint on the failure path; keep it
24
+ // deterministic here (its own behavior is covered by mcp-oauth-detect tests).
25
+ vi.mock("../../shared/mcp-oauth-detect.js", () => ({
26
+ detectOAuthChallenge: vi.fn().mockResolvedValue(null),
27
+ }));
28
+
29
+ describe("discovery against a 4xx-then-silent-SSE endpoint (issue #239)", () => {
30
+ let server: Server;
31
+ let url: string;
32
+
33
+ beforeEach(async () => {
34
+ server = createServer((req, res) => {
35
+ if (req.method === "POST") {
36
+ // monday-shaped: reject the streamable-HTTP initialize outright.
37
+ res.writeHead(405, { "Content-Type": "application/json" });
38
+ res.end(JSON.stringify({ error: "method not allowed" }));
39
+ return;
40
+ }
41
+ // The SSE fallback: accept the stream, never send an `endpoint` event.
42
+ res.writeHead(200, {
43
+ "Content-Type": "text/event-stream",
44
+ "Cache-Control": "no-cache",
45
+ Connection: "keep-alive",
46
+ });
47
+ res.write(": silent stream\n\n");
48
+ });
49
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
50
+ const address = server.address();
51
+ if (typeof address === "string" || address === null) throw new Error("no port");
52
+ url = `http://127.0.0.1:${address.port}/mcp`;
53
+ });
54
+
55
+ afterEach(() => {
56
+ // closeAllConnections: the hung SSE stream is a live socket that would
57
+ // otherwise keep server.close() (and the vitest worker) waiting.
58
+ server.closeAllConnections();
59
+ server.close();
60
+ vi.useRealTimers();
61
+ });
62
+
63
+ it("fails within the HTTP init bound with an endpoint-naming error", async () => {
64
+ const { discoverMcpServer } = await import("../discover-mcp-server.js");
65
+
66
+ const stigmerClient = {
67
+ getMcpServer: vi.fn().mockResolvedValue({
68
+ metadata: { slug: "monday", id: "mcp-monday" },
69
+ spec: {
70
+ serverType: {
71
+ case: "http",
72
+ value: { url, headers: {}, queryParams: {}, timeoutSeconds: 0 },
73
+ },
74
+ env: {},
75
+ pinnedToolApprovals: [],
76
+ },
77
+ status: undefined,
78
+ }),
79
+ getExecutionContextByExecutionId: vi.fn(),
80
+ };
81
+
82
+ // shouldAdvanceTime keeps real I/O flowing (the POST → 405 → SSE fallback
83
+ // happens over real sockets) while letting the test jump the 60s bound.
84
+ vi.useFakeTimers({ shouldAdvanceTime: true });
85
+
86
+ const promise = discoverMcpServer(
87
+ { mcpServerId: "mcp-monday" },
88
+ { stigmerClient: stigmerClient as never, transportPosture: "stdio-forbidden" },
89
+ );
90
+ // Attach the rejection expectation BEFORE advancing so the rejection is
91
+ // never momentarily unhandled.
92
+ const expectation = expect(promise).rejects.toThrow(
93
+ new RegExp(`at ${url.replaceAll(".", "\\.")}.*did not complete MCP initialization`, "s"),
94
+ );
95
+
96
+ // Give the real client a beat to reach the hang (POST + fallback), then
97
+ // jump past the 30s HTTP bound in fake time.
98
+ await new Promise((resolve) => setTimeout(resolve, 250));
99
+ await vi.advanceTimersByTimeAsync(31_000);
100
+
101
+ await expectation;
102
+ }, 15_000);
103
+ });