@stigmer/runner 3.12.3 → 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 (227) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/call-http.js +12 -0
  3. package/dist/activities/call-http.js.map +1 -1
  4. package/dist/activities/call-llm.d.ts +18 -0
  5. package/dist/activities/call-llm.js +56 -2
  6. package/dist/activities/call-llm.js.map +1 -1
  7. package/dist/activities/execute-cursor/agent-session-cache.d.ts +72 -0
  8. package/dist/activities/execute-cursor/agent-session-cache.js +186 -0
  9. package/dist/activities/execute-cursor/agent-session-cache.js.map +1 -0
  10. package/dist/activities/execute-cursor/cursor-mcp-config.d.ts +45 -0
  11. package/dist/activities/execute-cursor/cursor-mcp-config.js +76 -0
  12. package/dist/activities/execute-cursor/cursor-mcp-config.js.map +1 -0
  13. package/dist/activities/execute-cursor/index.d.ts +10 -1
  14. package/dist/activities/execute-cursor/index.js +108 -57
  15. package/dist/activities/execute-cursor/index.js.map +1 -1
  16. package/dist/activities/execute-cursor/prompt-builder.d.ts +13 -2
  17. package/dist/activities/execute-cursor/prompt-builder.js +11 -0
  18. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  19. package/dist/activities/execute-cursor/service-tier.d.ts +5 -15
  20. package/dist/activities/execute-cursor/service-tier.js +5 -21
  21. package/dist/activities/execute-cursor/service-tier.js.map +1 -1
  22. package/dist/activities/execute-cursor/session-lifecycle.d.ts +1 -1
  23. package/dist/activities/execute-cursor/skill-resolver.d.ts +2 -21
  24. package/dist/activities/execute-cursor/skill-resolver.js +5 -108
  25. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  26. package/dist/activities/execute-deep-agent/approval-file-change.d.ts +9 -0
  27. package/dist/activities/execute-deep-agent/approval-file-change.js +2 -1
  28. package/dist/activities/execute-deep-agent/approval-file-change.js.map +1 -1
  29. package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +18 -1
  30. package/dist/activities/execute-deep-agent/cas-capture-backend.js +20 -3
  31. package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
  32. package/dist/activities/execute-deep-agent/index.js +57 -5
  33. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  34. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +9 -0
  35. package/dist/activities/execute-deep-agent/prompt-builder.js +10 -0
  36. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  37. package/dist/activities/execute-deep-agent/setup.js +31 -16
  38. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  39. package/dist/activities/execute-deep-agent/shell-env.d.ts +8 -5
  40. package/dist/activities/execute-deep-agent/shell-env.js +10 -7
  41. package/dist/activities/execute-deep-agent/shell-env.js.map +1 -1
  42. package/dist/activities/execute-deep-agent/subagent-transformer.js +17 -11
  43. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  44. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +3 -3
  45. package/dist/config.js +10 -5
  46. package/dist/config.js.map +1 -1
  47. package/dist/encryption/config.js +7 -2
  48. package/dist/encryption/config.js.map +1 -1
  49. package/dist/index.d.ts +1 -1
  50. package/dist/index.js +1 -1
  51. package/dist/main.js +12 -6
  52. package/dist/main.js.map +1 -1
  53. package/dist/middleware/path-normalization.d.ts +49 -49
  54. package/dist/middleware/path-normalization.js +73 -84
  55. package/dist/middleware/path-normalization.js.map +1 -1
  56. package/dist/middleware/types.d.ts +3 -3
  57. package/dist/payload-codecs.js +2 -1
  58. package/dist/payload-codecs.js.map +1 -1
  59. package/dist/runner-manager.d.ts +1 -1
  60. package/dist/runner-manager.js +42 -22
  61. package/dist/runner-manager.js.map +1 -1
  62. package/dist/runner.js +30 -6
  63. package/dist/runner.js.map +1 -1
  64. package/dist/shared/args-preview.d.ts +8 -0
  65. package/dist/shared/args-preview.js +14 -3
  66. package/dist/shared/args-preview.js.map +1 -1
  67. package/dist/shared/artifact-storage.d.ts +11 -1
  68. package/dist/shared/artifact-storage.js +10 -1
  69. package/dist/shared/artifact-storage.js.map +1 -1
  70. package/dist/shared/declared-preferences.d.ts +47 -0
  71. package/dist/shared/declared-preferences.js +64 -0
  72. package/dist/shared/declared-preferences.js.map +1 -0
  73. package/dist/shared/fingerprint-secret.d.ts +3 -2
  74. package/dist/shared/fingerprint-secret.js +5 -3
  75. package/dist/shared/fingerprint-secret.js.map +1 -1
  76. package/dist/shared/llm-backend.js +8 -1
  77. package/dist/shared/llm-backend.js.map +1 -1
  78. package/dist/shared/mcp-enabled-tools.d.ts +2 -3
  79. package/dist/shared/mcp-enabled-tools.js +2 -3
  80. package/dist/shared/mcp-enabled-tools.js.map +1 -1
  81. package/dist/shared/mcp-resolver.d.ts +20 -15
  82. package/dist/shared/mcp-resolver.js +11 -12
  83. package/dist/shared/mcp-resolver.js.map +1 -1
  84. package/dist/shared/model-client.d.ts +15 -0
  85. package/dist/shared/model-client.js +57 -13
  86. package/dist/shared/model-client.js.map +1 -1
  87. package/dist/shared/placeholder-resolver.d.ts +9 -2
  88. package/dist/shared/placeholder-resolver.js +9 -2
  89. package/dist/shared/placeholder-resolver.js.map +1 -1
  90. package/dist/shared/plan-mode-permissions.d.ts +26 -46
  91. package/dist/shared/plan-mode-permissions.js +27 -55
  92. package/dist/shared/plan-mode-permissions.js.map +1 -1
  93. package/dist/shared/registry-endpoint.d.ts +5 -0
  94. package/dist/shared/registry-endpoint.js +7 -1
  95. package/dist/shared/registry-endpoint.js.map +1 -1
  96. package/dist/shared/runner-credential-keys.d.ts +26 -1
  97. package/dist/shared/runner-credential-keys.js +34 -1
  98. package/dist/shared/runner-credential-keys.js.map +1 -1
  99. package/dist/shared/runner-credential-store.d.ts +77 -0
  100. package/dist/shared/runner-credential-store.js +111 -0
  101. package/dist/shared/runner-credential-store.js.map +1 -0
  102. package/dist/shared/service-tier.d.ts +55 -0
  103. package/dist/shared/service-tier.js +67 -0
  104. package/dist/shared/service-tier.js.map +1 -0
  105. package/dist/shared/skill-mount.d.ts +89 -0
  106. package/dist/shared/skill-mount.js +142 -0
  107. package/dist/shared/skill-mount.js.map +1 -0
  108. package/dist/shared/skill-writer.d.ts +28 -26
  109. package/dist/shared/skill-writer.js +79 -102
  110. package/dist/shared/skill-writer.js.map +1 -1
  111. package/dist/shared/worker-shutdown.d.ts +67 -0
  112. package/dist/shared/worker-shutdown.js +79 -0
  113. package/dist/shared/worker-shutdown.js.map +1 -0
  114. package/dist/shared/workspace/types.d.ts +3 -2
  115. package/dist/shared/zip-extract.d.ts +10 -3
  116. package/dist/shared/zip-extract.js +10 -3
  117. package/dist/shared/zip-extract.js.map +1 -1
  118. package/dist/workflow-engine/loader.js +38 -1
  119. package/dist/workflow-engine/loader.js.map +1 -1
  120. package/dist/workflow-engine/tasks/call-function.d.ts +14 -0
  121. package/dist/workflow-engine/tasks/call-function.js +49 -5
  122. package/dist/workflow-engine/tasks/call-function.js.map +1 -1
  123. package/dist/workflow-engine/tasks/human-input.js +23 -2
  124. package/dist/workflow-engine/tasks/human-input.js.map +1 -1
  125. package/dist/workflow-engine/types.d.ts +6 -0
  126. package/dist/workflow-engine/types.js.map +1 -1
  127. package/dist/workflows/engine-core.js +36 -8
  128. package/dist/workflows/engine-core.js.map +1 -1
  129. package/package.json +15 -11
  130. package/src/activities/__tests__/call-http.test.ts +36 -0
  131. package/src/activities/__tests__/call-llm.test.ts +77 -0
  132. package/src/activities/call-http.ts +17 -0
  133. package/src/activities/call-llm.ts +78 -2
  134. package/src/activities/execute-cursor/__tests__/agent-session-cache.test.ts +220 -0
  135. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +50 -0
  136. package/src/activities/execute-cursor/__tests__/cursor-mcp-config.test.ts +111 -0
  137. package/src/activities/execute-cursor/__tests__/service-tier.test.ts +1 -1
  138. package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +3 -94
  139. package/src/activities/execute-cursor/agent-session-cache.ts +229 -0
  140. package/src/activities/execute-cursor/cursor-mcp-config.ts +107 -0
  141. package/src/activities/execute-cursor/index.ts +129 -55
  142. package/src/activities/execute-cursor/prompt-builder.ts +29 -2
  143. package/src/activities/execute-cursor/service-tier.ts +5 -29
  144. package/src/activities/execute-cursor/session-lifecycle.ts +1 -1
  145. package/src/activities/execute-cursor/skill-resolver.ts +10 -137
  146. package/src/activities/execute-deep-agent/__tests__/approval-file-change.test.ts +15 -0
  147. package/src/activities/execute-deep-agent/__tests__/cas-capture-backend.test.ts +75 -0
  148. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +1 -0
  149. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
  150. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
  151. package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
  152. package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +42 -29
  153. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +52 -0
  154. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
  155. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +11 -8
  156. package/src/activities/execute-deep-agent/approval-file-change.ts +11 -1
  157. package/src/activities/execute-deep-agent/cas-capture-backend.ts +20 -3
  158. package/src/activities/execute-deep-agent/index.ts +57 -5
  159. package/src/activities/execute-deep-agent/prompt-builder.ts +22 -0
  160. package/src/activities/execute-deep-agent/setup.ts +36 -19
  161. package/src/activities/execute-deep-agent/shell-env.ts +10 -7
  162. package/src/activities/execute-deep-agent/subagent-transformer.ts +19 -12
  163. package/src/activities/execute-deep-agent/subagent-wiring.ts +3 -3
  164. package/src/config.ts +10 -5
  165. package/src/encryption/config.ts +8 -2
  166. package/src/index.ts +1 -1
  167. package/src/main.ts +16 -6
  168. package/src/middleware/__tests__/path-normalization.test.ts +32 -32
  169. package/src/middleware/path-normalization.ts +78 -90
  170. package/src/middleware/types.ts +3 -3
  171. package/src/payload-codecs.ts +2 -1
  172. package/src/runner-manager.ts +55 -22
  173. package/src/runner.ts +39 -6
  174. package/src/shared/__tests__/artifact-storage.test.ts +28 -0
  175. package/src/shared/__tests__/bedrock-seam.test.ts +5 -5
  176. package/src/shared/__tests__/declared-preferences.test.ts +109 -0
  177. package/src/shared/__tests__/foundry-seam.test.ts +5 -5
  178. package/src/shared/__tests__/model-client.test.ts +99 -0
  179. package/src/shared/__tests__/plan-mode-permissions.test.ts +15 -44
  180. package/src/shared/__tests__/runner-credential-store.test.ts +155 -0
  181. package/src/shared/__tests__/skill-mount.test.ts +238 -0
  182. package/src/shared/__tests__/skill-writer.test.ts +151 -128
  183. package/src/shared/__tests__/vertex-adapter.test.ts +30 -3
  184. package/src/shared/__tests__/vertex-seam.test.ts +5 -5
  185. package/src/shared/__tests__/worker-shutdown.test.ts +101 -0
  186. package/src/shared/__tests__/zip-extract.test.ts +46 -11
  187. package/src/shared/args-preview.ts +17 -3
  188. package/src/shared/artifact-storage.ts +20 -1
  189. package/src/shared/declared-preferences.ts +84 -0
  190. package/src/shared/fingerprint-secret.ts +5 -3
  191. package/src/shared/llm-backend.ts +7 -1
  192. package/src/shared/mcp-enabled-tools.ts +2 -3
  193. package/src/shared/mcp-resolver.ts +20 -20
  194. package/src/shared/model-client.ts +76 -13
  195. package/src/shared/placeholder-resolver.ts +9 -2
  196. package/src/shared/plan-mode-permissions.ts +27 -58
  197. package/src/shared/registry-endpoint.ts +9 -1
  198. package/src/shared/runner-credential-keys.ts +36 -1
  199. package/src/shared/runner-credential-store.ts +115 -0
  200. package/src/shared/service-tier.ts +78 -0
  201. package/src/shared/skill-mount.ts +179 -0
  202. package/src/shared/skill-writer.ts +96 -130
  203. package/src/shared/worker-shutdown.ts +99 -0
  204. package/src/shared/workspace/types.ts +3 -2
  205. package/src/shared/zip-extract.ts +14 -7
  206. package/src/workflow-engine/__tests__/golden-execution.test.ts +20 -1
  207. package/src/workflow-engine/__tests__/loader.test.ts +51 -0
  208. package/src/workflow-engine/__tests__/tasks/call-function.test.ts +105 -0
  209. package/src/workflow-engine/__tests__/tasks/human-input.test.ts +117 -0
  210. package/src/workflow-engine/loader.ts +46 -1
  211. package/src/workflow-engine/tasks/call-function.ts +74 -13
  212. package/src/workflow-engine/tasks/human-input.ts +33 -5
  213. package/src/workflow-engine/types.ts +6 -0
  214. package/src/workflows/engine-core.ts +39 -8
  215. package/dist/activities/execute-cursor/connect-backfill.d.ts +0 -19
  216. package/dist/activities/execute-cursor/connect-backfill.js +0 -27
  217. package/dist/activities/execute-cursor/connect-backfill.js.map +0 -1
  218. package/dist/activities/execute-cursor/mcp-resolver.d.ts +0 -120
  219. package/dist/activities/execute-cursor/mcp-resolver.js +0 -194
  220. package/dist/activities/execute-cursor/mcp-resolver.js.map +0 -1
  221. package/dist/activities/execute-cursor/placeholder-resolver.d.ts +0 -34
  222. package/dist/activities/execute-cursor/placeholder-resolver.js +0 -82
  223. package/dist/activities/execute-cursor/placeholder-resolver.js.map +0 -1
  224. package/src/activities/execute-cursor/__tests__/mcp-resolver.test.ts +0 -125
  225. package/src/activities/execute-cursor/connect-backfill.ts +0 -52
  226. package/src/activities/execute-cursor/mcp-resolver.ts +0 -324
  227. package/src/activities/execute-cursor/placeholder-resolver.ts +0 -109
