@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
@@ -48,7 +48,8 @@ import { WorkflowQueryController } from "@stigmer/protos/ai/stigmer/agentic/work
48
48
  import type { Workflow } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/api_pb";
49
49
  import { WorkflowInstanceQueryController } from "@stigmer/protos/ai/stigmer/agentic/workflowinstance/v1/query_pb";
50
50
  import type { WorkflowInstance } from "@stigmer/protos/ai/stigmer/agentic/workflowinstance/v1/api_pb";
51
- import { PlatformQueryController } from "@stigmer/protos/ai/stigmer/platform/v1/server_info_pb";
51
+ import { PlatformQueryController, GetRunnerScopedTokenInputSchema } from "@stigmer/protos/ai/stigmer/platform/v1/server_info_pb";
52
+ import { isEmbeddedRunnerToken } from "./token-claims.js";
52
53
  import { assertCreateRequirements, assertReferenceRequirements } from "./server-contracts.js";
53
54
 
54
55
  /**
@@ -69,6 +70,28 @@ export interface RunnerBootstrapConfig {
69
70
  runnerAccessTokenExpiresInSeconds?: number;
70
71
  }
71
72
 
73
+ /**
74
+ * A runner token scoped to one unit of dispatched work (issue #156).
75
+ *
76
+ * Minted on demand by the control plane when the runner exchanges its
77
+ * bootstrap credential at task start; presented for the ExecutionContext
78
+ * fetch of exactly that execution. Absent when the server does not mint
79
+ * (OSS, or no signing key) — the runner keeps its existing credential.
80
+ */
81
+ export interface RunnerScopedToken {
82
+ token: string;
83
+ /** Lifetime in seconds from issuance; absent/0 when the server omitted it. */
84
+ expiresInSeconds?: number;
85
+ }
86
+
87
+ /**
88
+ * Names the unit of dispatched work a scoped runner token should serve.
89
+ * Exactly one id must be set — mirrors the proto oneof.
90
+ */
91
+ export type RunnerScopedTokenScope =
92
+ | { agentExecutionId: string }
93
+ | { workflowExecutionId: string };
94
+
72
95
  /**
73
96
  * A shared mutable token reference. When provided, the interceptor
74
97
  * reads from this on every request, enabling token updates to
@@ -82,6 +105,13 @@ export interface StigmerClientOptions {
82
105
  endpoint: string;
83
106
  token: string | null;
84
107
  tokenRef?: TokenRef;
108
+ /**
109
+ * Optional runner credential (a server-minted token with a runner-class
110
+ * `token_type` claim). When populated, ExecutionContext reads authenticate
111
+ * with this instead of the control-plane token — see the interceptor in the
112
+ * constructor for why the credential differs per service.
113
+ */
114
+ runnerTokenRef?: TokenRef;
85
115
  }
86
116
 
