@stigmer/runner 3.1.0 → 3.1.2

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 (111) 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/cas-observations.d.ts +13 -0
  6. package/dist/activities/execute-cursor/cas-observations.js +39 -5
  7. package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
  8. package/dist/activities/execute-cursor/hook-script.js +19 -1
  9. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  10. package/dist/activities/execute-cursor/index.d.ts +5 -0
  11. package/dist/activities/execute-cursor/index.js +102 -22
  12. package/dist/activities/execute-cursor/index.js.map +1 -1
  13. package/dist/activities/execute-cursor/message-translator.js +10 -1
  14. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  15. package/dist/activities/execute-cursor/prompt-builder.d.ts +32 -7
  16. package/dist/activities/execute-cursor/prompt-builder.js +38 -20
  17. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  18. package/dist/activities/execute-cursor/skill-resolver.d.ts +1 -10
  19. package/dist/activities/execute-cursor/skill-resolver.js +3 -55
  20. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  21. package/dist/activities/execute-cursor/stigmer-link.d.ts +35 -0
  22. package/dist/activities/execute-cursor/stigmer-link.js +73 -0
  23. package/dist/activities/execute-cursor/stigmer-link.js.map +1 -0
  24. package/dist/activities/execute-cursor/workspace-provision.d.ts +3 -2
  25. package/dist/activities/execute-cursor/workspace-provision.js +5 -3
  26. package/dist/activities/execute-cursor/workspace-provision.js.map +1 -1
  27. package/dist/activities/execute-deep-agent/index.js +59 -2
  28. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  29. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +17 -0
  30. package/dist/activities/execute-deep-agent/prompt-builder.js +16 -0
  31. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  32. package/dist/activities/execute-deep-agent/setup.js +22 -16
  33. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  34. package/dist/config.d.ts +7 -0
  35. package/dist/config.js +9 -0
  36. package/dist/config.js.map +1 -1
  37. package/dist/middleware/approval-gate.js +36 -6
  38. package/dist/middleware/approval-gate.js.map +1 -1
  39. package/dist/runner-manager.d.ts +2 -0
  40. package/dist/runner-manager.js +2 -1
  41. package/dist/runner-manager.js.map +1 -1
  42. package/dist/runner.d.ts +2 -0
  43. package/dist/runner.js +2 -1
  44. package/dist/runner.js.map +1 -1
  45. package/dist/shared/artifact-storage.d.ts +32 -0
  46. package/dist/shared/artifact-storage.js +74 -1
  47. package/dist/shared/artifact-storage.js.map +1 -1
  48. package/dist/shared/implement-plan-prompt.d.ts +42 -0
  49. package/dist/shared/implement-plan-prompt.js +66 -0
  50. package/dist/shared/implement-plan-prompt.js.map +1 -0
  51. package/dist/shared/plan-mode-prompt.d.ts +30 -0
  52. package/dist/shared/plan-mode-prompt.js +51 -0
  53. package/dist/shared/plan-mode-prompt.js.map +1 -0
  54. package/dist/shared/status.d.ts +11 -0
  55. package/dist/shared/status.js +12 -2
  56. package/dist/shared/status.js.map +1 -1
  57. package/dist/shared/tool-row.d.ts +30 -1
  58. package/dist/shared/tool-row.js +53 -4
  59. package/dist/shared/tool-row.js.map +1 -1
  60. package/dist/shared/workspace/session-root.d.ts +35 -0
  61. package/dist/shared/workspace/session-root.js +53 -0
  62. package/dist/shared/workspace/session-root.js.map +1 -0
  63. package/dist/shared/workspace/workspace-lock.d.ts +86 -0
  64. package/dist/shared/workspace/workspace-lock.js +187 -0
  65. package/dist/shared/workspace/workspace-lock.js.map +1 -0
  66. package/package.json +7 -5
  67. package/src/__tests__/config.test.ts +8 -0
  68. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  69. package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
  70. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +161 -0
  71. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +125 -2
  72. package/src/activities/execute-cursor/__tests__/cas-observations.test.ts +55 -0
  73. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +28 -0
  74. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +63 -4
  75. package/src/activities/execute-cursor/__tests__/workspace-provision.test.ts +36 -2
  76. package/src/activities/execute-cursor/attachment-resolver.ts +116 -34
  77. package/src/activities/execute-cursor/cas-observations.ts +41 -5
  78. package/src/activities/execute-cursor/hook-script.ts +19 -1
  79. package/src/activities/execute-cursor/index.ts +114 -26
  80. package/src/activities/execute-cursor/message-translator.ts +9 -1
  81. package/src/activities/execute-cursor/prompt-builder.ts +60 -22
  82. package/src/activities/execute-cursor/skill-resolver.ts +3 -59
  83. package/src/activities/execute-cursor/stigmer-link.ts +78 -0
  84. package/src/activities/execute-cursor/workspace-provision.ts +5 -3
  85. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +7 -2
  86. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +7 -2
  87. package/src/activities/execute-deep-agent/__tests__/index.test.ts +4 -1
  88. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +88 -0
  89. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +7 -2
  90. package/src/activities/execute-deep-agent/index.ts +70 -2
  91. package/src/activities/execute-deep-agent/prompt-builder.ts +39 -0
  92. package/src/activities/execute-deep-agent/setup.ts +25 -17
  93. package/src/config.ts +17 -0
  94. package/src/middleware/__tests__/approval-gate.test.ts +77 -12
  95. package/src/middleware/approval-gate.ts +39 -7
  96. package/src/runner-manager.ts +6 -1
  97. package/src/runner.ts +6 -1
  98. package/src/shared/__tests__/artifact-storage.test.ts +111 -1
  99. package/src/shared/__tests__/implement-plan-prompt.test.ts +48 -0
  100. package/src/shared/__tests__/secret-leak-scan.test.ts +105 -0
  101. package/src/shared/__tests__/status.test.ts +7 -2
  102. package/src/shared/__tests__/tool-row.test.ts +136 -1
  103. package/src/shared/artifact-storage.ts +83 -1
  104. package/src/shared/implement-plan-prompt.ts +73 -0
  105. package/src/shared/plan-mode-prompt.ts +51 -0
  106. package/src/shared/status.ts +11 -1
  107. package/src/shared/tool-row.ts +58 -5
  108. package/src/shared/workspace/__tests__/session-root.test.ts +42 -0
  109. package/src/shared/workspace/__tests__/workspace-lock.test.ts +200 -0
  110. package/src/shared/workspace/session-root.ts +60 -0
  111. package/src/shared/workspace/workspace-lock.ts +241 -0
