@stigmer/runner 3.1.7 → 3.1.8

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
@@ -14,18 +14,20 @@
14
14
  */
15
15
 
16
16
  import { Command } from "@langchain/langgraph";
17
- import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
17
+ import type { AgentExecution, AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
18
18
  import {
19
19
  ApprovalAction,
20
20
  ToolCallStatus,
21
21
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
22
- import type { ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
22
+ import type { AgentMessage } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
23
23
 
24
24
  // APPROVE_ALL resumes the interrupted tool exactly like APPROVE. Its
25
25
  // "auto-approve the rest of the run" effect is realized in setup.ts (the
26
26
  // approval gate is disabled for the whole execution once any APPROVE_ALL
27
27
  // decision exists), not here — this map only resolves the currently
28
- // interrupted tool calls.
28
+ // interrupted tool calls. REJECT resumes the gate too (the gate returns a
29
+ // denial ToolMessage that the model reads); it denies a single tool, it does
30
+ // NOT fail the run — see reconcileNonExecutingDecisions for the terminal status.
29
31
  const ACTION_MAP: ReadonlyMap<ApprovalAction, string> = new Map([
30
32
  [ApprovalAction.APPROVE, "approve"],
31
33
  [ApprovalAction.APPROVE_ALL, "approve"],
@@ -36,8 +38,6 @@ const ACTION_MAP: ReadonlyMap<ApprovalAction, string> = new Map([
36
38
  export interface ResumeResult {
37
39
  readonly graphInput: Command | Record<string, unknown>;
38
40
  readonly isResumeFromApproval: boolean;
39
- readonly hasRejection: boolean;
40
- readonly rejectionReason: string;
41
41
  }
42
42
 
43
43
  export interface GraphStateSnapshot {
@@ -87,8 +87,6 @@ export function resolveResumeInput(
87
87
  return {
88
88
  graphInput: { messages: [{ role: "user", content: userMessage }] },
89
89
  isResumeFromApproval: false,
90
- hasRejection: false,
91
- rejectionReason: "",
92
90
  };
93
91
  }
94
92
 
@@ -97,14 +95,10 @@ export function resolveResumeInput(
97
95
  return {
98
96
  graphInput: { messages: [{ role: "user", content: userMessage }] },
99
97
  isResumeFromApproval: false,
100
- hasRejection: false,
101
- rejectionReason: "",
102
98
  };
103
99
  }
104
100
 
105
101
  const resumeDict: Record<string, { action: string; comment?: string }> = {};
106
- let hasRejection = false;
107
- let rejectionReason = "";
108
102
 
109
103
  for (const intr of pendingInterrupts) {
110
104
  const toolCallId = intr.toolCallId;
@@ -118,32 +112,22 @@ export function resolveResumeInput(
118
112
  action: actionStr,
119
113
  ...(decision.comment ? { comment: decision.comment } : {}),
120
114
  };
121
-
122
- if (decision.action === ApprovalAction.REJECT) {
123
- hasRejection = true;
124
- rejectionReason = decision.comment || "Rejected by user";
125
- }
126
115
  }
127
116
 
128
117
  if (Object.keys(resumeDict).length === 0) {
129
118
  return {
130
119
  graphInput: { messages: [{ role: "user", content: userMessage }] },
131
120
  isResumeFromApproval: false,
132
- hasRejection: false,
133
- rejectionReason: "",
134
121
  };
135
122
  }
136
123
 
137
124
  console.log(
138
- `[hitl] Building resume for ${Object.keys(resumeDict).length} interrupt(s), ` +
139
- `rejection=${hasRejection}`,
125
+ `[hitl] Building resume for ${Object.keys(resumeDict).length} interrupt(s)`,
140
126
  );
141
127
 
142
128
  return {
143
129
  graphInput: new Command({ resume: resumeDict }),
144
130
  isResumeFromApproval: true,
145
- hasRejection,
146
- rejectionReason,
147
131
  };
148
132
  }
149
133
 
@@ -207,31 +191,45 @@ function extractApprovalDecisions(
207
191
  }
208
192
 
209
193
  /**
210
- * Reconcile tool call statuses after resume decisions are applied.
194
+ * Terminalize tool calls whose approval decision is non-executing — SKIP or
195
+ * REJECT — so a denied or skipped call is never left stuck at WAITING_APPROVAL.
196
+ *
197
+ * This is the single, authoritative, checkpointer-independent reconciliation of
198
+ * the two decisions that never run the tool: their outcome is fully determined
199
+ * by the recorded decision (ToolCall.approval_action), not by any graph event.
200
+ * APPROVE / APPROVE_ALL are intentionally NOT handled here — the tool actually
201
+ * executes, and real tool events (v3 tool_started → tool_finished) terminalize
202
+ * it in place.
203
+ *
204
+ * Why a decision-derived reconciler rather than the resumed stream: on the
205
+ * durable path (sqlite local / http cloud) the gate returns a denial/skip
206
+ * ToolMessage WITHOUT an on_tool_start/on_tool_end pair, and on the memory path
207
+ * the graph replays without ever re-driving the gate — so in both cases the
208
+ * seeded WAITING_APPROVAL row is never flipped by the stream and would persist
209
+ * on a COMPLETED execution. Folding the recorded decision into a terminal status
210
+ * makes every checkpointer backend behave identically by construction.
211
211
  *
212
- * Updates the StatusBuilder's tool call entries to reflect the approval
213
- * outcomes: APPROVE RUNNING, SKIP SKIPPED, REJECT FAILED.
212
+ * REJECT and SKIP share TOOL_CALL_SKIPPED as the terminal status (the tool did
213
+ * not run); they stay distinguishable by ToolCall.approval_action and by the
214
+ * append-only approval-event stream (REJECTED vs SKIPPED). Idempotent: a row
215
+ * already resolved carries the same decision and re-resolves identically.
214
216
  */
215
- export function reconcileToolCallStatuses(
216
- toolCalls: ReadonlyMap<string, ToolCall>,
217
- decisions: ReadonlyMap<string, ApprovalDecisionEntry>,
218
- ): void {
219
- for (const [toolCallId, decision] of decisions) {
220
- const tc = toolCalls.get(toolCallId);
221
- if (!tc) continue;
222
-
223
- switch (decision.action) {
224
- case ApprovalAction.APPROVE:
225
- case ApprovalAction.APPROVE_ALL:
226
- tc.status = ToolCallStatus.TOOL_CALL_RUNNING;
227
- break;
228
- case ApprovalAction.SKIP:
229
- tc.status = ToolCallStatus.TOOL_CALL_SKIPPED;
230
- break;
231
- case ApprovalAction.REJECT:
232
- tc.status = ToolCallStatus.TOOL_CALL_FAILED;
233
- tc.error = `Rejected by user: ${decision.comment || "no reason given"}`;
234
- break;
217
+ export function reconcileNonExecutingDecisions(status: AgentExecutionStatus): void {
218
+ const apply = (messages: readonly AgentMessage[]): void => {
219
+ for (const msg of messages) {
220
+ for (const tc of msg.toolCalls) {
221
+ if (tc.approvalAction === ApprovalAction.SKIP) {
222
+ tc.status = ToolCallStatus.TOOL_CALL_SKIPPED;
223
+ } else if (tc.approvalAction === ApprovalAction.REJECT) {
224
+ tc.status = ToolCallStatus.TOOL_CALL_SKIPPED;
225
+ if (!tc.error) tc.error = "Rejected by user";
226
+ }
227
+ }
235
228
  }
229
+ };
230
+
231
+ apply(status.messages);
232
+ for (const subAgent of status.subAgentExecutions) {
233
+ apply(subAgent.messages);
236
234
  }
237
235
  }
@@ -47,7 +47,7 @@ import { StatusBuilder } from "./status-builder.js";
47
47
  import { InlinePublisher } from "./inline-publisher.js";
48
48
  import { WriteBackCoordinator } from "../../shared/workspace/writeback-coordinator.js";
49
49
  import { processPostStream } from "./post-stream.js";
50
- import { resolveResumeInput, type GraphStateSnapshot } from "./hitl.js";
50
+ import { resolveResumeInput, reconcileNonExecutingDecisions, type GraphStateSnapshot } from "./hitl.js";
51
51
  import { captureApprovalArtifacts } from "./approval-file-change.js";
52
52
  import {
53
53
  applyCaptureDecisions,
@@ -85,6 +85,7 @@ export function createDeepAgentActivities(config: Config) {
85
85
  endpoint: config.stigmerBackendEndpoint,
86
86
  token: config.stigmerToken,
87
87
  tokenRef: config.stigmerTokenRef,
88
+ runnerTokenRef: config.stigmerRunnerTokenRef,
88
89
  });
89
90
 
90
91
  const streamingConfig = loadStreamingConfig();
@@ -151,23 +152,6 @@ export function createDeepAgentActivities(config: Config) {
151
152
  setup.execution.spec!.message,
152
153
  );
153
154
 
154
- if (resume.hasRejection) {
155
- const failedStatus = create(AgentExecutionStatusSchema, {
156
- phase: ExecutionPhase.EXECUTION_FAILED,
157
- error: `Execution rejected: ${resume.rejectionReason}`,
158
- completedAt: utcTimestamp(),
159
- messages: [
160
- create(AgentMessageSchema, {
161
- type: MessageType.MESSAGE_SYSTEM,
162
- content: `Execution rejected by user: ${resume.rejectionReason}`,
163
- timestamp: utcTimestamp(),
164
- }),
165
- ],
166
- });
167
- await persistStatus(client, executionId, failedStatus, { offload: statusOffload });
168
- return slimStatus(failedStatus);
169
- }
170
-
171
155
  const effectiveInput = resume.isResumeFromApproval
172
156
  ? resume.graphInput
173
157
  : setup.langgraphInput;
@@ -313,11 +297,13 @@ export function createDeepAgentActivities(config: Config) {
313
297
  // Complete now and push only the approved tree — NO model re-run.
314
298
  //
315
299
  // The discriminator is the PERSISTED transcript, not the live graph
316
- // checkpoint: the memory checkpointer (OSS local / desktop) recreates
317
- // the checkpoint empty each invocation, so a graphState-based check
318
- // would wrongly fire for a mixed turn. A mixed turn leaves
319
- // WAITING_APPROVAL tool rows in the transcript; a pure file-review
320
- // turn leaves none.
300
+ // checkpoint: it must behave identically across checkpointer
301
+ // backends, but the live checkpoint does not the memory backend
302
+ // (opt-in, tests) recreates it empty each invocation while the
303
+ // durable backends (sqlite local / http cloud) preserve it, so a
304
+ // graphState-based check would diverge and could wrongly fire for a
305
+ // mixed turn. A mixed turn leaves WAITING_APPROVAL tool rows in the
306
+ // transcript; a pure file-review turn leaves none.
321
307
  initialStatus.phase = ExecutionPhase.EXECUTION_COMPLETED;
322
308
  initialStatus.completedAt = utcTimestamp();
323
309
  if (writebackCoordinator) {
@@ -469,6 +455,16 @@ export function createDeepAgentActivities(config: Config) {
469
455
  initialStatus.subAgentExecutions,
470
456
  );
471
457
 
458
+ // Terminalize any tool call the user SKIPPED or REJECTED at the gate.
459
+ // These decisions never run the tool, so the resumed stream leaves the
460
+ // seeded WAITING_APPROVAL row untouched (the gate returns a ToolMessage
461
+ // with no on_tool_start on http; the memory replay never re-drives the
462
+ // gate). Folding the recorded decision into a terminal status here makes
463
+ // reject/skip resolve identically on both checkpointers — see
464
+ // reconcileNonExecutingDecisions. Runs before the WAITING-detection and
465
+ // completion persists below so the terminal status is what is persisted.
466
+ reconcileNonExecutingDecisions(initialStatus);
467
+
472
468
  // Turn boundary (capture mode): capture the candidate change set from the
473
469
  // git diff and author CANDIDATE_CAPTURED, then stamp the flowed file-edit
474
470
  // rows with the change set id — they stay visible in place as
@@ -917,10 +913,11 @@ async function processCaptureWriteback(
917
913
  *
918
914
  * The decision is therefore driven solely by whether the server already holds
919
915
  * committed history for this execution — NOT by the live graph checkpoint, which
920
- * the two checkpointers leave in different states for the same continuation:
921
- * - durable (http): the checkpoint survives, the graph resumes via
922
- * Command(resume), and streamEvents re-emits only post-checkpoint events;
923
- * - memory (the OSS local / desktop default): the checkpoint is recreated
916
+ * the checkpointer backends leave in different states for the same continuation:
917
+ * - durable (sqlite, the OSS local / desktop default; and http in cloud): the
918
+ * checkpoint survives, the graph resumes via Command(resume), and
919
+ * streamEvents re-emits only post-checkpoint events;
920
+ * - memory (ephemeral, opt-in — used by tests): the checkpoint is recreated
924
921
  * empty every invocation, so the graph REPLAYS from scratch and the blind
925
922
  * FIFO turns advance one gate at a time.
926
923
  * Keying on the live checkpoint skipped seeding on that memory-replay path, so a
@@ -960,6 +957,18 @@ async function cleanup(setup: SetupResult | null): Promise<void> {
960
957
  }
961
958
  }
962
959
 
960
+ // Close the checkpointer's backing resources. Only the durable sqlite saver
961
+ // holds an OS handle (an open DB file); memory/http savers have no close(),
962
+ // so this is duck-typed and best-effort.
963
+ const closable = setup.checkpointer as { close?: () => void } | undefined;
964
+ if (closable && typeof closable.close === "function") {
965
+ try {
966
+ closable.close();
967
+ } catch (err) {
968
+ console.warn("[ExecuteDeepAgent] Checkpointer cleanup failed:", err);
969
+ }
970
+ }
971
+
963
972
  // Drop the workspace→platform `.stigmer` symlink so an attached repo is
964
973
  // left untouched between turns (issue #173 semantics; a multi-turn session
965
974
  // recreates it next turn). Runs in the activity finally, BEFORE the
@@ -34,7 +34,7 @@ import { CasCaptureObserver } from "./cas-capture-observer.js";
34
34
  import { isGitWorkTree, isPathCapturable } from "../../shared/filereview/git-substrate.js";
35
35
  import { deriveCaptureMode } from "../../shared/filereview/capture.js";
36
36
  import { resolveWorkspacePath } from "../../shared/file-change.js";
37
- import { ensurePlatformDir } from "../../shared/workspace/platform-dir.js";
37
+ import { ensurePlatformDir, ensureCheckpointDbPath } from "../../shared/workspace/platform-dir.js";
38
38
  import { resolveSessionWorkspaceRoot } from "../../shared/workspace/session-root.js";
39
39
  import { buildWorkspaceFileTree } from "../../shared/workspace/file-tree.js";
40
40
  import { reportSetupProgress } from "../../shared/status.js";
@@ -75,6 +75,12 @@ import { transformAndCompileSubagents } from "./subagent-transformer.js";
75
75
 
76
76
  export interface SetupResult {
77
77
  readonly agentGraph: AgentGraph;
78
+ /**
79
+ * The checkpoint saver backing agentGraph. Retained so the activity can close
80
+ * it in cleanup — the durable sqlite saver holds an open file handle. Backends
81
+ * without a handle (memory/http) are duck-typed for an optional close().
82
+ */
83
+ readonly checkpointer: BaseCheckpointSaver;
78
84
  readonly langgraphConfig: Record<string, unknown>;
79
85
  readonly langgraphInput: Record<string, unknown>;
80
86
  readonly execution: AgentExecution;
@@ -191,11 +197,17 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
191
197
  const modelName = execution.spec!.executionConfig?.modelName
192
198
  || await getDefaultModel();
193
199
 
194
- // Step 4: Create checkpointer
200
+ // Step 4: Create checkpointer. The durable local (sqlite) backend keys its
201
+ // file per session — a session has exactly one thread (`thread-{sessionId}`),
202
+ // so checkpoint lifetime tracks session lifetime. The saver is closed in the
203
+ // activity cleanup (see index.ts), alongside the MCP connection.
195
204
  const checkpointer = await createCheckpointer({
196
205
  type: config.checkpointerType,
197
206
  proxyEndpoint: config.checkpointerProxyEndpoint ?? undefined,
198
207
  authToken: config.stigmerToken ?? undefined,
208
+ sqlitePath: config.checkpointerType === "sqlite"
209
+ ? await ensureCheckpointDbPath(sessionId)
210
+ : undefined,
199
211
  });
200
212
 
201
213
  // Step 5: Resolve environment
@@ -595,6 +607,7 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
595
607
 
596
608
  return {
597
609
  agentGraph,
610
+ checkpointer,
598
611
  langgraphConfig,
599
612
  langgraphInput,
600
613
  execution,
@@ -33,6 +33,7 @@ export function createHydrateWorkflowActivities(config: Config) {
33
33
  endpoint: config.stigmerBackendEndpoint,
34
34
  token: config.stigmerToken,
35
35
  tokenRef: config.stigmerTokenRef,
36
+ runnerTokenRef: config.stigmerRunnerTokenRef,
36
37
  });
37
38
 
38
39
  return {
@@ -264,9 +265,16 @@ async function fetchAndFlattenEnv(
264
265
  client: StigmerClient,
265
266
  executionId: string,
266
267
  ): Promise<Record<string, unknown>> {
268
+ // A desktop runner exchanges its bootstrap credential for a token scoped to
269
+ // this workflow execution, so cloud's decrypt gate binds the read (#156).
270
+ // No-op for cloud sandbox and OSS runners.
271
+ const scopedToken = await client.acquireScopedRunnerToken({
272
+ workflowExecutionId: executionId,
273
+ });
274
+
267
275
  let execCtx;
268
276
  try {
269
- execCtx = await client.getExecutionContextByExecutionId(executionId);
277
+ execCtx = await client.getExecutionContextByExecutionId(executionId, scopedToken);
270
278
  } catch (err: unknown) {
271
279
  // ConnectError uses numeric Code.NotFound (5); match the pattern
272
280
  // from execute-cursor/env-resolver.ts.
@@ -20,9 +20,35 @@ vi.mock("@connectrpc/connect", () => ({
20
20
  }));
21
21
 
22
22
  import { StigmerClient } from "../stigmer-client.js";
23
+ import { ExecutionContextQueryController } from "@stigmer/protos/ai/stigmer/agentic/executioncontext/v1/query_pb";
24
+ import { PlatformQueryController } from "@stigmer/protos/ai/stigmer/platform/v1/server_info_pb";
23
25
 
24
- function makeRequest(): { header: Map<string, string> } {
25
- return { header: new Map() };
26
+ function makeRequest(
27
+ serviceTypeName = "ai.stigmer.agentic.session.v1.SessionQueryController",
28
+ methodName = "get",
29
+ ): { header: Map<string, string>; service: { typeName: string }; method: { name: string } } {
30
+ return {
31
+ header: new Map(),
32
+ service: { typeName: serviceTypeName },
33
+ method: { name: methodName },
34
+ };
35
+ }
36
+
37
+ /** A request targeting the ExecutionContext query service (runner-credential path). */
38
+ function makeExecutionContextRequest() {
39
+ return makeRequest(ExecutionContextQueryController.typeName, "getByExecutionId");
40
+ }
41
+
42
+ /** A request for the scoped-token exchange (runner-credential path, #156). */
43
+ function makeScopedTokenExchangeRequest() {
44
+ return makeRequest(PlatformQueryController.typeName, "getRunnerScopedToken");
45
+ }
46
+
47
+ /** An unsigned JWT-shaped token carrying the given token_type claim. */
48
+ function fakeTokenOfType(tokenType: string): string {
49
+ const b64 = (obj: Record<string, unknown>) =>
50
+ Buffer.from(JSON.stringify(obj)).toString("base64url");
51
+ return `${b64({ alg: "RS256" })}.${b64({ token_type: tokenType })}.sig`;
26
52
  }
27
53
 
28
54
  async function runInterceptor(req: ReturnType<typeof makeRequest>) {
@@ -120,6 +146,217 @@ describe("StigmerClient", () => {
120
146
  });
121
147
  });
122
148
 
149
+ describe("runnerTokenRef (ExecutionContext credential selection)", () => {
150
+ // Cloud gates ExecutionContext secret decryption on a runner-class
151
+ // token_type claim (stigmer-cloud#152). These tests pin the selection
152
+ // policy: the runner credential is used for the ExecutionContext query
153
+ // service only, and only when present.
154
+
155
+ it("uses the runner credential for ExecutionContext reads", async () => {
156
+ const tokenRef: TokenRef = { current: "control-plane-tok" };
157
+ const runnerRef: TokenRef = { current: "runner-tok" };
158
+ new StigmerClient({
159
+ endpoint: "http://localhost",
160
+ token: null,
161
+ tokenRef,
162
+ runnerTokenRef: runnerRef,
163
+ });
164
+
165
+ const req = makeExecutionContextRequest();
166
+ await runInterceptor(req);
167
+ expect(req.header.get("authorization")).toBe("Bearer runner-tok");
168
+ });
169
+
170
+ it("keeps the control-plane token for every other service", async () => {
171
+ const tokenRef: TokenRef = { current: "control-plane-tok" };
172
+ const runnerRef: TokenRef = { current: "runner-tok" };
173
+ new StigmerClient({
174
+ endpoint: "http://localhost",
175
+ token: null,
176
+ tokenRef,
177
+ runnerTokenRef: runnerRef,
178
+ });
179
+
180
+ const req = makeRequest();
181
+ await runInterceptor(req);
182
+ expect(req.header.get("authorization")).toBe("Bearer control-plane-tok");
183
+ });
184
+
185
+ it("falls back to the control-plane token when no runner credential exists (OSS/local)", async () => {
186
+ const tokenRef: TokenRef = { current: "control-plane-tok" };
187
+ const runnerRef: TokenRef = { current: null };
188
+ new StigmerClient({
189
+ endpoint: "http://localhost",
190
+ token: null,
191
+ tokenRef,
192
+ runnerTokenRef: runnerRef,
193
+ });
194
+
195
+ const req = makeExecutionContextRequest();
196
+ await runInterceptor(req);
197
+ expect(req.header.get("authorization")).toBe("Bearer control-plane-tok");
198
+ });
199
+
200
+ it("propagates runner-credential refreshes on subsequent requests", async () => {
201
+ const runnerRef: TokenRef = { current: "minted-v1" };
202
+ new StigmerClient({
203
+ endpoint: "http://localhost",
204
+ token: null,
205
+ runnerTokenRef: runnerRef,
206
+ });
207
+
208
+ const req1 = makeExecutionContextRequest();
209
+ await runInterceptor(req1);
210
+ expect(req1.header.get("authorization")).toBe("Bearer minted-v1");
211
+
212
+ runnerRef.current = "minted-v2";
213
+
214
+ const req2 = makeExecutionContextRequest();
215
+ await runInterceptor(req2);
216
+ expect(req2.header.get("authorization")).toBe("Bearer minted-v2");
217
+ });
218
+
219
+ it("uses the runner credential for the scoped-token exchange (#156)", async () => {
220
+ // The exchange requires the embedded_runner bootstrap credential; the
221
+ // control-plane token (the user's Auth0 token) would be denied.
222
+ const tokenRef: TokenRef = { current: "control-plane-tok" };
223
+ const runnerRef: TokenRef = { current: "runner-tok" };
224
+ new StigmerClient({
225
+ endpoint: "http://localhost",
226
+ token: null,
227
+ tokenRef,
228
+ runnerTokenRef: runnerRef,
229
+ });
230
+
231
+ const req = makeScopedTokenExchangeRequest();
232
+ await runInterceptor(req);
233
+ expect(req.header.get("authorization")).toBe("Bearer runner-tok");
234
+ });
235
+
236
+ it("keeps the control-plane token for other platform-service methods", async () => {
237
+ const tokenRef: TokenRef = { current: "control-plane-tok" };
238
+ const runnerRef: TokenRef = { current: "runner-tok" };
239
+ new StigmerClient({
240
+ endpoint: "http://localhost",
241
+ token: null,
242
+ tokenRef,
243
+ runnerTokenRef: runnerRef,
244
+ });
245
+
246
+ const req = makeRequest(PlatformQueryController.typeName, "getRunnerBootstrapConfig");
247
+ await runInterceptor(req);
248
+ expect(req.header.get("authorization")).toBe("Bearer control-plane-tok");
249
+ });
250
+ });
251
+
252
+ describe("per-call credential precedence (#156)", () => {
253
+ it("never overwrites an authorization header set via call options", async () => {
254
+ // The scoped-token flow authenticates individual ExecutionContext reads
255
+ // with a per-execution credential. Concurrent sessions in one runner
256
+ // process mean the process-wide selection below must not clobber it.
257
+ const runnerRef: TokenRef = { current: "process-wide-runner-tok" };
258
+ new StigmerClient({
259
+ endpoint: "http://localhost",
260
+ token: "process-wide-tok",
261
+ runnerTokenRef: runnerRef,
262
+ });
263
+
264
+ const req = makeExecutionContextRequest();
265
+ req.header.set("authorization", "Bearer per-call-scoped-tok");
266
+ await runInterceptor(req);
267
+
268
+ expect(req.header.get("authorization")).toBe("Bearer per-call-scoped-tok");
269
+ });
270
+ });
271
+
272
+ describe("acquireScopedRunnerToken", () => {
273
+ function clientWithRunnerCredential(token: string | null): StigmerClient {
274
+ return new StigmerClient({
275
+ endpoint: "http://localhost",
276
+ token: null,
277
+ runnerTokenRef: { current: token },
278
+ });
279
+ }
280
+
281
+ it("exchanges when holding an embedded_runner bootstrap credential", async () => {
282
+ const client = clientWithRunnerCredential(fakeTokenOfType("embedded_runner"));
283
+ vi.spyOn(client, "getRunnerScopedToken").mockResolvedValue({
284
+ token: "scoped-tok",
285
+ expiresInSeconds: 14400,
286
+ });
287
+
288
+ const token = await client.acquireScopedRunnerToken({ agentExecutionId: "aex_1" });
289
+
290
+ expect(token).toBe("scoped-tok");
291
+ expect(client.getRunnerScopedToken).toHaveBeenCalledWith({ agentExecutionId: "aex_1" });
292
+ });
293
+
294
+ it("skips the exchange for an already-scoped sandbox credential (cloud sandbox runner)", async () => {
295
+ const client = clientWithRunnerCredential(fakeTokenOfType("sandbox"));
296
+ const spy = vi.spyOn(client, "getRunnerScopedToken");
297
+
298
+ const token = await client.acquireScopedRunnerToken({ agentExecutionId: "aex_1" });
299
+
300
+ expect(token).toBeUndefined();
301
+ expect(spy).not.toHaveBeenCalled();
302
+ });
303
+
304
+ it("skips the exchange when no runner credential exists (OSS/local)", async () => {
305
+ const client = clientWithRunnerCredential(null);
306
+ const spy = vi.spyOn(client, "getRunnerScopedToken");
307
+
308
+ const token = await client.acquireScopedRunnerToken({ workflowExecutionId: "wfx_1" });
309
+
310
+ expect(token).toBeUndefined();
311
+ expect(spy).not.toHaveBeenCalled();
312
+ });
313
+
314
+ it("falls back (undefined) when the server mints no token", async () => {
315
+ const client = clientWithRunnerCredential(fakeTokenOfType("embedded_runner"));
316
+ vi.spyOn(client, "getRunnerScopedToken").mockResolvedValue(undefined);
317
+
318
+ const token = await client.acquireScopedRunnerToken({ agentExecutionId: "aex_1" });
319
+
320
+ expect(token).toBeUndefined();
321
+ });
322
+
323
+ it("falls back (undefined) when the exchange fails, instead of failing the execution", async () => {
324
+ const client = clientWithRunnerCredential(fakeTokenOfType("embedded_runner"));
325
+ vi.spyOn(client, "getRunnerScopedToken").mockRejectedValue(new Error("boom"));
326
+
327
+ const token = await client.acquireScopedRunnerToken({ agentExecutionId: "aex_1" });
328
+
329
+ expect(token).toBeUndefined();
330
+ });
331
+ });
332
+
333
+ describe("getExecutionContextByExecutionId per-call credential", () => {
334
+ it("passes the scoped token as a per-call authorization header", async () => {
335
+ const client = new StigmerClient({ endpoint: "http://localhost", token: null });
336
+ const getByExecutionId = vi.fn().mockResolvedValue({});
337
+ // Reach into the private generated client: the mocked createClient()
338
+ // returned {}, so install the method it would have provided.
339
+ (client as any).executionContextQuery = { getByExecutionId };
340
+
341
+ await client.getExecutionContextByExecutionId("aex_1", "scoped-tok");
342
+
343
+ expect(getByExecutionId).toHaveBeenCalledWith(
344
+ expect.anything(),
345
+ { headers: { authorization: "Bearer scoped-tok" } },
346
+ );
347
+ });
348
+
349
+ it("passes no call options without a scoped token", async () => {
350
+ const client = new StigmerClient({ endpoint: "http://localhost", token: null });
351
+ const getByExecutionId = vi.fn().mockResolvedValue({});
352
+ (client as any).executionContextQuery = { getByExecutionId };
353
+
354
+ await client.getExecutionContextByExecutionId("aex_1");
355
+
356
+ expect(getByExecutionId).toHaveBeenCalledWith(expect.anything(), undefined);
357
+ });
358
+ });
359
+
123
360
  describe("updateToken", () => {
124
361
  it("does not affect tokenRef-based resolution", async () => {
125
362
  const ref: TokenRef = { current: "from-ref" };
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { tokenTypeOf, isEmbeddedRunnerToken } from "../token-claims.js";
3
+
4
+ /** Build an unsigned JWT-shaped token with the given payload. */
5
+ function fakeJwt(payload: Record<string, unknown>): string {
6
+ const b64 = (obj: Record<string, unknown>) =>
7
+ Buffer.from(JSON.stringify(obj)).toString("base64url");
8
+ return `${b64({ alg: "RS256", typ: "JWT" })}.${b64(payload)}.signature`;
9
+ }
10
+
11
+ describe("tokenTypeOf", () => {
12
+ it("extracts the token_type claim", () => {
13
+ expect(tokenTypeOf(fakeJwt({ token_type: "embedded_runner" }))).toBe("embedded_runner");
14
+ expect(tokenTypeOf(fakeJwt({ token_type: "sandbox" }))).toBe("sandbox");
15
+ });
16
+
17
+ it("returns undefined for tokens without the claim (e.g. a user's Auth0 token)", () => {
18
+ expect(tokenTypeOf(fakeJwt({ sub: "user-1" }))).toBeUndefined();
19
+ });
20
+
21
+ it("returns undefined for a non-string claim value", () => {
22
+ expect(tokenTypeOf(fakeJwt({ token_type: 42 }))).toBeUndefined();
23
+ });
24
+
25
+ it("returns undefined for null, empty, and malformed tokens", () => {
26
+ expect(tokenTypeOf(null)).toBeUndefined();
27
+ expect(tokenTypeOf(undefined)).toBeUndefined();
28
+ expect(tokenTypeOf("")).toBeUndefined();
29
+ expect(tokenTypeOf("not-a-jwt")).toBeUndefined();
30
+ expect(tokenTypeOf("only.two")).toBeUndefined();
31
+ expect(tokenTypeOf("a.%%%not-base64%%%.c")).toBeUndefined();
32
+ });
33
+ });
34
+
35
+ describe("isEmbeddedRunnerToken", () => {
36
+ it("is true only for token_type=embedded_runner", () => {
37
+ expect(isEmbeddedRunnerToken(fakeJwt({ token_type: "embedded_runner" }))).toBe(true);
38
+ // A cloud sandbox runner's credential is already scoped — must not gate in.
39
+ expect(isEmbeddedRunnerToken(fakeJwt({ token_type: "sandbox" }))).toBe(false);
40
+ expect(isEmbeddedRunnerToken(fakeJwt({ token_type: "workflow_sandbox" }))).toBe(false);
41
+ // A user token has no token_type claim at all.
42
+ expect(isEmbeddedRunnerToken(fakeJwt({ sub: "user-1" }))).toBe(false);
43
+ expect(isEmbeddedRunnerToken(null)).toBe(false);
44
+ });
45
+ });