87
117
  export class StigmerClient {
@@ -106,15 +136,49 @@ export class StigmerClient {
106
136
  private readonly platformQuery: Client<typeof PlatformQueryController>;
107
137
 
108
138
  private readonly tokenRef: TokenRef | null;
139
+ private readonly runnerTokenRef: TokenRef | null;
109
140
 
110
141
  constructor(options: StigmerClientOptions) {
111
142
  this.currentToken = options.token;
112
143
  this.tokenRef = options.tokenRef ?? null;
144
+ this.runnerTokenRef = options.runnerTokenRef ?? null;
113
145
  this.transport = createGrpcTransport({
114
146
  baseUrl: options.endpoint,
115
147
  interceptors: [
148
+ // Credential selection lives here — one tested decision point — rather
149
+ // than at call sites. Precedence:
150
+ //
151
+ // 1. An explicit per-call credential (an authorization header set via
152
+ // CallOptions) always wins. The scoped-token flow (issue #156)
153
+ // authenticates each ExecutionContext read with a token minted for
154
+ // that specific execution; concurrent sessions in one runner process
155
+ // make a shared mutable ref unusable for this — session A's read
156
+ // must never go out with session B's token.
157
+ //
158
+ // 2. The runner credential (runnerTokenRef) authenticates the services
159
+ // that require a runner-class token_type claim: ExecutionContext
160
+ // reads carry decrypted secrets on cloud, and the server gates that
161
+ // decrypt on runner class + scope (stigmer-cloud#152/#155); the
162
+ // scoped-token exchange itself requires the embedded_runner
163
+ // bootstrap credential (a desktop runner's control-plane token is
164
+ // the user's own Auth0 token, which the server correctly treats as
165
+ // a browsing user).
166
+ //
167
+ // 3. Everything else uses the control-plane token. Falls through
168
+ // unchanged when no runner token exists (OSS/local, where the
169
+ // server enforces no auth).
116
170
  (next) => async (req) => {
117
- const token = this.tokenRef?.current ?? this.currentToken;
171
+ if (req.header.has("authorization")) {
172
+ return next(req);
173
+ }
174
+ const usesRunnerCredential =
175
+ req.service.typeName === ExecutionContextQueryController.typeName ||
176
+ (req.service.typeName === PlatformQueryController.typeName &&
177
+ req.method.name === PlatformQueryController.method.getRunnerScopedToken.name);
178
+ const token =
179
+ (usesRunnerCredential ? this.runnerTokenRef?.current : null)
180
+ ?? this.tokenRef?.current
181
+ ?? this.currentToken;
118
182
  if (token) {
119
183
  req.header.set("authorization", `Bearer ${token}`);
120
184
  }
@@ -180,12 +244,97 @@ export class StigmerClient {
180
244
  return this.executionCommand.updateStatus(input);
181
245
  }
182
246
 
183
- async getExecutionContextByExecutionId(executionId: string): Promise<ExecutionContext> {
247
+ /**
248
+ * Fetch the ExecutionContext for an execution.
249
+ *
250
+ * When a scoped runner token is supplied (issue #156), the read
251
+ * authenticates with it per-call instead of the process-wide credential:
252
+ * on cloud the decrypt gate releases secrets only to a runner token whose
253
+ * scope binds it to this very execution, and one desktop runner process
254
+ * serves many sessions concurrently, so the credential cannot live in a
255
+ * shared ref.
256
+ */
257
+ async getExecutionContextByExecutionId(
258
+ executionId: string,
259
+ scopedToken?: string,
260
+ ): Promise<ExecutionContext> {
184
261
  return this.executionContextQuery.getByExecutionId(
185
262
  create(ExecutionContextExecutionIdInputSchema, { executionId }),
263
+ scopedToken
264
+ ? { headers: { authorization: `Bearer ${scopedToken}` } }
265
+ : undefined,
186
266
  );
187
267
  }
188
268
 
269
+ /**
270
+ * Exchange this runner's bootstrap credential for a token scoped to one
271
+ * unit of dispatched work (issue #156).
272
+ *
273
+ * The call authenticates with the runner credential (see the interceptor);
274
+ * the server verifies it is an embedded_runner token and that its identity
275
+ * can view the named execution, then mints the same session/execution-scoped
276
+ * sandbox token a cloud sandbox runner receives at provisioning. Returns
277
+ * undefined when the server does not mint (OSS, or no signing key) —
278
+ * presence-based, like the bootstrap token fields.
279
+ */
280
+ async getRunnerScopedToken(
281
+ scope: RunnerScopedTokenScope,
282
+ ): Promise<RunnerScopedToken | undefined> {
283
+ const input = create(GetRunnerScopedTokenInputSchema,
284
+ "agentExecutionId" in scope
285
+ ? { scope: { case: "agentExecutionId", value: scope.agentExecutionId } }
286
+ : { scope: { case: "workflowExecutionId", value: scope.workflowExecutionId } });
287
+ const res = await this.platformQuery.getRunnerScopedToken(input);
288
+ if (!res.runnerScopedToken) {
289
+ return undefined;
290
+ }
291
+ return {
292
+ token: res.runnerScopedToken,
293
+ expiresInSeconds: res.expiresInSeconds || undefined,
294
+ };
295
+ }
296
+
297
+ /**
298
+ * Acquire a scoped runner token for an ExecutionContext read, if this
299
+ * runner's credential situation calls for one.
300
+ *
301
+ * The gate is the credential itself: only an unscoped embedded_runner
302
+ * bootstrap token needs exchanging. A cloud sandbox runner's credential is
303
+ * already scoped (skip — the exchange would rightly refuse it), and an
304
+ * OSS/local runner holds no runner-class credential at all (skip — the
305
+ * server neither mints nor redacts).
306
+ *
307
+ * Failure falls back rather than failing the execution: returning undefined
308
+ * makes the read authenticate with the bootstrap credential, which remains
309
+ * decrypt-eligible until issue #156 item 3 removes it — at that point this
310
+ * fallback stops yielding secrets and executions surface the warning below.
311
+ */
312
+ async acquireScopedRunnerToken(
313
+ scope: RunnerScopedTokenScope,
314
+ ): Promise<string | undefined> {
315
+ if (!isEmbeddedRunnerToken(this.runnerTokenRef?.current)) {
316
+ return undefined;
317
+ }
318
+ try {
319
+ const scoped = await this.getRunnerScopedToken(scope);
320
+ if (!scoped) {
321
+ console.warn(
322
+ "[stigmer-client] Server minted no scoped runner token; " +
323
+ "falling back to the bootstrap credential for the ExecutionContext read",
324
+ );
325
+ return undefined;
326
+ }
327
+ return scoped.token;
328
+ } catch (err) {
329
+ console.warn(
330
+ "[stigmer-client] Scoped runner token exchange failed; " +
331
+ "falling back to the bootstrap credential for the ExecutionContext read: " +
332
+ `${err instanceof Error ? err.message : err}`,
333
+ );
334
+ return undefined;
335
+ }
336
+ }
337
+
189
338
  async getSession(sessionId: string): Promise<Session> {
190
339
  return this.sessionQuery.get({ value: sessionId });
191
340
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Read-only claim inspection for the runner's own credentials.
3
+ *
4
+ * The runner never verifies signatures — the server does that. It only needs
5
+ * to look at its own token's `token_type` claim to decide which credential
6
+ * flow applies (e.g. "do I hold an unscoped bootstrap credential that must be
7
+ * exchanged for a scoped one before an ExecutionContext read?"). Claim names
8
+ * and values mirror the server's single source of truth,
9
+ * `StigmerTokenType` (stigmer-cloud, api-authentication).
10
+ */
11
+
12
+ /** The `token_type` claim value of a bootstrap-minted embedded runner token. */
13
+ export const TOKEN_TYPE_EMBEDDED_RUNNER = "embedded_runner";
14
+
15
+ const CLAIM_TOKEN_TYPE = "token_type";
16
+
17
+ /**
18
+ * Extract the `token_type` claim from a JWT without verifying it.
19
+ *
20
+ * Returns undefined for null/malformed tokens and for tokens without the
21
+ * claim (e.g. a user's Auth0 token) — callers branch on the specific value,
22
+ * so "unknown" and "absent" collapse to the same answer.
23
+ */
24
+ export function tokenTypeOf(token: string | null | undefined): string | undefined {
25
+ if (!token) return undefined;
26
+ const parts = token.split(".");
27
+ if (parts.length !== 3) return undefined;
28
+ try {
29
+ const payload = JSON.parse(
30
+ Buffer.from(parts[1]!, "base64url").toString("utf8"),
31
+ ) as Record<string, unknown>;
32
+ const tokenType = payload[CLAIM_TOKEN_TYPE];
33
+ return typeof tokenType === "string" ? tokenType : undefined;
34
+ } catch {
35
+ return undefined;
36
+ }
37
+ }
38
+
39
+ /** Whether the token is a bootstrap-minted `embedded_runner` credential. */
40
+ export function isEmbeddedRunnerToken(token: string | null | undefined): boolean {
41
+ return tokenTypeOf(token) === TOKEN_TYPE_EMBEDDED_RUNNER;
42
+ }
package/src/config.ts CHANGED
@@ -73,7 +73,7 @@ export interface Config {
73
73
  readonly maxConcurrentActivities: number;
74
74
  readonly idleTimeoutSeconds: number | null;
75
75
  readonly cloudModeEnabled: boolean;
76
- readonly checkpointerType: "memory" | "http";
76
+ readonly checkpointerType: "memory" | "http" | "sqlite";
77
77
  readonly checkpointerProxyEndpoint: string | null;
78
78
  readonly primaryModel: string;
79
79
  /**
@@ -97,6 +97,14 @@ export interface Config {
97
97
  readonly workspaceLockTimeoutMs: number;
98
98
  /** Shared mutable token reference for dynamic token updates (manager mode). */
99
99
  readonly stigmerTokenRef?: { current: string | null };
100
+ /**
101
+ * Shared mutable reference to the runner credential (manager mode): the
102
+ * server-minted runner token once adopted, tracking the control-plane token
103
+ * in lockstep before that. StigmerClient authenticates ExecutionContext
104
+ * reads with it so the server's runner-class decrypt gate recognizes a
105
+ * desktop runner (see stigmer-client.ts).
106
+ */
107
+ readonly stigmerRunnerTokenRef?: { current: string | null };
100
108
  }
101
109
 
102
110
  export function loadConfig(): Config {
@@ -146,8 +154,13 @@ export function loadConfig(): Config {
146
154
  const idleTimeoutRaw = process.env.STIGMER_IDLE_TIMEOUT_SECONDS;
147
155
  const idleTimeoutSeconds = idleTimeoutRaw ? parseInt(idleTimeoutRaw, 10) : null;
148
156
 
149
- const checkpointerType = (process.env.STIGMER_CHECKPOINTER_TYPE as "memory" | "http" | undefined)
150
- ?? (mode === "cloud" ? "http" : "memory");
157
+ // Local/OSS default is the durable SQLite saver so HITL/pause/transient-recovery
158
+ // resume across ExecuteDeepAgent invocations (stigmer/stigmer#204); cloud stays
159
+ // on the proxy-backed http saver. STIGMER_CHECKPOINTER_TYPE overrides explicitly
160
+ // (e.g. "memory" for ephemeral test runs).
161
+ const checkpointerType =
162
+ (process.env.STIGMER_CHECKPOINTER_TYPE as "memory" | "http" | "sqlite" | undefined)
163
+ ?? (mode === "cloud" ? "http" : "sqlite");
151
164
  const checkpointerProxyEndpoint = process.env.STIGMER_CHECKPOINTER_PROXY_ENDPOINT
152
165
  ?? proxyEndpoint;
153
166
 
@@ -168,12 +168,19 @@ describe("ApprovalGateMiddleware", () => {
168
168
 
169
169
  mockedInterrupt.mockReturnValue({ action: "reject", comment: "too dangerous" });
170
170
 
171
+ // Spy handler so we can prove the tool never executes on reject.
172
+ const handler = vi.fn(passthrough);
171
173
  const req = makeRequest({ name: "tool_b" });
172
- const result = await mw.wrapToolCall!(req, passthrough);
174
+ const result = await mw.wrapToolCall!(req, handler);
173
175
 
174
176
  expect(result).toBeInstanceOf(ToolMessage);
175
177
  expect((result as ToolMessage).content).toContain("rejected");
176
178
  expect((result as ToolMessage).content).toContain("too dangerous");
179
+ // The tool is denied — the handler (execution) must NOT run.
180
+ expect(handler).not.toHaveBeenCalled();
181
+ // REJECT denies one tool and continues the run; it does not terminate the
182
+ // execution (issue #197) — the old "Execution will be terminated" copy is gone.
183
+ expect((result as ToolMessage).content).not.toContain("terminated");
177
184
  });
178
185
 
179
186
  describe("platform tool defaults", () => {
@@ -288,9 +288,16 @@ export function createApprovalGateMiddleware(
288
288
  }
289
289
 
290
290
  if (action === "reject") {
291
- const comment = response.comment ?? "rejected by user";
291
+ // REJECT denies THIS tool call and continues the run (it does not
292
+ // terminate the execution — see APPROVAL_ACTION_REJECT in enum.proto).
293
+ // Feed the user's objection back so the model adapts rather than
294
+ // retrying. Distinct from SKIP only by the strength of the signal.
295
+ const comment = response.comment ?? "";
296
+ const rejectMessage = comment
297
+ ? `Tool '${toolName}' was rejected by the user: ${comment}. Do not retry it; proceed by taking their objection into account.`
298
+ : `Tool '${toolName}' was rejected by the user. Do not retry it; proceed by taking their objection into account.`;
292
299
  return new ToolMessage({
293
- content: `Tool '${toolName}' was rejected: ${comment}. Execution will be terminated.`,
300
+ content: rejectMessage,
294
301
  tool_call_id: toolCall.id,
295
302
  name: toolName,
296
303
  });
@@ -94,8 +94,8 @@ export interface RunnerManagerOptions {
94
94
  /** Max wait for the per-workspace turn lock (ms). @default 900000 */
95
95
  readonly workspaceLockTimeoutMs?: number;
96
96
 
97
- /** Checkpointer type for LangGraph agent state. @default "memory" (or "http" if proxyEndpoint is set) */
98
- readonly checkpointerType?: "memory" | "http";
97
+ /** Checkpointer type for LangGraph agent state. @default "sqlite" (or "http" if proxyEndpoint is set) */
98
+ readonly checkpointerType?: "memory" | "http" | "sqlite";
99
99
 
100
100
  /** Checkpointer proxy endpoint. Falls back to proxyEndpoint. */
101
101
  readonly checkpointerProxyEndpoint?: string;
@@ -202,7 +202,16 @@ export async function createStigmerRunnerManager(
202
202
  // installed) — see that module for the two-writer rationale and the staleness
203
203
  // history it guards.
204
204
  const tokenRef = { current: options.stigmerToken ?? null };
205
- const baseConfig = mapManagerOptionsToConfig(options, tokenRef);
205
+ // `runnerTokenRef` mirrors the coordinator's proxy credential for gRPC use:
206
+ // the minted runner token (token_type=embedded_runner) once adopted, tracking
207
+ // the control-plane token in lockstep before that. StigmerClient authenticates
208
+ // ExecutionContext reads with it so cloud's runner-class decrypt gate
209
+ // (stigmer-cloud#152) recognizes the desktop runner — its control-plane token
210
+ // is the user's own Auth0 token, which the server treats as a browsing user
211
+ // and answers with redacted secrets. Pre-mint the ref equals the control-plane
212
+ // token, which is exactly what the client would fall back to anyway.
213
+ const runnerTokenRef = { current: options.stigmerToken ?? null };
214
+ const baseConfig = mapManagerOptionsToConfig(options, tokenRef, runnerTokenRef);
206
215
 
207
216
  // Install the Cursor SDK interceptors BEFORE resolving Temporal coordinates.
208
217
  // Coordinate discovery dials the control plane via StigmerClient, which loads
@@ -235,14 +244,17 @@ export async function createStigmerRunnerManager(
235
244
  await assertHttp2ConnectPatched();
236
245
 
237
246
  // The token coordinator owns the proxy credential (x-stigmer-auth) and its
238
- // refresh lifecycle. It writes ONLY the interceptors and re-mints using the
239
- // always-fresh control-plane token in `tokenRef` never the (possibly
240
- // expired) minted token itself, so a slept-past-TTL runner recovers without a
241
- // restart.
247
+ // refresh lifecycle. It writes ONLY its sinks (the interceptors plus the gRPC
248
+ // runner-credential ref) and re-mints using the always-fresh control-plane
249
+ // token in `tokenRef` — never the (possibly expired) minted token itself, so
250
+ // a slept-past-TTL runner recovers without a restart.
242
251
  const tokenCoordinator = createRunnerTokenCoordinator({
243
252
  applyProxyToken: (token) => {
244
253
  updateInterceptorToken(token);
245
254
  updateHttp2InterceptorToken(token);
255
+ // Third sink: keeps the ExecutionContext-read credential in lockstep with
256
+ // the proxy credential across mint and refresh (see runnerTokenRef above).
257
+ runnerTokenRef.current = token;
246
258
  },
247
259
  reMint: () =>
248
260
  refreshRunnerAccessToken({
@@ -545,6 +557,7 @@ function validateManagerOptions(options: RunnerManagerOptions): void {
545
557
  export function mapManagerOptionsToConfig(
546
558
  options: RunnerManagerOptions,
547
559
  tokenRef?: { current: string | null },
560
+ runnerTokenRef?: { current: string | null },
548
561
  ): Config {
549
562
  const proxyActive = !!options.proxyEndpoint;
550
563
 
@@ -563,6 +576,7 @@ export function mapManagerOptionsToConfig(
563
576
  stigmerBackendEndpoint: normalizeEndpoint(options.stigmerEndpoint),
564
577
  stigmerToken: options.stigmerToken ?? null,
565
578
  stigmerTokenRef: tokenRef,
579
+ stigmerRunnerTokenRef: runnerTokenRef,
566
580
  cursorApiKey: proxyActive
567
581
  ? (options.cursorApiKey ?? "proxy-managed")
568
582
  : (options.cursorApiKey ?? ""),
@@ -573,7 +587,7 @@ export function mapManagerOptionsToConfig(
573
587
  idleTimeoutSeconds: null,
574
588
  cloudModeEnabled: options.cloudModeEnabled ?? false,
575
589
  checkpointerType:
576
- options.checkpointerType ?? (proxyActive ? "http" : "memory"),
590
+ options.checkpointerType ?? (proxyActive ? "http" : "sqlite"),
577
591
  checkpointerProxyEndpoint:
578
592
  options.checkpointerProxyEndpoint ?? options.proxyEndpoint ?? null,
579
593
  primaryModel: options.primaryModel ?? "gpt-4.1",
package/src/runner.ts CHANGED
@@ -72,8 +72,8 @@ export interface StigmerRunnerOptions {
72
72
  /** Max wait for the per-workspace turn lock (ms). @default 900000 */
73
73
  readonly workspaceLockTimeoutMs?: number;
74
74
 
75
- /** Checkpointer type for LangGraph agent state. @default "memory" (or "http" if proxyEndpoint is set) */
76
- readonly checkpointerType?: "memory" | "http";
75
+ /** Checkpointer type for LangGraph agent state. @default "sqlite" (or "http" if proxyEndpoint is set) */
76
+ readonly checkpointerType?: "memory" | "http" | "sqlite";
77
77
 
78
78
  /** Checkpointer proxy endpoint. Falls back to proxyEndpoint. */
79
79
  readonly checkpointerProxyEndpoint?: string;
@@ -270,7 +270,7 @@ export function mapOptionsToConfig(options: StigmerRunnerOptions): Config {
270
270
  idleTimeoutSeconds: null,
271
271
  cloudModeEnabled: options.cloudModeEnabled ?? false,
272
272
  checkpointerType: options.checkpointerType
273
- ?? (proxyActive ? "http" : "memory"),
273
+ ?? (proxyActive ? "http" : "sqlite"),
274
274
  checkpointerProxyEndpoint: options.checkpointerProxyEndpoint
275
275
  ?? options.proxyEndpoint
276
276
  ?? null,
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Unit tests for the HTTP MCP OAuth-challenge classifier.
3
+ *
4
+ * Verifies that `detectOAuthChallenge` recognizes a real MCP OAuth challenge
5
+ * (401 + `WWW-Authenticate: Bearer ...` per RFC 9728), and does NOT misfire on
6
+ * a plain invalid-token 401, a success, a non-OAuth error, or a network fault —
7
+ * so it can only ever turn an opaque failure into a clearer one, never mask a
8
+ * different cause.
9
+ */
10
+
11
+ import { afterEach, describe, it, expect, vi } from "vitest";
12
+ import {
13
+ OAuthRequiredError,
14
+ detectOAuthChallenge,
15
+ isOAuthChallenge,
16
+ parseResourceMetadataUrl,
17
+ } from "../mcp-oauth-detect.js";
18
+
19
+ function mockFetchOnce(response: {
20
+ status: number;
21
+ wwwAuthenticate?: string;
22
+ }): void {
23
+ const headers = new Headers();
24
+ if (response.wwwAuthenticate !== undefined) {
25
+ headers.set("www-authenticate", response.wwwAuthenticate);
26
+ }
27
+ vi.stubGlobal(
28
+ "fetch",
29
+ vi.fn(async () =>
30
+ new Response(null, { status: response.status, headers }),
31
+ ),
32
+ );
33
+ }
34
+
35
+ afterEach(() => {
36
+ vi.unstubAllGlobals();
37
+ vi.restoreAllMocks();
38
+ });
39
+
40
+ describe("isOAuthChallenge", () => {
41
+ it("matches the MCP auth spec Bearer + OAuth realm challenge", () => {
42
+ expect(
43
+ isOAuthChallenge('Bearer realm="OAuth", resource_metadata="https://x/.well-known/oauth-protected-resource"'),
44
+ ).toBe(true);
45
+ });
46
+
47
+ it("matches a Bearer challenge that only advertises resource_metadata", () => {
48
+ expect(
49
+ isOAuthChallenge('Bearer resource_metadata="https://x/.well-known/oauth-protected-resource"'),
50
+ ).toBe(true);
51
+ });
52
+
53
+ it("does not match a plain Bearer 401 (invalid API key, not an OAuth requirement)", () => {
54
+ expect(isOAuthChallenge('Bearer error="invalid_token"')).toBe(false);
55
+ });
56
+
57
+ it("does not match a non-Bearer scheme", () => {
58
+ expect(isOAuthChallenge('Basic realm="oauth"')).toBe(false);
59
+ });
60
+
61
+ it("is empty-safe", () => {
62
+ expect(isOAuthChallenge("")).toBe(false);
63
+ });
64
+ });
65
+
66
+ describe("parseResourceMetadataUrl", () => {
67
+ it("extracts the resource_metadata URL", () => {
68
+ expect(
69
+ parseResourceMetadataUrl(
70
+ 'Bearer realm="OAuth", resource_metadata="https://mcp.notion.com/.well-known/oauth-protected-resource/mcp"',
71
+ ),
72
+ ).toBe("https://mcp.notion.com/.well-known/oauth-protected-resource/mcp");
73
+ });
74
+
75
+ it("returns undefined when absent", () => {
76
+ expect(parseResourceMetadataUrl('Bearer realm="OAuth"')).toBeUndefined();
77
+ });
78
+ });
79
+
80
+ describe("detectOAuthChallenge", () => {
81
+ it("returns an OAuthRequiredError for a 401 OAuth challenge", async () => {
82
+ mockFetchOnce({
83
+ status: 401,
84
+ wwwAuthenticate:
85
+ 'Bearer realm="OAuth", resource_metadata="https://mcp.notion.com/.well-known/oauth-protected-resource/mcp"',
86
+ });
87
+
88
+ const result = await detectOAuthChallenge("https://mcp.notion.com/mcp", {}, "notion");
89
+
90
+ expect(result).toBeInstanceOf(OAuthRequiredError);
91
+ expect(result?.serverSlug).toBe("notion");
92
+ expect(result?.resourceMetadataUrl).toBe(
93
+ "https://mcp.notion.com/.well-known/oauth-protected-resource/mcp",
94
+ );
95
+ // The message is self-contained and carries the "requires OAuth" marker the
96
+ // connect wrappers key on.
97
+ expect(result?.message).toContain("requires OAuth");
98
+ expect(result?.message).toContain("notion");
99
+ });
100
+
101
+ it("returns null for a 401 that is NOT an OAuth challenge (invalid static token)", async () => {
102
+ mockFetchOnce({ status: 401, wwwAuthenticate: 'Bearer error="invalid_token"' });
103
+ expect(
104
+ await detectOAuthChallenge("https://api.example.com/mcp", {}, "example"),
105
+ ).toBeNull();
106
+ });
107
+
108
+ it("returns null on a non-401 response", async () => {
109
+ mockFetchOnce({ status: 200 });
110
+ expect(
111
+ await detectOAuthChallenge("https://api.example.com/mcp", {}, "example"),
112
+ ).toBeNull();
113
+ });
114
+
115
+ it("returns null (never throws) when the probe itself fails", async () => {
116
+ vi.stubGlobal(
117
+ "fetch",
118
+ vi.fn(async () => {
119
+ throw new Error("network down");
120
+ }),
121
+ );
122
+ expect(
123
+ await detectOAuthChallenge("https://api.example.com/mcp", {}, "example"),
124
+ ).toBeNull();
125
+ });
126
+
127
+ it("forwards the resolved headers to the probe request", async () => {
128
+ const fetchMock = vi.fn(
129
+ async (_url: string | URL, _init?: RequestInit) =>
130
+ new Response(null, { status: 200 }),
131
+ );
132
+ vi.stubGlobal("fetch", fetchMock);
133
+
134
+ await detectOAuthChallenge(
135
+ "https://api.example.com/mcp",
136
+ { Authorization: "Bearer stale-token" },
137
+ "example",
138
+ );
139
+
140
+ expect(fetchMock).toHaveBeenCalledOnce();
141
+ const init = fetchMock.mock.calls[0]?.[1];
142
+ expect((init?.headers as Record<string, string>).Authorization).toBe(
143
+ "Bearer stale-token",
144
+ );
145
+ expect(init?.method).toBe("POST");
146
+ });
147
+ });
@@ -11,6 +11,7 @@ import { AgentMessageSchema, ToolCallSchema } from "@stigmer/protos/ai/stigmer/a
11
11
  import { SubAgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
12
12
  import { ToolCallStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
13
13
  import {
14
+ collectSettledToolCallIds,
14
15
  collectSubAgentToolCallIds,
15
16
  hideToolCallRow,
16
17
  isToolCallRowHidden,
@@ -354,3 +355,37 @@ describe("collectSubAgentToolCallIds", () => {
354
355
  expect([...collectSubAgentToolCallIds([sa]).values()].sort()).toEqual(["tc-a", "tc-b"]);
355
356
  });
356
357
  });
358
+
359
+ describe("collectSettledToolCallIds", () => {
360
+ function msgWith(id: string, status: ToolCallStatus) {
361
+ return create(AgentMessageSchema, {
362
+ type: 1, // MESSAGE_AI
363
+ toolCalls: [create(ToolCallSchema, { id, name: "Shell", status })],
364
+ });
365
+ }
366
+
367
+ it("collects every terminal status and skips every live one", () => {
368
+ const ids = collectSettledToolCallIds([
369
+ msgWith("tc-completed", ToolCallStatus.TOOL_CALL_COMPLETED),
370
+ msgWith("tc-failed", ToolCallStatus.TOOL_CALL_FAILED),
371
+ msgWith("tc-skipped", ToolCallStatus.TOOL_CALL_SKIPPED),
372
+ msgWith("tc-pending", ToolCallStatus.TOOL_CALL_PENDING),
373
+ msgWith("tc-running", ToolCallStatus.TOOL_CALL_RUNNING),
374
+ msgWith("tc-gated", ToolCallStatus.TOOL_CALL_WAITING_APPROVAL),
375
+ ]);
376
+ expect([...ids].sort()).toEqual(["tc-completed", "tc-failed", "tc-skipped"]);
377
+ });
378
+
379
+ it("treats a server-settled INTERRUPTED row as settled (recovery provenance scoping)", () => {
380
+ // A FAILED-then-recovered execution seeds INTERRUPTED rows from the prior
381
+ // invocation. For turn-boundary provenance those are PRIOR-turn history —
382
+ // scoping them as this-turn would mis-attribute a dead call to the
383
+ // resuming turn's change set. (The Cursor monotonic guard makes the
384
+ // opposite call for the same status so a replayed event can still advance
385
+ // the row; see isTerminalToolStatus in message-translator.ts.)
386
+ const ids = collectSettledToolCallIds([
387
+ msgWith("tc-interrupted", ToolCallStatus.TOOL_CALL_INTERRUPTED),
388
+ ]);
389
+ expect(ids.has("tc-interrupted")).toBe(true);
390
+ });
391
+ });
@@ -1,14 +1,40 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import { MemorySaver } from "@langchain/langgraph-checkpoint";
3
6
  import { createCheckpointer, CheckpointerCreationError } from "../factory.js";
4
7
  import { HttpCheckpointSaver } from "../http-saver.js";
8
+ import { SqliteCheckpointSaver } from "../sqlite-saver.js";
5
9
 
6
10
  describe("createCheckpointer", () => {
11
+ const tempDirs: string[] = [];
12
+ afterEach(() => {
13
+ for (const d of tempDirs.splice(0)) rmSync(d, { recursive: true, force: true });
14
+ });
15
+
7
16
  it("returns MemorySaver for type=memory", async () => {
8
17
  const saver = await createCheckpointer({ type: "memory" });
9
18
  expect(saver).toBeInstanceOf(MemorySaver);
10
19
  });
11
20
 
21
+ it("returns SqliteCheckpointSaver for type=sqlite", async () => {
22
+ const dir = mkdtempSync(join(tmpdir(), "stigmer-cp-factory-"));
23
+ tempDirs.push(dir);
24
+ const saver = await createCheckpointer({
25
+ type: "sqlite",
26
+ sqlitePath: join(dir, "checkpoints.db"),
27
+ });
28
+ expect(saver).toBeInstanceOf(SqliteCheckpointSaver);
29
+ (saver as SqliteCheckpointSaver).close();
30
+ });
31
+
32
+ it("throws CheckpointerCreationError when sqlite is missing sqlitePath", async () => {
33
+ await expect(
34
+ createCheckpointer({ type: "sqlite" }),
35
+ ).rejects.toThrow(CheckpointerCreationError);
36
+ });
37
+
12
38
  it("returns HttpCheckpointSaver for type=http", async () => {
13
39
  const saver = await createCheckpointer({
14
40
  type: "http",