package/src/runner.ts CHANGED
@@ -17,7 +17,7 @@ import { join } from "node:path";
17
17
  import { homedir, tmpdir } from "node:os";
18
18
  import type { PayloadCodec } from "@temporalio/common";
19
19
  import type { Config } from "./config.js";
20
- import { DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS } from "./config.js";
20
+ import { DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS, DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS } from "./config.js";
21
21
  import type { WorkerActivities } from "./worker.js";
22
22
  import { resolveRunnerBootstrap } from "./bootstrap.js";
23
23
 
@@ -69,6 +69,9 @@ export interface StigmerRunnerOptions {
69
69
  /** No-progress bound for the Cursor harness stream (ms). @default 180000 */
70
70
  readonly cursorStreamStallTimeoutMs?: number;
71
71
 
72
+ /** Max wait for the per-workspace turn lock (ms). @default 900000 */
73
+ readonly workspaceLockTimeoutMs?: number;
74
+
72
75
  /** Checkpointer type for LangGraph agent state. @default "memory" (or "http" if proxyEndpoint is set) */
73
76
  readonly checkpointerType?: "memory" | "http";
74
77
 
@@ -274,6 +277,8 @@ export function mapOptionsToConfig(options: StigmerRunnerOptions): Config {
274
277
  primaryModel: options.primaryModel ?? "gpt-4.1",
275
278
  cursorStreamStallTimeoutMs:
276
279
  options.cursorStreamStallTimeoutMs ?? DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS,
280
+ workspaceLockTimeoutMs:
281
+ options.workspaceLockTimeoutMs ?? DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS,
277
282
  };
278
283
  }
