@stigmer/runner 3.1.7 → 3.1.9

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 (110) hide show
  1. package/README.md +1 -1
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/discover-mcp-server.js +1 -0
  4. package/dist/activities/discover-mcp-server.js.map +1 -1
  5. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +5 -2
  6. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
  7. package/dist/activities/execute-cursor/approval-state.d.ts +58 -10
  8. package/dist/activities/execute-cursor/approval-state.js +37 -8
  9. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  10. package/dist/activities/execute-cursor/env-resolver.js +7 -1
  11. package/dist/activities/execute-cursor/env-resolver.js.map +1 -1
  12. package/dist/activities/execute-cursor/hook-script.d.ts +16 -3
  13. package/dist/activities/execute-cursor/hook-script.js +66 -21
  14. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  15. package/dist/activities/execute-cursor/index.js +59 -0
  16. package/dist/activities/execute-cursor/index.js.map +1 -1
  17. package/dist/activities/execute-cursor/message-translator.d.ts +52 -0
  18. package/dist/activities/execute-cursor/message-translator.js +107 -1
  19. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  20. package/dist/activities/execute-cursor/turn-boundary.d.ts +25 -3
  21. package/dist/activities/execute-cursor/turn-boundary.js +46 -8
  22. package/dist/activities/execute-cursor/turn-boundary.js.map +1 -1
  23. package/dist/activities/execute-cursor/turn-stream.js +6 -2
  24. package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
  25. package/dist/activities/execute-cursor/workspace-setup.d.ts +16 -2
  26. package/dist/activities/execute-cursor/workspace-setup.js +22 -12
  27. package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
  28. package/dist/activities/execute-deep-agent/environment.js +7 -1
  29. package/dist/activities/execute-deep-agent/environment.js.map +1 -1
  30. package/dist/activities/execute-deep-agent/hitl.d.ts +23 -14
  31. package/dist/activities/execute-deep-agent/hitl.js +42 -37
  32. package/dist/activities/execute-deep-agent/hitl.js.map +1 -1
  33. package/dist/activities/execute-deep-agent/index.js +35 -26
  34. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  35. package/dist/activities/execute-deep-agent/setup.d.ts +7 -0
  36. package/dist/activities/execute-deep-agent/setup.js +9 -2
  37. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  38. package/dist/activities/hydrate-workflow-execution.js +8 -1
  39. package/dist/activities/hydrate-workflow-execution.js.map +1 -1
  40. package/dist/client/stigmer-client.d.ts +69 -1
  41. package/dist/client/stigmer-client.js +108 -4
  42. package/dist/client/stigmer-client.js.map +1 -1
  43. package/dist/client/token-claims.d.ts +22 -0
  44. package/dist/client/token-claims.js +40 -0
  45. package/dist/client/token-claims.js.map +1 -0
  46. package/dist/config.d.ts +11 -1
  47. package/dist/config.js +5 -1
  48. package/dist/config.js.map +1 -1
  49. package/dist/middleware/approval-gate.js +9 -2
  50. package/dist/middleware/approval-gate.js.map +1 -1
  51. package/dist/runner-manager.d.ts +4 -2
  52. package/dist/runner-manager.js +20 -7
  53. package/dist/runner-manager.js.map +1 -1
  54. package/dist/runner.d.ts +2 -2
  55. package/dist/runner.js +1 -1
  56. package/dist/shared/checkpointer/factory.d.ts +11 -7
  57. package/dist/shared/checkpointer/factory.js +21 -8
  58. package/dist/shared/checkpointer/factory.js.map +1 -1
  59. package/dist/shared/checkpointer/sqlite-saver.d.ts +54 -0
  60. package/dist/shared/checkpointer/sqlite-saver.js +272 -0
  61. package/dist/shared/checkpointer/sqlite-saver.js.map +1 -0
  62. package/dist/shared/checkpointer/types.d.ts +8 -4
  63. package/dist/shared/tool-row.js +14 -1
  64. package/dist/shared/tool-row.js.map +1 -1
  65. package/dist/shared/workspace/platform-dir.d.ts +18 -0
  66. package/dist/shared/workspace/platform-dir.js +23 -0
  67. package/dist/shared/workspace/platform-dir.js.map +1 -1
  68. package/package.json +3 -3
  69. package/src/__test-utils__/mock-client.ts +3 -0
  70. package/src/activities/__tests__/hydrate-workflow-execution.test.ts +2 -0
  71. package/src/activities/discover-mcp-server.ts +1 -0
  72. package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +5 -3
  73. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +272 -14
  74. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +72 -16
  75. package/src/activities/execute-cursor/__tests__/message-translator.test.ts +65 -0
  76. package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +128 -1
  77. package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +95 -0
  78. package/src/activities/execute-cursor/approval-state.ts +75 -11
  79. package/src/activities/execute-cursor/env-resolver.ts +8 -1
  80. package/src/activities/execute-cursor/hook-script.ts +66 -21
  81. package/src/activities/execute-cursor/index.ts +61 -0
  82. package/src/activities/execute-cursor/message-translator.ts +124 -1
  83. package/src/activities/execute-cursor/turn-boundary.ts +79 -6
  84. package/src/activities/execute-cursor/turn-stream.ts +6 -2
  85. package/src/activities/execute-cursor/workspace-setup.ts +42 -14
  86. package/src/activities/execute-deep-agent/__tests__/environment.test.ts +5 -0
  87. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +314 -0
  88. package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +64 -46
  89. package/src/activities/execute-deep-agent/environment.ts +8 -1
  90. package/src/activities/execute-deep-agent/hitl.ts +43 -45
  91. package/src/activities/execute-deep-agent/index.ts +36 -27
  92. package/src/activities/execute-deep-agent/setup.ts +15 -2
  93. package/src/activities/hydrate-workflow-execution.ts +9 -1
  94. package/src/client/__tests__/stigmer-client.test.ts +239 -2
  95. package/src/client/__tests__/token-claims.test.ts +45 -0
  96. package/src/client/stigmer-client.ts +152 -3
  97. package/src/client/token-claims.ts +42 -0
  98. package/src/config.ts +16 -3
  99. package/src/middleware/__tests__/approval-gate.test.ts +8 -1
  100. package/src/middleware/approval-gate.ts +9 -2
  101. package/src/runner-manager.ts +22 -8
  102. package/src/runner.ts +3 -3
  103. package/src/shared/__tests__/tool-row.test.ts +35 -0
  104. package/src/shared/checkpointer/__tests__/factory.test.ts +27 -1
  105. package/src/shared/checkpointer/__tests__/sqlite-saver.test.ts +212 -0
  106. package/src/shared/checkpointer/factory.ts +25 -8
  107. package/src/shared/checkpointer/sqlite-saver.ts +384 -0
  108. package/src/shared/checkpointer/types.ts +8 -4
  109. package/src/shared/tool-row.ts +14 -1
  110. package/src/shared/workspace/platform-dir.ts +25 -0
