@stigmer/runner 3.12.4 → 3.12.5

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 (146) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/cursor-mcp-config.d.ts +45 -0
  3. package/dist/activities/execute-cursor/cursor-mcp-config.js +76 -0
  4. package/dist/activities/execute-cursor/cursor-mcp-config.js.map +1 -0
  5. package/dist/activities/execute-cursor/index.d.ts +10 -1
  6. package/dist/activities/execute-cursor/index.js +47 -29
  7. package/dist/activities/execute-cursor/index.js.map +1 -1
  8. package/dist/activities/execute-cursor/prompt-builder.d.ts +13 -2
  9. package/dist/activities/execute-cursor/prompt-builder.js +11 -0
  10. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  11. package/dist/activities/execute-cursor/session-lifecycle.d.ts +1 -1
  12. package/dist/activities/execute-cursor/skill-resolver.d.ts +2 -21
  13. package/dist/activities/execute-cursor/skill-resolver.js +5 -108
  14. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  15. package/dist/activities/execute-deep-agent/approval-file-change.d.ts +9 -0
  16. package/dist/activities/execute-deep-agent/approval-file-change.js +2 -1
  17. package/dist/activities/execute-deep-agent/approval-file-change.js.map +1 -1
  18. package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +18 -1
  19. package/dist/activities/execute-deep-agent/cas-capture-backend.js +20 -3
  20. package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
  21. package/dist/activities/execute-deep-agent/index.js +57 -5
  22. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  23. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +9 -0
  24. package/dist/activities/execute-deep-agent/prompt-builder.js +10 -0
  25. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  26. package/dist/activities/execute-deep-agent/setup.js +17 -16
  27. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  28. package/dist/activities/execute-deep-agent/subagent-transformer.js +17 -11
  29. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  30. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +3 -3
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.js +1 -1
  33. package/dist/middleware/path-normalization.d.ts +49 -49
  34. package/dist/middleware/path-normalization.js +73 -84
  35. package/dist/middleware/path-normalization.js.map +1 -1
  36. package/dist/middleware/types.d.ts +3 -3
  37. package/dist/runner-manager.d.ts +1 -1
  38. package/dist/runner-manager.js +22 -15
  39. package/dist/runner-manager.js.map +1 -1
  40. package/dist/runner.js +11 -0
  41. package/dist/runner.js.map +1 -1
  42. package/dist/shared/args-preview.d.ts +8 -0
  43. package/dist/shared/args-preview.js +14 -3
  44. package/dist/shared/args-preview.js.map +1 -1
  45. package/dist/shared/artifact-storage.d.ts +11 -1
  46. package/dist/shared/artifact-storage.js +10 -1
  47. package/dist/shared/artifact-storage.js.map +1 -1
  48. package/dist/shared/declared-preferences.d.ts +47 -0
  49. package/dist/shared/declared-preferences.js +64 -0
  50. package/dist/shared/declared-preferences.js.map +1 -0
  51. package/dist/shared/mcp-enabled-tools.d.ts +2 -3
  52. package/dist/shared/mcp-enabled-tools.js +2 -3
  53. package/dist/shared/mcp-enabled-tools.js.map +1 -1
  54. package/dist/shared/mcp-resolver.d.ts +20 -15
  55. package/dist/shared/mcp-resolver.js +11 -12
  56. package/dist/shared/mcp-resolver.js.map +1 -1
  57. package/dist/shared/placeholder-resolver.d.ts +9 -2
  58. package/dist/shared/placeholder-resolver.js +9 -2
  59. package/dist/shared/placeholder-resolver.js.map +1 -1
  60. package/dist/shared/plan-mode-permissions.d.ts +26 -46
  61. package/dist/shared/plan-mode-permissions.js +27 -55
  62. package/dist/shared/plan-mode-permissions.js.map +1 -1
  63. package/dist/shared/skill-mount.d.ts +89 -0
  64. package/dist/shared/skill-mount.js +142 -0
  65. package/dist/shared/skill-mount.js.map +1 -0
  66. package/dist/shared/skill-writer.d.ts +28 -26
  67. package/dist/shared/skill-writer.js +79 -102
  68. package/dist/shared/skill-writer.js.map +1 -1
  69. package/dist/shared/worker-shutdown.d.ts +67 -0
  70. package/dist/shared/worker-shutdown.js +79 -0
  71. package/dist/shared/worker-shutdown.js.map +1 -0
  72. package/dist/shared/workspace/types.d.ts +3 -2
  73. package/dist/workflow-engine/loader.js +38 -1
  74. package/dist/workflow-engine/loader.js.map +1 -1
  75. package/dist/workflow-engine/tasks/human-input.js +23 -2
  76. package/dist/workflow-engine/tasks/human-input.js.map +1 -1
  77. package/package.json +15 -11
  78. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +50 -0
  79. package/src/activities/execute-cursor/__tests__/cursor-mcp-config.test.ts +111 -0
  80. package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +3 -94
  81. package/src/activities/execute-cursor/cursor-mcp-config.ts +107 -0
  82. package/src/activities/execute-cursor/index.ts +63 -35
  83. package/src/activities/execute-cursor/prompt-builder.ts +29 -2
  84. package/src/activities/execute-cursor/session-lifecycle.ts +1 -1
  85. package/src/activities/execute-cursor/skill-resolver.ts +10 -137
  86. package/src/activities/execute-deep-agent/__tests__/approval-file-change.test.ts +15 -0
  87. package/src/activities/execute-deep-agent/__tests__/cas-capture-backend.test.ts +75 -0
  88. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +1 -0
  89. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
  90. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
  91. package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
  92. package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +42 -29
  93. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +52 -0
  94. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
  95. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +11 -8
  96. package/src/activities/execute-deep-agent/approval-file-change.ts +11 -1
  97. package/src/activities/execute-deep-agent/cas-capture-backend.ts +20 -3
  98. package/src/activities/execute-deep-agent/index.ts +57 -5
  99. package/src/activities/execute-deep-agent/prompt-builder.ts +22 -0
  100. package/src/activities/execute-deep-agent/setup.ts +21 -19
  101. package/src/activities/execute-deep-agent/subagent-transformer.ts +19 -12
  102. package/src/activities/execute-deep-agent/subagent-wiring.ts +3 -3
  103. package/src/index.ts +1 -1
  104. package/src/middleware/__tests__/path-normalization.test.ts +32 -32
  105. package/src/middleware/path-normalization.ts +78 -90
  106. package/src/middleware/types.ts +3 -3
  107. package/src/runner-manager.ts +26 -16
  108. package/src/runner.ts +14 -0
  109. package/src/shared/__tests__/artifact-storage.test.ts +28 -0
  110. package/src/shared/__tests__/bedrock-seam.test.ts +5 -5
  111. package/src/shared/__tests__/declared-preferences.test.ts +109 -0
  112. package/src/shared/__tests__/foundry-seam.test.ts +5 -5
  113. package/src/shared/__tests__/plan-mode-permissions.test.ts +15 -44
  114. package/src/shared/__tests__/skill-mount.test.ts +238 -0
  115. package/src/shared/__tests__/skill-writer.test.ts +151 -128
  116. package/src/shared/__tests__/vertex-adapter.test.ts +30 -3
  117. package/src/shared/__tests__/vertex-seam.test.ts +5 -5
  118. package/src/shared/__tests__/worker-shutdown.test.ts +101 -0
  119. package/src/shared/args-preview.ts +17 -3
  120. package/src/shared/artifact-storage.ts +20 -1
  121. package/src/shared/declared-preferences.ts +84 -0
  122. package/src/shared/mcp-enabled-tools.ts +2 -3
  123. package/src/shared/mcp-resolver.ts +20 -20
  124. package/src/shared/placeholder-resolver.ts +9 -2
  125. package/src/shared/plan-mode-permissions.ts +27 -58
  126. package/src/shared/skill-mount.ts +179 -0
  127. package/src/shared/skill-writer.ts +96 -130
  128. package/src/shared/worker-shutdown.ts +99 -0
  129. package/src/shared/workspace/types.ts +3 -2
  130. package/src/workflow-engine/__tests__/loader.test.ts +51 -0
  131. package/src/workflow-engine/__tests__/tasks/human-input.test.ts +117 -0
  132. package/src/workflow-engine/loader.ts +46 -1
  133. package/src/workflow-engine/tasks/human-input.ts +33 -5
  134. package/dist/activities/execute-cursor/connect-backfill.d.ts +0 -19
  135. package/dist/activities/execute-cursor/connect-backfill.js +0 -27
  136. package/dist/activities/execute-cursor/connect-backfill.js.map +0 -1
  137. package/dist/activities/execute-cursor/mcp-resolver.d.ts +0 -120
  138. package/dist/activities/execute-cursor/mcp-resolver.js +0 -194
  139. package/dist/activities/execute-cursor/mcp-resolver.js.map +0 -1
  140. package/dist/activities/execute-cursor/placeholder-resolver.d.ts +0 -34
  141. package/dist/activities/execute-cursor/placeholder-resolver.js +0 -82
  142. package/dist/activities/execute-cursor/placeholder-resolver.js.map +0 -1
  143. package/src/activities/execute-cursor/__tests__/mcp-resolver.test.ts +0 -125
  144. package/src/activities/execute-cursor/connect-backfill.ts +0 -52
  145. package/src/activities/execute-cursor/mcp-resolver.ts +0 -324
  146. package/src/activities/execute-cursor/placeholder-resolver.ts +0 -109