279
284
 
@@ -1,14 +1,16 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
- import { mkdtemp, rm, readFile } from "node:fs/promises";
2
+ import { mkdtemp, rm, readFile, readdir, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import {
6
6
  LocalArtifactStorage,
7
7
  ProxyArtifactStorage,
8
8
  createArtifactStorage,
9
+ resolveUsableArtifactStorage,
9
10
  loadArtifactStorageConfig,
10
11
  type ArtifactStorageConfig,
11
12
  } from "../artifact-storage.js";
13
+ import { deriveCaptureMode } from "../filereview/capture.js";
12
14
 
13
15
  // ── LocalArtifactStorage ─────────────────────────────────────────────
14
16
 
@@ -428,6 +430,7 @@ describe("loadArtifactStorageConfig", () => {
428
430
  checkpointerProxyEndpoint: null,
429
431
  primaryModel: "gpt-4.1",
430
432
  cursorStreamStallTimeoutMs: 180000,
433
+ workspaceLockTimeoutMs: 900000,
431
434
  };
432
435
 
433
436
  afterEach(() => {
@@ -476,3 +479,110 @@ describe("loadArtifactStorageConfig", () => {
476
479
  expect(cfg.type).toBe("proxy");
477
480
  });
478
481
  });
482
+
483
+ // ── resolveUsableArtifactStorage (DD-26 follow-up #1) ─────────────────
484
+ //
485
+ // The shared construct-or-degrade seam: it must return `undefined` (never throw)
486
+ // for every "no working substrate" condition so both harnesses fall to the
487
+ // deny-gate up front, instead of flowing file writes then crashing at the
488
+ // turn-boundary upload.
489
+
490
+ describe("resolveUsableArtifactStorage", () => {
491
+ const ctx = { executionId: "exec-test" };
492
+ let tempDir: string;
493
+
494
+ beforeEach(async () => {
495
+ tempDir = await mkdtemp(join(tmpdir(), "resolve-artifact-test-"));
496
+ });
497
+
498
+ afterEach(async () => {
499
+ await rm(tempDir, { recursive: true, force: true });
500
+ vi.restoreAllMocks();
501
+ });
502
+
503
+ const localCfg = (localPath: string): ArtifactStorageConfig => ({
504
+ type: "local",
505
+ localPath,
506
+ localServeUrl: "http://localhost:7235",
507
+ proxyEndpoint: null,
508
+ proxyAuthToken: null,
509
+ });
510
+
511
+ it("returns a LocalArtifactStorage for a writable path and leaves no residue", async () => {
512
+ const base = join(tempDir, "artifacts");
513
+ const storage = await resolveUsableArtifactStorage(localCfg(base), ctx);
514
+
515
+ expect(storage).toBeInstanceOf(LocalArtifactStorage);
516
+ // The write-probe file must be cleaned up; the base dir may be created.
517
+ const entries = await readdir(base);
518
+ expect(entries).toEqual([]);
519
+ });
520
+
521
+ it("returns undefined for an unwritable local path (mkdir ENOTDIR)", async () => {
522
+ // Deterministic, cross-platform: point localPath at a child of a regular
523
+ // FILE, so the resolver's recursive mkdir fails with ENOTDIR.
524
+ const filePath = join(tempDir, "not-a-dir");
525
+ await writeFile(filePath, "");
526
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
527
+
528
+ const storage = await resolveUsableArtifactStorage(
529
+ localCfg(join(filePath, "sub")),
530
+ ctx,
531
+ );
532
+
533
+ expect(storage).toBeUndefined();
534
+ expect(warn).toHaveBeenCalledWith(
535
+ expect.stringContaining("local path not writable"),
536
+ );
537
+ });
538
+
539
+ it("returns undefined when proxy config is a misconfig (construct throws)", async () => {
540
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
541
+ const cfg: ArtifactStorageConfig = {
542
+ type: "proxy",
543
+ localPath: "/tmp/artifacts",
544
+ localServeUrl: "http://localhost:7235",
545
+ proxyEndpoint: "https://proxy.example.com",
546
+ proxyAuthToken: null, // missing token
547
+ };
548
+
549
+ const storage = await resolveUsableArtifactStorage(cfg, ctx);
550
+
551
+ expect(storage).toBeUndefined();
552
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("unavailable"));
553
+ });
554
+
555
+ it("returns a ProxyArtifactStorage for a valid proxy config without any network call", async () => {
556
+ // Proxy is never probed at the network layer — assert no fetch happens.
557
+ const fetchSpy = vi.spyOn(globalThis, "fetch");
558
+ const cfg: ArtifactStorageConfig = {
559
+ type: "proxy",
560
+ localPath: "/tmp/artifacts",
561
+ localServeUrl: "http://localhost:7235",
562
+ proxyEndpoint: "https://proxy.example.com",
563
+ proxyAuthToken: "token-123",
564
+ };
565
+
566
+ const storage = await resolveUsableArtifactStorage(cfg, ctx);
567
+
568
+ expect(storage).toBeInstanceOf(ProxyArtifactStorage);
569
+ expect(fetchSpy).not.toHaveBeenCalled();
570
+ });
571
+
572
+ it("wires through to deriveCaptureMode: an unwritable local store degrades a non-git turn to the deny-gate", async () => {
573
+ const filePath = join(tempDir, "not-a-dir");
574
+ await writeFile(filePath, "");
575
+ vi.spyOn(console, "warn").mockImplementation(() => undefined);
576
+
577
+ const storage = await resolveUsableArtifactStorage(
578
+ localCfg(join(filePath, "sub")),
579
+ ctx,
580
+ );
581
+
582
+ // Non-git workspace + no usable storage => no capture substrate => deny-gate.
583
+ expect(deriveCaptureMode("/some/workspace", false, !!storage)).toBe(false);
584
+ // A git workspace still captures (git substrate needs no storage), but its
585
+ // gitignored->CAS edits and offload are off because the store is absent.
586
+ expect(deriveCaptureMode("/some/workspace", true, !!storage)).toBe(true);
587
+ });
588
+ });
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ buildImplementPlanDirective,
4
+ findApprovedPlanPath,
5
+ } from "../implement-plan-prompt.js";
6
+
7
+ describe("findApprovedPlanPath", () => {
8
+ it("finds the plan by its canonical filename among attachment paths", () => {
9
+ expect(
10
+ findApprovedPlanPath([
11
+ ".stigmer/inputs/data.csv",
12
+ ".stigmer/inputs/plan.md",
13
+ ]),
14
+ ).toBe(".stigmer/inputs/plan.md");
15
+ });
16
+
17
+ it("returns undefined when no plan attachment resolved", () => {
18
+ expect(findApprovedPlanPath([".stigmer/inputs/data.csv"])).toBeUndefined();
19
+ expect(findApprovedPlanPath([])).toBeUndefined();
20
+ });
21
+
22
+ it("matches the basename exactly — a plan-adjacent filename is not the plan", () => {
23
+ expect(
24
+ findApprovedPlanPath([".stigmer/inputs/my-plan.md.bak"]),
25
+ ).toBeUndefined();
26
+ });
27
+ });
28
+
29
+ describe("buildImplementPlanDirective", () => {
30
+ it("points the model at the attached plan and names it authoritative", () => {
31
+ const directive = buildImplementPlanDirective(".stigmer/inputs/plan.md");
32
+
33
+ expect(directive).toContain("APPROVED");
34
+ expect(directive).toContain("`.stigmer/inputs/plan.md`");
35
+ expect(directive).toContain("Read it FIRST");
36
+ // The user may have edited the document after the plan turn — the file,
37
+ // not the conversation, is the approved text.
38
+ expect(directive).toContain("authoritative");
39
+ });
40
+
41
+ it("falls back to the conversation's plan when nothing is attached", () => {
42
+ const directive = buildImplementPlanDirective(undefined);
43
+
44
+ expect(directive).toContain("APPROVED");
45
+ expect(directive).toContain("conversation above");
46
+ expect(directive).not.toContain("plan.md");
47
+ });
48
+ });
@@ -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
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The Build-from-plan prompt directive, shared by both harnesses.
3
+ *
4
+ * When the user approves a plan and clicks "Build from plan", the client
5
+ * submits an execution with `spec.execution_config.build_from_plan` set and a
6
+ * short human-readable message ("Build from plan") — it does NOT embed
7
+ * implement instructions in the message text. The runner owns the agent-facing
8
+ * instruction, injected from this module, so the chat thread can render the
9
+ * turn as a compact chip while the model still receives the full contract.
10
+ *
11
+ * The directive has two variants, chosen by whether the approved plan document
12
+ * actually materialized in the workspace:
13
+ * - Attached (the normal case): the client uploaded the approved plan text
14
+ * (edited or not) and attached it, mounted at `.stigmer/inputs/plan.md`.
15
+ * The directive points the model at that file and names it authoritative —
16
+ * the user may have refined the document after the plan turn, so the
17
+ * conversation's version can be stale.
18
+ * - Conversation-only (the client's upload failed): the directive falls back
19
+ * to the plan as proposed in the conversation.
20
+ *
21
+ * Like `PLAN_MODE_DIRECTIVE` (plan-mode-prompt.ts), this module is the single
22
+ * source of truth for the words; each harness wraps them in its own prompt
23
+ * framing (XML-tag section for Cursor, markdown heading for the native
24
+ * harness).
25
+ */
26
+
27
+ import { PLAN_ARTIFACT_NAME } from "./plan-artifact.js";
28
+
29
+ /**
30
+ * Find the approved plan document among the workspace paths the harness
31
+ * injected for this execution's attachments. Detection keys on the canonical
32
+ * plan filename — the same convention the UI uses to detect the plan artifact.
33
+ * Returns `undefined` when no plan attachment landed (upload failed, or the
34
+ * attachment itself failed to inject), which selects the conversation-only
35
+ * directive variant.
36
+ */
37
+ export function findApprovedPlanPath(
38
+ attachmentPaths: readonly string[],
39
+ ): string | undefined {
40
+ return attachmentPaths.find(
41
+ (p) => p.split("/").pop() === PLAN_ARTIFACT_NAME,
42
+ );
43
+ }
44
+
45
+ /**
46
+ * Build the implement-plan directive body for a Build-from-plan execution.
47
+ *
48
+ * @param planPath - Workspace-relative path of the attached approved plan
49
+ * (from {@link findApprovedPlanPath}), or `undefined` when the plan exists
50
+ * only in the conversation.
51
+ */
52
+ export function buildImplementPlanDirective(planPath?: string): string {
53
+ if (planPath) {
54
+ return [
55
+ "IMPORTANT: This turn implements a plan the user has reviewed and " +
56
+ "APPROVED.",
57
+ "",
58
+ `The approved plan document is attached at \`${planPath}\`. Read it ` +
59
+ "FIRST, then implement it step by step.",
60
+ "",
61
+ "That document is the authoritative version of the plan — the user may " +
62
+ "have edited it after it was proposed, so where it differs from the " +
63
+ "conversation above, follow the document.",
64
+ ].join("\n");
65
+ }
66
+
67
+ return [
68
+ "IMPORTANT: This turn implements a plan the user has reviewed and " +
69
+ "APPROVED.",
70
+ "",
71
+ "Implement the plan proposed in the conversation above, step by step.",
72
+ ].join("\n");
73
+ }