@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
@@ -7,7 +7,13 @@
7
7
  */
8
8
 
9
9
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
10
- import { synthesizeError, formatClassifiedError, shouldRetryWithFreshAgent } from "../execute-cursor/error-classifier.js";
10
+ import {
11
+ synthesizeError,
12
+ formatClassifiedError,
13
+ shouldRetryWithFreshAgent,
14
+ DETAIL_FREE_FALLBACK_USER_PREFIX,
15
+ TRANSPORT_TIMEOUT_USER_PREFIX,
16
+ } from "../execute-cursor/error-classifier.js";
11
17
 
12
18
  describe("synthesizeError", () => {
13
19
  let consoleLogSpy: ReturnType<typeof vi.spyOn>;
@@ -213,7 +219,7 @@ describe("synthesizeError", () => {
213
219
 
214
220
  expect(result.category).toBe("unknown");
215
221
  expect(result.source).toBe("fallback");
216
- expect(result.message).toContain("no detail from SDK");
222
+ expect(result.message).toContain(DETAIL_FREE_FALLBACK_USER_PREFIX);
217
223
  expect(result.message).toContain("claude-sonnet-4");
218
224
  });
219
225
 
@@ -261,6 +267,92 @@ describe("synthesizeError", () => {
261
267
  expect(result.message).toContain("agentId=agent-123");
262
268
  });
263
269
 
270
+ // The exact prod shape from oss#492: the Composer 2.5 capacity incident
271
+ // rejected every run with a bare { status: "error" } — all five detail
272
+ // channels empty, fresh agent (the poisoned-handle retry produces this
273
+ // same shape with isResumedHandle: false). End users read this message
274
+ // verbatim in embedded surfaces, so it must lead with actionable copy,
275
+ // keep the diagnostic parenthetical for operators, and be retryable
276
+ // (provider capacity is transient).
277
+ it("detail-free failure on a fresh agent leads with user-facing copy and is retryable (oss#492)", () => {
278
+ const result = synthesizeError({
279
+ sdkError: undefined,
280
+ sdkResultFields: undefined,
281
+ streamErrorMessage: undefined,
282
+ capturedRejection: undefined,
283
+ conversationErrorText: undefined,
284
+ isResumedHandle: false,
285
+ fallbackContext: { model: "composer-2.5", mode: "local", agentId: "agent-abc" },
286
+ });
287
+
288
+ expect(result.category).toBe("unknown");
289
+ expect(result.source).toBe("fallback");
290
+ expect(result.retryable).toBe(true);
291
+ expect(result.message.startsWith(DETAIL_FREE_FALLBACK_USER_PREFIX)).toBe(true);
292
+ // Diagnostic context survives for operators (and the env integration
293
+ // test's Model= matcher).
294
+ expect(result.message).toContain("Model=composer-2.5");
295
+ expect(result.message).toContain("mode=local");
296
+ expect(result.message).toContain("agentId=agent-abc");
297
+ });
298
+
299
+ // sdk-react's isInterruptedError reframes any error containing the phrase
300
+ // "retry or resume" as a neutral resumable notice instead of a failure
301
+ // alert. A capacity failure must render as a failure — guard the copy
302
+ // (formatted end-to-end, tail included) against ever matching that regex.
303
+ it("user-facing fallback copy never trips the sdk-react interrupted-error reframe", () => {
304
+ const interruptedReframe = /\[StallTimeoutError\]|execution interrupted|retry or resume/i;
305
+ for (const prefix of [DETAIL_FREE_FALLBACK_USER_PREFIX, TRANSPORT_TIMEOUT_USER_PREFIX]) {
306
+ expect(prefix).not.toMatch(interruptedReframe);
307
+ }
308
+ const formatted = formatClassifiedError(synthesizeError({
309
+ sdkResultFields: undefined,
310
+ streamErrorMessage: undefined,
311
+ capturedRejection: undefined,
312
+ isResumedHandle: false,
313
+ fallbackContext,
314
+ }));
315
+ expect(formatted).not.toMatch(interruptedReframe);
316
+ });
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Transport-timeout heuristic (0 messages, ~default SDK timeout)
320
+ // ---------------------------------------------------------------------------
321
+
322
+ it("classifies a 0-message ~30s failure as retryable network with user-facing copy", () => {
323
+ const result = synthesizeError({
324
+ sdkResultFields: undefined,
325
+ streamErrorMessage: undefined,
326
+ capturedRejection: undefined,
327
+ isResumedHandle: false,
328
+ fallbackContext,
329
+ durationMs: 30012,
330
+ messageCount: 0,
331
+ });
332
+
333
+ expect(result.category).toBe("network");
334
+ expect(result.source).toBe("fallback");
335
+ expect(result.retryable).toBe(true);
336
+ expect(result.message.startsWith(TRANSPORT_TIMEOUT_USER_PREFIX)).toBe(true);
337
+ expect(result.message).toContain("30012ms");
338
+ expect(result.message).toContain("Model=claude-sonnet-4");
339
+ });
340
+
341
+ it("does not apply the transport heuristic outside the default-timeout window", () => {
342
+ const result = synthesizeError({
343
+ sdkResultFields: undefined,
344
+ streamErrorMessage: undefined,
345
+ capturedRejection: undefined,
346
+ isResumedHandle: false,
347
+ fallbackContext,
348
+ durationMs: 120000,
349
+ messageCount: 0,
350
+ });
351
+
352
+ expect(result.category).toBe("unknown");
353
+ expect(result.message.startsWith(DETAIL_FREE_FALLBACK_USER_PREFIX)).toBe(true);
354
+ });
355
+
264
356
  // ---------------------------------------------------------------------------
265
357
  // Source priority
266
358
  // ---------------------------------------------------------------------------
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Env contract tests for the workflow `run` task (oss#384).
3
+ *
4
+ * These spawn REAL subprocesses through the RunShell / RunScript
5
+ * activities and inspect the environment the child actually received —
6
+ * the leak-reproduction style established by the #256 fix (PR #383).
7
+ * Subprocess output is env variable NAMES only, never values.
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
11
+ import { createRunCommandActivities } from "../run-command.js";
12
+ import { buildRunEnv, RUN_ENV_BASE_KEYS } from "../run-env.js";
13
+ import { RuntimePlaceholderResolutionError } from "../../workflow-engine/resolve.js";
14
+
15
+ /**
16
+ * Credentials planted into the runner process env before each test.
17
+ * Includes a name outside every known denylist to prove the contract
18
+ * is declare-to-receive, not deny-known-names.
19
+ */
20
+ const PLANTED_SENTINELS: Record<string, string> = {
21
+ STIGMER_RUNNER_HITL_SECRET: "sentinel-hitl",
22
+ STIGMER_TOKEN: "sentinel-token",
23
+ CURSOR_API_KEY: "sentinel-cursor",
24
+ ANTHROPIC_API_KEY: "sentinel-anthropic",
25
+ OPERATOR_PRIVATE_CREDENTIAL: "sentinel-arbitrary",
26
+ };
27
+
28
+ const PRINT_ENV_KEYS_SHELL = `node -p 'JSON.stringify(Object.keys(process.env))'`;
29
+ const PRINT_ENV_KEYS_SCRIPT = `console.log(JSON.stringify(Object.keys(process.env)))`;
30
+
31
+ const activities = createRunCommandActivities();
32
+
33
+ async function childEnvKeysViaShell(
34
+ environment?: Record<string, string>,
35
+ runtimeEnv: Record<string, unknown> = {},
36
+ ): Promise<string[]> {
37
+ const stdout = await activities.RunShell({
38
+ mode: "shell",
39
+ command: PRINT_ENV_KEYS_SHELL,
40
+ environment,
41
+ runtimeEnv,
42
+ });
43
+ return JSON.parse(String(stdout)) as string[];
44
+ }
45
+
46
+ async function childEnvKeysViaScript(
47
+ environment?: Record<string, string>,
48
+ runtimeEnv: Record<string, unknown> = {},
49
+ ): Promise<string[]> {
50
+ const stdout = await activities.RunScript({
51
+ mode: "script",
52
+ language: "js",
53
+ code: PRINT_ENV_KEYS_SCRIPT,
54
+ environment,
55
+ runtimeEnv,
56
+ });
57
+ return JSON.parse(String(stdout)) as string[];
58
+ }
59
+
60
+ describe("run task env contract (oss#384)", () => {
61
+ const saved: Record<string, string | undefined> = {};
62
+
63
+ beforeEach(() => {
64
+ for (const [key, value] of Object.entries(PLANTED_SENTINELS)) {
65
+ saved[key] = process.env[key];
66
+ process.env[key] = value;
67
+ }
68
+ });
69
+
70
+ afterEach(() => {
71
+ for (const key of Object.keys(PLANTED_SENTINELS)) {
72
+ if (saved[key] === undefined) {
73
+ delete process.env[key];
74
+ } else {
75
+ process.env[key] = saved[key];
76
+ }
77
+ }
78
+ });
79
+
80
+ describe("RunShell", () => {
81
+ it("does not leak runner process credentials to the child", async () => {
82
+ const keys = await childEnvKeysViaShell();
83
+
84
+ for (const sentinel of Object.keys(PLANTED_SENTINELS)) {
85
+ expect(keys, `runner credential "${sentinel}" leaked to shell child`)
86
+ .not.toContain(sentinel);
87
+ }
88
+ });
89
+
90
+ it("provides the minimal base env and the declared overlay", async () => {
91
+ const keys = await childEnvKeysViaShell({ DECLARED_VAR: "value" });
92
+
93
+ for (const base of RUN_ENV_BASE_KEYS) {
94
+ if (process.env[base] !== undefined) {
95
+ expect(keys, `base key "${base}" missing from shell child`).toContain(base);
96
+ }
97
+ }
98
+ expect(keys).toContain("DECLARED_VAR");
99
+ });
100
+
101
+ it("resolves ${.secrets.KEY} placeholders in declared values just-in-time", async () => {
102
+ const stdout = await activities.RunShell({
103
+ mode: "shell",
104
+ command: `node -p 'process.env.API_KEY'`,
105
+ environment: { API_KEY: "${.secrets.TEST_API_KEY}" },
106
+ runtimeEnv: { TEST_API_KEY: "resolved-secret-value" },
107
+ });
108
+
109
+ expect(stdout).toBe("resolved-secret-value");
110
+ });
111
+
112
+ it("fails non-retryably, naming the variable, when a placeholder key is missing", async () => {
113
+ await expect(
114
+ activities.RunShell({
115
+ mode: "shell",
116
+ command: "true",
117
+ environment: { API_KEY: "${.secrets.NOT_PROVIDED}" },
118
+ runtimeEnv: {},
119
+ }),
120
+ ).rejects.toMatchObject({
121
+ nonRetryable: true,
122
+ type: "RUN_ENV_UNRESOLVED_PLACEHOLDER",
123
+ message: expect.stringContaining("NOT_PROVIDED"),
124
+ });
125
+ });
126
+ });
127
+
128
+ describe("RunScript", () => {
129
+ it("does not leak runner process credentials to the child", async () => {
130
+ const keys = await childEnvKeysViaScript();
131
+
132
+ for (const sentinel of Object.keys(PLANTED_SENTINELS)) {
133
+ expect(keys, `runner credential "${sentinel}" leaked to script child`)
134
+ .not.toContain(sentinel);
135
+ }
136
+ });
137
+
138
+ it("provides the minimal base env and the declared overlay", async () => {
139
+ const keys = await childEnvKeysViaScript({ DECLARED_VAR: "value" });
140
+
141
+ for (const base of RUN_ENV_BASE_KEYS) {
142
+ if (process.env[base] !== undefined) {
143
+ expect(keys, `base key "${base}" missing from script child`).toContain(base);
144
+ }
145
+ }
146
+ expect(keys).toContain("DECLARED_VAR");
147
+ });
148
+
149
+ it("resolves ${.env_vars.KEY} placeholders in declared values just-in-time", async () => {
150
+ const stdout = await activities.RunScript({
151
+ mode: "script",
152
+ language: "js",
153
+ code: "console.log(process.env.REGION)",
154
+ environment: { REGION: "${.env_vars.DEPLOY_REGION}" },
155
+ runtimeEnv: { DEPLOY_REGION: "us-east-1" },
156
+ });
157
+
158
+ expect(stdout).toBe("us-east-1");
159
+ });
160
+ });
161
+ });
162
+
163
+ describe("buildRunEnv", () => {
164
+ it("copies only the base keys present in the runner env", () => {
165
+ const base = {
166
+ PATH: "/usr/bin",
167
+ HOME: "/home/runner",
168
+ TERM: "xterm",
169
+ };
170
+
171
+ const env = buildRunEnv(undefined, {}, base);
172
+
173
+ expect(env).toEqual({ PATH: "/usr/bin", HOME: "/home/runner", TERM: "xterm" });
174
+ });
175
+
176
+ it("never copies undeclared runner variables — the oss#384 tripwire", () => {
177
+ // Guards against a future reintroduction of `{ ...process.env }`:
178
+ // credentials must be structurally absent, whatever their names.
179
+ const base = {
180
+ PATH: "/usr/bin",
181
+ STIGMER_RUNNER_HITL_SECRET: "secret",
182
+ ANY_FUTURE_CREDENTIAL: "secret",
183
+ };
184
+
185
+ const env = buildRunEnv({ DECLARED: "yes" }, {}, base);
186
+
187
+ expect(Object.keys(env).sort()).toEqual(["DECLARED", "PATH"]);
188
+ });
189
+
190
+ it("lets a declared variable override a base variable", () => {
191
+ const base = { PATH: "/usr/bin" };
192
+
193
+ const env = buildRunEnv({ PATH: "/custom/bin" }, {}, base);
194
+
195
+ expect(env.PATH).toBe("/custom/bin");
196
+ });
197
+
198
+ it("passes non-placeholder declared values through untouched", () => {
199
+ const env = buildRunEnv(
200
+ { LITERAL: "plain-value", TEMPLATED: "prefix-${.secrets.KEY}-suffix" },
201
+ { KEY: "mid" },
202
+ {},
203
+ );
204
+
205
+ expect(env.LITERAL).toBe("plain-value");
206
+ expect(env.TEMPLATED).toBe("prefix-mid-suffix");
207
+ });
208
+
209
+ it("throws a named error for a missing placeholder key", () => {
210
+ expect(() =>
211
+ buildRunEnv({ API_KEY: "${.secrets.MISSING}" }, {}, {}),
212
+ ).toThrowError(RuntimePlaceholderResolutionError);
213
+
214
+ try {
215
+ buildRunEnv({ API_KEY: "${.secrets.MISSING}" }, {}, {});
216
+ } catch (err) {
217
+ expect((err as RuntimePlaceholderResolutionError).variableName).toBe("MISSING");
218
+ expect((err as Error).message).toContain('environment "API_KEY"');
219
+ }
220
+ });
221
+ });
@@ -81,6 +81,13 @@ const PLATFORM_INJECTABLE_MAP: Record<string, string> = {
81
81
  export interface DiscoverMcpServerInput {
82
82
  mcpServerId: string;
83
83
  executionContextId?: string | null;
84
+ /**
85
+ * Execution-scoped token unlocking the connect EC's decrypted credentials
86
+ * (oss#535). Minted by the OSS handler and carried with the work item —
87
+ * discovery has no execution of its own to exchange for one. Absent on
88
+ * cloud, where the ambient connect_sandbox credential decrypts.
89
+ */
90
+ executionContextToken?: string | null;
84
91
  invokerIdentityAccountId?: string | null;
85
92
  }
86
93
 
@@ -280,7 +287,7 @@ export async function discoverMcpServer(
280
287
  input: DiscoverMcpServerInput,
281
288
  deps: DiscoverDeps,
282
289
  ): Promise<DiscoverMcpServerOutput> {
283
- const { mcpServerId, executionContextId } = input;
290
+ const { mcpServerId, executionContextId, executionContextToken } = input;
284
291
  const { stigmerClient } = deps;
285
292
 
286
293
  console.log(
@@ -301,6 +308,7 @@ export async function discoverMcpServer(
301
308
  const envVars = await resolveEnvVarsForDiscovery(
302
309
  stigmerClient,
303
310
  executionContextId ?? null,
311
+ executionContextToken ?? null,
304
312
  slug,
305
313
  declaredEnv,
306
314
  );
@@ -382,6 +390,7 @@ export async function discoverMcpServer(
382
390
  async function resolveEnvVarsForDiscovery(
383
391
  client: StigmerClient,
384
392
  executionContextId: string | null,
393
+ executionContextToken: string | null,
385
394
  slug: string,
386
395
  declaredEnv: Record<string, EnvVarDeclaration>,
387
396
  ): Promise<Record<string, string>> {
@@ -394,7 +403,12 @@ async function resolveEnvVarsForDiscovery(
394
403
 
395
404
  let execCtx: Awaited<ReturnType<typeof client.getExecutionContextByExecutionId>>;
396
405
  try {
397
- execCtx = await client.getExecutionContextByExecutionId(executionContextId);
406
+ // The payload-carried token authenticates the read on OSS (oss#535);
407
+ // undefined on cloud, where the ambient credential applies instead.
408
+ execCtx = await client.getExecutionContextByExecutionId(
409
+ executionContextId,
410
+ executionContextToken ?? undefined,
411
+ );
398
412
  } catch (err) {
399
413
  const cause = err instanceof Error ? err.message : String(err);
400
414
  if (credentialsExpected) {
@@ -9,12 +9,24 @@
9
9
  *
10
10
  * Supported delivery targets:
11
11
  * - webhook: HTTP POST with Content-Type: application/cloudevents+json
12
- * - signal: Temporal signal to another running workflow's listen task
12
+ * - signal: signal to another workflow execution's listen task, routed
13
+ * through the server's SendSignal lane
14
+ *
15
+ * Signal delivery is deliberately server-mediated (oss#517): a direct
16
+ * Temporal client here would bypass the authorization boundary (any
17
+ * workflow could signal any workflow id in the namespace) and is
18
+ * structurally incompatible with payload encryption — emit→listen is
19
+ * the platform's only runner-to-runner channel, and under per-identity
20
+ * runner keys a sender-encrypted signal fails closed at a receiver
21
+ * holding a different key. The server re-produces the payload, so each
22
+ * side's codec passes it through.
13
23
  *
14
24
  * CloudEvents spec: https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/spec.md
15
25
  */
16
26
 
17
27
  import { randomUUID } from "node:crypto";
28
+ import type { JsonObject } from "@bufbuild/protobuf";
29
+ import { StigmerClient } from "../client/stigmer-client.js";
18
30
  import { loadConfig } from "../config.js";
19
31
  import { resolveRuntimePlaceholders } from "../workflow-engine/resolve.js";
20
32
 
@@ -24,7 +36,9 @@ export interface WebhookDeliveryTarget {
24
36
  }
25
37
 
26
38
  export interface SignalDeliveryTarget {
27
- readonly workflow_id: string;
39
+ /** Target workflow execution id ("wfx_..."), as returned by run/create. */
40
+ readonly execution_id: string;
41
+ /** Signal name matching the target's listen task event id (verbatim). */
28
42
  readonly signal_name: string;
29
43
  }
30
44
 
@@ -54,6 +68,11 @@ export interface EmitEventResult {
54
68
 
55
69
  const WEBHOOK_TIMEOUT_MS = 30_000;
56
70
 
71
+ // Delivery is best-effort by contract, so no single target may consume the
72
+ // CallFunction activity's whole 5m startToClose budget. Same bound as the
73
+ // webhook arm.
74
+ const SIGNAL_TIMEOUT_MS = 30_000;
75
+
57
76
  function buildEnvelope(
58
77
  config: EmitEventConfig,
59
78
  executionId: string,
@@ -120,33 +139,53 @@ async function deliverWebhook(
120
139
  }
121
140
  }
122
141
 
142
+ function buildClient(): StigmerClient {
143
+ const config = loadConfig();
144
+ return new StigmerClient({
145
+ endpoint: config.stigmerBackendEndpoint,
146
+ token: config.stigmerToken,
147
+ });
148
+ }
149
+
123
150
  async function deliverSignal(
124
151
  envelope: Record<string, unknown>,
125
152
  target: SignalDeliveryTarget,
153
+ client: StigmerClient,
126
154
  ): Promise<DeliveryError | null> {
127
- try {
128
- const { Connection, Client } = await import("@temporalio/client");
129
-
130
- // Resolve Temporal coordinates through the central config layer rather than
131
- // reading env directly: signal delivery must dial the SAME cluster/namespace
132
- // the worker connected with. config.ts is the single source of truth for the
133
- // canonical TEMPORAL_SERVICE_ADDRESS / TEMPORAL_NAMESPACE resolution — reading
134
- // env here would diverge (and previously dialed localhost via a stale name).
135
- const config = loadConfig();
136
- const temporalAddress = config.temporalAddress;
137
- const temporalNamespace = config.temporalNamespace;
138
-
139
- const connection = await Connection.connect({ address: temporalAddress });
140
- const client = new Client({ connection, namespace: temporalNamespace });
141
-
142
- const handle = client.workflow.getHandle(target.workflow_id);
143
- await handle.signal(target.signal_name, envelope);
155
+ // Refuse the pre-oss#517 field by name: direct-addressing raw Temporal
156
+ // workflow ids is exactly the capability server mediation removes.
157
+ const legacyWorkflowId = (target as { workflow_id?: unknown }).workflow_id;
158
+ if (!target.execution_id) {
159
+ const reason = legacyWorkflowId
160
+ ? "signal delivery addresses workflow executions by 'execution_id' (\"wfx_...\"); 'workflow_id' is not supported"
161
+ : "signal delivery requires 'execution_id'";
162
+ return {
163
+ target: `signal:${String(legacyWorkflowId ?? "")}/${target.signal_name ?? ""}`,
164
+ error: reason,
165
+ };
166
+ }
167
+ if (!target.signal_name) {
168
+ return {
169
+ target: `signal:${target.execution_id}/`,
170
+ error: "signal delivery requires 'signal_name'",
171
+ };
172
+ }
144
173
 
174
+ try {
175
+ await client.sendWorkflowSignal(
176
+ target.execution_id,
177
+ target.signal_name,
178
+ envelope as JsonObject,
179
+ { timeoutMs: SIGNAL_TIMEOUT_MS },
180
+ );
145
181
  return null;
146
182
  } catch (err) {
183
+ // Server refusals arrive as ConnectErrors whose messages carry the code
184
+ // (e.g. [not_found], [failed_precondition] for terminal executions) —
185
+ // surfaced verbatim in delivery_errors, same contract as the webhook arm.
147
186
  const message = err instanceof Error ? err.message : String(err);
148
187
  return {
149
- target: `signal:${target.workflow_id}/${target.signal_name}`,
188
+ target: `signal:${target.execution_id}/${target.signal_name}`,
150
189
  error: message,
151
190
  };
152
191
  }
@@ -173,13 +212,18 @@ export async function emitEventAction(
173
212
  const errors: DeliveryError[] = [];
174
213
  const env = runtimeEnv ?? {};
175
214
 
215
+ // One client serves all signal targets of this emit; constructed lazily so
216
+ // webhook-only emits never pay for a gRPC transport.
217
+ let client: StigmerClient | undefined;
218
+
176
219
  for (const target of config.delivery) {
177
220
  let err: DeliveryError | null = null;
178
221
 
179
222
  if ("webhook" in target) {
180
223
  err = await deliverWebhook(envelope, target.webhook, env);
181
224
  } else if ("signal" in target) {
182
- err = await deliverSignal(envelope, target.signal);
225
+ client ??= buildClient();
226
+ err = await deliverSignal(envelope, target.signal, client);
183
227
  }
184
228
 
185
229
  if (err) {
@@ -14,7 +14,7 @@ import { ApprovalAction, InteractionMode } from "@stigmer/protos/ai/stigmer/agen
14
14
  import { PendingApprovalSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
15
15
  import { ApiResourceReferenceSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
16
16
 
17
- import { buildPrompt, isHitlReinvocation } from "../index.js";
17
+ import { appendStructuredOutputDirective, buildPrompt, isHitlReinvocation, primarySendCarriesImages } from "../index.js";
18
18
  import type { BuildPromptInput } from "../index.js";
19
19
  import { buildReinvocationPrompt, formatInteractionModePrefix, formatImplementPlanSection, formatToolApprovalProtocol, buildToolApprovalRuleFile } from "../prompt-builder.js";
20
20
  import { PLAN_MODE_DIRECTIVE } from "../../../shared/plan-mode-prompt.js";
@@ -255,7 +255,7 @@ describe("buildPrompt", () => {
255
255
  });
256
256
  });
257
257
 
258
- describe("isHitlReinvocation (the single discriminator for message-accompanied payloads)", () => {
258
+ describe("isHitlReinvocation (paired with resolution.reason at every consumer — issue #366)", () => {
259
259
  it("is false with no decisions and false with an empty map", () => {
260
260
  expect(isHitlReinvocation(undefined)).toBe(false);
261
261
  expect(isHitlReinvocation(new Map())).toBe(false);
@@ -268,6 +268,115 @@ describe("isHitlReinvocation (the single discriminator for message-accompanied p
268
268
  });
269
269
  });
270
270
 
271
+ describe("HITL recovery — fresh agent mid-HITL (issue #366)", () => {
272
+ const decisions = () =>
273
+ new Map<string, ApprovalAction>([["tool-call-1", ApprovalAction.APPROVE]]);
274
+ const approvals = () => [
275
+ create(PendingApprovalSchema, {
276
+ toolCallId: "tool-call-1",
277
+ toolName: "Write",
278
+ message: "Write file: gated.txt",
279
+ }),
280
+ ];
281
+ const recoveryInput = (overrides: Partial<BuildPromptInput> = {}) =>
282
+ input({
283
+ resolution: resolution("local", "created_after_resume_failure"),
284
+ approvalDecisions: decisions(),
285
+ pendingApprovals: approvals(),
286
+ ...overrides,
287
+ });
288
+
289
+ it("keeps the resumed-HITL prompt byte-identical to the bare reinvocation prompt (regression guard)", () => {
290
+ const prompt = buildPrompt(
291
+ input({
292
+ resolution: resolution("local", "resumed_successfully"),
293
+ approvalDecisions: decisions(),
294
+ pendingApprovals: approvals(),
295
+ }),
296
+ );
297
+ expect(prompt).toBe(buildReinvocationPrompt(approvals(), decisions()));
298
+ });
299
+
300
+ it("rebuilds full context for a fresh agent mid-HITL: instructions, the ORIGINAL user message, the state-loss disclosure, and the decisions", () => {
301
+ const prompt = buildPrompt(recoveryInput());
302
+ // The pre-fix failure mode: bare decisions with no story.
303
+ expect(prompt).not.toBe(buildReinvocationPrompt(approvals(), decisions()));
304
+ expect(prompt).toContain("<agent_instructions>");
305
+ expect(prompt).toContain("<tool_approval_protocol>");
306
+ expect(prompt).toContain(`<user_request>\n${USER_MESSAGE}\n</user_request>`);
307
+ expect(prompt).toContain("<turn_recovery>");
308
+ expect(prompt).toContain("Write file: gated.txt");
309
+ expect(prompt).toContain("APPROVED");
310
+ // The opaque tool-call id must not leak into the prompt (reinvocation rule).
311
+ expect(prompt).not.toContain("tool-call-1");
312
+ });
313
+
314
+ it("orders the recovery chronologically: request, then the recovered work, then the decisions, ending on the continue directive", () => {
315
+ const prompt = buildPrompt(
316
+ recoveryInput({ turnRecoveryDigest: "Tool: Write file: draft.txt — completed" }),
317
+ );
318
+ const requestIdx = prompt.indexOf("<user_request>");
319
+ const recoveryIdx = prompt.indexOf("<turn_recovery>");
320
+ const decisionsIdx = prompt.indexOf("APPROVED");
321
+ expect(requestIdx).toBeGreaterThan(-1);
322
+ expect(recoveryIdx).toBeGreaterThan(requestIdx);
323
+ expect(decisionsIdx).toBeGreaterThan(recoveryIdx);
324
+ expect(prompt).toContain("Tool: Write file: draft.txt — completed");
325
+ expect(prompt.trimEnd().endsWith("do not ask the user for permission in prose.")).toBe(true);
326
+ });
327
+
328
+ it("discloses the state loss even with NO transcript — otherwise the decisions read as reactions to proposals this agent never made", () => {
329
+ const prompt = buildPrompt(recoveryInput({ turnRecoveryDigest: undefined }));
330
+ expect(prompt).toContain("<turn_recovery>");
331
+ expect(prompt).toContain("no transcript of your progress is available");
332
+ });
333
+
334
+ it("preserves already-applied semantics: exact-applied writes are described as done, not as work to redo", () => {
335
+ const prompt = buildPrompt(
336
+ recoveryInput({ appliedToolCallIds: new Set(["tool-call-1"]) }),
337
+ );
338
+ expect(prompt).toContain("ALREADY applied");
339
+ expect(prompt).not.toContain("Carry them out now");
340
+ });
341
+
342
+ it("carries the session-standing context a first turn would get — the replacement agent inherits the whole story, not just the blueprint", () => {
343
+ const prompt = buildPrompt(
344
+ recoveryInput({ contextBridge: "User: earlier thread\nAssistant: earlier reply" }),
345
+ );
346
+ expect(prompt).toContain("<previous_conversation_context>");
347
+ expect(prompt).toContain("User: earlier thread");
348
+ });
349
+
350
+ it("selects the recovery shape for ANY non-resumed reason — decisions-only is safe only when native context is guaranteed", () => {
351
+ // created_first_execution + decisions cannot happen in practice
352
+ // (decisions are reconstructed from a persisted transcript, which
353
+ // implies a prior invocation) — but if it ever did, the bare decisions
354
+ // prompt would reproduce the #366 amnesia. Fail safe.
355
+ const prompt = buildPrompt(
356
+ recoveryInput({ resolution: resolution("local", "created_first_execution") }),
357
+ );
358
+ expect(prompt).toContain("<turn_recovery>");
359
+ expect(prompt).toContain("<agent_instructions>");
360
+ });
361
+
362
+ it("primary send skips images ONLY for a resumed HITL agent (which holds them natively); every fresh agent gets the re-delivery", () => {
363
+ expect(primarySendCarriesImages(decisions(), "resumed_successfully")).toBe(false);
364
+ // The #366 vision corollary: fresh agent mid-HITL after a
365
+ // resolution-time resume failure.
366
+ expect(primarySendCarriesImages(decisions(), "created_after_resume_failure")).toBe(true);
367
+ // Non-HITL turns always carry the message's images, resumed or not.
368
+ expect(primarySendCarriesImages(undefined, "resumed_successfully")).toBe(true);
369
+ expect(primarySendCarriesImages(undefined, "created_first_execution")).toBe(true);
370
+ });
371
+
372
+ it("the structured-output directive is a pure append shared by the primary and recovery sends — absent schema, absent suffix", () => {
373
+ expect(appendStructuredOutputDirective("base", undefined)).toBe("base");
374
+ const withSchema = appendStructuredOutputDirective("base", { type: "object" });
375
+ expect(withSchema.startsWith("base\n\n---\nCRITICAL OUTPUT REQUIREMENT:")).toBe(true);
376
+ expect(withSchema).toContain('"type": "object"');
377
+ });
378
+ });
379
+
271
380
  describe("attachments on a resumed turn (T04 — the mid-session WhatsApp case)", () => {
272
381
  const RESUMED = { resolution: resolution("local", "resumed_successfully") };
273
382