@stigmer/runner 3.11.0 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) 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/attachment-resolver.d.ts +11 -0
  10. package/dist/activities/execute-cursor/attachment-resolver.js +8 -0
  11. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  12. package/dist/activities/execute-cursor/blueprint-resolver.d.ts +1 -9
  13. package/dist/activities/execute-cursor/blueprint-resolver.js +0 -1
  14. package/dist/activities/execute-cursor/blueprint-resolver.js.map +1 -1
  15. package/dist/activities/execute-cursor/error-classifier.d.ts +19 -0
  16. package/dist/activities/execute-cursor/error-classifier.js +34 -3
  17. package/dist/activities/execute-cursor/error-classifier.js.map +1 -1
  18. package/dist/activities/execute-cursor/index.d.ts +52 -12
  19. package/dist/activities/execute-cursor/index.js +141 -67
  20. package/dist/activities/execute-cursor/index.js.map +1 -1
  21. package/dist/activities/execute-cursor/model-pricing-data.d.ts +2 -0
  22. package/dist/activities/execute-cursor/model-pricing-data.js +13 -3
  23. package/dist/activities/execute-cursor/model-pricing-data.js.map +1 -1
  24. package/dist/activities/execute-cursor/prompt-builder.d.ts +48 -11
  25. package/dist/activities/execute-cursor/prompt-builder.js +57 -10
  26. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  27. package/dist/activities/execute-cursor/turn-recovery.d.ts +52 -0
  28. package/dist/activities/execute-cursor/turn-recovery.js +193 -0
  29. package/dist/activities/execute-cursor/turn-recovery.js.map +1 -0
  30. package/dist/activities/execute-deep-agent/attachment-injector.d.ts +11 -0
  31. package/dist/activities/execute-deep-agent/attachment-injector.js +7 -1
  32. package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
  33. package/dist/activities/execute-deep-agent/index.js +0 -15
  34. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  35. package/dist/activities/execute-deep-agent/mcp-gate.d.ts +0 -2
  36. package/dist/activities/execute-deep-agent/mcp-gate.js +0 -1
  37. package/dist/activities/execute-deep-agent/mcp-gate.js.map +1 -1
  38. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +7 -8
  39. package/dist/activities/execute-deep-agent/prompt-builder.js +14 -7
  40. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  41. package/dist/activities/execute-deep-agent/setup.d.ts +0 -10
  42. package/dist/activities/execute-deep-agent/setup.js +22 -49
  43. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  44. package/dist/activities/execute-deep-agent/shell-env.d.ts +5 -1
  45. package/dist/activities/execute-deep-agent/shell-env.js +7 -9
  46. package/dist/activities/execute-deep-agent/shell-env.js.map +1 -1
  47. package/dist/activities/execute-deep-agent/subagent-transformer.js +7 -1
  48. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  49. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +17 -3
  50. package/dist/activities/execute-deep-agent/subagent-wiring.js +14 -2
  51. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  52. package/dist/activities/hydrate-workflow-execution.js +8 -5
  53. package/dist/activities/hydrate-workflow-execution.js.map +1 -1
  54. package/dist/activities/run-command.d.ts +5 -2
  55. package/dist/activities/run-command.js +22 -10
  56. package/dist/activities/run-command.js.map +1 -1
  57. package/dist/activities/run-env.d.ts +40 -0
  58. package/dist/activities/run-env.js +66 -0
  59. package/dist/activities/run-env.js.map +1 -0
  60. package/dist/bootstrap.js +5 -0
  61. package/dist/bootstrap.js.map +1 -1
  62. package/dist/client/stigmer-client.d.ts +83 -26
  63. package/dist/client/stigmer-client.js +88 -24
  64. package/dist/client/stigmer-client.js.map +1 -1
  65. package/dist/config.d.ts +6 -5
  66. package/dist/config.js.map +1 -1
  67. package/dist/encryption/config.d.ts +41 -9
  68. package/dist/encryption/config.js +63 -23
  69. package/dist/encryption/config.js.map +1 -1
  70. package/dist/middleware/index.d.ts +3 -0
  71. package/dist/middleware/index.js +7 -0
  72. package/dist/middleware/index.js.map +1 -1
  73. package/dist/middleware/path-normalization.d.ts +57 -0
  74. package/dist/middleware/path-normalization.js +109 -0
  75. package/dist/middleware/path-normalization.js.map +1 -0
  76. package/dist/middleware/types.d.ts +8 -0
  77. package/dist/payload-codecs.d.ts +2 -1
  78. package/dist/payload-codecs.js +7 -3
  79. package/dist/payload-codecs.js.map +1 -1
  80. package/dist/runner-manager.js +17 -3
  81. package/dist/runner-manager.js.map +1 -1
  82. package/dist/runner.js +7 -3
  83. package/dist/runner.js.map +1 -1
  84. package/dist/shared/artifact-storage.d.ts +34 -1
  85. package/dist/shared/artifact-storage.js +53 -10
  86. package/dist/shared/artifact-storage.js.map +1 -1
  87. package/dist/shared/attachment-download-urls.d.ts +62 -0
  88. package/dist/shared/attachment-download-urls.js +79 -0
  89. package/dist/shared/attachment-download-urls.js.map +1 -0
  90. package/dist/shared/channel-attachment.d.ts +1 -1
  91. package/dist/shared/channel-attachment.js +1 -1
  92. package/dist/shared/checkpointer/http-saver.d.ts +4 -20
  93. package/dist/shared/checkpointer/http-saver.js +15 -57
  94. package/dist/shared/checkpointer/http-saver.js.map +1 -1
  95. package/dist/shared/conversation-attachment.d.ts +2 -3
  96. package/dist/shared/conversation-attachment.js +2 -3
  97. package/dist/shared/conversation-attachment.js.map +1 -1
  98. package/dist/shared/conversation-catchup.d.ts +9 -2
  99. package/dist/shared/conversation-catchup.js +39 -6
  100. package/dist/shared/conversation-catchup.js.map +1 -1
  101. package/dist/shared/grpc-retry.d.ts +5 -1
  102. package/dist/shared/grpc-retry.js +5 -1
  103. package/dist/shared/grpc-retry.js.map +1 -1
  104. package/dist/shared/http-retry.d.ts +51 -9
  105. package/dist/shared/http-retry.js +60 -9
  106. package/dist/shared/http-retry.js.map +1 -1
  107. package/dist/shared/mcp-manager.d.ts +8 -4
  108. package/dist/shared/mcp-manager.js +30 -4
  109. package/dist/shared/mcp-manager.js.map +1 -1
  110. package/dist/shared/mcp-schema-sanitizer.d.ts +70 -0
  111. package/dist/shared/mcp-schema-sanitizer.js +197 -0
  112. package/dist/shared/mcp-schema-sanitizer.js.map +1 -0
  113. package/dist/shared/mcp-transport-guard.d.ts +2 -2
  114. package/dist/shared/mcp-transport-guard.js +2 -2
  115. package/dist/shared/model-client.d.ts +6 -0
  116. package/dist/shared/model-client.js +14 -3
  117. package/dist/shared/model-client.js.map +1 -1
  118. package/dist/shared/model-pricing-data.d.ts +2 -0
  119. package/dist/shared/model-pricing-data.js +13 -3
  120. package/dist/shared/model-pricing-data.js.map +1 -1
  121. package/dist/shared/model-registry.js +3 -2
  122. package/dist/shared/model-registry.js.map +1 -1
  123. package/dist/shared/plan-mode-permissions.d.ts +7 -0
  124. package/dist/shared/plan-mode-permissions.js +7 -0
  125. package/dist/shared/plan-mode-permissions.js.map +1 -1
  126. package/dist/shared/registry-endpoint.d.ts +20 -0
  127. package/dist/shared/registry-endpoint.js +25 -0
  128. package/dist/shared/registry-endpoint.js.map +1 -1
  129. package/dist/shared/runner-credential-keys.d.ts +24 -0
  130. package/dist/shared/runner-credential-keys.js +47 -0
  131. package/dist/shared/runner-credential-keys.js.map +1 -0
  132. package/dist/shared/synthesized-attachment.d.ts +4 -4
  133. package/dist/shared/synthesized-attachment.js +4 -4
  134. package/dist/workflow-engine/resolve.d.ts +19 -1
  135. package/dist/workflow-engine/resolve.js +37 -2
  136. package/dist/workflow-engine/resolve.js.map +1 -1
  137. package/dist/workflows/connect-mcp-server.js +2 -0
  138. package/dist/workflows/connect-mcp-server.js.map +1 -1
  139. package/dist/workflows/types.d.ts +8 -0
  140. package/package.json +3 -4
  141. package/src/__test-utils__/__tests__/vitest-global-setup.test.ts +37 -0
  142. package/src/__test-utils__/fake-artifact-storage.ts +10 -1
  143. package/src/__test-utils__/vitest-global-setup.ts +50 -0
  144. package/src/__tests__/bootstrap.test.ts +43 -0
  145. package/src/__tests__/encryption-codec.test.ts +53 -0
  146. package/src/__tests__/preflight.test.ts +15 -0
  147. package/src/activities/__tests__/discover-mcp-server.test.ts +39 -1
  148. package/src/activities/__tests__/error-classifier.test.ts +94 -2
  149. package/src/activities/__tests__/run-command.test.ts +221 -0
  150. package/src/activities/discover-mcp-server.ts +16 -2
  151. package/src/activities/emit-event.ts +65 -21
  152. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +63 -3
  153. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +173 -31
  154. package/src/activities/execute-cursor/__tests__/model-pricing.test.ts +62 -1
  155. package/src/activities/execute-cursor/__tests__/turn-recovery.test.ts +160 -0
  156. package/src/activities/execute-cursor/attachment-resolver.ts +24 -0
  157. package/src/activities/execute-cursor/blueprint-resolver.ts +1 -10
  158. package/src/activities/execute-cursor/error-classifier.ts +40 -3
  159. package/src/activities/execute-cursor/index.ts +175 -74
  160. package/src/activities/execute-cursor/model-pricing-data.ts +22 -3
  161. package/src/activities/execute-cursor/prompt-builder.ts +89 -22
  162. package/src/activities/execute-cursor/turn-recovery.ts +208 -0
  163. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +105 -0
  164. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +0 -2
  165. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +0 -1
  166. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +0 -1
  167. package/src/activities/execute-deep-agent/__tests__/mcp-gate.test.ts +2 -7
  168. package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +174 -0
  169. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +71 -0
  170. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +0 -1
  171. package/src/activities/execute-deep-agent/__tests__/shell-env.test.ts +24 -10
  172. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +22 -17
  173. package/src/activities/execute-deep-agent/attachment-injector.ts +20 -1
  174. package/src/activities/execute-deep-agent/index.ts +0 -19
  175. package/src/activities/execute-deep-agent/mcp-gate.ts +0 -3
  176. package/src/activities/execute-deep-agent/prompt-builder.ts +27 -15
  177. package/src/activities/execute-deep-agent/setup.ts +22 -73
  178. package/src/activities/execute-deep-agent/shell-env.ts +8 -9
  179. package/src/activities/execute-deep-agent/subagent-transformer.ts +7 -1
  180. package/src/activities/execute-deep-agent/subagent-wiring.ts +26 -3
  181. package/src/activities/hydrate-workflow-execution.ts +8 -5
  182. package/src/activities/run-command.ts +25 -12
  183. package/src/activities/run-env.ts +79 -0
  184. package/src/bootstrap.ts +5 -0
  185. package/src/client/__tests__/stigmer-client.test.ts +104 -5
  186. package/src/client/stigmer-client.ts +128 -29
  187. package/src/config.ts +6 -5
  188. package/src/encryption/config.ts +87 -23
  189. package/src/middleware/__tests__/path-normalization.test.ts +140 -0
  190. package/src/middleware/index.ts +8 -0
  191. package/src/middleware/path-normalization.ts +125 -0
  192. package/src/middleware/types.ts +11 -0
  193. package/src/payload-codecs.ts +8 -2
  194. package/src/runner-manager.ts +21 -3
  195. package/src/runner.ts +7 -3
  196. package/src/shared/__tests__/artifact-storage.test.ts +154 -2
  197. package/src/shared/__tests__/attachment-download-urls.test.ts +70 -0
  198. package/src/shared/__tests__/channel-attachment.test.ts +2 -2
  199. package/src/shared/__tests__/conversation-catchup.test.ts +24 -0
  200. package/src/shared/__tests__/http-retry.test.ts +5 -4
  201. package/src/shared/__tests__/mcp-manager.test.ts +53 -13
  202. package/src/shared/__tests__/mcp-schema-sanitizer.test.ts +267 -0
  203. package/src/shared/__tests__/model-client.test.ts +46 -0
  204. package/src/shared/__tests__/model-pricing.test.ts +62 -2
  205. package/src/shared/__tests__/model-registry.test.ts +22 -0
  206. package/src/shared/__tests__/synthesized-attachment.test.ts +19 -41
  207. package/src/shared/artifact-storage.ts +86 -10
  208. package/src/shared/attachment-download-urls.ts +98 -0
  209. package/src/shared/channel-attachment.ts +1 -1
  210. package/src/shared/checkpointer/http-saver.ts +15 -60
  211. package/src/shared/conversation-attachment.ts +2 -3
  212. package/src/shared/conversation-catchup.ts +39 -6
  213. package/src/shared/grpc-retry.ts +5 -1
  214. package/src/shared/http-retry.ts +98 -9
  215. package/src/shared/mcp-manager.ts +35 -4
  216. package/src/shared/mcp-schema-sanitizer.ts +224 -0
  217. package/src/shared/mcp-transport-guard.ts +2 -2
  218. package/src/shared/model-client.ts +21 -3
  219. package/src/shared/model-pricing-data.ts +22 -3
  220. package/src/shared/model-registry.ts +7 -2
  221. package/src/shared/plan-mode-permissions.ts +7 -0
  222. package/src/shared/registry-endpoint.ts +27 -0
  223. package/src/shared/runner-credential-keys.ts +46 -0
  224. package/src/shared/synthesized-attachment.ts +4 -4
  225. package/src/workflow-engine/__tests__/tasks/emit-event.test.ts +127 -49
  226. package/src/workflow-engine/resolve.ts +48 -2
  227. package/src/workflows/__tests__/connect-mcp-server.test.ts +25 -0
  228. package/src/workflows/connect-mcp-server.ts +2 -0
  229. package/src/workflows/types.ts +8 -0
  230. package/dist/activities/execute-cursor/mcp-config.d.ts +0 -30
  231. package/dist/activities/execute-cursor/mcp-config.js +0 -39
  232. package/dist/activities/execute-cursor/mcp-config.js.map +0 -1
  233. package/dist/shared/datastore-attachment.d.ts +0 -99
  234. package/dist/shared/datastore-attachment.js +0 -184
  235. package/dist/shared/datastore-attachment.js.map +0 -1
  236. package/src/activities/execute-cursor/mcp-config.ts +0 -66
  237. package/src/activities/execute-deep-agent/__tests__/datastore-degradation.test.ts +0 -104
  238. package/src/shared/__tests__/datastore-attachment.test.ts +0 -293
  239. package/src/shared/datastore-attachment.ts +0 -209
