@stigmer/runner 3.1.1 → 3.1.3

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 (166) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/attachment-resolver.d.ts +49 -8
  3. package/dist/activities/execute-cursor/attachment-resolver.js +83 -26
  4. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  5. package/dist/activities/execute-cursor/capture-flow.d.ts +18 -0
  6. package/dist/activities/execute-cursor/capture-flow.js +21 -0
  7. package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
  8. package/dist/activities/execute-cursor/cas-observations.d.ts +13 -0
  9. package/dist/activities/execute-cursor/cas-observations.js +39 -5
  10. package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
  11. package/dist/activities/execute-cursor/hook-script.js +19 -1
  12. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  13. package/dist/activities/execute-cursor/index.d.ts +5 -0
  14. package/dist/activities/execute-cursor/index.js +222 -160
  15. package/dist/activities/execute-cursor/index.js.map +1 -1
  16. package/dist/activities/execute-cursor/message-translator.js +10 -1
  17. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  18. package/dist/activities/execute-cursor/prompt-builder.d.ts +32 -7
  19. package/dist/activities/execute-cursor/prompt-builder.js +38 -20
  20. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  21. package/dist/activities/execute-cursor/skill-resolver.d.ts +1 -10
  22. package/dist/activities/execute-cursor/skill-resolver.js +3 -55
  23. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  24. package/dist/activities/execute-cursor/stigmer-link.d.ts +35 -0
  25. package/dist/activities/execute-cursor/stigmer-link.js +73 -0
  26. package/dist/activities/execute-cursor/stigmer-link.js.map +1 -0
  27. package/dist/activities/execute-cursor/turn-boundary.d.ts +108 -0
  28. package/dist/activities/execute-cursor/turn-boundary.js +163 -0
  29. package/dist/activities/execute-cursor/turn-boundary.js.map +1 -0
  30. package/dist/activities/execute-cursor/workspace-provision.d.ts +3 -2
  31. package/dist/activities/execute-cursor/workspace-provision.js +5 -3
  32. package/dist/activities/execute-cursor/workspace-provision.js.map +1 -1
  33. package/dist/activities/execute-deep-agent/index.js +89 -5
  34. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  35. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +17 -0
  36. package/dist/activities/execute-deep-agent/prompt-builder.js +16 -0
  37. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  38. package/dist/activities/execute-deep-agent/setup.js +22 -16
  39. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  40. package/dist/activities/execute-deep-agent/streaming-v3.js +6 -1
  41. package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
  42. package/dist/activities/execute-deep-agent/streaming.d.ts +7 -0
  43. package/dist/activities/execute-deep-agent/streaming.js +5 -1
  44. package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
  45. package/dist/config.d.ts +7 -0
  46. package/dist/config.js +9 -0
  47. package/dist/config.js.map +1 -1
  48. package/dist/middleware/approval-gate.js +36 -6
  49. package/dist/middleware/approval-gate.js.map +1 -1
  50. package/dist/runner-manager.d.ts +2 -0
  51. package/dist/runner-manager.js +2 -1
  52. package/dist/runner-manager.js.map +1 -1
  53. package/dist/runner.d.ts +2 -0
  54. package/dist/runner.js +2 -1
  55. package/dist/runner.js.map +1 -1
  56. package/dist/shared/artifact-storage.d.ts +32 -0
  57. package/dist/shared/artifact-storage.js +74 -1
  58. package/dist/shared/artifact-storage.js.map +1 -1
  59. package/dist/shared/filereview/capture.d.ts +18 -1
  60. package/dist/shared/filereview/capture.js +105 -28
  61. package/dist/shared/filereview/capture.js.map +1 -1
  62. package/dist/shared/filereview/cas-substrate.d.ts +9 -0
  63. package/dist/shared/filereview/cas-substrate.js +9 -1
  64. package/dist/shared/filereview/cas-substrate.js.map +1 -1
  65. package/dist/shared/filereview/events.d.ts +16 -0
  66. package/dist/shared/filereview/events.js +37 -0
  67. package/dist/shared/filereview/events.js.map +1 -1
  68. package/dist/shared/filereview/git-substrate.d.ts +42 -0
  69. package/dist/shared/filereview/git-substrate.js +78 -0
  70. package/dist/shared/filereview/git-substrate.js.map +1 -1
  71. package/dist/shared/filereview/index.d.ts +3 -1
  72. package/dist/shared/filereview/index.js +3 -1
  73. package/dist/shared/filereview/index.js.map +1 -1
  74. package/dist/shared/filereview/line-counts.d.ts +48 -0
  75. package/dist/shared/filereview/line-counts.js +72 -0
  76. package/dist/shared/filereview/line-counts.js.map +1 -0
  77. package/dist/shared/filereview/progress.d.ts +96 -0
  78. package/dist/shared/filereview/progress.js +134 -0
  79. package/dist/shared/filereview/progress.js.map +1 -0
  80. package/dist/shared/implement-plan-prompt.d.ts +44 -0
  81. package/dist/shared/implement-plan-prompt.js +98 -0
  82. package/dist/shared/implement-plan-prompt.js.map +1 -0
  83. package/dist/shared/plan-artifact.d.ts +59 -11
  84. package/dist/shared/plan-artifact.js +144 -16
  85. package/dist/shared/plan-artifact.js.map +1 -1
  86. package/dist/shared/plan-mode-prompt.d.ts +32 -0
  87. package/dist/shared/plan-mode-prompt.js +56 -0
  88. package/dist/shared/plan-mode-prompt.js.map +1 -0
  89. package/dist/shared/status.d.ts +11 -0
  90. package/dist/shared/status.js +12 -2
  91. package/dist/shared/status.js.map +1 -1
  92. package/dist/shared/tool-row.d.ts +30 -1
  93. package/dist/shared/tool-row.js +53 -4
  94. package/dist/shared/tool-row.js.map +1 -1
  95. package/dist/shared/workspace/session-root.d.ts +35 -0
  96. package/dist/shared/workspace/session-root.js +53 -0
  97. package/dist/shared/workspace/session-root.js.map +1 -0
  98. package/dist/shared/workspace/workspace-lock.d.ts +86 -0
  99. package/dist/shared/workspace/workspace-lock.js +187 -0
  100. package/dist/shared/workspace/workspace-lock.js.map +1 -0
  101. package/package.json +8 -5
  102. package/src/__tests__/config.test.ts +8 -0
  103. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  104. package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
  105. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +161 -0
  106. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +132 -2
  107. package/src/activities/execute-cursor/__tests__/cas-observations.test.ts +55 -0
  108. package/src/activities/execute-cursor/__tests__/delta-enricher.test.ts +68 -0
  109. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +28 -0
  110. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +63 -4
  111. package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +280 -0
  112. package/src/activities/execute-cursor/__tests__/workspace-provision.test.ts +36 -2
  113. package/src/activities/execute-cursor/attachment-resolver.ts +116 -34
  114. package/src/activities/execute-cursor/capture-flow.ts +32 -0
  115. package/src/activities/execute-cursor/cas-observations.ts +41 -5
  116. package/src/activities/execute-cursor/hook-script.ts +19 -1
  117. package/src/activities/execute-cursor/index.ts +243 -178
  118. package/src/activities/execute-cursor/message-translator.ts +9 -1
  119. package/src/activities/execute-cursor/prompt-builder.ts +60 -22
  120. package/src/activities/execute-cursor/skill-resolver.ts +3 -59
  121. package/src/activities/execute-cursor/stigmer-link.ts +78 -0
  122. package/src/activities/execute-cursor/turn-boundary.ts +274 -0
  123. package/src/activities/execute-cursor/workspace-provision.ts +5 -3
  124. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +7 -2
  125. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +7 -2
  126. package/src/activities/execute-deep-agent/__tests__/index.test.ts +4 -1
  127. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +99 -0
  128. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +7 -2
  129. package/src/activities/execute-deep-agent/index.ts +104 -5
  130. package/src/activities/execute-deep-agent/prompt-builder.ts +39 -0
  131. package/src/activities/execute-deep-agent/setup.ts +25 -17
  132. package/src/activities/execute-deep-agent/streaming-v3.ts +6 -1
  133. package/src/activities/execute-deep-agent/streaming.ts +12 -1
  134. package/src/config.ts +17 -0
  135. package/src/middleware/__tests__/approval-gate.test.ts +77 -12
  136. package/src/middleware/approval-gate.ts +39 -7
  137. package/src/runner-manager.ts +6 -1
  138. package/src/runner.ts +6 -1
  139. package/src/shared/__tests__/artifact-storage.test.ts +111 -1
  140. package/src/shared/__tests__/implement-plan-prompt.test.ts +86 -0
  141. package/src/shared/__tests__/plan-artifact.test.ts +142 -9
  142. package/src/shared/__tests__/secret-leak-scan.test.ts +105 -0
  143. package/src/shared/__tests__/status.test.ts +7 -2
  144. package/src/shared/__tests__/tool-row.test.ts +136 -1
  145. package/src/shared/artifact-storage.ts +83 -1
  146. package/src/shared/filereview/__tests__/capture.test.ts +182 -1
  147. package/src/shared/filereview/__tests__/cas-substrate.test.ts +34 -0
  148. package/src/shared/filereview/__tests__/events.test.ts +105 -0
  149. package/src/shared/filereview/__tests__/line-counts.test.ts +73 -0
  150. package/src/shared/filereview/__tests__/progress.test.ts +236 -0
  151. package/src/shared/filereview/capture.ts +134 -30
  152. package/src/shared/filereview/cas-substrate.ts +17 -1
  153. package/src/shared/filereview/events.ts +50 -0
  154. package/src/shared/filereview/git-substrate.ts +108 -0
  155. package/src/shared/filereview/index.ts +22 -1
  156. package/src/shared/filereview/line-counts.ts +83 -0
  157. package/src/shared/filereview/progress.ts +186 -0
  158. package/src/shared/implement-plan-prompt.ts +104 -0
  159. package/src/shared/plan-artifact.ts +152 -16
  160. package/src/shared/plan-mode-prompt.ts +56 -0
  161. package/src/shared/status.ts +11 -1
  162. package/src/shared/tool-row.ts +58 -5
  163. package/src/shared/workspace/__tests__/session-root.test.ts +42 -0
  164. package/src/shared/workspace/__tests__/workspace-lock.test.ts +200 -0
  165. package/src/shared/workspace/session-root.ts +60 -0
  166. package/src/shared/workspace/workspace-lock.ts +241 -0
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { describe, it, expect } from "vitest";
2
3
  import { create } from "@bufbuild/protobuf";