@@ -2,8 +2,8 @@
2
2
  * Resolves skill resources and writes them to the platform-managed directory.
3
3
  *
4
4
  * - Fetches skills via gRPC (by reference)
5
- * - Writes SKILL.md to .stigmer/skills/{name}/SKILL.md
6
- * - Downloads and extracts ZIP artifacts (references/, scripts/, etc.)
5
+ * - Mounts each skill via the shared skill-mount mechanics (SKILL.md +
6
+ * extracted artifact, hash-keyed cache see shared/skill-mount.ts)
7
7
  * - Uses a platform-managed directory outside the workspace
8
8
  * - Ensures the workspace `.stigmer` symlink (see stigmer-link.ts)
9
9
  * - Returns metadata for prompt injection
@@ -17,38 +17,20 @@
17
17
  * transfer.
18
18
  */
19
19
 
20
- import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
21
- import { join, dirname } from "node:path";
22
- import { ConnectError, Code } from "@connectrpc/connect";
20
+ import { mkdir } from "node:fs/promises";
21
+ import { join } from "node:path";
23
22
  import type { StigmerClient } from "../../client/stigmer-client.js";
24
- import type { Skill } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
25
23
  import type { ApiResourceReference } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
26
24
  import type { SkillMetadata } from "./prompt-builder.js";