@@ -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
  });
@@ -27,6 +27,7 @@ import { posix } from "node:path";
27
27
  import type { Attachment } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
28
28
  import type { WorkspaceBackend } from "../../shared/workspace/types.js";
29
29
  import type { ArtifactStorage } from "../../shared/artifact-storage.js";
30
+ import { mintAttachmentDownloadUrl } from "../../shared/attachment-download-urls.js";
30
31
  import { allocateUniqueName } from "../../shared/attachment-naming.js";
31
32
  import type {
32
33
  VisionBudget,
@@ -66,6 +67,17 @@ export interface InjectedFile {
66
67
  * Attachments that were never image-shaped carry neither field.
67
68
  */
68
69
  readonly visionDegraded?: VisionDegradedReason;
70
+ /**
71
+ * A download URL for the attachment's stored object, listed in the system
72
+ * prompt's Input Files section so the agent can hand the file to a tool
73
+ * whose backend cannot read the sandbox filesystem (issue #532). Minted
74
+ * whenever the attachment has a `storageKey` and a usable storage; never
75
+ * present on extracted ZIP entries (no attachment-level object). Absent
76
+ * when there is nothing to mint or the mint failed (non-fatal — policy,
77
+ * degrade log, and prompt wording live in
78
+ * shared/attachment-download-urls.ts).
79
+ */
80
+ readonly downloadUrl?: string;
69
81
  }
70
82
 
71
83
  export interface InjectAttachmentsOptions {
@@ -300,7 +312,10 @@ export async function injectAttachments(opts: InjectAttachmentsOptions): Promise
300
312
  );
301
313
  // A renamed mount DIR is visible through every extracted path; the
302
314
  // entries themselves were not renamed, so they carry no renamedFrom
303
- // (per-file disclosure would misattribute the rename).
315
+ // (per-file disclosure would misattribute the rename). Extracted
316
+ // entries also carry no downloadUrl: the stored object is the ZIP, not
317
+ // any listed file, and a URL to bytes that differ from the listing
318
+ // would be a false promise.
304
319
  injectedFiles.push(...extracted);
305
320
  } else {
306
321
  await backend.writeFileBuffer(mountPath, content);
@@ -313,6 +328,9 @@ export async function injectAttachments(opts: InjectAttachmentsOptions): Promise
313
328
  // the vision budget before they go out of scope (the sniff decides
314
329
  // eligibility; the budget owns every size/count rule).
315
330
  const vision = visionBudget?.offer(filename, attachment.contentType, content);
331
+ const downloadUrl = await mintAttachmentDownloadUrl(
332
+ storage, attachment.storageKey, filename,
333
+ );
316
334
  injectedFiles.push({
317
335
  filename,
318
336
  path: mountPath,
@@ -320,6 +338,7 @@ export async function injectAttachments(opts: InjectAttachmentsOptions): Promise
320
338
  ...(renamedFrom !== undefined ? { renamedFrom } : {}),
321
339
  ...(vision?.kind === "accepted" ? { vision: vision.image } : {}),
322
340
  ...(vision?.kind === "degraded" ? { visionDegraded: vision.reason } : {}),
341
+ ...(downloadUrl !== undefined ? { downloadUrl } : {}),
323
342
  });
324
343
  }