3
4
  import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
@@ -11,8 +12,9 @@ import { makeInMemoryArtifactStorage } from "../../__test-utils__/fake-artifact-
11
12
  import {
12
13
  extractFinalPlanText,
13
14
  publishPlanArtifact,
15
+ planArtifactName,
16
+ isPlanArtifactName,
14
17
  PLAN_ARTIFACT_NAME,
15
- PLAN_ARTIFACT_SANDBOX_PATH,
16
18
  } from "../plan-artifact.js";
17
19
 
18
20
  /** Records uploads and returns deterministic download URLs (canonical double + shim). */
@@ -27,6 +29,11 @@ function fakeStorage(): ArtifactStorage & { uploads: { key: string; content: Buf
27
29
  return Object.assign(storage, { uploads });
28
30
  }
29
31
 
32
+ /** Mirrors the runner's content-hash discriminator so tests can assert exact names. */
33
+ function planId(text: string): string {
34
+ return createHash("sha256").update(text, "utf-8").digest("hex").slice(0, 8);
35
+ }
36
+
30
37
  function statusWith(...messages: { type: MessageType; content: string }[]) {
31
38
  return create(AgentExecutionStatusSchema, {
32
39
  messages: messages.map((m) =>
@@ -59,30 +66,129 @@ describe("extractFinalPlanText", () => {
59
66
  });
60
67
  });
61
68
 
69
+ describe("planArtifactName", () => {
70
+ it("derives a hyphenated <slug>_<id>.plan.md name from the plan's leading # H1", () => {
71
+ const text = "# Plan Card UX Cleanup\n\nBody";
72
+ // "Plan Card…" has no label separator, so the genuine word "plan" survives.
73
+ expect(planArtifactName(text)).toBe(`plan-card-ux-cleanup_${planId(text)}.plan.md`);
74
+ });
75
+
76
+ it("strips a leading 'Plan:' LABEL (colon/dash-anchored) from the slug", () => {
77
+ const text = "# Plan: Fix the download UX\n\nbody";
78
+ expect(planArtifactName(text)).toBe(`fix-the-download-ux_${planId(text)}.plan.md`);
79
+ });
80
+
81
+ it("collapses runs of punctuation/whitespace and trims edge hyphens", () => {
82
+ const text = "# Fix: the (download) UX!! \nx";
83
+ expect(planArtifactName(text)).toBe(`fix-the-download-ux_${planId(text)}.plan.md`);
84
+ });
85
+
86
+ it("unwraps a ```markdown fenced plan before reading the title (SDK parity)", () => {
87
+ const text = "```markdown\n# Named Plan\n\nbody\n```";
88
+ expect(planArtifactName(text)).toBe(`named-plan_${planId(text)}.plan.md`);
89
+ });
90
+
91
+ it("unwraps a bare ``` fenced plan before reading the title (SDK parity)", () => {
92
+ const text = "```\n# Bare Fenced Plan\n\nbody\n```";
93
+ expect(planArtifactName(text)).toBe(`bare-fenced-plan_${planId(text)}.plan.md`);
94
+ });
95
+
96
+ it("caps the slug length so filenames stay bounded", () => {
97
+ const longTitle = "# " + "word ".repeat(40); // ~200 chars of words
98
+ const name = planArtifactName(longTitle);
99
+ const match = /^(.+)_[0-9a-f]{8}\.plan\.md$/.exec(name);
100
+ expect(match).not.toBeNull();
101
+ const slug = match![1];
102
+ expect(slug.length).toBeLessThanOrEqual(60);
103
+ expect(slug.endsWith("-")).toBe(false);
104
+ });
105
+
106
+ it("falls back to a bare <id>.plan.md when there is no leading H1", () => {
107
+ const noTitle = "No title here\n\nbody";
108
+ expect(planArtifactName(noTitle)).toBe(`${planId(noTitle)}.plan.md`);
109
+ // An H1 further down is body content, not the title.
110
+ const later = "intro\n\n# Later Heading";
111
+ expect(planArtifactName(later)).toBe(`${planId(later)}.plan.md`);
112
+ });
113
+
114
+ it("falls back to a bare <id>.plan.md when the title is only the 'Plan:' label", () => {
115
+ const text = "# Plan:\n\nbody";
116
+ expect(planArtifactName(text)).toBe(`${planId(text)}.plan.md`);
117
+ });
118
+
119
+ it("falls back to a bare <id>.plan.md when the title has no alphanumerics", () => {
120
+ const text = "# ---\n\nbody";
121
+ expect(planArtifactName(text)).toBe(`${planId(text)}.plan.md`);
122
+ });
123
+
124
+ it("never freshly emits the legacy plan.md name", () => {
125
+ for (const text of ["# A Title\n\nbody", "no heading", "# Plan:\n\nx"]) {
126
+ expect(planArtifactName(text)).not.toBe(PLAN_ARTIFACT_NAME);
127
+ }
128
+ });
129
+ });
130
+
131
+ describe("isPlanArtifactName", () => {
132
+ it("accepts the legacy exact name and any *.plan.md", () => {
133
+ expect(isPlanArtifactName("plan.md")).toBe(true);
134
+ expect(isPlanArtifactName("feature_x.plan.md")).toBe(true);
135
+ expect(isPlanArtifactName("fix-the-download-ux_a1b2c3d4.plan.md")).toBe(true);
136
+ expect(isPlanArtifactName("a1b2c3d4.plan.md")).toBe(true);
137
+ });
138
+
139
+ it("rejects non-plan filenames", () => {
140
+ expect(isPlanArtifactName("notes.md")).toBe(false);
141
+ expect(isPlanArtifactName("plan.md.bak")).toBe(false);
142
+ expect(isPlanArtifactName("myplan.md")).toBe(false);
143
+ });
144
+ });
145
+
62
146
  describe("publishPlanArtifact", () => {
63
- it("uploads plan.md and registers a FILE artifact on the status", async () => {
147
+ it("uploads the named plan and registers a FILE artifact on the status", async () => {
64
148
  const status = create(AgentExecutionStatusSchema, {});
65
149
  const storage = fakeStorage();
66
150
 
151
+ const planText = "# Plan Card UX Cleanup\n1. step one\n2. step two\n";
152
+ const name = `plan-card-ux-cleanup_${planId(planText)}.plan.md`;
67
153
  await publishPlanArtifact({
68
154
  status,
69
155
  executionId: "aex_123",
70
- planText: "# Plan\n1. step one\n2. step two\n",
156
+ planText,
71
157
  artifactStorage: storage,
72
158
  });
73
159
 
74
160
  expect(storage.uploads).toHaveLength(1);
75
- expect(storage.uploads[0].key).toBe("artifacts/aex_123/plan.md");
161
+ expect(storage.uploads[0].key).toBe(`artifacts/aex_123/${name}`);
76
162
  expect(storage.uploads[0].contentType).toBe("text/markdown");
77
163
 
78
164
  expect(status.artifacts).toHaveLength(1);
79
165
  const artifact = status.artifacts[0];
80
- expect(artifact.name).toBe(PLAN_ARTIFACT_NAME);
81
- expect(artifact.sandboxPath).toBe(PLAN_ARTIFACT_SANDBOX_PATH);
166
+ expect(artifact.name).toBe(name);
167
+ expect(artifact.sandboxPath).toBe(`.stigmer/plans/${name}`);
82
168
  expect(artifact.kind).toBe(ExecutionArtifactKind.FILE);
83
- expect(artifact.storageKey).toBe("artifacts/aex_123/plan.md");
169
+ expect(artifact.storageKey).toBe(`artifacts/aex_123/${name}`);
84
170
  expect(artifact.sizeBytes).toBeGreaterThan(0n);
85
171
  expect(artifact.contentHash).toHaveLength(64);
172
+ // The filename's <id> is the leading 8 hex of the full content hash.
173
+ expect(artifact.contentHash.startsWith(planId(planText))).toBe(true);
174
+ });
175
+
176
+ it("uses a bare <id>.plan.md name for a titleless plan (never the legacy plan.md)", async () => {
177
+ const status = create(AgentExecutionStatusSchema, {});
178
+ const storage = fakeStorage();
179
+
180
+ const planText = "just a paragraph, no heading";
181
+ const name = `${planId(planText)}.plan.md`;
182
+ await publishPlanArtifact({
183
+ status,
184
+ executionId: "aex_notitle",
185
+ planText,
186
+ artifactStorage: storage,
187
+ });
188
+
189
+ expect(status.artifacts[0].name).toBe(name);
190
+ expect(status.artifacts[0].name).not.toBe(PLAN_ARTIFACT_NAME);
191
+ expect(storage.uploads[0].key).toBe(`artifacts/aex_notitle/${name}`);
86
192
  });
87
193
 
88
194
  it("is a no-op for empty plan text (nothing to publish)", async () => {
@@ -100,20 +206,47 @@ describe("publishPlanArtifact", () => {
100
206
  expect(status.artifacts).toHaveLength(0);
101
207
  });
102
208
 
103
- it("replaces an existing plan.md rather than appending a duplicate", async () => {
209
+ it("replaces an existing plan rather than appending a duplicate", async () => {
104
210
  const status = create(AgentExecutionStatusSchema, {});
105
211
  const storage = fakeStorage();
106
212
 
107
213
  await publishPlanArtifact({ status, executionId: "aex_re", planText: "v1", artifactStorage: storage });
108
214
  await publishPlanArtifact({ status, executionId: "aex_re", planText: "v2 longer plan", artifactStorage: storage });
109
215
 
216
+ // Replacement is at the STATUS level, matched by the *.plan.md predicate:
217
+ // exactly one plan artifact survives. Because names are content-derived,
218
+ // the two differing-content writes land under DIFFERENT keys (the first is
219
+ // an orphan blob) — but this synthetic double-publish never happens in
220
+ // production, where publishPlanArtifact runs once per execution finalization
221
+ // and a same-content retry re-writes the same key.
110
222
  expect(status.artifacts).toHaveLength(1);
111
223
  expect(storage.uploads).toHaveLength(2);
112
- // The surviving artifact reflects the latest content hash.
113
224
  const latestHash = storage.uploads[1].content;
114
225
  expect(status.artifacts[0].sizeBytes).toBe(BigInt(latestHash.length));
115
226
  });
116
227
 
228
+ it("replaces a prior plan even when the derived name changed (predicate match)", async () => {
229
+ const status = create(AgentExecutionStatusSchema, {});
230
+ const storage = fakeStorage();
231
+
232
+ await publishPlanArtifact({
233
+ status,
234
+ executionId: "aex_rename",
235
+ planText: "# First Title\n\nbody",
236
+ artifactStorage: storage,
237
+ });
238
+ const secondText = "# Second Title\n\nbody";
239
+ await publishPlanArtifact({
240
+ status,
241
+ executionId: "aex_rename",
242
+ planText: secondText,
243
+ artifactStorage: storage,
244
+ });
245
+
246
+ expect(status.artifacts).toHaveLength(1);
247
+ expect(status.artifacts[0].name).toBe(`second-title_${planId(secondText)}.plan.md`);
248
+ });
249
+
117
250
  it("never throws when the upload fails (publish is non-fatal)", async () => {
118
251
  const status = create(AgentExecutionStatusSchema, {});
119
252
  const { storage: failing } = makeInMemoryArtifactStorage();
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Deny-gate secret leak-scan (DD-26 follow-up #2) — the load-bearing guarantee.
3
+ *
4
+ * The runner-level analog of the offline Go `TestOffline_FileReview_
5
+ * SecretUnderGlobalBypass_NeverPersisted`: assemble a realistic deny-gate status
6
+ * whose transcript carries a secret-like write with its content, run the
7
+ * Invariant-A backstop that BOTH harnesses call before persisting, then serialize
8
+ * the whole status and assert none of the secret bytes survive.
9
+ *
10
+ * An offline end-to-end test is structurally infeasible for this path (DD-23 /
11
+ * DD-26 F3: the offline harness always runs a git workspace with LocalArtifactDir,
12
+ * so deriveCaptureMode is always true and the no-storage deny-gate is unreachable).
13
+ * This test exercises the exact function both `execute-deep-agent/index.ts` and the
14
+ * Cursor `persist` wrapper invoke, over a full AgentExecutionStatus.
15
+ */
16
+
17
+ import { describe, it, expect } from "vitest";
18
+ import { create, toJsonString } from "@bufbuild/protobuf";
19
+ import {
20
+ AgentMessageSchema,
21
+ ToolCallSchema,
22
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
23
+ import { SubAgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
24
+ import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
25
+ import { ToolCallStatus, MessageType } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
26
+ import { withholdSecretContentFromMessages } from "../tool-row.js";
27
+
28
+ const SECRET = "API_KEY=SUPER_SECRET_LEAK_TOKEN_9f3a";
29
+
30
+ function secretWriteRow(id: string, path: string) {
31
+ return create(ToolCallSchema, {
32
+ id,
33
+ name: "write",
34
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
35
+ result: "wrote file",
36
+ argsPreview: JSON.stringify({ path, content: SECRET }),
37
+ args: { path, content: SECRET },
38
+ });
39
+ }
40
+
41
+ describe("deny-gate secret leak-scan", () => {
42
+ it("no secret bytes survive in the serialized status (top-level + sub-agent), non-secret content preserved", () => {
43
+ const status = create(AgentExecutionStatusSchema, {
44
+ messages: [
45
+ create(AgentMessageSchema, {
46
+ type: MessageType.MESSAGE_AI,
47
+ toolCalls: [
48
+ secretWriteRow("tc-secret", ".env"),
49
+ // A non-secret write in the same turn must keep its content (the
50
+ // backstop is scoped to secret-like paths only).
51
+ create(ToolCallSchema, {
52
+ id: "tc-ok",
53
+ name: "write",
54
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
55
+ argsPreview: JSON.stringify({ path: "notes.md" }),
56
+ args: { path: "notes.md", content: "PUBLIC_NOTES_BODY" },
57
+ }),
58
+ ],
59
+ }),
60
+ ],
61
+ subAgentExecutions: [
62
+ create(SubAgentExecutionSchema, {
63
+ id: "sa-1",
64
+ messages: [
65
+ create(AgentMessageSchema, {
66
+ type: MessageType.MESSAGE_AI,
67
+ toolCalls: [secretWriteRow("tc-sa-secret", "config/credentials.json")],
68
+ }),
69
+ ],
70
+ }),
71
+ ],
72
+ });
73
+
74
+ // The mechanism both harnesses call before every persist.
75
+ withholdSecretContentFromMessages(status.messages, status.subAgentExecutions);
76
+
77
+ const serialized = toJsonString(AgentExecutionStatusSchema, status);
78
+ expect(serialized).not.toContain(SECRET);
79
+ // The paths remain (a filename is not the secret); non-secret content survives.
80
+ expect(serialized).toContain(".env");
81
+ expect(serialized).toContain("config/credentials.json");
82
+ expect(serialized).toContain("PUBLIC_NOTES_BODY");
83
+ });
84
+
85
+ it("global bypass: the backstop is the sole guarantee when no gate scrubbed the row", () => {
86
+ // Under auto_approve_all neither harness installs a gate, so a secret write
87
+ // flows and its content lands on the streamed row unscrubbed. The backstop —
88
+ // called unconditionally before persist — is the only thing standing between
89
+ // that row and durable storage.
90
+ const status = create(AgentExecutionStatusSchema, {
91
+ messages: [
92
+ create(AgentMessageSchema, {
93
+ type: MessageType.MESSAGE_AI,
94
+ toolCalls: [secretWriteRow("tc-flowed", ".aws/credentials")],
95
+ }),
96
+ ],
97
+ });
98
+
99
+ withholdSecretContentFromMessages(status.messages, status.subAgentExecutions);
100
+
101
+ const serialized = toJsonString(AgentExecutionStatusSchema, status);
102
+ expect(serialized).not.toContain(SECRET);
103
+ expect(serialized).toContain(".aws/credentials"); // path kept, honest record
104
+ });
105
+ });
@@ -324,7 +324,7 @@ describe("persistStatus — transient retry", () => {
324
324
  });
325
325
 
326
326
  describe("reportSetupProgress", () => {
327
- it("persists a status with the given phase name", async () => {
327
+ it("persists the label WITHOUT forcing a phase transition", async () => {
328
328
  const mockClient = {
329
329
  updateStatus: vi.fn().mockResolvedValue({
330
330
  signal: ExecutionControlSignal.UNSPECIFIED,
@@ -334,7 +334,12 @@ describe("reportSetupProgress", () => {
334
334
  expect(mockClient.updateStatus).toHaveBeenCalledOnce();
335
335
  const [id, status] = mockClient.updateStatus.mock.calls[0];
336
336
  expect(id).toBe("exec-3");
337
- expect(status.phase).toBe(ExecutionPhase.EXECUTION_IN_PROGRESS);
337
+ // UNSPECIFIED = "leave the phase unchanged". The server keeps
338
+ // setup_progress only while the merged phase is still PENDING, so a
339
+ // setup report must never flip the phase itself — sending IN_PROGRESS
340
+ // here made every setup label (and the workspace-lock waiting state)
341
+ // self-destruct on arrival.
342
+ expect(status.phase).toBe(ExecutionPhase.EXECUTION_PHASE_UNSPECIFIED);
338
343
  expect(status.setupProgress?.currentPhase).toBe("Resolving MCP servers");
339
344
  });
340
345
  });
@@ -10,7 +10,14 @@ import { create } from "@bufbuild/protobuf";
10
10
  import { AgentMessageSchema, ToolCallSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
11
11
  import { SubAgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
12
12
  import { ToolCallStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
13
- import { collectSubAgentToolCallIds, hideToolCallRow, isToolCallRowHidden, stampFileEditRow } from "../tool-row.js";
13
+ import {
14
+ collectSubAgentToolCallIds,
15
+ hideToolCallRow,
16
+ isToolCallRowHidden,
17
+ stampFileEditRow,
18
+ withholdSecretContentFromMessages,
19
+ withholdSecretFileContent,
20
+ } from "../tool-row.js";
14
21
 
15
22
  describe("stampFileEditRow", () => {
16
23
  it("stamps additively: content, status, and identity all survive", () => {
@@ -90,6 +97,134 @@ describe("stampFileEditRow", () => {
90
97
  });
91
98
  });
92
99
 
100
+ describe("withholdSecretFileContent", () => {
101
+ it("reduces a secret-like row's args to { path } and clears args_preview, keeping result", () => {
102
+ const tc = create(ToolCallSchema, {
103
+ id: "tc-1",
104
+ name: "write",
105
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
106
+ result: "Tool 'write' was blocked for security: '.env' matches a secret-like path. Nothing was written.",
107
+ argsPreview: '{"path":".env"}',
108
+ args: { path: ".env", content: "API_KEY=super-secret" },
109
+ });
110
+
111
+ expect(withholdSecretFileContent(tc)).toBe(true);
112
+ expect(tc.args).toEqual({ path: ".env" });
113
+ expect(tc.argsPreview).toBe("");
114
+ // result is left intact — the deny-gate uses it for the safe "blocked" message.
115
+ expect(tc.result).toContain("blocked for security");
116
+ });
117
+
118
+ it("leaves a non-secret row untouched and returns false", () => {
119
+ const tc = create(ToolCallSchema, {
120
+ id: "tc-2",
121
+ name: "write",
122
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
123
+ argsPreview: '{"path":"src/app.ts"}',
124
+ args: { path: "src/app.ts", content: "console.log('hi')" },
125
+ });
126
+
127
+ expect(withholdSecretFileContent(tc)).toBe(false);
128
+ expect(tc.args).toEqual({ path: "src/app.ts", content: "console.log('hi')" });
129
+ expect(tc.argsPreview).toBe('{"path":"src/app.ts"}');
130
+ });
131
+
132
+ it("fail-closes when the path cannot be determined (args → undefined)", () => {
133
+ const tc = create(ToolCallSchema, {
134
+ id: "tc-3",
135
+ name: "write",
136
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
137
+ args: { unrecognized_shape: "payload" },
138
+ });
139
+
140
+ expect(withholdSecretFileContent(tc)).toBe(true);
141
+ expect(tc.args).toBeUndefined();
142
+ });
143
+ });
144
+
145
+ describe("withholdSecretContentFromMessages", () => {
146
+ function writeRow(id: string, path: string, content = "SECRET_BODY"): ReturnType<typeof create<typeof ToolCallSchema>> {
147
+ return create(ToolCallSchema, {
148
+ id,
149
+ name: "write",
150
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
151
+ argsPreview: JSON.stringify({ path }),
152
+ args: { path, content },
153
+ });
154
+ }
155
+
156
+ it("withholds content from a secret write row but leaves a non-secret one intact", () => {
157
+ const msg = create(AgentMessageSchema, {
158
+ type: 1,
159
+ toolCalls: [writeRow("tc-secret", ".env"), writeRow("tc-ok", "src/app.ts")],
160
+ });
161
+
162
+ withholdSecretContentFromMessages([msg]);
163
+
164
+ expect(msg.toolCalls[0].args).toEqual({ path: ".env" });
165
+ expect(msg.toolCalls[0].argsPreview).toBe("");
166
+ expect(msg.toolCalls[1].args).toEqual({ path: "src/app.ts", content: "SECRET_BODY" });
167
+ });
168
+
169
+ it("covers edit-family rows (category write) too", () => {
170
+ const editRow = create(ToolCallSchema, {
171
+ id: "tc-edit",
172
+ name: "StrReplace",
173
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
174
+ args: { path: ".ssh/id_rsa", old_string: "a", new_string: "b" },
175
+ });
176
+ const msg = create(AgentMessageSchema, { type: 1, toolCalls: [editRow] });
177
+
178
+ withholdSecretContentFromMessages([msg]);
179
+
180
+ expect(editRow.args).toEqual({ path: ".ssh/id_rsa" });
181
+ });
182
+
183
+ it("does NOT touch a delete row (deletes carry no content)", () => {
184
+ const deleteRow = create(ToolCallSchema, {
185
+ id: "tc-del",
186
+ name: "delete",
187
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
188
+ args: { path: ".env" },
189
+ });
190
+ const msg = create(AgentMessageSchema, { type: 1, toolCalls: [deleteRow] });
191
+
192
+ withholdSecretContentFromMessages([msg]);
193
+
194
+ // A delete's args are path-only already; the write-scoped pass leaves it alone.
195
+ expect(deleteRow.args).toEqual({ path: ".env" });
196
+ });
197
+
198
+ it("walks sub-agent transcripts", () => {
199
+ const topMsg = create(AgentMessageSchema, { type: 1, toolCalls: [writeRow("tc-top", "notes.md")] });
200
+ const sa = create(SubAgentExecutionSchema, {
201
+ id: "sa-1",
202
+ messages: [create(AgentMessageSchema, { type: 1, toolCalls: [writeRow("tc-sa", "credentials.json")] })],
203
+ });
204
+
205
+ withholdSecretContentFromMessages([topMsg], [sa]);
206
+
207
+ expect(topMsg.toolCalls[0].args).toEqual({ path: "notes.md", content: "SECRET_BODY" }); // non-secret untouched
208
+ expect(sa.messages[0].toolCalls[0].args).toEqual({ path: "credentials.json" }); // sub-agent secret scrubbed
209
+ });
210
+
211
+ it("is idempotent and agrees with stampFileEditRow's content-less shape", () => {
212
+ const row = writeRow("tc-secret", ".env");
213
+ const msg = create(AgentMessageSchema, { type: 1, toolCalls: [row] });
214
+
215
+ withholdSecretContentFromMessages([msg]);
216
+ const afterFirst = { args: row.args, argsPreview: row.argsPreview };
217
+ withholdSecretContentFromMessages([msg]);
218
+ expect(row.args).toEqual(afterFirst.args);
219
+ expect(row.argsPreview).toBe(afterFirst.argsPreview);
220
+
221
+ // stampFileEditRow on the already-scrubbed row yields the same content-less shape.
222
+ stampFileEditRow(row, "exec-1:0");
223
+ expect(row.args).toEqual({ path: ".env" });
224
+ expect(row.argsPreview).toBe("");
225
+ });
226
+ });
227
+
93
228
  describe("hideToolCallRow", () => {
94
229
  it("collapses a completed file-edit row to the hidden shape", () => {
95
230
  const tc = create(ToolCallSchema, {
@@ -16,7 +16,7 @@
16
16
  * DD-6: No direct R2 backend. Local + Proxy only.
17
17
  */
18
18
 
19
- import { mkdir, writeFile, readFile, access } from "node:fs/promises";
19
+ import { mkdir, writeFile, readFile, access, rm } from "node:fs/promises";
20
20
  import { dirname, join } from "node:path";
21
21
  import type { Config } from "../config.js";
22
22
 
@@ -256,3 +256,85 @@ export function createArtifactStorage(cfg: ArtifactStorageConfig): ArtifactStora
256
256
 
257
257
  return new LocalArtifactStorage(cfg.localPath, cfg.localServeUrl);
258
258
  }
259
+
260
+ /**
261
+ * Prove that `basePath` can actually be written to, the way {@link
262
+ * LocalArtifactStorage.upload} writes: create the directory tree, write a
263
+ * throwaway file, then remove it. Returns `false` on any failure.
264
+ *
265
+ * We do an actual write rather than `access(basePath, W_OK)` deliberately:
266
+ * `access` can lie under root / ACLs / overlay filesystems (it checks the
267
+ * permission bits, not the real outcome), it does not exercise the recursive
268
+ * `mkdir` + `writeFile` that `upload` performs (so it misses a `basePath` whose
269
+ * parent is a file, an ENOTDIR), and it cannot catch a full disk. The scratch
270
+ * file is uniquely named so concurrent runners never collide, and is removed
271
+ * even though `basePath` (which we want to exist anyway) is left in place.
272
+ */
273
+ async function isLocalPathWritable(basePath: string): Promise<boolean> {
274
+ const probePath = join(basePath, `.write-probe-${process.pid}-${Date.now()}`);
275
+ try {
276
+ await mkdir(basePath, { recursive: true });
277
+ await writeFile(probePath, "");
278
+ await rm(probePath, { force: true });
279
+ return true;
280
+ } catch {
281
+ return false;
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Resolve a *usable* artifact store for the file-review capture / status-offload
287
+ * path, degrading to `undefined` instead of crashing when there is no working
288
+ * substrate. This is the single construct-or-degrade seam shared by both
289
+ * harnesses (the deep-agent and Cursor activities) so they degrade identically.
290
+ *
291
+ * Two degrade paths:
292
+ * - **Proxy misconfig** — {@link createArtifactStorage} throws for a missing
293
+ * endpoint/token; caught here and reported absent.
294
+ * - **Unwritable local path** — {@link LocalArtifactStorage} constructs around a
295
+ * path string and never throws, so an unwritable base path (bad mount, EPERM,
296
+ * full disk) would otherwise let file writes FLOW during the turn and only
297
+ * crash at the turn-boundary upload, with the workspace already mutated and no
298
+ * review authored. We probe writability up front and report absent instead.
299
+ *
300
+ * Proxy is intentionally NOT probed at the network layer: a live-endpoint check
301
+ * would add a round-trip to every cloud setup and risk falsely degrading on a
302
+ * transient blip; construction already validates its config.
303
+ *
304
+ * An absent store is a first-class, already-supported state (DD-26): capture
305
+ * degrades to the deny-gate (via {@link deriveCaptureMode}'s `hasArtifactStorage`
306
+ * argument), tool-output offload is disabled (the aggregate size guard still
307
+ * applies), and attachment / plan-artifact publishing surface a clear error.
308
+ * This is the fail-safe realization of DD-26 follow-up #1.
309
+ *
310
+ * NOTE: this resolver is for the capture/offload path only. Claimcheck (Temporal
311
+ * payload offload) MUST have storage and has no deny-gate to fall back to, so it
312
+ * deliberately keeps calling {@link createArtifactStorage} directly (fail-hard).
313
+ */
314
+ export async function resolveUsableArtifactStorage(
315
+ cfg: ArtifactStorageConfig,
316
+ ctx: { executionId: string },
317
+ ): Promise<ArtifactStorage | undefined> {
318
+ let storage: ArtifactStorage;
319
+ try {
320
+ storage = createArtifactStorage(cfg);
321
+ } catch (err) {
322
+ console.warn(
323
+ `[artifact-storage] unavailable — file capture degrades to the deny-gate ` +
324
+ `and tool-output offload is disabled: execution=${ctx.executionId}, ` +
325
+ `type=${cfg.type}, error=${err}`,
326
+ );
327
+ return undefined;
328
+ }
329
+
330
+ if (cfg.type === "local" && !(await isLocalPathWritable(cfg.localPath))) {
331
+ console.warn(
332
+ `[artifact-storage] local path not writable — file capture degrades to the ` +
333
+ `deny-gate and tool-output offload is disabled: execution=${ctx.executionId}, ` +
334
+ `path=${cfg.localPath}`,
335
+ );
336
+ return undefined;
337
+ }
338
+
339
+ return storage;
340
+ }