@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.
- package/README.md +1 -1
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/discover-mcp-server.js +1 -0
- package/dist/activities/discover-mcp-server.js.map +1 -1
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +5 -2
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
- package/dist/activities/execute-cursor/approval-state.d.ts +58 -10
- package/dist/activities/execute-cursor/approval-state.js +37 -8
- package/dist/activities/execute-cursor/approval-state.js.map +1 -1
- package/dist/activities/execute-cursor/env-resolver.js +7 -1
- package/dist/activities/execute-cursor/env-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/hook-script.d.ts +16 -3
- package/dist/activities/execute-cursor/hook-script.js +66 -21
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +59 -0
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/message-translator.d.ts +52 -0
- package/dist/activities/execute-cursor/message-translator.js +107 -1
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- package/dist/activities/execute-cursor/turn-boundary.d.ts +25 -3
- package/dist/activities/execute-cursor/turn-boundary.js +46 -8
- package/dist/activities/execute-cursor/turn-boundary.js.map +1 -1
- package/dist/activities/execute-cursor/turn-stream.js +6 -2
- package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
- package/dist/activities/execute-cursor/workspace-setup.d.ts +16 -2
- package/dist/activities/execute-cursor/workspace-setup.js +22 -12
- package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/environment.js +7 -1
- package/dist/activities/execute-deep-agent/environment.js.map +1 -1
- package/dist/activities/execute-deep-agent/hitl.d.ts +23 -14
- package/dist/activities/execute-deep-agent/hitl.js +42 -37
- package/dist/activities/execute-deep-agent/hitl.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +35 -26
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.d.ts +7 -0
- package/dist/activities/execute-deep-agent/setup.js +9 -2
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/hydrate-workflow-execution.js +8 -1
- package/dist/activities/hydrate-workflow-execution.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +69 -1
- package/dist/client/stigmer-client.js +108 -4
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/client/token-claims.d.ts +22 -0
- package/dist/client/token-claims.js +40 -0
- package/dist/client/token-claims.js.map +1 -0
- package/dist/config.d.ts +11 -1
- package/dist/config.js +5 -1
- package/dist/config.js.map +1 -1
- package/dist/middleware/approval-gate.js +9 -2
- package/dist/middleware/approval-gate.js.map +1 -1
- package/dist/runner-manager.d.ts +4 -2
- package/dist/runner-manager.js +20 -7
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.d.ts +2 -2
- package/dist/runner.js +1 -1
- package/dist/shared/checkpointer/factory.d.ts +11 -7
- package/dist/shared/checkpointer/factory.js +21 -8
- package/dist/shared/checkpointer/factory.js.map +1 -1
- package/dist/shared/checkpointer/sqlite-saver.d.ts +54 -0
- package/dist/shared/checkpointer/sqlite-saver.js +272 -0
- package/dist/shared/checkpointer/sqlite-saver.js.map +1 -0
- package/dist/shared/checkpointer/types.d.ts +8 -4
- package/dist/shared/tool-row.js +14 -1
- package/dist/shared/tool-row.js.map +1 -1
- package/dist/shared/workspace/platform-dir.d.ts +18 -0
- package/dist/shared/workspace/platform-dir.js +23 -0
- package/dist/shared/workspace/platform-dir.js.map +1 -1
- package/package.json +3 -3
- package/src/__test-utils__/mock-client.ts +3 -0
- package/src/activities/__tests__/hydrate-workflow-execution.test.ts +2 -0
- package/src/activities/discover-mcp-server.ts +1 -0
- package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +5 -3
- package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +272 -14
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +72 -16
- package/src/activities/execute-cursor/__tests__/message-translator.test.ts +65 -0
- package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +128 -1
- package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +95 -0
- package/src/activities/execute-cursor/approval-state.ts +75 -11
- package/src/activities/execute-cursor/env-resolver.ts +8 -1
- package/src/activities/execute-cursor/hook-script.ts +66 -21
- package/src/activities/execute-cursor/index.ts +61 -0
- package/src/activities/execute-cursor/message-translator.ts +124 -1
- package/src/activities/execute-cursor/turn-boundary.ts +79 -6
- package/src/activities/execute-cursor/turn-stream.ts +6 -2
- package/src/activities/execute-cursor/workspace-setup.ts +42 -14
- package/src/activities/execute-deep-agent/__tests__/environment.test.ts +5 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +314 -0
- package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +64 -46
- package/src/activities/execute-deep-agent/environment.ts +8 -1
- package/src/activities/execute-deep-agent/hitl.ts +43 -45
- package/src/activities/execute-deep-agent/index.ts +36 -27
- package/src/activities/execute-deep-agent/setup.ts +15 -2
- package/src/activities/hydrate-workflow-execution.ts +9 -1
- package/src/client/__tests__/stigmer-client.test.ts +239 -2
- package/src/client/__tests__/token-claims.test.ts +45 -0
- package/src/client/stigmer-client.ts +152 -3
- package/src/client/token-claims.ts +42 -0
- package/src/config.ts +16 -3
- package/src/middleware/__tests__/approval-gate.test.ts +8 -1
- package/src/middleware/approval-gate.ts +9 -2
- package/src/runner-manager.ts +22 -8
- package/src/runner.ts +3 -3
- package/src/shared/__tests__/tool-row.test.ts +35 -0
- package/src/shared/checkpointer/__tests__/factory.test.ts +27 -1
- package/src/shared/checkpointer/__tests__/sqlite-saver.test.ts +212 -0
- package/src/shared/checkpointer/factory.ts +25 -8
- package/src/shared/checkpointer/sqlite-saver.ts +384 -0
- package/src/shared/checkpointer/types.ts +8 -4
- package/src/shared/tool-row.ts +14 -1
- 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 {
|
|
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
|
-
*
|
|
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
|
-
*
|
|
213
|
-
*
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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:
|
|
317
|
-
// the checkpoint
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
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
|
|
921
|
-
* - durable (
|
|
922
|
-
* Command(resume), and
|
|
923
|
-
*
|
|
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(
|
|
25
|
-
|
|
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
|
+
});
|