@stigmer/runner 3.1.6 → 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.
- package/README.md +1 -1
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/discover-mcp-server.js +24 -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/mcp-oauth-detect.d.ts +53 -0
- package/dist/shared/mcp-oauth-detect.js +99 -0
- package/dist/shared/mcp-oauth-detect.js.map +1 -0
- 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 +26 -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__/mcp-oauth-detect.test.ts +147 -0
- 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/mcp-oauth-detect.ts +112 -0
- package/src/shared/tool-row.ts +14 -1
- package/src/shared/workspace/platform-dir.ts +25 -0
|
@@ -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
|
+
});
|