@stigmer/runner 3.12.1 → 3.12.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 (62) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/capture-flow.d.ts +5 -4
  3. package/dist/activities/execute-cursor/capture-flow.js +5 -4
  4. package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
  5. package/dist/activities/execute-cursor/cas-observations.d.ts +10 -4
  6. package/dist/activities/execute-cursor/cas-observations.js +10 -4
  7. package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
  8. package/dist/activities/execute-cursor/hook-script.js +39 -13
  9. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  10. package/dist/activities/execute-cursor/skill-resolver.d.ts +8 -0
  11. package/dist/activities/execute-cursor/skill-resolver.js +77 -26
  12. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  13. package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +3 -1
  14. package/dist/activities/execute-deep-agent/cas-capture-backend.js +3 -1
  15. package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
  16. package/dist/activities/execute-deep-agent/setup.js +8 -0
  17. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  18. package/dist/activities/execute-deep-agent/subagent-wiring.js +16 -9
  19. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  20. package/dist/activities/generate-session-subject.d.ts +80 -0
  21. package/dist/activities/generate-session-subject.js +283 -0
  22. package/dist/activities/generate-session-subject.js.map +1 -0
  23. package/dist/client/stigmer-client.d.ts +8 -0
  24. package/dist/client/stigmer-client.js +10 -0
  25. package/dist/client/stigmer-client.js.map +1 -1
  26. package/dist/middleware/approval-gate.d.ts +25 -10
  27. package/dist/middleware/approval-gate.js +33 -14
  28. package/dist/middleware/approval-gate.js.map +1 -1
  29. package/dist/middleware/otel-spans.d.ts +2 -1
  30. package/dist/middleware/otel-spans.js +2 -1
  31. package/dist/middleware/otel-spans.js.map +1 -1
  32. package/dist/runner-manager.js +3 -1
  33. package/dist/runner-manager.js.map +1 -1
  34. package/dist/runner.js +3 -1
  35. package/dist/runner.js.map +1 -1
  36. package/dist/shared/attachment-vision.js +9 -0
  37. package/dist/shared/attachment-vision.js.map +1 -1
  38. package/dist/shared/mcp-manager.js +8 -0
  39. package/dist/shared/mcp-manager.js.map +1 -1
  40. package/package.json +2 -2
  41. package/src/activities/__tests__/generate-session-subject.test.ts +348 -0
  42. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +60 -9
  43. package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +163 -7
  44. package/src/activities/execute-cursor/capture-flow.ts +5 -4
  45. package/src/activities/execute-cursor/cas-observations.ts +10 -4
  46. package/src/activities/execute-cursor/hook-script.ts +39 -13
  47. package/src/activities/execute-cursor/skill-resolver.ts +98 -31
  48. package/src/activities/execute-deep-agent/cas-capture-backend.ts +3 -1
  49. package/src/activities/execute-deep-agent/setup.ts +8 -0
  50. package/src/activities/execute-deep-agent/subagent-wiring.ts +16 -9
  51. package/src/activities/generate-session-subject.ts +370 -0
  52. package/src/client/stigmer-client.ts +11 -0
  53. package/src/middleware/__tests__/approval-gate.test.ts +130 -1
  54. package/src/middleware/approval-gate.ts +58 -24
  55. package/src/middleware/otel-spans.ts +2 -1
  56. package/src/runner-manager.ts +3 -0
  57. package/src/runner.ts +3 -0
  58. package/src/shared/__tests__/attachment-vision.test.ts +4 -0
  59. package/src/shared/__tests__/mcp-manager.test.ts +7 -2
  60. package/src/shared/attachment-vision.ts +9 -0
  61. package/src/shared/filereview/__tests__/capture.test.ts +48 -0
  62. package/src/shared/mcp-manager.ts +8 -0