325
344
  }
@@ -32,7 +32,6 @@ import {
32
32
  } from "../../shared/workspace/stigmer-link.js";
33
33
  import type { ToolOutputOffloadContext } from "../../shared/status-offload.js";
34
34
  import { publishPlanArtifact } from "../../shared/plan-artifact.js";
35
- import { formatDatastoreDegradationNotice } from "../../shared/datastore-attachment.js";
36
35
  import { classifyTool } from "../../shared/tool-kind.js";
37
36
  import {
38
37
  POLICY_ENGINE_VERSION,
@@ -153,24 +152,6 @@ export function createDeepAgentActivities(config: Config) {
153
152
  ? seedStatusFromExecution(setup.execution)
154
153
  : create(AgentExecutionStatusSchema, {});
155
154
 
156
- // Datastore degradation disclosure, operator half (issue #325): the
157
- // prompt already tells the agent to disclose the outage; this makes
158
- // it visible on the execution record without reading a transcript.
159
- // Appended to initialStatus BEFORE streaming (not via a status
160
- // builder): both the v2 and v3 stream paths construct their own
161
- // builder around this same proto and only ever append messages, so
162
- // this row survives whichever path runs and rides every persist.
163
- if (setup.datastoreToolsMissing.length > 0) {
164
- initialStatus.messages.push(create(AgentMessageSchema, {
165
- type: MessageType.MESSAGE_SYSTEM,
166
- content: formatDatastoreDegradationNotice(
167
- setup.agent.spec?.datastoreUsages?.length ?? 0,
168
- setup.datastoreToolsMissing,
169
- ),
170
- timestamp: utcTimestamp(),
171
- }));
172
- }
173
-
174
155
  const statusBuilder = new StatusBuilder(executionId, initialStatus);
175
156
 
176
157
  statusBuilder.setApprovalProvider({
@@ -17,8 +17,6 @@
17
17
  export interface McpToolSources {
18
18
  /** Declared MCP server usages (agent spec + session spec). */
19
19
  readonly mcpServerUsageCount: number;
20
- /** Declared datastore usages (the records attachment, T05). */
21
- readonly datastoreUsageCount: number;
22
20
  /** Serving proactive-messaging channels (the channels attachment, DD-006). */
23
21
  readonly channelMessagingCount: number;
24
22
  /** The serving channel id when this session IS a live channel
@@ -30,7 +28,6 @@ export interface McpToolSources {
30
28
  export function shouldConnectMcp(sources: McpToolSources): boolean {
31
29
  return (
32
30
  sources.mcpServerUsageCount > 0 ||
33
- sources.datastoreUsageCount > 0 ||
34
31
  sources.channelMessagingCount > 0 ||
35
32
  sources.conversationChannelId !== undefined
36
33
  );
@@ -21,6 +21,10 @@ import {
21
21
  visionDisclosureLines,
22
22
  type NotViewableEntry,
23
23
  } from "../../shared/attachment-vision.js";
24
+ import {
25
+ downloadUrlDisclosureLine,
26
+ type DownloadUrlKind,
27
+ } from "../../shared/attachment-download-urls.js";
24
28
  import { PLAN_MODE_DIRECTIVE } from "../../shared/plan-mode-prompt.js";
25
29
  import {
26
30
  buildImplementPlanDirective,
@@ -99,14 +103,6 @@ export interface PromptBuilderInput {
99
103
  provisionResults: ProvisionResult[];
100
104
  containerRoot: string;
101
105
  skillsPromptSection: string;
102
- /**
103
- * The datastores section (shared/datastore-attachment.ts
104
- * formatDatastoresSection): `<available_datastores>` when the connected
105
- * record-tool roster is complete, `<unavailable_datastores>` when setup's
106
- * reconciliation found record tools missing (issue #325). Empty when the
107
- * agent uses no datastores.
108
- */
109
- datastoresPromptSection?: string;
110
106
  /**
111
107
  * The `<available_channel_templates>` section
112
108
  * (shared/channel-attachment.ts formatChannelTemplatesSection); absent
@@ -122,6 +118,12 @@ export interface PromptBuilderInput {
122
118
  * Rendered inside the Input Files section.
123
119
  */
124
120
  vision?: VisionPromptInfo;
121
+ /**
122
+ * What kind of URL the turn's storage backend mints (issue #532) — keys
123
+ * the Input Files section's hand-off wording (attachment-download-urls.ts).
124
+ * One turn-level fact: all attachments ride the one configured storage.
125
+ */
126
+ downloadUrlKind?: DownloadUrlKind;
125
127
  /**
126
128
  * The execution's interaction mode. PLAN appends the shared plan-mode
127
129
  * directive so the model knows the turn's deliverable is a plan document.
@@ -201,10 +203,6 @@ export function buildEnhancedSystemPrompt(input: PromptBuilderInput): string {
201
203
  prompt += input.skillsPromptSection;
202
204
  }
203
205
 
204
- if (input.datastoresPromptSection) {
205
- prompt += "\n\n" + input.datastoresPromptSection;
206
- }
207
-
208
206
  if (input.channelTemplatesPromptSection) {
209
207
  prompt += "\n\n" + input.channelTemplatesPromptSection;
210
208
  }
@@ -220,7 +218,9 @@ export function buildEnhancedSystemPrompt(input: PromptBuilderInput): string {
220
218
  }
221
219
 
222
220
  if (input.injectedFiles.length > 0) {
223
- prompt += buildInjectedFilesSection(input.injectedFiles, input.vision);
221
+ prompt += buildInjectedFilesSection(
222
+ input.injectedFiles, input.vision, input.downloadUrlKind,
223
+ );
224
224
  }
225
225
 
226
226
  if (input.senderIdentity) {
@@ -401,6 +401,7 @@ function buildReferencedFilesSection(
401
401
  function buildInjectedFilesSection(
402
402
  files: readonly InjectedFile[],
403
403
  vision?: VisionPromptInfo,
404
+ downloadUrlKind?: DownloadUrlKind,
404
405
  ): string {
405
406
  let section = "\n\n## Input Files\n\n";
406
407
  section +=
@@ -416,12 +417,23 @@ function buildInjectedFilesSection(
416
417
  const sizeInfo = ` (${f.sizeBytes} bytes)`;
417
418
  // A duplicate-renamed file (attachment-naming.ts) discloses its original
418
419
  // name so the agent can connect "the two report.pdfs" in the user's
419
- // message to distinct files on disk.
420
+ // message to distinct files on disk. A file with a minted download URL
421
+ // (attachment-download-urls.ts) lists it beside the path for the remote
422
+ // hand-off story.
420
423
  const renameInfo =
421
424
  f.renamedFrom !== undefined
422
425
  ? ` (renamed from duplicate '${f.renamedFrom}')`
423
426
  : "";
424
- section += `- \`${f.path}\`${sizeInfo}${renameInfo}\n`;
427
+ const urlInfo =
428
+ f.downloadUrl !== undefined ? ` — download URL: ${f.downloadUrl}` : "";
429
+ section += `- \`${f.path}\`${sizeInfo}${renameInfo}${urlInfo}\n`;
430
+ }
431
+
432
+ // The URL hand-off line (shared wording, attachment-download-urls.ts)
433
+ // renders only when some listed file actually carries a URL — its wording
434
+ // keys on what kind of URL the storage backend mints.
435
+ if (downloadUrlKind !== undefined && files.some((f) => f.downloadUrl !== undefined)) {
436
+ section += "\n" + downloadUrlDisclosureLine(downloadUrlKind) + "\n";
425
437
  }
426
438
 
427
439
  // The vision lines (shared wording, attachment-vision.ts) tell the model
@@ -36,12 +36,6 @@ import { connectMcpServers, type McpConnectionResult } from "../../shared/mcp-ma
36
36
  import { mergeMcpServerUsages, resolveMcpServers } from "../../shared/mcp-resolver.js";
37
37
  import { resolveMcpTransportPosture } from "../../shared/mcp-transport-guard.js";
38
38
  import { backfillMcpServersIfNeeded } from "../../shared/connect-backfill.js";
39
- import {
40
- DATASTORE_ATTACHMENT_SLUG,
41
- formatDatastoresSection,
42
- missingRecordTools,
43
- synthesizeDatastoreAttachment,
44
- } from "../../shared/datastore-attachment.js";
45
39
  import {
46
40
  discoverChannelMessaging,
47
41
  formatChannelTemplatesSection,
@@ -129,16 +123,6 @@ export interface SetupResult {
129
123
  readonly session: Session;
130
124
  readonly workspaceBackend: WorkspaceBackend;
131
125
  readonly mcpConnection: McpConnectionResult | null;
132
- /**
133
- * Record tools declared via datastore_usages but absent from the connected
134
- * stigmer-records roster this turn (issue #325). Empty = healthy (also when
135
- * the agent uses no datastores). Non-empty selects the degraded prompt
136
- * section in Step 8, and the activity surfaces it to operators as a
137
- * MESSAGE_SYSTEM row — the vision-facts pattern: setup computes the honest
138
- * struct, the caller discloses. Recomputed every turn, so a healed outage
139
- * stops disclosing on the next turn.
140
- */
141
- readonly datastoreToolsMissing: readonly string[];
142
126
  readonly mergedEnvVars: Record<string, string>;
143
127
  readonly secretKeys: ReadonlySet<string>;
144
128
  readonly modelName: string;
@@ -369,7 +353,6 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
369
353
  agent.spec!.mcpServerUsages || [],
370
354
  session.spec!.mcpServerUsages || [],
371
355
  );
372
- const datastoreUsages = agent.spec!.datastoreUsages || [];
373
356
 
374
357
  // The synthesized attachments' credential story (DD-006 D4): the
375
358
  // exchanged token authenticates the discovery reads per-call (the
@@ -410,7 +393,6 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
410
393
  let resolvedMcpServers: Awaited<ReturnType<typeof resolveMcpServers>> | null = null;
411
394
  if (shouldConnectMcp({
412
395
  mcpServerUsageCount: mcpServerUsages.length,
413
- datastoreUsageCount: datastoreUsages.length,
414
396
  channelMessagingCount: channelMessaging.length,
415
397
  conversationChannelId,
416
398
  })) {
@@ -444,25 +426,9 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
444
426
  envResult.secretKeys,
445
427
  );
446
428
 
447
- // The datastore records attachment (T05) — injected AFTER resolve +
448
- // backfill so the destructiveHint tightener can never gate it; empty
449
- // approval maps keep it approval-free by construction (see
450
- // shared/datastore-attachment.ts).
451
- if (datastoreUsages.length > 0) {
452
- const attachment = synthesizeDatastoreAttachment(datastoreUsages, {
453
- bridgeEndpoint: config.mcpBridgeEndpoint,
454
- credential: attachmentCredential,
455
- backendEndpoint: config.stigmerBackendEndpoint,
456
- });
457
- if (attachment) {
458
- backfilledServers = injectSynthesizedAttachment(
459
- backfilledServers, attachment, "datastore records",
460
- );
461
- }
462
- }
463
-
464
- // The channel messaging attachment (DD-006 D7/D8) — the records
465
- // attachment's twin, injected under the same after-backfill rule.
429
+ // The channel messaging attachment (DD-006 D7/D8) — injected AFTER
430
+ // resolve + backfill so the destructiveHint tightener can never gate
431
+ // it; empty approval maps keep it approval-free by construction.
466
432
  if (channelMessaging.length > 0) {
467
433
  const attachment = synthesizeChannelAttachment(channelMessaging, {
468
434
  bridgeEndpoint: config.mcpBridgeEndpoint,
@@ -477,8 +443,8 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
477
443
  }
478
444
 
479
445
  // The conversation participation attachment (DD-008 D-c) — the
480
- // third sibling, same after-backfill rule. HTTP-only: synthesize
481
- // answers undefined with no bridge endpoint by design (see
446
+ // channel attachment's sibling, same after-backfill rule. HTTP-only:
447
+ // synthesize answers undefined with no bridge endpoint by design (see
482
448
  // shared/conversation-attachment.ts).
483
449
  const conversationAttachment = synthesizeConversationAttachment(conversationChannelId, {
484
450
  bridgeEndpoint: config.mcpBridgeEndpoint,
@@ -585,37 +551,12 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
585
551
  }
586
552
  timing.mark("inject_attachments");
587
553
 
588
- // Reconcile the declared datastores against the record tools actually
589
- // connected (issue #325): the records roster always serves all five
590
- // tools, so any absence means the store is degraded, and the prompt
591
- // must say so instead of promising tools the agent does not have. The
592
- // connected roster is post-connect truth — when usages exist, MCP
593
- // connect always ran above (a thrown connect already failed the run).
594
- const datastoreToolsMissing = datastoreUsages.length > 0 && mcpConnection
595
- ? missingRecordTools(
596
- (mcpConnection.serverToolMap[DATASTORE_ATTACHMENT_SLUG] ?? [])
597
- .map((t) => t.name),
598
- )
599
- : [];
600
- if (datastoreToolsMissing.length > 0) {
601
- console.warn(
602
- `[setup] Datastore record tools degraded: declared ` +
603
- `${datastoreUsages.length} datastore usage(s) but the ` +
604
- `'${DATASTORE_ATTACHMENT_SLUG}' roster is missing ` +
605
- `[${datastoreToolsMissing.join(", ")}] — the prompt discloses the ` +
606
- `outage instead of the available-datastores section.`,
607
- );
608
- }
609
-
610
554
  // Step 8: Build enhanced system prompt
611
555
  const systemPrompt = buildEnhancedSystemPrompt({
612
556
  instructions,
613
557
  provisionResults,
614
558
  containerRoot: workspaceBackend.rootDir,
615
559
  skillsPromptSection,
616
- datastoresPromptSection: datastoreUsages.length > 0
617
- ? formatDatastoresSection(datastoreUsages, datastoreToolsMissing)
618
- : undefined,
619
560
  // "" (nothing sendable) threads as undefined: the tool alone still
620
561
  // serves text sends inside a 24-hour window (DD-006 D6).
621
562
  channelTemplatesPromptSection: channelMessaging.length > 0
@@ -625,6 +566,7 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
625
566
  workspaceRoot: workspaceBackend.rootDir,
626
567
  injectedFiles,
627
568
  vision: visionPromptInfo,
569
+ downloadUrlKind: artifactStorage?.downloadUrlKind,
628
570
  interactionMode: execution.spec!.executionConfig?.interactionMode,
629
571
  buildFromPlan: execution.spec!.executionConfig?.buildFromPlan,
630
572
  contextBridge: readContextBridge(session.spec!.metadata),
@@ -635,17 +577,14 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
635
577
  // Step 9: Construct the LLM model. Resolution to the provider API id
636
578
  // happens inside buildChatModel; modelName stays the registry id for
637
579
  // 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;
580
+ // The operator's STIGMER_LLM_REQUEST_TIMEOUT_MS bound is applied inside
581
+ // buildChatModel (#468) the sub-agent modelFactory below silently
582
+ // dropped it when each caller parsed the env itself.
643
583
  const { model } = await buildChatModel({
644
584
  modelName,
645
585
  proxyEndpoint: config.proxyEndpoint ?? undefined,
646
586
  stigmerToken: config.stigmerToken ?? undefined,
647
587
  headerScope: { executionId },
648
- timeoutMs: requestTimeoutMs,
649
588
  });
650
589
  timing.mark("build_model");
651
590
 
@@ -655,6 +594,12 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
655
594
  const execConfig = execution.spec!.executionConfig;
656
595
  const isPlanMode = execConfig?.interactionMode === InteractionMode.PLAN;
657
596
  const shellEnv = isPlanMode ? undefined : buildShellEnv(envResult.mergedEnvVars);
597
+ // Plan mode's filesystem permission rules, hoisted once: the parent graph,
598
+ // every sub-agent graph, AND the path-normalization middleware (issue #429)
599
+ // all derive from this single value, so a rule-bearing graph can never miss
600
+ // the shim that keeps prompt-compliant relative paths from dying in rule
601
+ // validation (`path must be absolute`).
602
+ const planModePermissions = isPlanMode ? PLAN_MODE_PERMISSIONS : undefined;
658
603
 
659
604
  const toolServerMap = new Map<string, string>();
660
605
  if (mcpConnection) {
@@ -760,6 +705,9 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
760
705
  } : null,
761
706
  otelSpans: { toolServerMap },
762
707
  approvalGate: approvalGateConfig,
708
+ pathNormalization: planModePermissions
709
+ ? { rootDir: workspaceBackend.rootDir }
710
+ : null,
763
711
  });
764
712
  timing.mark("build_middleware");
765
713
 
@@ -823,7 +771,9 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
823
771
  // Plan mode's deny-all-writes rule, mirrored onto every sub-agent graph
824
772
  // (issue #255) — pre-built CompiledSubAgents never inherit the parent's
825
773
  // permissions, so read-only-by-construction must be baked in here.
826
- ...(isPlanMode ? { permissions: PLAN_MODE_PERMISSIONS } : {}),
774
+ // compileSubagents derives each sub-agent's path-normalization shim
775
+ // from this same value (issue #429).
776
+ ...(planModePermissions ? { permissions: planModePermissions } : {}),
827
777
  });
828
778
  timing.mark("compile_subagents");
829
779
  }
@@ -857,7 +807,7 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
857
807
  middleware: middleware as any,
858
808
  subagents: compiledSubagents ?? undefined,
859
809
  ...(responseFormat ? { responseFormat } : {}),
860
- ...(isPlanMode ? { permissions: PLAN_MODE_PERMISSIONS } : {}),
810
+ ...(planModePermissions ? { permissions: planModePermissions } : {}),
861
811
  } as Parameters<typeof createDeepAgent>[0]);
862
812
 
863
813
  // Step 11: Prepare invocation input and config. The conversation catchup
@@ -928,7 +878,6 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
928
878
  session,
929
879
  workspaceBackend,
930
880
  mcpConnection,
931
- datastoreToolsMissing,
932
881
  mergedEnvVars: envResult.mergedEnvVars,
933
882
  secretKeys: envResult.secretKeys,
934
883
  modelName,
@@ -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.
@@ -554,11 +554,17 @@ export async function compileSubagents(
554
554
  // Structural coupling (DD-19): a sub-agent gate flows gitignored writes into
555
555
  // CAS iff a CAS observer backs that sub-agent's filesystem backend. Deriving
556
556
  // both from the same `casObserver` makes "unobserved unreviewable bytes"
557
- // impossible by construction.
557
+ // impossible by construction. Same idiom for path normalization
558
+ // (issue #429): derived from the same `permissions` baked into the graph
559
+ // below, so a rule-bearing graph always carries the shim that keeps
560
+ // prompt-compliant relative paths from dying in rule validation.
558
561
  const middleware = buildSubAgentMiddleware({
559
562
  costCap: opts.costCap,
560
563
  approvalGate: opts.approvalGate,
561
564
  captureIgnored: !!opts.casObserver,
565
+ ...(opts.permissions?.length
566
+ ? { pathNormalization: { rootDir: opts.workspaceRootDir } }
567
+ : {}),
562
568
  });
563
569
 
564
570
  const modelName = spec.model ?? opts.parentModelName;
@@ -2,6 +2,12 @@
2
2
  * Sub-agent middleware composition for ExecuteDeepAgent.
3
3
  *
4
4
  * Each sub-agent gets its own middleware stack with:
5
+ * - Path normalization (issue #429), FIRST and only on permission-rule-
6
+ * bearing graphs (plan mode) — workspace-relative paths are rewritten to
7
+ * workspace-absolute before deepagents' permission validation refuses
8
+ * them, exactly as on the parent. `compileSubagents` derives it from the
9
+ * same `permissions` option it bakes into the graph, keeping the rules
10
+ * and their normalization shim coupled.
5
11
  * - Fresh loop detection (independent cycle tracking)
6
12
  * - Fresh tool truncation (same limits as parent)
7
13
  * - Periodic execution budget (interval=30, max=4 advisories)
@@ -27,8 +33,13 @@
27
33
  * the live HITL bypass this wiring closes.
28
34
  */
29
35
 
30
- import type { StigmerMiddleware, ToolTruncationConfig } from "../../middleware/types.js";
36
+ import type {
37
+ StigmerMiddleware,
38
+ ToolTruncationConfig,
39
+ PathNormalizationConfig,
40
+ } from "../../middleware/types.js";
31
41
  import type { CostCapMiddleware } from "../../middleware/index.js";
42
+ import { createPathNormalizationMiddleware } from "../../middleware/path-normalization.js";
32
43
  import { createLoopDetectionMiddleware } from "../../middleware/loop-detection.js";
33
44
  import { createToolTruncationMiddleware } from "../../middleware/tool-truncation.js";
34
45
  import { createExecutionBudgetMiddleware } from "../../middleware/execution-budget.js";
@@ -59,14 +70,22 @@ export interface SubAgentMiddlewareOptions {
59
70
  * unobserved backend would apply unreviewable bytes.
60
71
  */
61
72
  readonly captureIgnored?: boolean;
73
+ /**
74
+ * Workspace-relative path normalization (issue #429). Present iff this
75
+ * sub-agent's graph carries filesystem permission rules — the caller
76
+ * derives it from the same `permissions` value it bakes into the graph.
77
+ */
78
+ readonly pathNormalization?: PathNormalizationConfig;
62
79
  }
63
80
 
64
81
  /**
65
82
  * Build the middleware stack for a single sub-agent.
66
83
  *
67
84
  * Returns an ordered array mirroring the parent composition:
68
- * loop detection → execution budget (periodic) → tool truncation →
69
- * [approval gate] → cost cap view → error hints. The gate sits before the
85
+ * [path normalization] → loop detection → execution budget (periodic) →
86
+ * tool truncation → [approval gate] → cost cap view → error hints.
87
+ * Normalization is outermost so everything downstream observes canonical
88
+ * workspace-absolute paths (matching the parent). The gate sits before the
70
89
  * cost-cap view so an approval pause happens before budget accounting, and
71
90
  * error hints come after the gate — both matching the parent order
72
91
  * (…→ truncation → graceful-stop → approval gate → cost cap → error hints …),
@@ -77,6 +96,10 @@ export function buildSubAgentMiddleware(
77
96
  ): StigmerMiddleware[] {
78
97
  const stack: StigmerMiddleware[] = [];
79
98
 
99
+ if (options.pathNormalization) {
100
+ stack.push(createPathNormalizationMiddleware(options.pathNormalization));
101
+ }
102
+
80
103
  stack.push(createLoopDetectionMiddleware({ enabled: true }));
81
104
 
82
105
  stack.push(createExecutionBudgetMiddleware({
@@ -265,11 +265,14 @@ async function fetchAndFlattenEnv(
265
265
  client: StigmerClient,
266
266
  executionId: string,
267
267
  ): Promise<Record<string, unknown>> {
268
- // A desktop runner exchanges its bootstrap credential for a token scoped to
269
- // this workflow execution, so cloud's decrypt gate binds the read (#156).
270
- // No-op for cloud sandbox and OSS runners. A failed exchange throws and
271
- // fails the activity: the bootstrap credential no longer decrypts
272
- // (stigmer-cloud#218), so proceeding would hydrate redacted placeholders.
268
+ // The runner acquires a token scoped to this workflow execution so the
269
+ // server's decrypt gate binds the read: a desktop runner exchanges its
270
+ // bootstrap credential (#156, hard error on failure the bootstrap
271
+ // credential no longer decrypts since stigmer-cloud#218, so proceeding
272
+ // would hydrate redacted placeholders), and an OSS runner asks the
273
+ // credential-less best-effort exchange (oss#535 — the OSS server redacts
274
+ // EC reads by default and mints execution-scoped tokens). No-op for cloud
275
+ // sandbox runners, whose ambient credential is already scoped.
273
276
  const scopedToken = await client.acquireScopedRunnerToken({
274
277
  workflowExecutionId: executionId,
275
278
  });