@stigmer/runner 3.12.1 → 3.12.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 (77) 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 +23 -0
  11. package/dist/activities/execute-cursor/skill-resolver.js +121 -32
  12. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  13. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.d.ts +9 -1
  14. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js +13 -2
  15. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js.map +1 -1
  16. package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +3 -1
  17. package/dist/activities/execute-deep-agent/cas-capture-backend.js +3 -1
  18. package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
  19. package/dist/activities/execute-deep-agent/setup.js +8 -0
  20. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  21. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +5 -1
  22. package/dist/activities/execute-deep-agent/subagent-wiring.js +25 -10
  23. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  24. package/dist/activities/generate-session-subject.d.ts +80 -0
  25. package/dist/activities/generate-session-subject.js +283 -0
  26. package/dist/activities/generate-session-subject.js.map +1 -0
  27. package/dist/client/stigmer-client.d.ts +17 -1
  28. package/dist/client/stigmer-client.js +20 -0
  29. package/dist/client/stigmer-client.js.map +1 -1
  30. package/dist/middleware/approval-gate.d.ts +25 -10
  31. package/dist/middleware/approval-gate.js +33 -14
  32. package/dist/middleware/approval-gate.js.map +1 -1
  33. package/dist/middleware/index.d.ts +6 -5
  34. package/dist/middleware/index.js +8 -5
  35. package/dist/middleware/index.js.map +1 -1
  36. package/dist/middleware/otel-spans.d.ts +2 -1
  37. package/dist/middleware/otel-spans.js +2 -1
  38. package/dist/middleware/otel-spans.js.map +1 -1
  39. package/dist/middleware/tool-intent.d.ts +57 -0
  40. package/dist/middleware/tool-intent.js +152 -0
  41. package/dist/middleware/tool-intent.js.map +1 -0
  42. package/dist/runner-manager.js +3 -1
  43. package/dist/runner-manager.js.map +1 -1
  44. package/dist/runner.js +3 -1
  45. package/dist/runner.js.map +1 -1
  46. package/dist/shared/attachment-vision.js +9 -0
  47. package/dist/shared/attachment-vision.js.map +1 -1
  48. package/dist/shared/mcp-manager.js +8 -0
  49. package/dist/shared/mcp-manager.js.map +1 -1
  50. package/package.json +2 -2
  51. package/src/activities/__tests__/generate-session-subject.test.ts +348 -0
  52. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +60 -9
  53. package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +267 -8
  54. package/src/activities/execute-cursor/capture-flow.ts +5 -4
  55. package/src/activities/execute-cursor/cas-observations.ts +10 -4
  56. package/src/activities/execute-cursor/hook-script.ts +39 -13
  57. package/src/activities/execute-cursor/skill-resolver.ts +148 -37
  58. package/src/activities/execute-deep-agent/__test-utils__/scripted-model.ts +13 -2
  59. package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +21 -20
  60. package/src/activities/execute-deep-agent/cas-capture-backend.ts +3 -1
  61. package/src/activities/execute-deep-agent/setup.ts +8 -0
  62. package/src/activities/execute-deep-agent/subagent-wiring.ts +26 -10
  63. package/src/activities/generate-session-subject.ts +370 -0
  64. package/src/client/stigmer-client.ts +23 -1
  65. package/src/middleware/__tests__/approval-gate.test.ts +130 -1
  66. package/src/middleware/__tests__/tool-intent.test.ts +266 -0
  67. package/src/middleware/approval-gate.ts +58 -24
  68. package/src/middleware/index.ts +9 -5
  69. package/src/middleware/otel-spans.ts +2 -1
  70. package/src/middleware/tool-intent.ts +174 -0
  71. package/src/runner-manager.ts +3 -0
  72. package/src/runner.ts +3 -0
  73. package/src/shared/__tests__/attachment-vision.test.ts +4 -0
  74. package/src/shared/__tests__/mcp-manager.test.ts +7 -2
  75. package/src/shared/attachment-vision.ts +9 -0
  76. package/src/shared/filereview/__tests__/capture.test.ts +48 -0
  77. 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
+ }
@@ -35,7 +35,7 @@ import type { AgentInstance } from "@stigmer/protos/ai/stigmer/agentic/agentinst
35
35
  import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
36
36
  import type { Skill } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
37
37
  import type { ApiResourceReference } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
38
- import type { GetArtifactResponse } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/io_pb";
38
+ import type { GetArtifactResponse, SkillArtifactDownloadUrl } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/io_pb";
39
39
  import { create } from "@bufbuild/protobuf";
40
40
  import { ConnectInputSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/io_pb";
41
41
  import { ExecutionValueSchema } from "@stigmer/protos/ai/stigmer/agentic/executioncontext/v1/spec_pb";
@@ -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
  }
@@ -572,6 +583,17 @@ export class StigmerClient {
572
583
  return this.skillQuery.getArtifact({ artifactStorageKey });
573
584
  }
574
585
 
586
+ /**
587
+ * Mint an HTTP download URL for a skill artifact (#675). Preferred over
588
+ * getSkillArtifact for the actual bytes: the unary response is capped by
589
+ * the server's 10MB gRPC message limit, while skills may be 100MB.
590
+ * Throws ConnectError with Code.Unimplemented against servers that
591
+ * predate the transfer lane — callers fall back to getSkillArtifact.
592
+ */
593
+ async getSkillArtifactDownloadUrl(artifactStorageKey: string): Promise<SkillArtifactDownloadUrl> {
594
+ return this.skillQuery.getArtifactDownloadUrl({ artifactStorageKey });
595
+ }
596
+
575
597
  async createArtifact(input: CreateArtifactInput): Promise<Artifact> {
576
598
  return this.artifactCommand.create(input);
577
599
  }
@@ -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