@@ -124,6 +124,81 @@ describe("CasCaptureShellBackend (shell + CAS adapter)", () => {
124
124
  });
125
125
  });
126
126
 
127
+ /**
128
+ * The issue #754 regression pins: the backends are virtual-rooted, so a
129
+ * leading-"/" tool path resolves INSIDE the workspace — never onto the host
130
+ * filesystem — and the CAS observer sees it under the same key the
131
+ * turn-boundary capture and InlinePublisher resolve. Before the fix, an
132
+ * absolute `write_file("/tmp/x")` landed in the REAL /tmp: outside the
133
+ * workspace, outside CAS capture, outside the file_review ledger — an
134
+ * auto-approved, unreviewed host write.
135
+ */
136
+ describe("virtual-root confinement (issue #754)", () => {
137
+ let root: string;
138
+
139
+ beforeEach(async () => {
140
+ root = await mkdtemp(join(tmpdir(), "cas-virtual-"));
141
+ });
142
+
143
+ afterEach(async () => {
144
+ await rm(root, { recursive: true, force: true });
145
+ });
146
+
147
+ function makeBackend(): { backend: CasCaptureFilesystemBackend; observer: CasCaptureObserver } {
148
+ // Non-git shape: the observer owns EVERY touched path (setup.ts wires
149
+ // isIgnored to always-true when there is no git work tree).
150
+ const observer = new CasCaptureObserver({ rootDir: root, isIgnored: async () => true });
151
+ const backend = new CasCaptureFilesystemBackend(
152
+ { rootDir: root, virtualMode: true },
153
+ { observer },
154
+ );
155
+ return { backend, observer };
156
+ }
157
+
158
+ it("an absolute write resolves inside the workspace, never onto the host", async () => {
159
+ const { backend, observer } = makeBackend();
160
+ const hostPath = `/tmp/stigmer-754-escape-${Date.now()}.txt`;
161
+
162
+ await backend.write(hostPath, "confined");
163
+
164
+ // Landed under the workspace root, keyed workspace-relative for capture…
165
+ expect(await readFile(join(root, hostPath.slice(1)), "utf8")).toBe("confined");
166
+ expect(observer.before.get(hostPath.slice(1))).toBeNull();
167
+ // …and the REAL host path was never touched.
168
+ await expect(readFile(hostPath, "utf8")).rejects.toThrow();
169
+ });
170
+
171
+ it("reads resolve inside the workspace — '/etc/hosts' is the workspace's, not the host's", async () => {
172
+ const { backend } = makeBackend();
173
+
174
+ const result = await backend.read("/etc/hosts");
175
+
176
+ // The workspace has no etc/hosts: the honest not-found, never host bytes.
177
+ expect(JSON.stringify(result)).toMatch(/not found|no such file/i);
178
+ expect(JSON.stringify(result)).not.toContain("localhost");
179
+ });
180
+
181
+ it("traversal is rejected at resolution", async () => {
182
+ const { backend } = makeBackend();
183
+
184
+ const result = await backend.write("../escape.txt", "nope");
185
+
186
+ expect((result as { error?: string }).error).toMatch(/traversal/i);
187
+ await expect(readFile(join(root, "..", "escape.txt"), "utf8")).rejects.toThrow();
188
+ });
189
+
190
+ it("the shell variant is confined identically", async () => {
191
+ const observer = new CasCaptureObserver({ rootDir: root, isIgnored: async () => true });
192
+ const backend = await createCasCaptureBackend({ rootDir: root, observer, shellEnv: {} });
193
+ const hostPath = `/tmp/stigmer-754-shell-escape-${Date.now()}.txt`;
194
+
195
+ await backend.write(hostPath, "confined");
196
+
197
+ expect(await readFile(join(root, hostPath.slice(1)), "utf8")).toBe("confined");
198
+ await expect(readFile(hostPath, "utf8")).rejects.toThrow();
199
+ });
200
+ });
201
+
127
202
  describe("plan-mode filesystem backend", () => {
128
203
  it("is not sandbox-capable", async () => {
129
204
  const root = await mkdtemp(join(tmpdir(), "cas-plan-"));
@@ -42,6 +42,7 @@ vi.mock("@temporalio/activity", () => ({
42
42
  current: () => ({
43
43
  cancellationSignal: new AbortController().signal,
44
44
  heartbeat: vi.fn(),
45
+ info: { taskQueue: "test-queue" },
45
46
  }),
46
47
  },
47
48
  CancelledFailure: class CancelledFailure extends Error {},
@@ -53,6 +53,7 @@ vi.mock("@temporalio/activity", () => ({
53
53
  // on it, which a bare `{ aborted: false }` stub cannot satisfy.
54
54
  cancellationSignal: new AbortController().signal,
55
55
  heartbeat: vi.fn(),
56
+ info: { taskQueue: "test-queue" },
56
57
  }),
57
58
  },
58
59
  CancelledFailure: class CancelledFailure extends Error {},
@@ -46,6 +46,7 @@ vi.mock("@temporalio/activity", () => ({
46
46
  // on it, which a bare `{ aborted: false }` stub cannot satisfy.
47
47
  cancellationSignal: new AbortController().signal,
48
48
  heartbeat: vi.fn(),
49
+ info: { taskQueue: "test-queue" },
49
50
  }),
50
51
  },
51
52
  CancelledFailure: class CancelledFailure extends Error {},
@@ -12,6 +12,7 @@ vi.mock("@temporalio/activity", () => ({
12
12
  // on it, which a bare `{ aborted: false }` stub cannot satisfy.
13
13
  cancellationSignal: new AbortController().signal,
14
14
  heartbeat: vi.fn(),
15
+ info: { taskQueue: "test-queue" },
15
16
  }),
16
17
  },
17
18
  CancelledFailure: class CancelledFailure extends Error {},
@@ -1,25 +1,25 @@
1
1
  /**
2
2
  * End-to-end proof (real deepagents + LangGraph runtime, no LLM/network) that
3
3
  * a plan-mode PARENT graph accepts workspace-relative paths (issue #429) and
4
- * scopes reads to the workspace (issue #528).
4
+ * confines every read to the workspace (issues #528, #754).
5
5
  *
6
6
  * Before the #429 fix, deepagents' permission enforcement canonicalized every
7
7
  * filesystem tool-call path BEFORE any rule ran and refused non-absolute
8
8
  * shapes, so on a rule-bearing graph a workspace-relative call — reads
9
9
  * included — died with `path must be absolute` instead of just working. The
10
10
  * path-normalization middleware (middleware/path-normalization.ts) rewrites
11
- * relative paths to workspace-absolute at our seam, before enforcement sees
12
- * them. #528 then made the workspace the READ boundary (owner ruling): the
13
- * rules deny out-of-root reads, the middleware fills the ls/glob/grep
14
- * omitted-path case (whose schema default is the OS root), and the
15
- * `.stigmer` symlink keeps platform-dir reads in-root as path strings.
11
+ * relative paths to VIRTUAL-absolute at our seam, before enforcement sees
12
+ * them. The read boundary #528 built out of rules is structural since #754:
13
+ * the backend is virtual-rooted, so every expressible path resolves inside
14
+ * the workspace an out-of-root name is simply nonexistent, and the
15
+ * `.stigmer` symlink keeps platform-dir reads addressable in-root.
16
16
  *
17
17
  * The graph here is composed exactly the way setup.ts composes the parent:
18
- * the PRODUCTION buildMiddlewareStack (pathNormalization present, the
19
- * rule-bearing shape) + the CAS capture backend + the PRODUCTION
20
- * buildPlanModePermissions rules. These tests are also the empirical proof
21
- * that langchain's wrapToolCall seam delivers rewritten args to the tool —
22
- * if it did not, the relative read below could never succeed.
18
+ * the PRODUCTION buildMiddlewareStack (pathNormalization present on every
19
+ * graph) + the CAS capture backend + the PRODUCTION buildPlanModePermissions
20
+ * rules. These tests are also the empirical proof that langchain's
21
+ * wrapToolCall seam delivers rewritten args to the tool — if it did not, the
22
+ * relative read below could never succeed.
23
23
  *
24
24
  * The sub-agent twin of this contract is pinned in
25
25
  * subagent-plan-mode-permissions.test.ts (issue #255 wiring).
@@ -65,7 +65,7 @@ async function buildPlanModeParent(
65
65
  checkpointer: new MemorySaver() as never,
66
66
  backend,
67
67
  middleware: middleware as never[],
68
- permissions: buildPlanModePermissions(root),
68
+ permissions: buildPlanModePermissions(),
69
69
  } as Parameters<typeof createDeepAgent>[0]);
70
70
  }
71
71
 
@@ -222,7 +222,11 @@ describe("plan-mode workspace read boundary (issue #528)", () => {
222
222
  )) as { messages: BaseMessage[] };
223
223
  }
224
224
 
225
- it("denies an out-of-root absolute read — the exposure #429 deliberately preserved is closed", async () => {
225
+ it("an out-of-root absolute read cannot reach the bytes — the name resolves in-workspace and finds nothing", async () => {
226
+ // Pre-#754 this was a rule-based denial; the boundary is structural now:
227
+ // the virtual root makes the outside path unaddressable, so the honest
228
+ // answer is not-found. The security property — the bytes never cross —
229
+ // is what this test pins.
226
230
  const result = await invokeOnce(
227
231
  () => ({
228
232
  toolCalls: [
@@ -234,7 +238,7 @@ describe("plan-mode workspace read boundary (issue #528)", () => {
234
238
  );
235
239
 
236
240
  const readResult = toolResultById(result.messages, "c_read_out");
237
- expect(readResult).toMatch(/permission denied for read/i);
241
+ expect(readResult).toMatch(/not found|no such file/i);
238
242
  expect(readResult).not.toContain("OUT_OF_ROOT_SECRET_TOKEN");
239
243
  });
240
244
 
@@ -269,9 +273,9 @@ describe("plan-mode workspace read boundary (issue #528)", () => {
269
273
  });
270
274
 
271
275
  it("a bare ls (no path argument) lists the workspace, not the OS root", async () => {
272
- // The tool's schema default is "/" — the OS root applied inside the
273
- // tool, after the middleware seam. The middleware fills the omission
274
- // with the workspace root, so the model's first listing just works.
276
+ // The tool's schema default is "/" — which under the virtual root IS the
277
+ // workspace root (pre-#754 it was the OS root and the middleware had to
278
+ // fill the omission), so the model's first listing just works.
275
279
  const result = await invokeOnce(
276
280
  () => ({
277
281
  toolCalls: [{ name: "ls", args: {}, id: "c_ls_bare" }],
@@ -287,7 +291,8 @@ describe("plan-mode workspace read boundary (issue #528)", () => {
287
291
 
288
292
  it("a bare grep (no path argument) searches the workspace, not the whole filesystem", async () => {
289
293
  // Pre-#528, a bare grep recursively scanned the ENTIRE OS filesystem
290
- // (schema default "/" + the legacy backend's literal pass-through).
294
+ // (schema default "/" + the legacy backend's literal pass-through);
295
+ // under the virtual root the default itself means the workspace.
291
296
  const result = await invokeOnce(
292
297
  () => ({
293
298
  toolCalls: [
@@ -303,7 +308,11 @@ describe("plan-mode workspace read boundary (issue #528)", () => {
303
308
  expect(grepResult).not.toMatch(/permission denied/i);
304
309
  });
305
310
 
306
- it("an explicit ls of '/' is denied honestlynot silently redirected to the workspace", async () => {
311
+ it("an explicit ls of '/' lists the workspace root '/' MEANS the workspace in the virtual dialect", async () => {
312
+ // Deliberate reversal of the pre-#754 pin ("denied honestly"): back then
313
+ // "/" named the OS root, so denial was the honest answer. Under the
314
+ // virtual root "/" IS the workspace root — the model asked for the
315
+ // workspace and gets it.
307
316
  const result = await invokeOnce(
308
317
  () => ({
309
318
  toolCalls: [{ name: "ls", args: { path: "/" }, id: "c_ls_slash" }],
@@ -312,9 +321,9 @@ describe("plan-mode workspace read boundary (issue #528)", () => {
312
321
  "t_ls_slash",
313
322
  );
314
323
 
315
- expect(toolResultById(result.messages, "c_ls_slash")).toMatch(
316
- /permission denied for read on \//i,
317
- );
324
+ const lsResult = toolResultById(result.messages, "c_ls_slash");
325
+ expect(lsResult).toContain("src");
326
+ expect(lsResult).not.toMatch(/permission denied/i);
318
327
  });
319
328
 
320
329
  it("writes stay denied everywhere — the read-allow rule admits reads only", async () => {
@@ -333,12 +342,12 @@ describe("plan-mode workspace read boundary (issue #528)", () => {
333
342
  });
334
343
  });
335
344
 
336
- describe("plan-mode read boundary with a glob-special workspace root (issue #528)", () => {
345
+ describe("plan-mode boundary with a glob-special workspace root (issues #528/#754)", () => {
337
346
  // Desktop localPath workspaces use the user's real project directory AS
338
- // the root — names like "My (work) [v2]" are legal there. This suite pins
339
- // escapeGlobLiteral against deepagents' real matcher end-to-end: without
340
- // escaping, the read-allow rule would silently never match and every
341
- // plan-mode read in such a workspace would be denied.
347
+ // the root — names like "My (work) [v2]" are legal there. Pre-#754 the
348
+ // read-allow rule embedded the root as a glob and needed escapeGlobLiteral
349
+ // to survive such names; the rules no longer embed the root at all, so
350
+ // this suite now pins that special-character roots just work.
342
351
  let base: string;
343
352
  let root: string;
344
353
  let observer: CasCaptureObserver;
@@ -363,7 +372,7 @@ describe("plan-mode read boundary with a glob-special workspace root (issue #528
363
372
  )) as { messages: BaseMessage[] };
364
373
  }
365
374
 
366
- it("in-root reads work, absolute and relative alike; out-of-root reads stay denied", async () => {
375
+ it("in-root reads work, absolute and relative alike; host files stay unreachable", async () => {
367
376
  const result = await invokeOnce(
368
377
  () => ({
369
378
  toolCalls: [
@@ -378,6 +387,10 @@ describe("plan-mode read boundary with a glob-special workspace root (issue #528
378
387
 
379
388
  expect(toolResultById(result.messages, "c_abs")).toContain("PLAN_MODE_README_TOKEN");
380
389
  expect(toolResultById(result.messages, "c_rel")).toContain("PLAN_MODE_README_TOKEN");
381
- expect(toolResultById(result.messages, "c_out")).toMatch(/permission denied for read/i);
390
+ // "/etc/hosts" names the WORKSPACE's etc/hosts, which doesn't exist —
391
+ // the host file is structurally unaddressable.
392
+ const outResult = toolResultById(result.messages, "c_out");
393
+ expect(outResult).toMatch(/not found|no such file/i);
394
+ expect(outResult).not.toContain("localhost");
382
395
  });
383
396
  });
@@ -411,6 +411,58 @@ describe("buildEnhancedSystemPrompt", () => {
411
411
  });
412
412
  });
413
413
 
414
+ describe("declared preferences", () => {
415
+ const base = {
416
+ instructions: "Test",
417
+ provisionResults: [],
418
+ containerRoot: "",
419
+ skillsPromptSection: "",
420
+ workspaceFileRefs: [],
421
+ workspaceRoot: "",
422
+ injectedFiles: [],
423
+ };
424
+
425
+ it("appends the preferences with per-scope attribution (every-turn injection)", () => {
426
+ const prompt = buildEnhancedSystemPrompt({
427
+ ...base,
428
+ declaredPreferences: {
429
+ orgContext: "We deploy to us-east-1.",
430
+ userContext: "Keep answers terse.",
431
+ },
432
+ });
433
+
434
+ expect(prompt).toContain("## Declared preferences");
435
+ expect(prompt).toContain("Declared by the organization:\nWe deploy to us-east-1.");
436
+ expect(prompt).toContain("Declared by the user:\nKeep answers terse.");
437
+ expect(prompt).toContain("Do not repeat them back");
438
+ });
439
+
440
+ it("omits the section when the execution carries no preferences", () => {
441
+ const prompt = buildEnhancedSystemPrompt(base);
442
+
443
+ expect(prompt).not.toContain("## Declared preferences");
444
+ });
445
+
446
+ it("places platform-declared preferences after the sender, before the embedder's session context (DD-002 D3)", () => {
447
+ const prompt = buildEnhancedSystemPrompt({
448
+ ...base,
449
+ senderIdentity: { value: "15550001111", kind: "whatsapp_phone" },
450
+ declaredPreferences: { orgContext: "We deploy to us-east-1." },
451
+ sessionContext: "Role: platform admin",
452
+ contextBridge: "User: hi\nAssistant: hello",
453
+ });
454
+
455
+ const sender = prompt.indexOf("## Conversation sender");
456
+ const preferences = prompt.indexOf("## Declared preferences");
457
+ const context = prompt.indexOf("## Session context");
458
+ const bridge = prompt.indexOf("## Previous conversation context");
459
+ expect(sender).toBeGreaterThan(-1);
460
+ expect(preferences).toBeGreaterThan(sender);
461
+ expect(context).toBeGreaterThan(preferences);
462
+ expect(bridge).toBeGreaterThan(context);
463
+ });
464
+ });
465
+
414
466
  describe("plan mode", () => {
415
467
  const base = {
416
468
  instructions: "Test",
@@ -55,6 +55,7 @@ vi.mock("@temporalio/activity", () => ({
55
55
  // on it, which a bare `{ aborted: false }` stub cannot satisfy.
56
56
  cancellationSignal: new AbortController().signal,
57
57
  heartbeat: vi.fn(),
58
+ info: { taskQueue: "test-queue" },
58
59
  }),
59
60
  },
60
61
  CancelledFailure: class CancelledFailure extends Error {},
@@ -64,7 +64,7 @@ describe("plan-mode sub-agent filesystem permissions (issue #255)", () => {
64
64
  workspaceRootDir: root,
65
65
  casObserver: observer,
66
66
  modelFactory: async () => new ScriptedModel(script),
67
- ...(planMode ? { permissions: buildPlanModePermissions(root) } : {}),
67
+ ...(planMode ? { permissions: buildPlanModePermissions() } : {}),
68
68
  },
69
69
  );
70
70
  expect(compiled).toHaveLength(1);
@@ -176,12 +176,14 @@ describe("plan-mode sub-agent filesystem permissions (issue #255)", () => {
176
176
  expect(readResult).not.toMatch(/path must be absolute/i);
177
177
  });
178
178
 
179
- it("scopes sub-agent reads to the workspace and fills the bare-ls default (issue #528)", async () => {
180
- // The read boundary must hold on SUB-AGENT graphs too — they carry their
181
- // own rules (issue #255) and their own normalization shim (#429), so a
182
- // parent-only fix would leave sub-agents reading anywhere. One turn,
183
- // three calls: out-of-root read denied, in-root read flows, bare ls
184
- // (whose schema default is the OS root) lists the workspace.
179
+ it("confines sub-agent reads to the workspace; the bare-ls default lists it (issues #528/#754)", async () => {
180
+ // The boundary must hold on SUB-AGENT graphs too — they carry their own
181
+ // rules (issue #255), their own normalization shim (#429), and their own
182
+ // virtual-rooted backend (#754), so a parent-only fix would leave
183
+ // sub-agents reading anywhere. One turn, three calls: a host-file read
184
+ // resolves in-workspace and finds nothing, an in-root read flows, a bare
185
+ // ls (schema default "/", the workspace root in the virtual dialect)
186
+ // lists the workspace.
185
187
  const script: ScriptSelector = () => ({
186
188
  toolCalls: [
187
189
  { name: "read_file", args: { file_path: "/etc/hosts" }, id: "c_read_out" },
@@ -198,7 +200,8 @@ describe("plan-mode sub-agent filesystem permissions (issue #255)", () => {
198
200
  )) as { messages: BaseMessage[] };
199
201
 
200
202
  const outResult = toolResultById(result.messages, "c_read_out");
201
- expect(outResult).toMatch(/permission denied for read/i);
203
+ expect(outResult).toMatch(/not found|no such file/i);
204
+ expect(outResult).not.toContain("localhost");
202
205
 
203
206
  expect(toolResultById(result.messages, "c_read_in")).toContain("PLAN_MODE_README_TOKEN");
204
207
 
@@ -18,9 +18,19 @@
18
18
  export interface ApprovalCaptureResult {
19
19
  /** Sanitized JSON args preview, omitted when there are no args to show. */
20
20
  readonly argsPreview?: string;
21
+ /**
22
+ * The secret-redacted args object, for stamping `ToolCall.args` on the
23
+ * interrupt-placeholder row. Without it the placeholder carried ONLY the
24
+ * preview string, so every args-driven UI read — the row header's
25
+ * filename-first path above all — rendered nothing for a pending gate
26
+ * (issue #754). Redacted (never raw): the placeholder must not widen the
27
+ * exposure the preview sanitizer bounds.
28
+ */
29
+ readonly args?: Record<string, unknown>;
21
30
  }
22
31
 
23
32
  import { sanitizeArgsPreview } from "./status-builder-shared.js";
33
+ import { redactSensitiveArgs } from "../../shared/args-preview.js";
24
34
 
25
35
  /**
26
36
  * Correlate a gated `tool_call_id` to its arguments by scanning graph-state
@@ -76,5 +86,5 @@ export function captureApprovalArtifacts(opts: {
76
86
  if (!args || Object.keys(args).length === 0) return {};
77
87
 
78
88
  const argsPreview = sanitizeArgsPreview(args) || undefined;
79
- return { argsPreview };
89
+ return { argsPreview, args: redactSensitiveArgs(args) };
80
90
  }
@@ -25,8 +25,25 @@
25
25
  * records its before-bytes at authorization time instead (`captureDeleteBefore`,
26
26
  * issue #303). A delete via shell (`rm`) stays on the approval gate as always.
27
27
  *
28
+ * VIRTUAL ROOT — THE ONE PATH DIALECT (issue #754)
29
+ * ------------------------------------------------
30
+ * Every backend here is constructed with `virtualMode: true`: a leading "/"
31
+ * denotes the WORKSPACE ROOT, traversal (`..`, `~`) is rejected, and every
32
+ * resolution stays inside `rootDir` by construction. This is the same dialect
33
+ * the rest of the harness already speaks — the system prompt's path-resolution
34
+ * directive, `resolveWorkspacePath(..., virtualRoot=true)` in the CAS observer
35
+ * and turn-boundary capture, `InlinePublisher.normalizePath`, and the approval
36
+ * gate's capturability checks. Before this flag, deepagents' legacy default
37
+ * passed absolute paths through to the REAL filesystem: a `write_file("/tmp/x")`
38
+ * escaped the session workspace onto the host AND escaped review entirely
39
+ * (auto-approved as capture-mode flow, but the observer/boundary/publisher all
40
+ * looked inside the workspace and found nothing). Read-side, the same legacy
41
+ * pass-through was why plan mode needed rule-based read fencing at all. Do not
42
+ * remove this flag: workspace confinement is structural, not policy.
43
+ *
28
44
  * @since File-Change HITL Redesign (Phase 3 — CAS deep-agent wiring); sub-agent
29
- * gitignored capture parity (Session 26, DD-19); shell restore (issue #248)
45
+ * gitignored capture parity (Session 26, DD-19); shell restore (issue #248);
46
+ * virtual-root confinement (issue #754)
30
47
  */
31
48
 
32
49
  import { FilesystemBackend, LocalShellBackend } from "deepagents";
@@ -111,12 +128,12 @@ export async function createCasCaptureBackend(
111
128
 
112
129
  if (shellEnv !== undefined) {
113
130
  const backend = new CasCaptureShellBackend(
114
- { rootDir, env: shellEnv },
131
+ { rootDir, virtualMode: true, env: shellEnv },
115
132
  { observer },
116
133
  );
117
134
  await backend.initialize();
118
135
  return backend;
119
136
  }
120
137
 
121
- return new CasCaptureFilesystemBackend({ rootDir }, { observer });
138
+ return new CasCaptureFilesystemBackend({ rootDir, virtualMode: true }, { observer });
122
139
  }
@@ -78,10 +78,33 @@ import { stampFlowedFileEditRows, stampFlowedSubAgentFileEditRows } from "./stam
78
78
  import { deriveTurnCommandProvenance } from "./command-provenance.js";
79
79
  import { describeExecutionError } from "../../shared/model-error.js";
80
80
  import { inferProvider, type LlmProvider } from "../../shared/llm-proxy.js";
81
+ import { getShutdownSignalForQueue } from "../../shared/worker-shutdown.js";
81
82
 
82
83
  /** The harness id stamped on the deep-agent's file-review ledger events. */
83
84
  const DEEP_AGENT_HARNESS_ID = "deep-agent";
84
85
 
86
+ /**
87
+ * The worker-shutdown terminal status (#776). A shutdown is not a pause: the
88
+ * execution will not resume on this worker, so persisting PAUSED would strand
89
+ * the user with a lie. The copy is byte-identical to the Cursor harness's
90
+ * worker-shutdown branch so every downstream status.error consumer (the Go and
91
+ * Java workflow fallbacks, the cloud channel decision table) keys on ONE shape.
92
+ */
93
+ function buildWorkerShutdownStatus(): AgentExecutionStatus {
94
+ return create(AgentExecutionStatusSchema, {
95
+ phase: ExecutionPhase.EXECUTION_FAILED,
96
+ error: "Execution interrupted: runner worker was shut down. Retry or resume.",
97
+ completedAt: utcTimestamp(),
98
+ messages: [
99
+ create(AgentMessageSchema, {
100
+ type: MessageType.MESSAGE_SYSTEM,
101
+ content: "Execution interrupted: the runner worker was shut down while the agent was still running. You can retry or resume.",
102
+ timestamp: utcTimestamp(),
103
+ }),
104
+ ],
105
+ });
106
+ }
107
+
85
108
  /**
86
109
  * Best-effort provider inference for error-message wording. inferProvider
87
110
  * throws on unrecognized names; an error path must never throw over a label.
@@ -115,6 +138,11 @@ export function createDeepAgentActivities(config: Config) {
115
138
  ): Promise<unknown> => {
116
139
  const { executionId, threadId, turnSeq } = normalizeActivityInput(arg0, arg1);
117
140
  activityStarted();
141
+ // Worker-shutdown classification channel (#776): the pause paths below
142
+ // consult this signal to distinguish "my worker is draining" (SIGTERM,
143
+ // desktop quit) from "the orchestrator cancelled me" (a real user
144
+ // pause). See shared/worker-shutdown.ts for the ownership contract.
145
+ const shutdownSignal = getShutdownSignalForQueue(Context.current().info.taskQueue);
118
146
  let setup: SetupResult | null = null;
119
147
  // Exclusive turn lock on the workspace working tree — held across the
120
148
  // entire tree-mutating window (decision reconcile, agent writes,
@@ -567,6 +595,13 @@ export function createDeepAgentActivities(config: Config) {
567
595
 
568
596
  if (result.terminalStatus) {
569
597
  if (initialStatus.phase === ExecutionPhase.EXECUTION_PAUSED) {
598
+ // The stream loop marks PAUSED for ANY platform cancellation; a
599
+ // worker shutdown is not a pause and must not persist as one (#776).
600
+ if (shutdownSignal?.aborted) {
601
+ console.log(`[ExecuteDeepAgent] Cancelled (worker shutdown) for execution ${executionId}: events=${result.eventsProcessed}`);
602
+ await persistStatus(client, executionId, buildWorkerShutdownStatus(), { offload: statusOffload }).catch(() => {});
603
+ throw new CancelledFailure("Activity cancelled (worker shutdown, not user pause)");
604
+ }
570
605
  await persistStatus(client, executionId, initialStatus, { offload: statusOffload });
571
606
  console.log(`[ExecuteDeepAgent] Paused for execution ${executionId}: events=${result.eventsProcessed}`);
572
607
  throw new CancelledFailure("Activity paused by orchestrator");
@@ -617,15 +652,20 @@ export function createDeepAgentActivities(config: Config) {
617
652
  policyEngineVersion: intr.policySource ? POLICY_ENGINE_VERSION : "",
618
653
  });
619
654
 
620
- // Capture a sanitized args preview while the graph is paused, so the
621
- // approval UI renders the proposed change before the tool runs. Args
622
- // are correlated from the AI-message tool call in graph state (the
623
- // single source of truth).
624
- const { argsPreview } = captureApprovalArtifacts({
655
+ // Capture a sanitized args preview AND the redacted args object
656
+ // while the graph is paused, so the approval UI renders the
657
+ // proposed change before the tool runs and the row header can
658
+ // extract its filename-first path from `args` like every other
659
+ // row (issue #754: a placeholder without args rendered a
660
+ // pathless "Write" header). Args are correlated from the
661
+ // AI-message tool call in graph state (the single source of
662
+ // truth).
663
+ const { argsPreview, args } = captureApprovalArtifacts({
625
664
  toolCallId: intr.toolCallId,
626
665
  messages: aiMessages,
627
666
  });
628
667
  if (argsPreview) toolCall.argsPreview = argsPreview;
668
+ if (args) toolCall.args = args as typeof toolCall.args;
629
669
 
630
670
  aiMsg.toolCalls.push(toolCall);
631
671
  }
@@ -749,6 +789,13 @@ export function createDeepAgentActivities(config: Config) {
749
789
 
750
790
  } catch (err: unknown) {
751
791
  if (err instanceof CancelledFailure) {
792
+ // Worker shutdown is infrastructure failure, not pause — the caught
793
+ // CancelledFailure is itself the interruption evidence (#776).
794
+ if (shutdownSignal?.aborted) {
795
+ console.log(`[ExecuteDeepAgent] Cancelled (worker shutdown) for execution ${executionId}`);
796
+ await persistStatus(client, executionId, buildWorkerShutdownStatus()).catch(() => {});
797
+ throw err;
798
+ }
752
799
  console.log(`[ExecuteDeepAgent] Cancelled (pause) for execution ${executionId}`);
753
800
  const pausedStatus = create(AgentExecutionStatusSchema, {
754
801
  phase: ExecutionPhase.EXECUTION_PAUSED,
@@ -758,6 +805,11 @@ export function createDeepAgentActivities(config: Config) {
758
805
  }
759
806
 
760
807
  if (Context.current().cancellationSignal.aborted) {
808
+ if (shutdownSignal?.aborted) {
809
+ console.log(`[ExecuteDeepAgent] Error during worker-shutdown cancellation for ${executionId}: ${err}`);
810
+ await persistStatus(client, executionId, buildWorkerShutdownStatus()).catch(() => {});
811
+ throw new CancelledFailure("Activity cancelled (worker shutdown, not user pause)");
812
+ }
761
813
  console.log(`[ExecuteDeepAgent] Error during cancellation for ${executionId}, treating as pause: ${err}`);
762
814
  const pausedStatus = create(AgentExecutionStatusSchema, {
763
815
  phase: ExecutionPhase.EXECUTION_PAUSED,
@@ -17,6 +17,10 @@ import {
17
17
  type SenderIdentity,
18
18
  } from "../../shared/sender-identity.js";
19
19
  import { formatSessionContextText } from "../../shared/session-context.js";
20
+ import {
21
+ formatDeclaredPreferencesText,
22
+ type DeclaredPreferencesContent,
23
+ } from "../../shared/declared-preferences.js";
20
24
  import {
21
25
  visionDisclosureLines,
22
26
  type NotViewableEntry,
@@ -166,6 +170,14 @@ export interface PromptBuilderInput {
166
170
  * context is standing session state, like skills.
167
171
  */
168
172
  sessionContext?: string;
173
+ /**
174
+ * Platform-declared standing preferences (stigmer/stigmer#293): the org's
175
+ * and user's standing context, server-snapshotted onto the execution
176
+ * spec's `declared_preferences` at create. Injected on EVERY turn like
177
+ * the bridge — the native system prompt is rebuilt per invocation, so an
178
+ * edited preference reaches the very next turn.
179
+ */
180
+ declaredPreferences?: DeclaredPreferencesContent;
169
181
  }
170
182
 
171
183
  // The prompt renders the injector's own result type — a local structural twin
@@ -229,6 +241,16 @@ export function buildEnhancedSystemPrompt(input: PromptBuilderInput): string {
229
241
  formatSenderIdentityText(input.senderIdentity);
230
242
  }
231
243
 
244
+ // Platform-declared standing facts precede embedder-supplied context
245
+ // (DD-002 D3): both are standing background, but the declared preferences
246
+ // are platform-authored while session context is the embedder's overlay —
247
+ // the more specific overlay reads later and naturally refines.
248
+ if (input.declaredPreferences) {
249
+ prompt +=
250
+ "\n\n## Declared preferences\n\n" +
251
+ formatDeclaredPreferencesText(input.declaredPreferences);
252
+ }
253
+
232
254
  // Standing facts about the user (session context) come before the
233
255
  // carried conversation (bridge): the bridge may refer back to them.
234
256
  if (input.sessionContext) {