@@ -0,0 +1,370 @@
1
+ /**
2
+ * GenerateSessionSubject Temporal activity — replaces the sentinel subject of
3
+ * an auto-created session with a concise LLM-generated conversation title.
4
+ *
5
+ * Called fire-and-forget by the Go InvokeAgentExecutionWorkflow (Step 1.5 of
6
+ * BOTH the deep-agent and cursor flows) on the runner task queue, with a 60 s
7
+ * deadline and a single attempt. Failures are non-critical by contract: the
8
+ * session simply keeps its sentinel subject, and the workflow logs a warning.
9
+ *
10
+ * Cross-edition parity: this is the behavioral twin of stigmer-cloud's
11
+ * GenerateSessionSubjectActivityImpl (Java, runs in-process on the server
12
+ * because the Java server owns an LLM call service; the OSS LLM stack lives
13
+ * here in the runner). The system prompt, skip rules, 50-char cap, and the
14
+ * heuristic fallback are kept in lockstep so both editions title sessions
15
+ * identically. The previous owner was the retired Python agent-runner
16
+ * (stigmer/stigmer#665).
17
+ *
18
+ * Model + credential resolution follows ClassifyToolApprovals: the economy
19
+ * model for the configured primary (registry costTier=economy, graceful
20
+ * degrade to the primary), routed through the Stigmer proxy when one is
21
+ * configured, else called directly with the operator's provider key. Unlike
22
+ * classification — a security gate that fails closed — a missing credential
23
+ * here degrades to a heuristic title (the first words of the user message):
24
+ * a title is worth having even when no model is reachable.
25
+ *
26
+ * Activity contract (the Go stub dispatches exactly ONE argument — the cloud
27
+ * Java signature carries a second, invokerIdentityAccountId, that never
28
+ * crosses this wire):
29
+ * Name: "GenerateSessionSubject"
30
+ * Input: (executionId: string)
31
+ * Output: void
32
+ */
33
+
34
+ import { SystemMessage, HumanMessage } from "@langchain/core/messages";
35
+ import { ConnectError, Code } from "@connectrpc/connect";
36
+ import { activityStarted, activityFinished } from "../idle-watchdog.js";
37
+ import { StigmerClient } from "../client/stigmer-client.js";
38
+ import { getSummarizationModel } from "../shared/model-registry.js";
39
+ import { buildChatModel } from "../shared/model-client.js";
40
+ import { checkDirectCredentials } from "../shared/llm-backend.js";
41
+ import { tryInferProvider } from "../shared/llm-proxy.js";
42
+ import type { Config } from "../config.js";
43
+ import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
44
+ import type { Session } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
45
+ import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
46
+ import type { AgentInstance } from "@stigmer/protos/ai/stigmer/agentic/agentinstance/v1/api_pb";
47
+
48
+ /**
49
+ * The sentinel written by the server on auto-created sessions — byte-identical
50
+ * to `autoCreatedSessionSubject` in stigmer-server's agentexecution create
51
+ * pipeline and to AUTO_CREATED_SUBJECT in the cloud activity. Only sessions
52
+ * still carrying it (or an empty subject) are ever titled.
53
+ */
54
+ export const AUTO_CREATED_SUBJECT = "Auto-created session";
55
+
56
+ const MAX_SUBJECT_LENGTH = 50;
57
+ /** Titles are tiny; matches the cloud activity's completion budget. */
58
+ const MAX_COMPLETION_TOKENS = 100;
59
+ const TEMPERATURE = 0.7;
60
+
61
+ /** Kept verbatim in lockstep with the cloud activity's SYSTEM_PROMPT. */
62
+ const SYSTEM_PROMPT = `\
63
+ You are a session title generator. Given a user's message and agent context, \
64
+ produce a concise conversation title.
65
+
66
+ Rules:
67
+ - 3 to 7 words, maximum 50 characters
68
+ - Capture the user's core intent or topic
69
+ - Be specific (e.g. "PostgreSQL Multi-AZ Setup" not "Database Help")
70
+ - No filler words ("help with", "question about", "I need")
71
+ - No quotes, no punctuation at the end
72
+ - Output ONLY the title, nothing else`;
73
+
74
+ // ─────────────────────────────────────────────────────────────────────────────
75
+ // Core logic (no Temporal coupling)
76
+ // ─────────────────────────────────────────────────────────────────────────────
77
+
78
+ /** The client surface this activity needs — StigmerClient satisfies it. */
79
+ export interface SessionSubjectClient {
80
+ getExecution(executionId: string): Promise<AgentExecution>;
81
+ getSession(sessionId: string): Promise<Session>;
82
+ getAgent(agentId: string): Promise<Agent>;
83
+ getAgentInstance(instanceId: string): Promise<AgentInstance>;
84
+ updateSessionSubject(sessionId: string, subject: string): Promise<Session>;
85
+ }
86
+
87
+ export interface GenerateSessionSubjectOptions {
88
+ /** Null when the deployment has no proxy — the runner calls providers directly. */
89
+ proxyEndpoint: string | null;
90
+ stigmerToken: string | null;
91
+ primaryModel: string;
92
+ }
93
+
94
+ export async function generateSessionSubject(
95
+ executionId: string,
96
+ client: SessionSubjectClient,
97
+ options: GenerateSessionSubjectOptions,
98
+ ): Promise<void> {
99
+ const execution = await getOrSkip(
100
+ () => client.getExecution(executionId),
101
+ `execution not found: ${executionId}`,
102
+ );
103
+ if (execution === undefined) return;
104
+
105
+ const sessionId = execution.spec?.sessionId ?? "";
106
+ const userMessage = execution.spec?.message ?? "";
107
+ if (sessionId === "") {
108
+ log(`no session_id on execution ${executionId}, skipping`);
109
+ return;
110
+ }
111
+ if (userMessage === "") {
112
+ log(`no user message on execution ${executionId}, skipping`);
113
+ return;
114
+ }
115
+
116
+ const session = await getOrSkip(
117
+ () => client.getSession(sessionId),
118
+ `session not found: ${sessionId}`,
119
+ );
120
+ if (session === undefined) return;
121
+
122
+ const currentSubject = session.spec?.subject ?? "";
123
+ if (currentSubject !== "" && currentSubject !== AUTO_CREATED_SUBJECT) {
124
+ log(`subject is '${currentSubject}' (already set), skipping`);
125
+ return;
126
+ }
127
+
128
+ const agentId = await resolveAgentId(execution, session, client);
129
+ if (agentId === "") {
130
+ log(`cannot resolve agent_id for execution ${executionId}, skipping`);
131
+ return;
132
+ }
133
+
134
+ const agent = await getOrSkip(
135
+ () => client.getAgent(agentId),
136
+ `agent not found: ${agentId}`,
137
+ );
138
+ if (agent === undefined) return;
139
+
140
+ const subject = await generateTitle({
141
+ userMessage,
142
+ agentName: agent.metadata?.name ?? "",
143
+ agentDescription: agent.spec?.description ?? "",
144
+ executionId,
145
+ options,
146
+ });
147
+
148
+ if (subject === "") {
149
+ log("subject is empty after generation, skipping");
150
+ return;
151
+ }
152
+
153
+ try {
154
+ await client.updateSessionSubject(sessionId, subject);
155
+ } catch (err) {
156
+ log(`failed to persist subject for session ${sessionId}: ${message(err)}`);
157
+ return;
158
+ }
159
+
160
+ log(`updated session ${sessionId} subject to '${subject}'`);
161
+ }
162
+
163
+ /**
164
+ * The agent behind the execution: the direct agent_id when the execution
165
+ * carries one, else resolved through the session's agent-instance chain.
166
+ * Empty string when unresolvable. Mirrors the cloud activity's resolveAgentId.
167
+ */
168
+ export async function resolveAgentId(
169
+ execution: AgentExecution,
170
+ session: Session,
171
+ client: Pick<SessionSubjectClient, "getAgentInstance">,
172
+ ): Promise<string> {
173
+ const direct = execution.spec?.agentId ?? "";
174
+ if (direct !== "") return direct;
175
+
176
+ const instanceId = session.spec?.agentInstanceId ?? "";
177
+ if (instanceId === "") return "";
178
+
179
+ const instance = await getOrSkip(
180
+ () => client.getAgentInstance(instanceId),
181
+ `agent instance not found: ${instanceId}`,
182
+ );
183
+ return instance?.spec?.agentId ?? "";
184
+ }
185
+
186
+ interface GenerateTitleParams {
187
+ userMessage: string;
188
+ agentName: string;
189
+ agentDescription: string;
190
+ executionId: string;
191
+ options: GenerateSessionSubjectOptions;
192
+ }
193
+
194
+ /**
195
+ * LLM title with heuristic degrade. Every unavailability class — no direct
196
+ * credential, model build failure, provider error, empty completion — lands on
197
+ * the heuristic, matching the cloud activity (its LlmCallService swallows
198
+ * provider failures into a null completion; heuristicSubject documents the
199
+ * same "expired API key, rate limit, registry miss" cases).
200
+ */
201
+ async function generateTitle(params: GenerateTitleParams): Promise<string> {
202
+ const { userMessage, agentName, agentDescription, executionId, options } = params;
203
+
204
+ const model = await getSummarizationModel(options.primaryModel);
205
+
206
+ // Direct mode with no credential path degrades up front with one actionable
207
+ // message (the ClassifyToolApprovals idiom — the provider follows the
208
+ // economy-model resolution, not the operator's key, so the log names it).
209
+ if (!options.proxyEndpoint) {
210
+ const provider = tryInferProvider(model);
211
+ const missing = provider === null ? null : checkDirectCredentials(provider);
212
+ if (missing !== null) {
213
+ log(
214
+ `title model '${model}' (${provider}) has no credential path — ` +
215
+ `using heuristic fallback. ${missing}`,
216
+ );
217
+ return heuristicSubject(userMessage);
218
+ }
219
+ }
220
+
221
+ try {
222
+ const { model: llm } = await buildChatModel({
223
+ modelName: model,
224
+ proxyEndpoint: options.proxyEndpoint ?? undefined,
225
+ stigmerToken: options.stigmerToken ?? undefined,
226
+ headerScope: { executionId },
227
+ maxTokens: MAX_COMPLETION_TOKENS,
228
+ temperature: TEMPERATURE,
229
+ });
230
+
231
+ const result = await llm.invoke([
232
+ new SystemMessage(SYSTEM_PROMPT),
233
+ new HumanMessage(buildUserPrompt(userMessage, agentName, agentDescription)),
234
+ ]);
235
+
236
+ const generated = contentToString(result.content).trim();
237
+ if (generated === "") {
238
+ log("LLM returned empty content, using heuristic fallback");
239
+ return heuristicSubject(userMessage);
240
+ }
241
+ return cleanSubject(generated);
242
+ } catch (err) {
243
+ log(`LLM title generation failed, using heuristic fallback: ${message(err)}`);
244
+ return heuristicSubject(userMessage);
245
+ }
246
+ }
247
+
248
+ /** Kept in lockstep with the cloud activity's buildUserPrompt. */
249
+ export function buildUserPrompt(
250
+ userMessage: string,
251
+ agentName: string,
252
+ agentDescription: string,
253
+ ): string {
254
+ let prompt = `User's first message:\n"${userMessage}"\n\n`;
255
+ prompt += `Agent: ${agentName}\n`;
256
+ if (agentDescription !== "") {
257
+ prompt += `Agent purpose: ${agentDescription}\n`;
258
+ }
259
+ prompt += "\nGenerate the title:";
260
+ return prompt;
261
+ }
262
+
263
+ /**
264
+ * Best-effort subject from the first few words of the user message. Used when
265
+ * the LLM is unavailable. Kept in lockstep with the cloud activity.
266
+ */
267
+ export function heuristicSubject(userMessage: string): string {
268
+ const words = userMessage.trim().split(/\s+/);
269
+ let subject = words.slice(0, 7).join(" ");
270
+ if (subject.length > MAX_SUBJECT_LENGTH) {
271
+ subject = subject.slice(0, MAX_SUBJECT_LENGTH - 3) + "...";
272
+ }
273
+ return subject;
274
+ }
275
+
276
+ /**
277
+ * Strip one layer of wrapping quotes and enforce the length cap. Kept in
278
+ * lockstep with the cloud activity.
279
+ */
280
+ export function cleanSubject(raw: string): string {
281
+ let subject = raw.trim();
282
+ if (subject.length > 1 && subject.startsWith('"') && subject.endsWith('"')) {
283
+ subject = subject.slice(1, -1);
284
+ }
285
+ if (subject.length > 1 && subject.startsWith("'") && subject.endsWith("'")) {
286
+ subject = subject.slice(1, -1);
287
+ }
288
+ subject = subject.trim();
289
+ if (subject.length > MAX_SUBJECT_LENGTH) {
290
+ subject = subject.slice(0, MAX_SUBJECT_LENGTH - 3) + "...";
291
+ }
292
+ return subject;
293
+ }
294
+
295
+ // ─────────────────────────────────────────────────────────────────────────────
296
+ // Helpers
297
+ // ─────────────────────────────────────────────────────────────────────────────
298
+
299
+ /**
300
+ * Run a lookup, translating NOT_FOUND into a logged skip (undefined) — the
301
+ * cloud activity's Optional.isEmpty branches. Anything else propagates as a
302
+ * genuine activity failure; the workflow's fire-and-forget wrapper logs it
303
+ * as non-critical.
304
+ */
305
+ async function getOrSkip<T>(
306
+ fetch: () => Promise<T>,
307
+ skipMessage: string,
308
+ ): Promise<T | undefined> {
309
+ try {
310
+ return await fetch();
311
+ } catch (err) {
312
+ if (err instanceof ConnectError && err.code === Code.NotFound) {
313
+ log(`${skipMessage}, skipping`);
314
+ return undefined;
315
+ }
316
+ throw err;
317
+ }
318
+ }
319
+
320
+ /** LangChain message content is a string or an array of typed parts. */
321
+ function contentToString(content: unknown): string {
322
+ if (typeof content === "string") return content;
323
+ if (Array.isArray(content)) {
324
+ return content
325
+ .map((part) =>
326
+ typeof part === "object" && part !== null && "text" in part
327
+ ? String((part as { text: unknown }).text)
328
+ : "",
329
+ )
330
+ .join("");
331
+ }
332
+ return "";
333
+ }
334
+
335
+ function message(err: unknown): string {
336
+ return err instanceof Error ? err.message : String(err);
337
+ }
338
+
339
+ function log(msg: string): void {
340
+ console.log(`[GenerateSessionSubject] ${msg}`);
341
+ }
342
+
343
+ // ─────────────────────────────────────────────────────────────────────────────
344
+ // Temporal Activity Factory
345
+ // ─────────────────────────────────────────────────────────────────────────────
346
+
347
+ export function createGenerateSessionSubjectActivities(config: Config) {
348
+ const client = new StigmerClient({
349
+ endpoint: config.stigmerBackendEndpoint,
350
+ token: config.stigmerToken,
351
+ tokenRef: config.stigmerTokenRef,
352
+ runnerTokenRef: config.stigmerRunnerTokenRef,
353
+ });
354
+
355
+ return {
356
+ GenerateSessionSubject: async (executionId: string): Promise<void> => {
357
+ activityStarted();
358
+ try {
359
+ log(`started: execution=${executionId}`);
360
+ await generateSessionSubject(executionId, client, {
361
+ proxyEndpoint: config.proxyEndpoint,
362
+ stigmerToken: config.stigmerToken,
363
+ primaryModel: config.primaryModel,
364
+ });
365
+ } finally {
366
+ activityFinished();
367
+ }
368
+ },
369
+ };
370
+ }
@@ -524,6 +524,17 @@ export class StigmerClient {
524
524
  return this.sessionCommand.update(session);
525
525
  }