@@ -103,6 +103,19 @@ export interface HitlGateHandle {
103
103
  * gate is inert between turns.
104
104
  */
105
105
  gateDir: string;
106
+ /**
107
+ * Commands of FOREIGN (non-Stigmer) hooks registered on the gating events
108
+ * (preToolUse/beforeMCPExecution) in the workspace's pre-existing hooks.json.
109
+ * The merge deliberately PRESERVES them (they are the user's own config), but
110
+ * because Cursor runs every configured hook and a deny from ANY of them blocks
111
+ * the tool, a foreign hook can deny the runner's tools without writing our
112
+ * denial ledger — the issue #205 silent-failure trigger. Surfaced here so the
113
+ * runner can log the exposure at install and name the likely culprit when the
114
+ * turn boundary detects an unattributed hook block. Empty when the workspace
115
+ * had no hooks.json, only Stigmer entries, or an unparseable file (which is
116
+ * REPLACED for the turn, so its hooks cannot run against us).
117
+ */
118
+ foreignGatingHooks: readonly string[];
106
119
  }
107
120
 
108
121
  /** Absolute path + restore target for a single workspace file the gate manages. */
@@ -334,12 +347,12 @@ async function installWorkspaceHook(
334
347
  originalRaw = null;
335
348
  }
336
349
 
337
- const { merged, restoreTo } = buildMergedConfig(originalRaw, registrations);
350
+ const { merged, restoreTo, foreignGatingHooks } = buildMergedConfig(originalRaw, registrations);
338
351
 
339
352
  await mkdir(cursorDir, { recursive: true });
340
353
  await writeFile(hooksJsonPath, merged, "utf-8");
341
354
 
342
- return { hooksJsonPath, restoreTo };
355
+ return { hooksJsonPath, restoreTo, foreignGatingHooks };
343
356
  }
344
357
 
345
358
  /**
@@ -406,21 +419,35 @@ const STANDALONE_CONFIG = (registrations: HookRegistration[]): string =>
406
419
  /**
407
420
  * Merge our registrations into a hooks object: for each event, drop any stale
408
421
  * Stigmer entry, then append our fresh one. Returns the merged hooks object, the
409
- * cleaned (Stigmer-free) hooks for restore, and whether anything was stripped.
422
+ * cleaned (Stigmer-free) hooks for restore, whether anything was stripped, and
423
+ * the FOREIGN commands preserved on the gating events (see
424
+ * {@link HitlGateHandle.foreignGatingHooks} for why they matter).
410
425
  */
