@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.
Files changed (115) hide show
  1. package/README.md +1 -1
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/discover-mcp-server.js +24 -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/mcp-oauth-detect.d.ts +53 -0
  64. package/dist/shared/mcp-oauth-detect.js +99 -0
  65. package/dist/shared/mcp-oauth-detect.js.map +1 -0
  66. package/dist/shared/tool-row.js +14 -1
  67. package/dist/shared/tool-row.js.map +1 -1
  68. package/dist/shared/workspace/platform-dir.d.ts +18 -0
  69. package/dist/shared/workspace/platform-dir.js +23 -0
  70. package/dist/shared/workspace/platform-dir.js.map +1 -1
  71. package/package.json +3 -3
  72. package/src/__test-utils__/mock-client.ts +3 -0
  73. package/src/activities/__tests__/hydrate-workflow-execution.test.ts +2 -0
  74. package/src/activities/discover-mcp-server.ts +26 -0
  75. package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +5 -3
  76. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +272 -14
  77. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +72 -16
  78. package/src/activities/execute-cursor/__tests__/message-translator.test.ts +65 -0
  79. package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +128 -1
  80. package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +95 -0
  81. package/src/activities/execute-cursor/approval-state.ts +75 -11
  82. package/src/activities/execute-cursor/env-resolver.ts +8 -1
  83. package/src/activities/execute-cursor/hook-script.ts +66 -21
  84. package/src/activities/execute-cursor/index.ts +61 -0
  85. package/src/activities/execute-cursor/message-translator.ts +124 -1
  86. package/src/activities/execute-cursor/turn-boundary.ts +79 -6
  87. package/src/activities/execute-cursor/turn-stream.ts +6 -2
  88. package/src/activities/execute-cursor/workspace-setup.ts +42 -14
  89. package/src/activities/execute-deep-agent/__tests__/environment.test.ts +5 -0
  90. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +314 -0
  91. package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +64 -46
  92. package/src/activities/execute-deep-agent/environment.ts +8 -1
  93. package/src/activities/execute-deep-agent/hitl.ts +43 -45
  94. package/src/activities/execute-deep-agent/index.ts +36 -27
  95. package/src/activities/execute-deep-agent/setup.ts +15 -2
  96. package/src/activities/hydrate-workflow-execution.ts +9 -1
  97. package/src/client/__tests__/stigmer-client.test.ts +239 -2
  98. package/src/client/__tests__/token-claims.test.ts +45 -0
  99. package/src/client/stigmer-client.ts +152 -3
  100. package/src/client/token-claims.ts +42 -0
  101. package/src/config.ts +16 -3
  102. package/src/middleware/__tests__/approval-gate.test.ts +8 -1
  103. package/src/middleware/approval-gate.ts +9 -2
  104. package/src/runner-manager.ts +22 -8
  105. package/src/runner.ts +3 -3
  106. package/src/shared/__tests__/mcp-oauth-detect.test.ts +147 -0
  107. package/src/shared/__tests__/tool-row.test.ts +35 -0
  108. package/src/shared/checkpointer/__tests__/factory.test.ts +27 -1
  109. package/src/shared/checkpointer/__tests__/sqlite-saver.test.ts +212 -0
  110. package/src/shared/checkpointer/factory.ts +25 -8
  111. package/src/shared/checkpointer/sqlite-saver.ts +384 -0
  112. package/src/shared/checkpointer/types.ts +8 -4
  113. package/src/shared/mcp-oauth-detect.ts +112 -0
  114. package/src/shared/tool-row.ts +14 -1
  115. 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: 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
+ });