27
25
  import { getPlatformDir } from "../../shared/workspace/platform-dir.js";
28
- import { extractZipFileEntries } from "../../shared/zip-extract.js";
26
+ import {
27
+ SKILLS_SUBDIR,
28
+ mountIsFresh,
29
+ downloadArtifact,
30
+ writeSkillMount,
31
+ } from "../../shared/skill-mount.js";
29
32
  import { ensureStigmerSymlink, STIGMER_LOCAL_STATE_DIR } from "../../shared/workspace/stigmer-link.js";
30
33
 
31
- const SKILLS_SUBDIR = "skills";
32
-
33
- /**
34
- * Marker recording what a skill's mount directory currently holds. Written
35
- * LAST, after every file of the mount landed — a crash mid-write leaves no
36
- * marker, so the next execution remounts instead of trusting a partial tree.
37
- */
38
- const MOUNT_MARKER_FILE = ".stigmer-mount.json";
39
-
40
- interface MountMarker {
41
- /** Content-addressed version hash (`Skill.status.version_hash`) of the mounted content. */
42
- versionHash: string;
43
- /**
44
- * Whether the artifact's files are part of the mount. `false` when the
45
- * skill has no artifact OR when the download failed and the mount fell
46
- * back to SKILL.md only — the latter makes the next execution retry the
47
- * download rather than cache the degraded mount.
48
- */
49
- artifactMounted: boolean;
50
- }
51
-
52
34
  export interface SkillResolverOptions {
53
35
  sessionId: string;
54
36
  primaryWorkspaceDir: string;
@@ -145,112 +127,3 @@ export async function resolveSkills(
145
127
 
146
128
  return results;
147
129
  }
148
-
149
- /**
150
- * Whether the mount at `skillDir` already holds this version's content.
151
- *
152
- * Fresh means: the marker's hash matches AND the mount isn't a degraded
153
- * SKILL.md-only fallback when the skill does carry an artifact. Any read or
154
- * parse failure counts as stale — the remount is the safe default.
155
- */
156
- async function mountIsFresh(skillDir: string, versionHash: string, wantsArtifact: boolean): Promise<boolean> {
157
- try {
158
- const raw = await readFile(join(skillDir, MOUNT_MARKER_FILE), "utf-8");
159
- const marker = JSON.parse(raw) as Partial<MountMarker>;
160
- return marker.versionHash === versionHash && (marker.artifactMounted === true || !wantsArtifact);
161
- } catch {
162
- return false;
163
- }
164
- }
165
-
166
- /**
167
- * Download a skill artifact's ZIP bytes, transfer lane first (#675).
168
- *
169
- * The URL lane (getArtifactDownloadUrl → HTTP GET) carries any valid skill
170
- * size; the unary getArtifact response is capped by the server's 10MB gRPC
171
- * message limit. Servers that predate the lane (and cloud until its sibling
172
- * lands) answer the mint with UNIMPLEMENTED — those fall back to the unary
173
- * path, which behaves exactly as before for ≤10MB artifacts.
174
- *
175
- * Runs only on a mount-cache miss (#672's hash-keyed marker above) — a hit
176
- * skips the transfer entirely, whichever lane would have carried it.
177
- *
178
- * Exported for tests.
179
- */
180
- export async function downloadArtifact(
181
- client: StigmerClient,
182
- artifactStorageKey: string,
183
- ): Promise<Uint8Array | undefined> {
184
- let minted;
185
- try {
186
- minted = await client.getSkillArtifactDownloadUrl(artifactStorageKey);
187
- } catch (err) {
188
- if (err instanceof ConnectError && err.code === Code.Unimplemented) {
189
- const resp = await client.getSkillArtifact(artifactStorageKey);
190
- return resp.artifact && resp.artifact.length > 0 ? resp.artifact : undefined;
191
- }
192
- throw err;
193
- }
194
-
195
- const resp = await fetch(minted.url);
196
- if (!resp.ok) {
197
- throw new Error(`artifact fetch failed: HTTP ${resp.status} from ${minted.url}`);
198
- }
199
- const bytes = new Uint8Array(await resp.arrayBuffer());
200
- if (minted.sizeBytes > 0n && BigInt(bytes.length) !== minted.sizeBytes) {
201
- throw new Error(
202
- `artifact fetch truncated: got ${bytes.length} bytes, expected ${minted.sizeBytes}`,
203
- );
204
- }
205
- return bytes.length > 0 ? bytes : undefined;
206
- }
207
-
208
- /**
209
- * (Re)write a skill's mount directory from scratch.
210
- *
211
- * The directory is removed first so files deleted between versions don't
212
- * linger in the mount, then SKILL.md and the artifact files are written, and
213
- * the marker is stamped LAST (see MOUNT_MARKER_FILE for the crash-safety
214
- * contract). SKILL.md always comes from `spec.skillMd` — the server's
215
- * authoritative copy — never from the zip; both the zip's SKILL.md and any
216
- * stray marker-named entry are excluded from extraction so the mount's
217
- * ownership of those two files is unconditional.
218
- */
219
- async function writeSkillMount(
220
- skill: Skill,
221
- skillDir: string,
222
- artifactBytes: Uint8Array | undefined,
223
- ): Promise<void> {
224
- await rm(skillDir, { recursive: true, force: true });
225
- await mkdir(skillDir, { recursive: true });
226
-
227
- await writeFile(join(skillDir, "SKILL.md"), skill.spec!.skillMd, "utf-8");
228
-
229
- const artifactMounted = artifactBytes !== undefined && artifactBytes.length > 0;
230
- if (artifactMounted) {
231
- const entries = await extractZipFileEntries(artifactBytes, { exclude: ["SKILL.md", MOUNT_MARKER_FILE] });
232
- for (const entry of entries) {
233
- const filePath = join(skillDir, entry.path);
234
- await mkdir(dirname(filePath), { recursive: true });
235
- await writeFile(filePath, entry.content, "utf-8");
236
- }
237
- }
238
-
239
- const versionHash = skill.status?.versionHash ?? "";
240
- if (versionHash !== "") {
241
- const marker: MountMarker = { versionHash, artifactMounted };
242
- await writeFile(join(skillDir, MOUNT_MARKER_FILE), JSON.stringify(marker), "utf-8");
243
- }
244
- }
245
-
246
- /**
247
- * Clean up platform-managed skill directory for a session.
248
- */
249
- export async function cleanupSkills(sessionId: string): Promise<void> {
250
- const platformDir = getPlatformDir(sessionId);
251
- try {
252
- await rm(platformDir, { recursive: true, force: true });
253
- } catch {
254
- // best-effort
255
- }
256
- }
@@ -81,4 +81,19 @@ describe("captureApprovalArtifacts", () => {
81
81
  const { argsPreview } = captureApprovalArtifacts({ toolCallId: "call-1", messages });
82
82
  expect(argsPreview).toContain("needle");
83
83
  });
84
+
85
+ it("returns the redacted args object for stamping the placeholder row (issue #754)", () => {
86
+ // The interrupt-placeholder ToolCall must carry `args`, not just the
87
+ // preview string: the row header extracts its filename-first path from
88
+ // args, so a placeholder without them rendered a pathless "Write" gate.
89
+ // Redaction posture matches the preview: secret keys never surface.
90
+ const messages = [
91
+ aiMessage([
92
+ { id: "call-1", name: "write_file", args: { file_path: "a.txt", content: "hi", token: "sk-secret" } },
93
+ ]),
94
+ ];
95
+ const { args } = captureApprovalArtifacts({ toolCallId: "call-1", messages });
96
+
97
+ expect(args).toEqual({ file_path: "a.txt", content: "hi", token: "[REDACTED]" });
98
+ });
84
99
  });
@@ -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
  }