@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
@@ -36,6 +36,7 @@ import {
36
36
  visionDisclosureLines,
37
37
  type NotViewableEntry,
38
38
  } from "../../shared/attachment-vision.js";
39
+ import { formatTurnRecoveryText } from "./turn-recovery.js";
39
40
  import { PLAN_MODE_DIRECTIVE } from "../../shared/plan-mode-prompt.js";
40
41
  import {
41
42
  buildImplementPlanDirective,
@@ -338,6 +339,56 @@ export function buildReinvocationPrompt(
338
339
  return parts.join("\n\n");
339
340
  }
340
341
 
342
+ /**
343
+ * Build the prompt for a fresh agent that replaced a lost one MID-HITL —
344
+ * the stored handle failed to resume when the approval landed, or the
345
+ * resumed handle proved poisoned mid-send (issue #366, both crossings).
346
+ *
347
+ * A resumed agent gets {@link buildReinvocationPrompt} alone because its
348
+ * native conversation carries everything else. A replacement agent's
349
+ * conversation is EMPTY, so the bare decisions prompt would strand it with
350
+ * instructions and no story — and, because recovery repoints the session at
351
+ * the new agent, every later turn would inherit that amnesia. This prompt
352
+ * rebuilds the whole story in chronological-narrative order:
353
+ *
354
+ * 1. the full enhanced prompt (blueprint, standing context, protocol, and
355
+ * the turn's ORIGINAL user message as `<user_request>`),
356
+ * 2. `<turn_recovery>` — the state-loss disclosure plus the recorded
357
+ * transcript of the turn so far (turn-recovery.ts; rendered even with no
358
+ * transcript, since without the disclosure the decisions below would read
359
+ * as reactions to proposals this agent never made),
360
+ * 3. the reinvocation decisions verbatim — already-applied / approved /
361
+ * skipped semantics byte-identical to the resumed path, ending on its
362
+ * "continue the task" directive.
363
+ *
364
+ * Appending past `<user_request>` is the chronology speaking (request →
365
+ * work done → decisions → continue), the same shape as the activity-level
366
+ * structured-output suffix.
367
+ */
368
+ export function buildHitlRecoveryPrompt(
369
+ options: EnhancedPromptOptions,
370
+ recovery: {
371
+ turnDigest: string | undefined;
372
+ pendingApprovals: PendingApproval[];
373
+ approvalDecisions: Map<string, ApprovalAction>;
374
+ appliedToolCallIds?: ReadonlySet<string>;
375
+ },
376
+ ): string {
377
+ return [
378
+ buildEnhancedPrompt(options),
379
+ formatTurnRecoverySection(recovery.turnDigest),
380
+ buildReinvocationPrompt(
381
+ recovery.pendingApprovals,
382
+ recovery.approvalDecisions,
383
+ recovery.appliedToolCallIds,
384
+ ),
385
+ ].join("\n\n---\n\n");
386
+ }
387
+
388
+ export function formatTurnRecoverySection(digest: string | undefined): string {
389
+ return `<turn_recovery>\n${formatTurnRecoveryText(digest)}\n</turn_recovery>`;
390
+ }
391
+
341
392
  /**
342
393
  * Render a pending approval as a short human-readable action description for the
343
394
  * reinvocation prompt. Prefers the already-resolved approval message; falls back
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Turn-recovery digest: the platform's recorded transcript of the current
3
+ * turn, rendered as prompt context for a fresh agent that replaced one whose
4
+ * conversation state was lost mid-turn (issue #366).
5
+ *
6
+ * A HITL-paused turn can lose its agent two ways — the stored handle fails
7
+ * to resume when the approval lands, or the resumed handle turns out to be
8
+ * poisoned mid-send. Either way the replacement agent starts with an EMPTY
9
+ * conversation, so the approval decisions alone read as instructions with no
10
+ * story: the agent no longer knows what it was doing. This module rebuilds
11
+ * that story from `status.messages` — the persisted execution transcript
12
+ * seeded on every re-invocation — which is the platform's durable record of
13
+ * the turn AND exactly what the user watched happen (introspecting the dead
14
+ * agent's own conversation is best-effort at most; the handle is the thing
15
+ * that failed).
16
+ *
17
+ * Doctrine (mirrors `ChannelRolloverBridgeComposer`, cloud DD-013 — the
18
+ * platform's reference for conversation digests):
19
+ * - Content here, presentation framing separate: {@link composeTurnRecoveryDigest}
20
+ * emits bare `Assistant:` / `Tool:` / `System:` lines;
21
+ * {@link formatTurnRecoveryText} adds the behavioral preamble; the prompt
22
+ * builder wraps the section tag (the context-bridge / conversation-catchup
23
+ * split).
24
+ * - Oldest-first reading order; over budget, the OLDEST lines drop first
25
+ * (recency wins) and the drop is disclosed.
26
+ * - Best-effort by contract: composition never throws — a failed digest
27
+ * degrades to recovery-without-transcript, never a failed recovery.
28
+ *
29
+ * Scope: the CURRENT turn only. Prior turns live in other execution records
30
+ * and are not recoverable runner-side (that is the rollover bridge's domain,
31
+ * composed cloud-side); the enhanced prompt's standing-context sections carry
32
+ * whatever the session already had.
33
+ */
34
+
35
+ import type { AgentMessage, ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
36
+ import { MessageType, ToolCallStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
37
+
38
+ /** Per-line text budget; longer texts are cut with an ellipsis (DD-013 twin). */
39
+ const MAX_LINE_CHARS = 400;
40
+
41
+ /**
42
+ * Args-preview budget inside a tool line — the `describeApproval` precedent
43
+ * (prompt-builder.ts), so tool renderings are bounded the same way on both
44
+ * surfaces that describe them.
45
+ */
46
+ const MAX_ARGS_CHARS = 200;
47
+
48
+ /**
49
+ * Whole-digest budget (DD-013 twin). Enforced by dropping the OLDEST lines
50
+ * first — recency wins, matching how a human skims their own recent work
51
+ * before continuing it.
52
+ */
53
+ const MAX_DIGEST_CHARS = 4000;
54
+
55
+ const ELLIPSIS = "\u2026";
56
+
57
+ /** Disclosed in place of the dropped prefix so the record never lies by omission. */
58
+ const OMISSION_NOTICE = `[${ELLIPSIS} earlier activity in this turn omitted for length]`;
59
+
60
+ /**
61
+ * How the transcript is introduced to the model. Establishes the three facts
62
+ * the replacement agent must hold: the work below is its OWN (continue, don't
63
+ * restart), completed actions are DONE (don't redo them), and the workspace —
64
+ * not this summary — is the source of truth for exact current state.
65
+ */
66
+ const TURN_RECOVERY_PREAMBLE =
67
+ "You had already started working on the user's request above, but the " +
68
+ "session holding that conversation was lost, so you do not remember it. " +
69
+ "Below is the platform's recorded transcript of your progress in this " +
70
+ "turn, oldest first. Treat it as work YOU already did: do not start the " +
71
+ "task over, do not redo actions shown as completed, and check the " +
72
+ "workspace's current state where exact details matter.";
73
+
74
+ /**
75
+ * Fallback framing when the turn produced no renderable transcript (or
76
+ * composition failed): the state-loss disclosure still matters — without it,
77
+ * the appended approval decisions read as reactions to proposals this agent
78
+ * never made.
79
+ */
80
+ const TURN_RECOVERY_NO_TRANSCRIPT =
81
+ "You had already started working on the user's request above, but the " +
82
+ "session holding that conversation was lost, so you do not remember it " +
83
+ "and no transcript of your progress is available. Check the workspace's " +
84
+ "current state to see what you already did before continuing.";
85
+
86
+ /**
87
+ * Render the turn's persisted transcript as bare digest lines, or undefined
88
+ * when there is nothing worth carrying. Never throws.
89
+ *
90
+ * Human messages are skipped — the turn's user message is already rendered
91
+ * in the enhanced prompt's `<user_request>`, and duplicating it here would
92
+ * dilute the budget. Thinking content is skipped as model-internal (the
93
+ * replacement agent forms its own). System notices are kept: platform
94
+ * messages like budget warnings or degradation notices are turn context the
95
+ * agent acted under.
96
+ */
97
+ export function composeTurnRecoveryDigest(
98
+ messages: readonly AgentMessage[],
99
+ ): string | undefined {
100
+ try {
101
+ const lines: string[] = [];
102
+ for (const message of messages) {
103
+ lines.push(...messageLines(message));
104
+ }
105
+ if (lines.length === 0) return undefined;
106
+
107
+ // Oldest-first; drop from the FRONT when over budget so the newest work
108
+ // always survives, and disclose the drop (DD-013 enforcement shape). The
109
+ // notice participates in the budget so the result never overshoots.
110
+ let first = 0;
111
+ while (first < lines.length && totalLength(lines, first) > MAX_DIGEST_CHARS) {
112
+ first++;
113
+ }
114
+ if (first >= lines.length) {
115
+ // Degenerate case: even the newest line alone busts the budget (it is
116
+ // already line-truncated, so this would take a pathological budget/line
117
+ // ratio) — keep that one line rather than rendering nothing.
118
+ first = lines.length - 1;
119
+ }
120
+ const kept = lines.slice(first);
121
+ if (first > 0) kept.unshift(OMISSION_NOTICE);
122
+ return kept.join("\n");
123
+ } catch (err) {
124
+ console.warn("Turn-recovery digest composition failed — recovering without a transcript:", err);
125
+ return undefined;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * The framed recovery body (preamble + digest), ready for section wrapping.
131
+ * Accepts an absent digest: the state-loss disclosure is load-bearing even
132
+ * when there is no transcript to show (see {@link TURN_RECOVERY_NO_TRANSCRIPT}).
133
+ */
134
+ export function formatTurnRecoveryText(digest: string | undefined): string {
135
+ if (digest === undefined || digest.trim() === "") {
136
+ return TURN_RECOVERY_NO_TRANSCRIPT;
137
+ }
138
+ return `${TURN_RECOVERY_PREAMBLE}\n\n${digest.trim()}`;
139
+ }
140
+
141
+ /** The digest lines one transcript message contributes (possibly none). */
142
+ function messageLines(message: AgentMessage): string[] {
143
+ const lines: string[] = [];
144
+ const text = message.content.trim();
145
+ switch (message.type) {
146
+ case MessageType.MESSAGE_AI:
147
+ if (text) lines.push(`Assistant: ${truncate(text, MAX_LINE_CHARS)}`);
148
+ // Tool calls ride AI messages (message.proto contract); render them
149
+ // even when the surrounding text is blank.
150
+ for (const toolCall of message.toolCalls) {
151
+ lines.push(toolCallLine(toolCall));
152
+ }
153
+ return lines;
154
+ case MessageType.MESSAGE_SYSTEM:
155
+ if (text) lines.push(`System: ${truncate(text, MAX_LINE_CHARS)}`);
156
+ return lines;
157
+ default:
158
+ // Human (already in <user_request>), thinking (model-internal), tool
159
+ // results (each ToolCall line already carries its outcome), unknown.
160
+ return lines;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * One bounded line per tool call: what was attempted and how it ended.
166
+ * Prefers the resolved approval message where one exists — the same
167
+ * human-meaningful description the user approved against
168
+ * (the `describeApproval` convention).
169
+ */
170
+ function toolCallLine(toolCall: ToolCall): string {
171
+ const action = toolCall.approvalMessage
172
+ ? toolCall.approvalMessage
173
+ : toolCall.argsPreview
174
+ ? `${toolCall.name}(${truncate(toolCall.argsPreview, MAX_ARGS_CHARS)})`
175
+ : toolCall.name;
176
+ return `Tool: ${truncate(action, MAX_LINE_CHARS)} — ${outcome(toolCall)}`;
177
+ }
178
+
179
+ function outcome(toolCall: ToolCall): string {
180
+ switch (toolCall.status) {
181
+ case ToolCallStatus.TOOL_CALL_COMPLETED:
182
+ return "completed";
183
+ case ToolCallStatus.TOOL_CALL_FAILED:
184
+ return toolCall.error
185
+ ? `failed: ${truncate(toolCall.error, MAX_ARGS_CHARS)}`
186
+ : "failed";
187
+ case ToolCallStatus.TOOL_CALL_WAITING_APPROVAL:
188
+ return "paused for user approval";
189
+ case ToolCallStatus.TOOL_CALL_SKIPPED:
190
+ return "skipped";
191
+ default:
192
+ // PENDING / RUNNING / unknown: in flight when the session was lost.
193
+ return "interrupted before it finished";
194
+ }
195
+ }
196
+
197
+ function truncate(text: string, max: number): string {
198
+ return text.length <= max ? text : `${text.slice(0, max)}${ELLIPSIS}`;
199
+ }
200
+
201
+ /** Joined length of lines[first..] plus the omission notice when one would render. */
202
+ function totalLength(lines: readonly string[], first: number): number {
203
+ let total = first > 0 ? OMISSION_NOTICE.length + 1 : 0;
204
+ for (let i = first; i < lines.length; i++) {
205
+ total += lines[i].length + 1;
206
+ }
207
+ return total;
208
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * End-to-end proof (real deepagents + LangGraph runtime, no LLM/network) that
3
+ * a plan-mode PARENT graph accepts workspace-relative paths (issue #429).
4
+ *
5
+ * Before the fix, deepagents' permission enforcement canonicalized every
6
+ * filesystem tool-call path BEFORE any rule ran and refused non-absolute
7
+ * shapes, so on a rule-bearing graph a workspace-relative call — reads
8
+ * included — died with `path must be absolute` instead of just working. The
9
+ * path-normalization middleware (middleware/path-normalization.ts) rewrites
10
+ * relative paths to workspace-absolute at our seam, before enforcement sees
11
+ * them.
12
+ *
13
+ * The graph here is composed exactly the way setup.ts composes the parent:
14
+ * the PRODUCTION buildMiddlewareStack (pathNormalization present, the
15
+ * rule-bearing shape) + the CAS capture backend + the PRODUCTION
16
+ * PLAN_MODE_PERMISSIONS constant. These tests are also the empirical proof
17
+ * that langchain's wrapToolCall seam delivers rewritten args to the tool —
18
+ * if it did not, the relative read below could never succeed.
19
+ *
20
+ * The sub-agent twin of this contract is pinned in
21
+ * subagent-plan-mode-permissions.test.ts (issue #255 wiring).
22
+ */
23
+
24
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
25
+ import { mkdtemp, mkdir, rm, readFile, writeFile, access } from "node:fs/promises";
26
+ import { tmpdir } from "node:os";
27
+ import { join } from "node:path";
28
+ import { HumanMessage, ToolMessage, type BaseMessage } from "@langchain/core/messages";
29
+ import { MemorySaver } from "@langchain/langgraph";
30
+ import { createDeepAgent } from "deepagents";
31
+
32
+ import { PLAN_MODE_PERMISSIONS } from "../../../shared/plan-mode-permissions.js";
33
+ import { buildMiddlewareStack } from "../../../middleware/index.js";
34
+ import { createCasCaptureBackend } from "../cas-capture-backend.js";
35
+ import { CasCaptureObserver } from "../cas-capture-observer.js";
36
+ import { ScriptedModel, type ScriptSelector } from "../__test-utils__/scripted-model.js";
37
+
38
+ const SEEDED_CONTENT = "PLAN_MODE_README_TOKEN: hello from the seeded file";
39
+
40
+ describe("plan-mode parent path normalization (issue #429)", () => {
41
+ let root: string;
42
+ let observer: CasCaptureObserver;
43
+
44
+ beforeEach(async () => {
45
+ root = await mkdtemp(join(tmpdir(), "plan-path-"));
46
+ await mkdir(join(root, "src"), { recursive: true });
47
+ await writeFile(join(root, "src/notes.md"), SEEDED_CONTENT);
48
+ observer = new CasCaptureObserver({ rootDir: root, isIgnored: async () => true });
49
+ });
50
+
51
+ afterEach(async () => {
52
+ await rm(root, { recursive: true, force: true });
53
+ });
54
+
55
+ /**
56
+ * Build a parent graph the way setup.ts does in plan mode: the production
57
+ * middleware stack with pathNormalization set (derived, like the graph's
58
+ * permissions, from the plan-mode rules), a filesystem-only CAS capture
59
+ * backend (no shellEnv — plan mode clears it), and PLAN_MODE_PERMISSIONS
60
+ * on the graph.
61
+ */
62
+ async function buildPlanModeParent(script: ScriptSelector) {
63
+ const { middleware } = buildMiddlewareStack({
64
+ approvalGate: null,
65
+ pathNormalization: { rootDir: root },
66
+ });
67
+ const backend = await createCasCaptureBackend({ rootDir: root, observer });
68
+ return createDeepAgent({
69
+ model: new ScriptedModel(script),
70
+ checkpointer: new MemorySaver() as never,
71
+ backend,
72
+ middleware: middleware as never[],
73
+ permissions: PLAN_MODE_PERMISSIONS,
74
+ } as Parameters<typeof createDeepAgent>[0]);
75
+ }
76
+
77
+ function toolResultById(messages: BaseMessage[], toolCallId: string): string {
78
+ const match = messages.find(
79
+ (m): m is ToolMessage => m instanceof ToolMessage && m.tool_call_id === toolCallId,
80
+ );
81
+ expect(match, `expected a tool result for call '${toolCallId}'`).toBeDefined();
82
+ return typeof match!.content === "string" ? match!.content : JSON.stringify(match!.content);
83
+ }
84
+
85
+ async function invokeOnce(script: ScriptSelector, threadId: string) {
86
+ const agent = await buildPlanModeParent(script);
87
+ return (await agent.invoke(
88
+ { messages: [new HumanMessage({ content: "go" })] },
89
+ { configurable: { thread_id: threadId }, recursionLimit: 50 },
90
+ )) as { messages: BaseMessage[] };
91
+ }
92
+
93
+ it("a prompt-compliant relative read succeeds on the first tool round", async () => {
94
+ const result = await invokeOnce(
95
+ () => ({
96
+ toolCalls: [
97
+ { name: "read_file", args: { file_path: "src/notes.md" }, id: "c_read_rel" },
98
+ ],
99
+ done: "done",
100
+ }),
101
+ "t_read_rel",
102
+ );
103
+
104
+ const readResult = toolResultById(result.messages, "c_read_rel");
105
+ expect(readResult).toContain("PLAN_MODE_README_TOKEN");
106
+ expect(readResult).not.toMatch(/path must be absolute/i);
107
+ expect(readResult).not.toMatch(/permission denied/i);
108
+ });
109
+
110
+ it("a relative write is refused by the RULES — permission denied, not a path-shape error", async () => {
111
+ const result = await invokeOnce(
112
+ () => ({
113
+ toolCalls: [
114
+ { name: "write_file", args: { file_path: "src/out.txt", content: "x" }, id: "c_write_rel" },
115
+ ],
116
+ done: "done",
117
+ }),
118
+ "t_write_rel",
119
+ );
120
+
121
+ // The deny-all-writes rule fires on the normalized path — the honest
122
+ // plan-mode refusal the model can reason about, instead of the
123
+ // pre-fix validation error that taught it to abandon relative paths.
124
+ expect(toolResultById(result.messages, "c_write_rel")).toMatch(/permission denied for write/i);
125
+ expect(toolResultById(result.messages, "c_write_rel")).not.toMatch(/path must be absolute/i);
126
+ await expect(access(join(root, "src/out.txt"))).rejects.toThrow();
127
+ expect(observer.before.size).toBe(0);
128
+ });
129
+
130
+ it("an escaping relative read is still refused — normalization grants no new reachability", async () => {
131
+ // Outside the root, reachable only if `..` were naively joined away.
132
+ const result = await invokeOnce(
133
+ () => ({
134
+ toolCalls: [
135
+ { name: "read_file", args: { file_path: "../escape.txt" }, id: "c_read_escape" },
136
+ ],
137
+ done: "done",
138
+ }),
139
+ "t_read_escape",
140
+ );
141
+
142
+ // Left raw by the middleware, refused by upstream validation exactly as
143
+ // before the fix.
144
+ expect(toolResultById(result.messages, "c_read_escape")).toMatch(/must not contain|path must be absolute/i);
145
+ });
146
+
147
+ it("absolute in-workspace paths keep working unchanged", async () => {
148
+ const result = await invokeOnce(
149
+ () => ({
150
+ toolCalls: [
151
+ { name: "read_file", args: { file_path: join(root, "src/notes.md") }, id: "c_read_abs" },
152
+ ],
153
+ done: "done",
154
+ }),
155
+ "t_read_abs",
156
+ );
157
+
158
+ expect(toolResultById(result.messages, "c_read_abs")).toContain("PLAN_MODE_README_TOKEN");
159
+ });
160
+
161
+ it("relative ls flows through normalization to list the workspace directory", async () => {
162
+ const result = await invokeOnce(
163
+ () => ({
164
+ toolCalls: [{ name: "ls", args: { path: "src" }, id: "c_ls_rel" }],
165
+ done: "done",
166
+ }),
167
+ "t_ls_rel",
168
+ );
169
+
170
+ const lsResult = toolResultById(result.messages, "c_ls_rel");
171
+ expect(lsResult).toContain("notes.md");
172
+ expect(lsResult).not.toMatch(/path must be absolute/i);
173
+ });
174
+ });
@@ -1,22 +1,36 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { buildShellEnv, SHELL_ENV_DENYLIST } from "../shell-env.js";
3
+ import { RUNNER_CREDENTIAL_ENV_KEYS } from "../../../shared/runner-credential-keys.js";
3
4
 
4
5
  describe("buildShellEnv", () => {
5
- it("strips runner-internal credentials from the base env", () => {
6
- const base = {
7
- PATH: "/usr/bin",
8
- HOME: "/home/runner",
9
- STIGMER_RUNNER_HITL_SECRET: "secret",
10
- CURSOR_API_KEY: "cursor-key",
11
- STIGMER_TOKEN: "stigmer-token",
12
- };
6
+ it("strips every runner credential from the base env", () => {
7
+ // Plant a value for every name in the source-of-truth list, so a key
8
+ // added to runner-credential-keys.ts is covered here automatically.
9
+ const base: NodeJS.ProcessEnv = { PATH: "/usr/bin", HOME: "/home/runner" };
10
+ for (const key of RUNNER_CREDENTIAL_ENV_KEYS) {
11
+ base[key] = `leaked-${key}`;
12
+ }
13
13
 
14
14
  const env = buildShellEnv({}, base);
15
15
 
16
16
  expect(env.PATH).toBe("/usr/bin");
17
17
  expect(env.HOME).toBe("/home/runner");
18
- for (const key of SHELL_ENV_DENYLIST) {
19
- expect(env[key]).toBeUndefined();
18
+ for (const key of RUNNER_CREDENTIAL_ENV_KEYS) {
19
+ expect(env[key], `runner credential '${key}' must not reach the shell env`).toBeUndefined();
20
+ }
21
+ });
22
+
23
+ it("denies the full credential list, not just the original three (issue #385)", () => {
24
+ // Pins the five names #385 added. The parameterized test above would
25
+ // pass even if the list regressed to three; this one cannot.
26
+ for (const key of [
27
+ "ANTHROPIC_API_KEY",
28
+ "OPENAI_API_KEY",
29
+ "STIGMER_AUTH_TOKEN",
30
+ "ANTHROPIC_FOUNDRY_API_KEY",
31
+ "AWS_BEARER_TOKEN_BEDROCK",
32
+ ]) {
33
+ expect(SHELL_ENV_DENYLIST, `'${key}' must be in the shell denylist`).toContain(key);
20
34
  }
21
35
  });
22
36
 
@@ -83,12 +83,10 @@ describe("plan-mode sub-agent filesystem permissions (issue #255)", () => {
83
83
  // One turn, three tool calls: both mutation tools must be denied by the
84
84
  // permission rules; the read must pass through untouched (the deny rule
85
85
  // covers the `write` operation class only).
86
- // Real absolute workspace paths, as production models use (the prompt's
87
- // file tree is absolute, and the tools' contract says absolute). With
88
- // permission rules present this is also the only shape that reaches the
89
- // rules at all: enforcePermission's canonicalization refuses relative
90
- // paths with a validation error before any rule or backend runs — a
91
- // different denial, but still nothing written.
86
+ // Absolute workspace paths the canonical shape after enforcement
87
+ // canonicalization, and what the single-workspace prompt's prose (the
88
+ // absolute rootDir) steers models toward. The workspace-relative shape
89
+ // is pinned separately below (issue #429).
92
90
  const script: ScriptSelector = () => ({
93
91
  toolCalls: [
94
92
  { name: "write_file", args: { file_path: join(root, "dist/out.txt"), content: "new file" }, id: "c_write" },
@@ -143,19 +141,20 @@ describe("plan-mode sub-agent filesystem permissions (issue #255)", () => {
143
141
  expect(observer.before.size).toBe(1);
144
142
  });
145
143
 
146
- it("relative-path mutations are refused too by validation, before the rules", async () => {
147
- // The native prompt steers models to workspace-relative paths, but
148
- // deepagents' permission canonicalization accepts only absolute ones, so
149
- // with rules present a relative-path call fails validation before any
150
- // rule (or backend) runs. Read-only still holds nothing is written —
151
- // but the model sees "path must be absolute", not "permission denied",
152
- // and prompt-compliant relative READS degrade the same way. That
153
- // relative-vs-absolute friction is a pre-existing parent-plan-mode
154
- // behavior, tracked as issue #429; this pins that the sub-agent's
155
- // read-only contract survives it.
144
+ it("relative-path writes are refused by the RULES, and relative reads flow (issue #429)", async () => {
145
+ // The multi-workspace prompt mandates workspace-relative paths, and
146
+ // models routinely choose them in single-workspace sessions too. Before
147
+ // the #429 fix, deepagents' permission canonicalization refused the
148
+ // shape outright every relative call, reads included, died with
149
+ // "path must be absolute" before any rule ran. compileSubagents now
150
+ // derives a path-normalization shim from the same `permissions` it
151
+ // bakes into the graph, so the relative shape reaches the rules in
152
+ // canonical form: writes get the honest plan-mode denial, reads just
153
+ // work. Read-only-by-construction (issue #255) holds throughout.
156
154
  const script: ScriptSelector = () => ({
157
155
  toolCalls: [
158
156
  { name: "write_file", args: { file_path: "dist/out.txt", content: "new file" }, id: "c_write_rel" },
157
+ { name: "read_file", args: { file_path: "notes.md" }, id: "c_read_rel" },
159
158
  ],
160
159
  done: "worker done",
161
160
  });
@@ -166,8 +165,14 @@ describe("plan-mode sub-agent filesystem permissions (issue #255)", () => {
166
165
  { configurable: { thread_id: "t3" }, recursionLimit: 50 },
167
166
  )) as { messages: BaseMessage[] };
168
167
 
169
- expect(toolResultById(result.messages, "c_write_rel")).toMatch(/path must be absolute/i);
168
+ const writeResult = toolResultById(result.messages, "c_write_rel");
169
+ expect(writeResult).toMatch(/permission denied for write/i);
170
+ expect(writeResult).not.toMatch(/path must be absolute/i);
170
171
  await expect(access(join(root, "dist/out.txt"))).rejects.toThrow();
171
172
  expect(observer.before.size).toBe(0);
173
+
174
+ const readResult = toolResultById(result.messages, "c_read_rel");
175
+ expect(readResult).toContain("PLAN_MODE_README_TOKEN");
176
+ expect(readResult).not.toMatch(/path must be absolute/i);
172
177
  });
173
178
  });
@@ -635,17 +635,14 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
635
635
  // Step 9: Construct the LLM model. Resolution to the provider API id
636
636
  // happens inside buildChatModel; modelName stays the registry id for
637
637
  // pricing, the native-thinking heuristic, and sub-agent inheritance.
638
- // Operator input: only a positive integer means "bound the request"
639
- // unset, non-numeric, zero, and negative all normalize to no bound.
640
- const parsedTimeoutMs =
641
- Number.parseInt(process.env.STIGMER_LLM_REQUEST_TIMEOUT_MS ?? "", 10);
642
- const requestTimeoutMs = parsedTimeoutMs > 0 ? parsedTimeoutMs : undefined;
638
+ // The operator's STIGMER_LLM_REQUEST_TIMEOUT_MS bound is applied inside
639
+ // buildChatModel (#468) the sub-agent modelFactory below silently
640
+ // dropped it when each caller parsed the env itself.
643
641
  const { model } = await buildChatModel({
644
642
  modelName,
645
643
  proxyEndpoint: config.proxyEndpoint ?? undefined,
646
644
  stigmerToken: config.stigmerToken ?? undefined,
647
645
  headerScope: { executionId },
648
- timeoutMs: requestTimeoutMs,
649
646
  });
650
647
  timing.mark("build_model");
651
648
 
@@ -655,6 +652,12 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
655
652
  const execConfig = execution.spec!.executionConfig;
656
653
  const isPlanMode = execConfig?.interactionMode === InteractionMode.PLAN;
657
654
  const shellEnv = isPlanMode ? undefined : buildShellEnv(envResult.mergedEnvVars);
655
+ // Plan mode's filesystem permission rules, hoisted once: the parent graph,
656
+ // every sub-agent graph, AND the path-normalization middleware (issue #429)
657
+ // all derive from this single value, so a rule-bearing graph can never miss
658
+ // the shim that keeps prompt-compliant relative paths from dying in rule
659
+ // validation (`path must be absolute`).
660
+ const planModePermissions = isPlanMode ? PLAN_MODE_PERMISSIONS : undefined;
658
661
 
659
662
  const toolServerMap = new Map<string, string>();
660
663
  if (mcpConnection) {
@@ -760,6 +763,9 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
760
763
  } : null,
761
764
  otelSpans: { toolServerMap },
762
765
  approvalGate: approvalGateConfig,
766
+ pathNormalization: planModePermissions
767
+ ? { rootDir: workspaceBackend.rootDir }
768
+ : null,
763
769
  });
764
770
  timing.mark("build_middleware");
765
771
 
@@ -823,7 +829,9 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
823
829
  // Plan mode's deny-all-writes rule, mirrored onto every sub-agent graph
824
830
  // (issue #255) — pre-built CompiledSubAgents never inherit the parent's
825
831
  // permissions, so read-only-by-construction must be baked in here.
826
- ...(isPlanMode ? { permissions: PLAN_MODE_PERMISSIONS } : {}),
832
+ // compileSubagents derives each sub-agent's path-normalization shim
833
+ // from this same value (issue #429).
834
+ ...(planModePermissions ? { permissions: planModePermissions } : {}),
827
835
  });
828
836
  timing.mark("compile_subagents");
829
837
  }
@@ -857,7 +865,7 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
857
865
  middleware: middleware as any,
858
866
  subagents: compiledSubagents ?? undefined,
859
867
  ...(responseFormat ? { responseFormat } : {}),
860
- ...(isPlanMode ? { permissions: PLAN_MODE_PERMISSIONS } : {}),
868
+ ...(planModePermissions ? { permissions: planModePermissions } : {}),
861
869
  } as Parameters<typeof createDeepAgent>[0]);
862
870
 
863
871
  // Step 11: Prepare invocation input and config. The conversation catchup
@@ -6,15 +6,14 @@
6
6
  * never once at process start.
7
7
  */
8
8
 
9
- /** Runner-internal keys that must never reach agent shell commands. */
10
- export const SHELL_ENV_DENYLIST: readonly string[] = [
11
- // Derives HITL approval fingerprints — an agent that reads this could forge receipts.
12
- "STIGMER_RUNNER_HITL_SECRET",
13
- // Cursor harness credential; shell commands do not need direct Cursor API access.
14
- "CURSOR_API_KEY",
15
- // Stigmer control-plane auth; shell commands use ExecutionContext overlay instead.
16
- "STIGMER_TOKEN",
17
- ];
9
+ import { RUNNER_CREDENTIAL_ENV_KEYS } from "../../shared/runner-credential-keys.js";
10
+
11
+ /**
12
+ * Runner-internal keys that must never reach agent shell commands: every
13
+ * credential the runner holds for its own outbound calls (issue #385). The
14
+ * names — and the rule for adding one — live in runner-credential-keys.ts.
15
+ */
16
+ export const SHELL_ENV_DENYLIST: readonly string[] = RUNNER_CREDENTIAL_ENV_KEYS;
18
17
 
19
18
  /**
20
19
  * Build the environment map passed to deepagents' LocalShellBackend.