411
426
  function mergeHooks(
412
427
  existingHooks: Record<string, unknown>,
413
428
  registrations: HookRegistration[],
414
- ): { hooks: Record<string, unknown>; cleaned: Record<string, unknown>; strippedStale: boolean } {
429
+ ): {
430
+ hooks: Record<string, unknown>;
431
+ cleaned: Record<string, unknown>;
432
+ strippedStale: boolean;
433
+ foreignGatingHooks: string[];
434
+ } {
415
435
  const hooks: Record<string, unknown> = { ...existingHooks };
416
436
  const cleaned: Record<string, unknown> = { ...existingHooks };
417
437
  let strippedStale = false;
438
+ const foreignGatingHooks: string[] = [];
418
439
 
419
440
  for (const { event, scriptPath } of registrations) {
420
441
  const hadEvent = Array.isArray(existingHooks[event]);
421
442
  const existing = hadEvent ? (existingHooks[event] as unknown[]) : [];
422
443
  const userEntries = existing.filter((e) => !isStigmerHookEntry(e));
423
444
  if (userEntries.length !== existing.length) strippedStale = true;
445
+ // The user entries surviving on a GATING event are exactly the hooks that
446
+ // can deny the runner's tools without touching our denial ledger.
447
+ for (const entry of userEntries) {
448
+ const command = (entry as { command?: unknown } | null)?.command;
449
+ if (typeof command === "string" && command) foreignGatingHooks.push(command);
450
+ }
424
451
 
425
452
  hooks[event] = [...userEntries, buildHookEntry(scriptPath)];
426
453
  // Restore target keeps the event key only if the user originally had it, so
@@ -428,12 +455,12 @@ function mergeHooks(
428
455
  if (hadEvent) cleaned[event] = userEntries;
429
456
  }
430
457
 
431
- return { hooks, cleaned, strippedStale };
458
+ return { hooks, cleaned, strippedStale, foreignGatingHooks };
432
459
  }
433
460
 
434
461
  /**
435
- * Compute the merged hooks.json to write for this turn and the content to
436
- * restore afterward.
462
+ * Compute the merged hooks.json to write for this turn, the content to restore
463
+ * afterward, and the foreign gating hooks the merge preserved.
437
464
  *
438
465
  * - No existing file → write our standalone config; restore by deleting (null).
439
466
  * - Existing, parseable file → append our entry to each registered event array,
@@ -448,19 +475,19 @@ function mergeHooks(
448
475
  export function buildMergedConfig(
449
476
  originalRaw: string | null,
450
477
  registrations: HookRegistration[],
451
- ): { merged: string; restoreTo: string | null } {
478
+ ): { merged: string; restoreTo: string | null; foreignGatingHooks: string[] } {
452
479
  if (originalRaw === null) {
453
- return { merged: STANDALONE_CONFIG(registrations), restoreTo: null };
480
+ return { merged: STANDALONE_CONFIG(registrations), restoreTo: null, foreignGatingHooks: [] };
454
481
  }
455
482
 
456
483
  let parsed: unknown;
457
484
  try {
458
485
  parsed = JSON.parse(originalRaw);
459
486
  } catch {
460
- return { merged: STANDALONE_CONFIG(registrations), restoreTo: originalRaw };
487
+ return { merged: STANDALONE_CONFIG(registrations), restoreTo: originalRaw, foreignGatingHooks: [] };
461
488
  }
462
489
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
463
- return { merged: STANDALONE_CONFIG(registrations), restoreTo: originalRaw };
490
+ return { merged: STANDALONE_CONFIG(registrations), restoreTo: originalRaw, foreignGatingHooks: [] };
464
491
  }
465
492
 
466
493
  const root = parsed as Record<string, unknown>;
@@ -470,13 +497,14 @@ export function buildMergedConfig(
470
497
  : {};
471
498
  const version = typeof root.version === "number" ? root.version : 1;
472
499
 
473
- const { hooks: mergedHooks, cleaned, strippedStale } = mergeHooks(hooks, registrations);
500
+ const { hooks: mergedHooks, cleaned, strippedStale, foreignGatingHooks } =
501
+ mergeHooks(hooks, registrations);
474
502
 
475
503
  const merged = JSON.stringify({ ...root, version, hooks: mergedHooks }, null, 2);
476
504
 
477
505
  // No stale Stigmer entry → restore the user's exact original bytes.
478
506
  if (!strippedStale) {
479
- return { merged, restoreTo: originalRaw };
507
+ return { merged, restoreTo: originalRaw, foreignGatingHooks };
480
508
  }
481
509
 
482
510
  // We stripped a stale Stigmer entry, so never restore the original bytes (that
@@ -495,5 +523,5 @@ export function buildMergedConfig(
495
523
  ? null
496
524
  : JSON.stringify({ ...root, version, hooks: cleaned }, null, 2);
497
525
 
498
- return { merged, restoreTo };
526
+ return { merged, restoreTo, foreignGatingHooks };
499
527
  }
@@ -3,12 +3,15 @@ import { resolveEnvironment } from "../environment.js";
3
3
 
4
4
  function createMockClient(execContext: any) {
5
5
  return {
6
+ // No scoped token — the OSS/local shape (no runner credential to exchange).
7
+ acquireScopedRunnerToken: vi.fn().mockResolvedValue(undefined),
6
8
  getExecutionContextByExecutionId: vi.fn().mockResolvedValue(execContext),
7
9
  } as any;
8
10
  }
9
11
 
10
12
  function createNotFoundClient() {
11
13
  return {
14
+ acquireScopedRunnerToken: vi.fn().mockResolvedValue(undefined),
12
15
  getExecutionContextByExecutionId: vi.fn().mockRejectedValue(
13
16
  Object.assign(new Error("not found"), { code: 5 }),
14
17
  ),
@@ -81,6 +84,7 @@ describe("resolveEnvironment", () => {
81
84
 
82
85
  it("propagates non-NOT_FOUND errors", async () => {
83
86
  const client = {
87
+ acquireScopedRunnerToken: vi.fn().mockResolvedValue(undefined),
84
88
  getExecutionContextByExecutionId: vi.fn().mockRejectedValue(
85
89
  new Error("connection refused"),
86
90
  ),
@@ -92,6 +96,7 @@ describe("resolveEnvironment", () => {
92
96
 
93
97
  it("handles NOT_FOUND with lowercase code", async () => {
94
98
  const client = {
99
+ acquireScopedRunnerToken: vi.fn().mockResolvedValue(undefined),
95
100
  getExecutionContextByExecutionId: vi.fn().mockRejectedValue(
96
101
  Object.assign(new Error("not found"), { code: "not_found" }),
97
102
  ),
@@ -0,0 +1,314 @@
1
+ /**
2
+ * REJECT semantics across both checkpointers (issue #197).
3
+ *
4
+ * These tests exercise the full ExecuteDeepAgent activity on the post-decision
5
+ * re-invocation after a user REJECTS a gated tool call, under BOTH the durable
6
+ * (http, cloud) and ephemeral (memory, OSS/desktop) checkpointers.
7
+ *
8
+ * The contract (deny-tool-and-continue): a rejected tool is NOT executed, the
9
+ * user's objection is fed back to the model, and the execution CONTINUES to
10
+ * EXECUTION_COMPLETED — identically on both checkpointers. The rejected tool
11
+ * call is terminalized (never left stuck at WAITING_APPROVAL) with
12
+ * approval_action=REJECT recorded for audit.
13
+ *
14
+ * Modeled on hitl-resume-history.test.ts (http) and sequential-gate-resume.test.ts
15
+ * (memory): performSetup + the agent graph are mocked so the assertions isolate
16
+ * the activity's resume orchestration and status reconciliation.
17
+ */
18
+
19
+ import { describe, it, expect, vi, beforeEach } from "vitest";
20
+ import { makeInMemoryArtifactStorage } from "../../../__test-utils__/fake-artifact-storage.js";
21
+ import { create, clone } from "@bufbuild/protobuf";
22
+ import {
23
+ AgentExecutionStatusSchema,
24
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
25
+ import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
26
+ import {
27
+ AgentMessageSchema,
28
+ ToolCallSchema,
29
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
30
+ import {
31
+ ApprovalAction,
32
+ ExecutionPhase,
33
+ MessageType,
34
+ ToolCallStatus,
35
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
36
+ import type { V3ProtocolEvent } from "../v3-event-recorder.js";
37
+ import type { Config } from "../../../config.js";
38
+ import type { SetupResult } from "../setup.js";
39
+
40
+ vi.mock("@temporalio/activity", () => ({
41
+ Context: {
42
+ current: () => ({
43
+ cancellationSignal: new AbortController().signal,
44
+ heartbeat: vi.fn(),
45
+ }),
46
+ },
47
+ CancelledFailure: class CancelledFailure extends Error {},
48
+ }));
49
+
50
+ vi.mock("../../../idle-watchdog.js", () => ({
51
+ activityStarted: vi.fn(),
52
+ activityFinished: vi.fn(),
53
+ }));
54
+
55
+ const { persistedStatuses } = vi.hoisted(() => ({
56
+ persistedStatuses: [] as AgentExecutionStatus[],
57
+ }));
58
+
59
+ vi.mock("../../../client/stigmer-client.js", () => ({
60
+ StigmerClient: vi.fn().mockImplementation(() => ({
61
+ updateStatus: vi.fn(async (_id: string, status: AgentExecutionStatus) => {
62
+ persistedStatuses.push(clone(AgentExecutionStatusSchema, status));
63
+ return { signal: 0 };
64
+ }),
65
+ getExecution: vi.fn(),
66
+ })),
67
+ }));
68
+
69
+ vi.mock("../setup.js", () => ({ performSetup: vi.fn() }));
70
+
71
+ import { createDeepAgentActivities } from "../index.js";
72
+ import { performSetup } from "../setup.js";
73
+
74
+ const GATED_TOOL_CALL_ID = "toolu_reject_01";
75
+ const PRE_AI_TEXT = "I'll call the echo tool.";
76
+ const POST_AI_TEXT = "Understood — I'll proceed without that tool.";
77
+ const REJECT_COMMENT = "not this time";
78
+
79
+ function v3Event(seq: number, method: string, data: unknown, namespace: string[] = []): V3ProtocolEvent {
80
+ return { type: "event", seq, method, params: { namespace, timestamp: Date.now(), data } };
81
+ }
82
+
83
+ /**
84
+ * A rejected tool does NOT execute, so the resumed stream carries only the
85
+ * model's follow-up assistant turn reacting to the rejection — no
86
+ * tool-started / tool-finished for the gated call.
87
+ */
88
+ function postRejectAssistantTurn(): V3ProtocolEvent[] {
89
+ return [
90
+ v3Event(0, "messages", { event: "message-start", run_id: "r2" }),
91
+ v3Event(1, "messages", {
92
+ event: "content-block-delta",
93
+ index: 0,
94
+ delta: { type: "text-delta", text: POST_AI_TEXT },
95
+ run_id: "r2",
96
+ }),
97
+ v3Event(2, "messages", { event: "message-finish", run_id: "r2" }),
98
+ ];
99
+ }
100
+
101
+ function mockV3Run(events: V3ProtocolEvent[]) {
102
+ return {
103
+ [Symbol.asyncIterator]: async function* () {
104
+ for (const e of events) yield e;
105
+ },
106
+ output: Promise.resolve({ messages: [] }),
107
+ abort: vi.fn(),
108
+ signal: new AbortController().signal,
109
+ };
110
+ }
111
+
112
+ /** Run-1 transcript with the gated call carrying the user's REJECT decision. */
113
+ function persistedRejectedStatus(): AgentExecutionStatus {
114
+ return create(AgentExecutionStatusSchema, {
115
+ phase: ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL,
116
+ startedAt: "2026-06-15T00:00:00.000Z",
117
+ messages: [
118
+ create(AgentMessageSchema, {
119
+ type: MessageType.MESSAGE_AI,
120
+ content: PRE_AI_TEXT,
121
+ timestamp: "2026-06-15T00:00:01.000Z",
122
+ toolCalls: [
123
+ create(ToolCallSchema, {
124
+ id: GATED_TOOL_CALL_ID,
125
+ name: "echo",
126
+ status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
127
+ requiresApproval: true,
128
+ approvalMessage: "Execute echo tool",
129
+ approvalAction: ApprovalAction.REJECT,
130
+ mcpServerSlug: "test-mcp",
131
+ }),
132
+ ],
133
+ }),
134
+ ],
135
+ });
136
+ }
137
+
138
+ const approvalPolicies = new Map([
139
+ ["test-mcp/echo", {
140
+ toolName: "echo",
141
+ mcpServerSlug: "test-mcp",
142
+ requiresApproval: true,
143
+ approvalMessage: "Execute echo tool",
144
+ }],
145
+ ]);
146
+
147
+ /** Durable (http) resume: getState reports a pending interrupt, then resolves. */
148
+ function fakeHttpRejectSetup(): SetupResult {
149
+ let streamed = false;
150
+ const pendingInterruptState = {
151
+ values: { messages: [{ role: "assistant", content: PRE_AI_TEXT }] },
152
+ tasks: [{
153
+ id: "task-1",
154
+ interrupts: [{
155
+ value: {
156
+ tool_call_id: GATED_TOOL_CALL_ID,
157
+ tool_name: "echo",
158
+ mcp_server_slug: "test-mcp",
159
+ message: "Execute echo tool",
160
+ },
161
+ resumeValue: undefined,
162
+ }],
163
+ }],
164
+ };
165
+ const resolvedState = { values: { messages: [{ role: "assistant", content: PRE_AI_TEXT }] }, tasks: [] };
166
+
167
+ const agentGraph = {
168
+ getState: vi.fn(async () => (streamed ? resolvedState : pendingInterruptState)),
169
+ streamEvents: vi.fn(async () => {
170
+ streamed = true;
171
+ return mockV3Run(postRejectAssistantTurn());
172
+ }),
173
+ };
174
+
175
+ return {
176
+ agentGraph,
177
+ langgraphConfig: { configurable: { thread_id: "thread-reject-http" } },
178
+ langgraphInput: { messages: [{ role: "user", content: "Call echo" }] },
179
+ execution: {
180
+ spec: { message: "Call echo with 'hello'." },
181
+ status: persistedRejectedStatus(),
182
+ },
183
+ agent: {},
184
+ session: { spec: { workspaceEntries: [] } },
185
+ workspaceBackend: { rootDir: "/tmp/stigmer-test-ws-reject-http" },
186
+ mcpConnection: null,
187
+ mergedEnvVars: {},
188
+ secretKeys: new Set<string>(),
189
+ modelName: "claude-sonnet",
190
+ gracefulStop: undefined,
191
+ artifactStorage: makeInMemoryArtifactStorage({ urlBase: "https://artifacts.local/" }).storage,
192
+ provisionResults: [],
193
+ approvalPolicies,
194
+ toolServerMap: new Map([["echo", "test-mcp"]]),
195
+ leasedCategories: new Set(),
196
+ globalBypass: false,
197
+ hasStructuredOutput: false,
198
+ streamVersion: "v3",
199
+ } as unknown as SetupResult;
200
+ }
201
+
202
+ /** Memory resume: empty live checkpoint (replay), stream completes with a turn. */
203
+ function fakeMemoryRejectSetup(): SetupResult {
204
+ const emptyState = { values: { messages: [] as unknown[] }, tasks: [] };
205
+ const agentGraph = {
206
+ getState: vi.fn(async () => emptyState),
207
+ streamEvents: vi.fn(async () => mockV3Run(postRejectAssistantTurn())),
208
+ };
209
+
210
+ return {
211
+ agentGraph,
212
+ langgraphConfig: { configurable: { thread_id: "thread-reject-mem" } },
213
+ langgraphInput: { messages: [{ role: "user", content: "Call echo" }] },
214
+ execution: {
215
+ spec: { message: "Call echo with 'hello'." },
216
+ status: persistedRejectedStatus(),
217
+ },
218
+ agent: {},
219
+ session: { spec: { workspaceEntries: [] } },
220
+ workspaceBackend: { rootDir: "/tmp/stigmer-test-ws-reject-mem" },
221
+ mcpConnection: null,
222
+ mergedEnvVars: {},
223
+ secretKeys: new Set<string>(),
224
+ modelName: "claude-sonnet",
225
+ gracefulStop: undefined,
226
+ artifactStorage: makeInMemoryArtifactStorage({ urlBase: "https://artifacts.local/" }).storage,
227
+ provisionResults: [],
228
+ approvalPolicies,
229
+ toolServerMap: new Map([["echo", "test-mcp"]]),
230
+ leasedCategories: new Set(),
231
+ globalBypass: false,
232
+ hasStructuredOutput: false,
233
+ streamVersion: "v3",
234
+ } as unknown as SetupResult;
235
+ }
236
+
237
+ const httpConfig: Config = {
238
+ taskQueue: "test-queue",
239
+ temporalAddress: "localhost:7233",
240
+ temporalNamespace: "default",
241
+ stigmerBackendEndpoint: "http://localhost:7234",
242
+ stigmerToken: null,
243
+ cursorApiKey: "",
244
+ workspaceRootDir: "/tmp/test",
245
+ mode: "cloud",
246
+ proxyEndpoint: "http://localhost:7234",
247
+ maxConcurrentActivities: 5,
248
+ idleTimeoutSeconds: null,
249
+ cloudModeEnabled: true,
250
+ checkpointerType: "http",
251
+ checkpointerProxyEndpoint: "http://localhost:7234",
252
+ primaryModel: "claude-sonnet",
253
+ cursorStreamStallTimeoutMs: 180000,
254
+ workspaceLockTimeoutMs: 900000,
255
+ };
256
+
257
+ const memoryConfig: Config = {
258
+ ...httpConfig,
259
+ mode: "local",
260
+ proxyEndpoint: null,
261
+ cloudModeEnabled: false,
262
+ checkpointerType: "memory",
263
+ checkpointerProxyEndpoint: null,
264
+ };
265
+
266
+ function gatedToolCall(status: AgentExecutionStatus) {
267
+ return status.messages.flatMap(m => m.toolCalls).find(tc => tc.id === GATED_TOOL_CALL_ID);
268
+ }
269
+
270
+ describe("ExecuteDeepAgent — REJECT continues the execution (issue #197)", () => {
271
+ beforeEach(() => {
272
+ persistedStatuses.length = 0;
273
+ });
274
+
275
+ it("http checkpointer: reject denies the tool and the execution COMPLETES", async () => {
276
+ vi.mocked(performSetup).mockResolvedValue(fakeHttpRejectSetup());
277
+ const activities = createDeepAgentActivities(httpConfig);
278
+ await activities.ExecuteDeepAgent("exec-reject-http", "thread-reject-http");
279
+
280
+ const final = persistedStatuses.at(-1)!;
281
+ expect(final.phase, "rejected execution continues to COMPLETED").toBe(
282
+ ExecutionPhase.EXECUTION_COMPLETED,
283
+ );
284
+
285
+ const tc = gatedToolCall(final);
286
+ expect(tc, "the gated tool call is present").toBeDefined();
287
+ expect(
288
+ tc!.status,
289
+ "rejected tool call is terminalized (SKIPPED), never stuck at WAITING_APPROVAL",
290
+ ).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
291
+ expect(tc!.approvalAction, "the REJECT decision is preserved for audit").toBe(
292
+ ApprovalAction.REJECT,
293
+ );
294
+ });
295
+
296
+ it("memory checkpointer: reject denies the tool and the execution COMPLETES", async () => {
297
+ vi.mocked(performSetup).mockResolvedValue(fakeMemoryRejectSetup());
298
+ const activities = createDeepAgentActivities(memoryConfig);
299
+ await activities.ExecuteDeepAgent("exec-reject-mem", "thread-reject-mem");
300
+
301
+ const final = persistedStatuses.at(-1)!;
302
+ expect(final.phase, "rejected execution continues to COMPLETED on memory too").toBe(
303
+ ExecutionPhase.EXECUTION_COMPLETED,
304
+ );
305
+
306
+ const tc = gatedToolCall(final);
307
+ expect(tc, "the gated tool call is present").toBeDefined();
308
+ expect(
309
+ tc!.status,
310
+ "rejected tool call is terminalized identically on memory",
311
+ ).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
312
+ expect(tc!.approvalAction).toBe(ApprovalAction.REJECT);
313
+ });
314
+ });
@@ -1,13 +1,12 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { Command } from "@langchain/langgraph";
3
- import { resolveResumeInput, reconcileToolCallStatuses } from "../hitl.js";
3
+ import { resolveResumeInput, reconcileNonExecutingDecisions } from "../hitl.js";
4
4
  import { ApprovalAction, ToolCallStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
5
5
  import { create } from "@bufbuild/protobuf";
6
6
  import {
7
7
  AgentMessageSchema,
8
8
  ToolCallSchema,
9
9
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
10
- import type { ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
11
10
  import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
12
11
  import { MessageType } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
13
12
  import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
@@ -78,7 +77,6 @@ describe("resolveResumeInput", () => {
78
77
  const result = resolveResumeInput(execution, state, "hello");
79
78
 
80
79
  expect(result.isResumeFromApproval).toBe(false);
81
- expect(result.hasRejection).toBe(false);
82
80
  expect(result.graphInput).toEqual({
83
81
  messages: [{ role: "user", content: "hello" }],
84
82
  });
@@ -105,11 +103,10 @@ describe("resolveResumeInput", () => {
105
103
  const result = resolveResumeInput(execution, state, "hello");
106
104
 
107
105
  expect(result.isResumeFromApproval).toBe(true);
108
- expect(result.hasRejection).toBe(false);
109
106
  expect(result.graphInput).toBeInstanceOf(Command);
110
107
  });
111
108
 
112
- it("detects rejection when a decision is REJECT", () => {
109
+ it("builds a resume Command for a REJECT decision (denies the tool, does not fail the run)", () => {
113
110
  const execution = makeExecution([{
114
111
  id: "call-1",
115
112
  status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
@@ -120,9 +117,12 @@ describe("resolveResumeInput", () => {
120
117
 
121
118
  const result = resolveResumeInput(execution, state, "hello");
122
119
 
120
+ // REJECT resumes the gate like any other decision — the gate returns a
121
+ // denial ToolMessage and the run continues. There is no execution-level
122
+ // "rejection" flag any more; the terminal tool status is set by
123
+ // reconcileNonExecutingDecisions.
123
124
  expect(result.isResumeFromApproval).toBe(true);
124
- expect(result.hasRejection).toBe(true);
125
- expect(result.rejectionReason).toContain("Rejected by user");
125
+ expect(result.graphInput).toBeInstanceOf(Command);
126
126
  });
127
127
 
128
128
  it("handles multiple interrupts with mixed decisions", () => {
@@ -147,7 +147,6 @@ describe("resolveResumeInput", () => {
147
147
  const result = resolveResumeInput(execution, state, "hello");
148
148
 
149
149
  expect(result.isResumeFromApproval).toBe(true);
150
- expect(result.hasRejection).toBe(false);
151
150
  });
152
151
 
153
152
  it("skips already-resumed interrupts", () => {
@@ -167,56 +166,75 @@ describe("resolveResumeInput", () => {
167
166
  });
168
167
  });
169
168
 
170
- describe("reconcileToolCallStatuses", () => {
171
- it("updates APPROVE to RUNNING", () => {
172
- const tc = create(ToolCallSchema, {
173
- id: "call-1",
174
- status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
169
+ describe("reconcileNonExecutingDecisions", () => {
170
+ function statusWith(
171
+ toolCalls: Array<{ id: string; status: ToolCallStatus; approvalAction: ApprovalAction }>,
172
+ ) {
173
+ return create(AgentExecutionStatusSchema, {
174
+ messages: [
175
+ create(AgentMessageSchema, {
176
+ toolCalls: toolCalls.map(tc =>
177
+ create(ToolCallSchema, {
178
+ id: tc.id,
179
+ name: "test_tool",
180
+ status: tc.status,
181
+ approvalAction: tc.approvalAction,
182
+ }),
183
+ ),
184
+ }),
185
+ ],
175
186
  });
176
- const toolCalls = new Map<string, ToolCall>([["call-1", tc]]);
177
- const decisions = new Map([
178
- ["call-1", { action: ApprovalAction.APPROVE, comment: "" }],
179
- ]);
180
-
181
- reconcileToolCallStatuses(toolCalls, decisions);
182
- expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_RUNNING);
183
- });
187
+ }
184
188
 
185
- it("updates SKIP to SKIPPED", () => {
186
- const tc = create(ToolCallSchema, {
187
- id: "call-2",
189
+ it("terminalizes a REJECT decision to SKIPPED with a reason (does not FAIL)", () => {
190
+ const status = statusWith([{
191
+ id: "call-reject",
188
192
  status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
189
- });
190
- const toolCalls = new Map<string, ToolCall>([["call-2", tc]]);
191
- const decisions = new Map([
192
- ["call-2", { action: ApprovalAction.SKIP, comment: "" }],
193
- ]);
193
+ approvalAction: ApprovalAction.REJECT,
194
+ }]);
195
+
196
+ reconcileNonExecutingDecisions(status);
194
197
 
195
- reconcileToolCallStatuses(toolCalls, decisions);
198
+ const tc = status.messages[0].toolCalls[0];
196
199
  expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
200
+ expect(tc.error).toContain("Rejected by user");
201
+ // The REJECT decision is preserved so the audit trail stays honest.
202
+ expect(tc.approvalAction).toBe(ApprovalAction.REJECT);
197
203
  });
198
204
 
199
- it("updates REJECT to FAILED with error message", () => {
200
- const tc = create(ToolCallSchema, {
201
- id: "call-3",
205
+ it("terminalizes a SKIP decision to SKIPPED (fixes the stuck-WAITING for skip too)", () => {
206
+ const status = statusWith([{
207
+ id: "call-skip",
202
208
  status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
203
- });
204
- const toolCalls = new Map<string, ToolCall>([["call-3", tc]]);
205
- const decisions = new Map([
206
- ["call-3", { action: ApprovalAction.REJECT, comment: "too risky" }],
207
- ]);
209
+ approvalAction: ApprovalAction.SKIP,
210
+ }]);
208
211
 
209
- reconcileToolCallStatuses(toolCalls, decisions);
210
- expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_FAILED);
211
- expect(tc.error).toContain("too risky");
212
+ reconcileNonExecutingDecisions(status);
213
+
214
+ expect(status.messages[0].toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
212
215
  });
213
216
 
214
- it("ignores tool calls not in the map", () => {
215
- const toolCalls = new Map<string, ToolCall>();
216
- const decisions = new Map([
217
- ["call-missing", { action: ApprovalAction.APPROVE, comment: "" }],
217
+ it("leaves APPROVE / APPROVE_ALL untouched (the tool executes)", () => {
218
+ const status = statusWith([
219
+ { id: "call-approve", status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL, approvalAction: ApprovalAction.APPROVE },
220
+ { id: "call-approve-all", status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL, approvalAction: ApprovalAction.APPROVE_ALL },
218
221
  ]);
219
222
 
220
- expect(() => reconcileToolCallStatuses(toolCalls, decisions)).not.toThrow();
223
+ reconcileNonExecutingDecisions(status);
224
+
225
+ // Still WAITING — real tool events (not this reconciler) terminalize them.
226
+ expect(status.messages[0].toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
227
+ expect(status.messages[0].toolCalls[1].status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
228
+ });
229
+
230
+ it("is a no-op when there are no decided tool calls", () => {
231
+ const status = statusWith([{
232
+ id: "call-pending",
233
+ status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
234
+ approvalAction: ApprovalAction.UNSPECIFIED,
235
+ }]);
236
+
237
+ expect(() => reconcileNonExecutingDecisions(status)).not.toThrow();
238
+ expect(status.messages[0].toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
221
239
  });
222
240
  });
@@ -28,9 +28,16 @@ export async function resolveEnvironment(
28
28
  client: StigmerClient,
29
29
  executionId: string,
30
30
  ): Promise<EnvironmentResult> {
31
+ // A desktop runner exchanges its bootstrap credential for a token scoped to
32
+ // this execution's session, so cloud's decrypt gate binds the read (#156).
33
+ // No-op for cloud sandbox and OSS runners.
34
+ const scopedToken = await client.acquireScopedRunnerToken({
35
+ agentExecutionId: executionId,
36
+ });
37
+
31
38
  let execCtx;
32
39
  try {
33
- execCtx = await client.getExecutionContextByExecutionId(executionId);
40
+ execCtx = await client.getExecutionContextByExecutionId(executionId, scopedToken);
34
41
  } catch (err: unknown) {
35
42
  // ConnectError uses numeric Code.NotFound (5); match the pattern
36
43
  // from execute-cursor/env-resolver.ts.