526
526
 
527
+ /**
528
+ * Race-safe, field-level subject write. The dedicated updateSubject RPC
529
+ * exists precisely so concurrent writers (title generation vs a user
530
+ * rename) cannot lose each other's updates the way full-resource
531
+ * {@link updateSession} round-trips can — always prefer it for subject
532
+ * changes.
533
+ */
534
+ async updateSessionSubject(sessionId: string, subject: string): Promise<Session> {
535
+ return this.sessionCommand.updateSubject({ id: sessionId, subject });
536
+ }
537
+
527
538
  async getAgent(agentId: string): Promise<Agent> {
528
539
  return this.agentQuery.get({ value: agentId });
529
540
  }
@@ -423,7 +423,10 @@ describe("ApprovalGateMiddleware", () => {
423
423
  expect(mockedInterrupt).toHaveBeenCalledTimes(1);
424
424
  });
425
425
 
426
- it("KEEPS GATING a gitignored delete", async () => {
426
+ it("KEEPS GATING a gitignored delete when no CAS routing is configured", async () => {
427
+ // captureIgnored unset: there is no substrate to capture the before-bytes
428
+ // into, so the delete stays on the interrupt gate (fail-closed). The CAS
429
+ // delete-flow cases live in the captureIgnored suite below (issue #303).
427
430
  const mw = createApprovalGateMiddleware(makeConfig({
428
431
  fileCaptureMode: true,
429
432
  isCapturablePath: async () => false,
@@ -624,6 +627,132 @@ describe("ApprovalGateMiddleware", () => {
624
627
  });
625
628
  });
626
629
 
630
+ describe("capture mode — CAS delete capture (captureDeleteBefore, issue #303)", () => {
631
+ it("flows a non-secret CAS-owned delete: captures before-bytes, runs, never interrupts", async () => {
632
+ const handler = vi.fn(passthrough);
633
+ const captureDeleteBefore = vi.fn(async () => {});
634
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
635
+ try {
636
+ const mw = createApprovalGateMiddleware(makeConfig({
637
+ fileCaptureMode: true,
638
+ isCapturablePath: async () => false, // gitignored / non-git
639
+ captureIgnored: true,
640
+ captureDeleteBefore,
641
+ fingerprintKey: "test-key",
642
+ executionId: "exec-del",
643
+ }));
644
+
645
+ const result = await mw.wrapToolCall!(
646
+ makeRequest({ name: "delete", args: { path: "scratch/tmp.txt" } }),
647
+ handler,
648
+ );
649
+
650
+ expect((result as ToolMessage).content).toBe("tool result");
651
+ expect(captureDeleteBefore).toHaveBeenCalledWith("scratch/tmp.txt");
652
+ expect(handler).toHaveBeenCalledTimes(1); // the delete flowed (apply-then-review)
653
+ expect(mockedInterrupt).not.toHaveBeenCalled();
654
+
655
+ // The gateway records the flow with file_capture provenance, like writes.
656
+ const receipt = logSpy.mock.calls
657
+ .map((c) => String(c[0] ?? ""))
658
+ .filter((line) => line.startsWith("[hitl-gateway] receipt "))
659
+ .map((line) => JSON.parse(line.slice("[hitl-gateway] receipt ".length)) as Record<string, unknown>)[0];
660
+ expect(receipt.policySource).toBe("file_capture");
661
+ expect(receipt.category).toBe("delete");
662
+ } finally {
663
+ logSpy.mockRestore();
664
+ }
665
+ });
666
+
667
+ it("captures before-bytes BEFORE the tool runs (the bytes exist only until then)", async () => {
668
+ const order: string[] = [];
669
+ const captureDeleteBefore = vi.fn(async () => { order.push("capture"); });
670
+ const handler = vi.fn((req: ToolCallRequest) => {
671
+ order.push("delete");
672
+ return passthrough(req);
673
+ });
674
+ const mw = createApprovalGateMiddleware(makeConfig({
675
+ fileCaptureMode: true,
676
+ isCapturablePath: async () => false,
677
+ captureIgnored: true,
678
+ captureDeleteBefore,
679
+ }));
680
+
681
+ await mw.wrapToolCall!(
682
+ makeRequest({ name: "delete", args: { path: "scratch/tmp.txt" } }),
683
+ handler,
684
+ );
685
+
686
+ expect(order).toEqual(["capture", "delete"]);
687
+ });
688
+
689
+ it("keeps a SECRET-LIKE delete on the interrupt gate: approvable, never captured, never hard-blocked", async () => {
690
+ // A delete's args expose no secret content (unlike a write), so a human
691
+ // may safely approve it — but its before-bytes must never enter CAS.
692
+ const handler = vi.fn(passthrough);
693
+ const captureDeleteBefore = vi.fn(async () => {});
694
+ const recordBlockedSecret = vi.fn();
695
+ const mw = createApprovalGateMiddleware(makeConfig({
696
+ fileCaptureMode: true,
697
+ isCapturablePath: async () => false,
698
+ captureIgnored: true,
699
+ captureDeleteBefore,
700
+ recordBlockedSecret,
701
+ }));
702
+ mockedInterrupt.mockReturnValue({ action: "approve" });
703
+
704
+ const result = await mw.wrapToolCall!(
705
+ makeRequest({ name: "delete", args: { path: ".env" } }),
706
+ handler,
707
+ );
708
+
709
+ expect(mockedInterrupt).toHaveBeenCalledTimes(1); // gated, not blocked
710
+ expect(captureDeleteBefore).not.toHaveBeenCalled(); // bytes never staged
711
+ expect(recordBlockedSecret).not.toHaveBeenCalled(); // not a blocked write
712
+ expect(handler).toHaveBeenCalledTimes(1); // the human approved it
713
+ expect((result as ToolMessage).content).toBe("tool result");
714
+ });
715
+
716
+ it("keeps gating a CAS-owned delete when no captureDeleteBefore is configured", async () => {
717
+ // Fail-closed: a delete whose before-bytes cannot be captured cannot be
718
+ // reviewed post-hoc, so it must be approved up front.
719
+ const mw = createApprovalGateMiddleware(makeConfig({
720
+ fileCaptureMode: true,
721
+ isCapturablePath: async () => false,
722
+ captureIgnored: true,
723
+ }));
724
+ mockedInterrupt.mockReturnValue({ action: "approve" });
725
+
726
+ await mw.wrapToolCall!(
727
+ makeRequest({ name: "delete", args: { path: "scratch/tmp.txt" } }),
728
+ passthrough,
729
+ );
730
+
731
+ expect(mockedInterrupt).toHaveBeenCalledTimes(1);
732
+ });
733
+
734
+ it("keeps gating a delete with captureIgnored OFF even when captureDeleteBefore is present", async () => {
735
+ // An unobserved-backend gate (captureIgnored false) must not flow deletes:
736
+ // the callback alone is not a substrate.
737
+ const captureDeleteBefore = vi.fn(async () => {});
738
+ const mw = createApprovalGateMiddleware(makeConfig({
739
+ fileCaptureMode: true,
740
+ isCapturablePath: async () => false,
741
+ captureIgnored: false,
742
+ captureDeleteBefore,
743
+ }));
744
+ mockedInterrupt.mockReturnValue({ action: "approve" });
745
+
746
+ await mw.wrapToolCall!(
747
+ makeRequest({ name: "delete", args: { path: "scratch/tmp.txt" } }),
748
+ passthrough,
749
+ );
750
+
751
+ expect(mockedInterrupt).toHaveBeenCalledTimes(1);
752
+ expect(captureDeleteBefore).not.toHaveBeenCalled();
753
+ });
754
+ });
755
+
627
756
  describe("degraded deny-gate (no capture substrate — non-git + no storage)", () => {
628
757
  // When deriveCaptureMode returns false (a non-git workspace with no artifact
629
758
  // storage), setup builds the gate with fileCaptureMode/captureIgnored OFF, so
@@ -88,10 +88,11 @@ export interface ApprovalGateConfig {
88
88
  * `write`/`delete` whose target path is capturable (git-tracked — see
89
89
  * {@link isCapturablePath}) FLOWS instead of interrupting: the edit is reviewed
90
90
  * post-hoc as a captured `FileChangeSet`, not gated before it runs. A gitignored
91
- * path is NOT capturable, so it stays gated (the git substrate cannot capture or
92
- * revert it) — the exact twin of the Cursor hook's `__stigmer_is_gitignored`
93
- * allow-branch. `shell` and MCP tools are never bypassed by this flag. Off (the
94
- * default) keeps the classic true-pause gate for every mutation.
91
+ * path is NOT capturable by the git substrate, so it stays gated unless
92
+ * {@link captureIgnored} routes it into CAS capture — the exact twin of the
93
+ * Cursor hook's `__stigmer_is_gitignored` allow-branch. `shell` and MCP tools
94
+ * are never bypassed by this flag. Off (the default) keeps the classic
95
+ * true-pause gate for every mutation.
95
96
  */
96
97
  readonly fileCaptureMode?: boolean;
97
98
  /**
@@ -107,12 +108,12 @@ export interface ApprovalGateConfig {
107
108
  * instead of the interrupt gate, applying the DD-E secret gate. When off, a
108
109
  * gitignored path stays on the interrupt gate exactly as before.
109
110
  *
110
- * TRUE ONLY FOR THE PARENT GATE. Sub-agents build their own plain filesystem
111
- * backends, which the CAS observer does not wrap, so flowing their gitignored
112
- * edits would apply unobserved, unreviewable bytes. `buildSubAgentMiddleware`
113
- * therefore forces this to false for sub-agent gates — it must NOT be inherited
114
- * as true. (Sub-agent git-tracked edits are still captured by the turn-boundary
115
- * git diff, which is backend-agnostic.)
111
+ * True only for gates whose backend a CAS observer wraps: the parent gate
112
+ * always (setup.ts), and since DD-19 sub-agent gates too, because
113
+ * `compileSubagents` gives every sub-agent a CAS-observing backend wired to
114
+ * the SAME shared observer and `buildSubAgentMiddleware` then inherits this
115
+ * config verbatim. A gate over an UNOBSERVED backend must keep this false:
116
+ * flowing its gitignored edits would apply unobserved, unreviewable bytes.
116
117
  */
117
118
  readonly captureIgnored?: boolean;
118
119
  /**
@@ -122,6 +123,20 @@ export interface ApprovalGateConfig {
122
123
  * secret; the CONTENT never leaves the workspace). Absent ⇒ nothing recorded.
123
124
  */
124
125
  readonly recordBlockedSecret?: (rawPath: string) => void;
126
+ /**
127
+ * Capture a file's pre-delete bytes into the CAS observer so a CAS-owned
128
+ * (gitignored / non-git) DELETE can flow under {@link captureIgnored} and be
129
+ * reviewed post-hoc exactly like a write (issue #303) — the delete twin of the
130
+ * backend's `recordBefore` write observation. A delete is the one mutation the
131
+ * backend cannot observe (deepagents has no backend delete method to wrap), so
132
+ * the gate captures at authorization time instead: the file's bytes are still
133
+ * on disk here, and the turn boundary then reads after=null and authors a
134
+ * `FILE_CHANGE_KIND_DELETE` with the same discard-restores contract.
135
+ *
136
+ * Absent ⇒ deletes keep today's interrupt-gate behavior (fail-closed: a delete
137
+ * we cannot capture cannot be reviewed, so it must be approved up front).
138
+ */
139
+ readonly captureDeleteBefore?: (rawPath: string) => Promise<void>;
125
140
  /**
126
141
  * Unattended approval mode (ExecutionConfig.approval_mode = UNATTENDED):
127
142
  * the creating surface — a messaging channel, a guest share — has no
@@ -213,22 +228,39 @@ export function createApprovalGateMiddleware(
213
228
  emitExecutionReceipt(config, toolCall, serverSlug, category, "auto_approve", "file_capture");
214
229
  return await handler(request);
215
230
  }
216
- // Gitignored path. Only the PARENT gate (captureIgnored) routes it into
217
- // CAS capture; sub-agent gates fall through to the interrupt gate below,
218
- // exactly as before (their backends are not CAS-observed).
231
+ // Gitignored / non-git path: a gate whose backend the shared CAS
232
+ // observer wraps (captureIgnored the parent gate, and sub-agent
233
+ // gates since DD-19) routes it into CAS capture; any other gate falls
234
+ // through to the interrupt gate below.
219
235
  if (config.captureIgnored) {
220
- if (isSecretLikePath(path)) {
221
- // DD-E fail-closed: a secret-like gitignored edit is NEVER applied
222
- // and NEVER captured. Record it so the turn boundary authors a
223
- // DIFF_UNREVIEWABLE entry (blocking approval); nothing is written.
224
- config.recordBlockedSecret?.(path);
225
- return secretBlockToolMessage(toolName, path, toolCall.id);
226
- }
227
236
  if (category === "write") {
237
+ if (isSecretLikePath(path)) {
238
+ // DD-E fail-closed: a secret-like gitignored WRITE is NEVER
239
+ // applied and NEVER captured — its content must not surface
240
+ // anywhere, approval prompt included. Record it so the turn
241
+ // boundary authors a DIFF_UNREVIEWABLE entry (blocking
242
+ // approval); nothing is written.
243
+ config.recordBlockedSecret?.(path);
244
+ return secretBlockToolMessage(toolName, path, toolCall.id);
245
+ }
228
246
  // Non-secret gitignored write/edit: flows (apply-then-review). The
229
247
  // CAS observer already holds its before-bytes and the turn boundary
230
- // captures it into CAS. (A gitignored delete has no backend capture
231
- // path, so it falls through to the interrupt gate.)
248
+ // captures it into CAS.
249
+ emitExecutionReceipt(config, toolCall, serverSlug, category, "auto_approve", "file_capture");
250
+ return await handler(request);
251
+ }
252
+ if (category === "delete" && config.captureDeleteBefore && !isSecretLikePath(path)) {
253
+ // Non-secret CAS-owned delete: capture the before-bytes NOW (the
254
+ // one moment they still exist on disk — no backend delete method
255
+ // to observe them), then flow. The turn boundary reads after=null
256
+ // and authors a reviewable, restorable DELETE entry (issue #303).
257
+ //
258
+ // A SECRET-LIKE delete deliberately falls through to the interrupt
259
+ // gate instead: unlike a write, its args expose no secret content,
260
+ // so a human may safely approve it — but its before-bytes must
261
+ // never enter CAS, so it cannot flow. (This also aligns the twins:
262
+ // the Cursor hook routes secret deletes to its deny-gate.)
263
+ await config.captureDeleteBefore(path);
232
264
  emitExecutionReceipt(config, toolCall, serverSlug, category, "auto_approve", "file_capture");
233
265
  return await handler(request);
234
266
  }
@@ -244,8 +276,10 @@ export function createApprovalGateMiddleware(
244
276
  // continues) exactly like the capture-mode secret block — a secret write is
245
277
  // never applied or persisted in ANY mode. Placed AFTER the capture block so
246
278
  // capture-mode paths stay byte-identical (a capturable write already flowed;
247
- // a captureIgnored gitignored secret is already blocked). Deletes carry no
248
- // content and stay on the deny-gate. recordBlockedSecret is a no-op unless a
279
+ // a captureIgnored gitignored secret write is already blocked). A delete
280
+ // carries no content, so a secret-like delete needs no hard-block: it stays
281
+ // on the interrupt gate below, where a human may still approve it (its
282
+ // bytes are simply never captured). recordBlockedSecret is a no-op unless a
249
283
  // turn boundary reads it (the git-no-storage case, where it authors a
250
284
  // content-less DIFF_UNREVIEWABLE).
251
285
  if (!serverSlug && category === "write") {
@@ -2,7 +2,8 @@
2
2
  * OpenTelemetry spans and metrics middleware for deep-agent execution.
3
3
  *
4
4
  * Creates stigmer.llm.call and stigmer.mcp.tool_call spans with
5
- * attributes matching the Go workflow-runner schema (pkg/otel/spans.go).
5
+ * attributes matching the span schema inherited from the retired Go
6
+ * workflow-runner (kept stable so dashboards survived the migration).
6
7
  * Also records metrics (histograms + counters) for aggregate dashboards.
7
8
  *
8
9
  * Graceful no-op when no TracerProvider/MeterProvider is configured.