@stigmer/runner 3.6.0 → 3.8.0

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 (100) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/call-agent.js +85 -10
  3. package/dist/activities/call-agent.js.map +1 -1
  4. package/dist/activities/execute-cursor/index.d.ts +12 -0
  5. package/dist/activities/execute-cursor/index.js +80 -10
  6. package/dist/activities/execute-cursor/index.js.map +1 -1
  7. package/dist/activities/execute-cursor/model-pricing.d.ts +9 -0
  8. package/dist/activities/execute-cursor/model-pricing.js +19 -0
  9. package/dist/activities/execute-cursor/model-pricing.js.map +1 -1
  10. package/dist/activities/execute-cursor/prompt-builder.d.ts +11 -0
  11. package/dist/activities/execute-cursor/prompt-builder.js +11 -0
  12. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  13. package/dist/activities/execute-cursor/service-tier.d.ts +68 -0
  14. package/dist/activities/execute-cursor/service-tier.js +187 -0
  15. package/dist/activities/execute-cursor/service-tier.js.map +1 -0
  16. package/dist/activities/execute-cursor/session-lifecycle.d.ts +16 -1
  17. package/dist/activities/execute-cursor/session-lifecycle.js +12 -4
  18. package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
  19. package/dist/activities/execute-cursor/usage-accumulator.d.ts +21 -1
  20. package/dist/activities/execute-cursor/usage-accumulator.js +23 -3
  21. package/dist/activities/execute-cursor/usage-accumulator.js.map +1 -1
  22. package/dist/activities/execute-deep-agent/mcp-gate.d.ts +28 -0
  23. package/dist/activities/execute-deep-agent/mcp-gate.js +22 -0
  24. package/dist/activities/execute-deep-agent/mcp-gate.js.map +1 -0
  25. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +11 -0
  26. package/dist/activities/execute-deep-agent/prompt-builder.js +16 -0
  27. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  28. package/dist/activities/execute-deep-agent/setup.js +30 -4
  29. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  30. package/dist/client/stigmer-client.d.ts +6 -1
  31. package/dist/client/stigmer-client.js +5 -2
  32. package/dist/client/stigmer-client.js.map +1 -1
  33. package/dist/main.js +18 -0
  34. package/dist/main.js.map +1 -1
  35. package/dist/runner.js +48 -0
  36. package/dist/runner.js.map +1 -1
  37. package/dist/sandbox-token-renewal.d.ts +65 -0
  38. package/dist/sandbox-token-renewal.js +169 -0
  39. package/dist/sandbox-token-renewal.js.map +1 -0
  40. package/dist/shared/artifact-storage.d.ts +17 -3
  41. package/dist/shared/artifact-storage.js +22 -4
  42. package/dist/shared/artifact-storage.js.map +1 -1
  43. package/dist/shared/channel-attachment.d.ts +3 -1
  44. package/dist/shared/channel-attachment.js +3 -1
  45. package/dist/shared/channel-attachment.js.map +1 -1
  46. package/dist/shared/conversation-attachment.d.ts +81 -0
  47. package/dist/shared/conversation-attachment.js +102 -0
  48. package/dist/shared/conversation-attachment.js.map +1 -0
  49. package/dist/shared/conversation-catchup.d.ts +33 -0
  50. package/dist/shared/conversation-catchup.js +53 -0
  51. package/dist/shared/conversation-catchup.js.map +1 -0
  52. package/dist/workflow-engine/loader.js +99 -2
  53. package/dist/workflow-engine/loader.js.map +1 -1
  54. package/dist/workflow-engine/tasks/call-agent.d.ts +0 -2
  55. package/dist/workflow-engine/tasks/call-agent.js +0 -2
  56. package/dist/workflow-engine/tasks/call-agent.js.map +1 -1
  57. package/dist/workflow-engine/types.d.ts +39 -7
  58. package/dist/workflow-engine/types.js.map +1 -1
  59. package/dist/workflows/call-agent-orchestrator.d.ts +3 -2
  60. package/dist/workflows/call-agent-orchestrator.js +8 -2
  61. package/dist/workflows/call-agent-orchestrator.js.map +1 -1
  62. package/package.json +2 -2
  63. package/src/__tests__/sandbox-token-renewal.test.ts +174 -0
  64. package/src/activities/__tests__/call-agent-contracts.test.ts +4 -4
  65. package/src/activities/__tests__/call-agent.test.ts +219 -4
  66. package/src/activities/call-agent.ts +94 -10
  67. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +79 -0
  68. package/src/activities/execute-cursor/__tests__/model-pricing.test.ts +20 -0
  69. package/src/activities/execute-cursor/__tests__/service-tier.test.ts +170 -0
  70. package/src/activities/execute-cursor/__tests__/usage-accumulator.test.ts +87 -1
  71. package/src/activities/execute-cursor/index.ts +111 -11
  72. package/src/activities/execute-cursor/model-pricing.ts +23 -0
  73. package/src/activities/execute-cursor/prompt-builder.ts +23 -0
  74. package/src/activities/execute-cursor/service-tier.ts +244 -0
  75. package/src/activities/execute-cursor/session-lifecycle.ts +33 -5
  76. package/src/activities/execute-cursor/usage-accumulator.ts +35 -3
  77. package/src/activities/execute-deep-agent/__tests__/mcp-gate.test.ts +42 -0
  78. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +39 -1
  79. package/src/activities/execute-deep-agent/mcp-gate.ts +37 -0
  80. package/src/activities/execute-deep-agent/prompt-builder.ts +22 -2
  81. package/src/activities/execute-deep-agent/setup.ts +40 -4
  82. package/src/client/stigmer-client.ts +11 -4
  83. package/src/main.ts +20 -0
  84. package/src/runner.ts +62 -0
  85. package/src/sandbox-token-renewal.ts +212 -0
  86. package/src/shared/__tests__/channel-attachment.test.ts +3 -3
  87. package/src/shared/__tests__/conversation-attachment.test.ts +138 -0
  88. package/src/shared/__tests__/conversation-catchup.test.ts +70 -0
  89. package/src/shared/__tests__/synthesized-attachment.test.ts +120 -0
  90. package/src/shared/artifact-storage.ts +32 -7
  91. package/src/shared/channel-attachment.ts +3 -1
  92. package/src/shared/conversation-attachment.ts +115 -0
  93. package/src/shared/conversation-catchup.ts +60 -0
  94. package/src/workflow-engine/__tests__/golden-execution.test.ts +8 -8
  95. package/src/workflow-engine/__tests__/loader.test.ts +192 -7
  96. package/src/workflow-engine/__tests__/tasks/call-agent.test.ts +9 -9
  97. package/src/workflow-engine/loader.ts +113 -2
  98. package/src/workflow-engine/tasks/call-agent.ts +0 -2
  99. package/src/workflow-engine/types.ts +40 -7
  100. package/src/workflows/call-agent-orchestrator.ts +8 -2
@@ -25,6 +25,7 @@ import type { StigmerClient } from "../../client/stigmer-client.js";
25
25
  import { TimingRecorder, emitTimingLog } from "../../shared/cold-start-timing.js";
26
26
  import { createCheckpointer } from "../../shared/checkpointer/factory.js";
27
27
  import { readContextBridge } from "../../shared/context-bridge.js";
28
+ import { readConversationCatchup } from "../../shared/conversation-catchup.js";
28
29
  import { readSenderIdentity } from "../../shared/sender-identity.js";
29
30
  import {
30
31
  injectCallerIdentityEnv,
@@ -44,7 +45,12 @@ import {
44
45
  formatChannelTemplatesSection,
45
46
  synthesizeChannelAttachment,
46
47
  } from "../../shared/channel-attachment.js";
48
+ import {
49
+ readChannelConversationId,
50
+ synthesizeConversationAttachment,
51
+ } from "../../shared/conversation-attachment.js";
47
52
  import { injectSynthesizedAttachment } from "../../shared/synthesized-attachment.js";
53
+ import { shouldConnectMcp } from "./mcp-gate.js";
48
54
  import { WorkspaceProvisioner } from "../../shared/workspace/provisioner.js";
49
55
  import { LocalWorkspaceBackend } from "../../shared/workspace/local-backend.js";
50
56
  import type { WorkspaceBackend, ProvisionResult } from "../../shared/workspace/types.js";
@@ -59,7 +65,7 @@ import { resolveSessionWorkspaceRoot } from "../../shared/workspace/session-root
59
65
  import { buildWorkspaceFileTree } from "../../shared/workspace/file-tree.js";
60
66
  import { reportSetupProgress } from "../../shared/status.js";
61
67
  import { resolveEnvironment, type EnvironmentResult } from "./environment.js";
62
- import { buildEnhancedSystemPrompt } from "./prompt-builder.js";
68
+ import { buildEnhancedSystemPrompt, composeUserMessage } from "./prompt-builder.js";
63
69
  import { buildMiddlewareStack } from "../../middleware/index.js";
64
70
  import type { GracefulStopMiddleware } from "../../middleware/index.js";
65
71
  import { createThinkTool, createWebFetchTool, resolveGuardPosture } from "../../tools/index.js";
@@ -361,8 +367,18 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
361
367
  // answer — no tool, no section, execution unharmed.
362
368
  const channelMessaging = await discoverChannelMessaging(client, exchangedRunnerToken);
363
369
 
370
+ // The conversation-attachment decision (DD-008 D-c): the channel-id
371
+ // session label, stamped server-side on every channel session — a
372
+ // free, synchronous read, so no hoisted discovery needed.
373
+ const conversationChannelId = readChannelConversationId(session.metadata?.labels);
374
+
364
375
  let resolvedMcpServers: Awaited<ReturnType<typeof resolveMcpServers>> | null = null;
365
- if (mcpServerUsages.length > 0 || datastoreUsages.length > 0 || channelMessaging.length > 0) {
376
+ if (shouldConnectMcp({
377
+ mcpServerUsageCount: mcpServerUsages.length,
378
+ datastoreUsageCount: datastoreUsages.length,
379
+ channelMessagingCount: channelMessaging.length,
380
+ conversationChannelId,
381
+ })) {
366
382
  await reportSetupProgress(client, executionId, "Connecting tools…");
367
383
  const transportPosture = resolveMcpTransportPosture(config.mode);
368
384
  // The MCP-bound env map (and ONLY it) carries the reserved
@@ -424,6 +440,21 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
424
440
  );
425
441
  }
426
442
  }
443
+
444
+ // The conversation participation attachment (DD-008 D-c) — the
445
+ // third sibling, same after-backfill rule. HTTP-only: synthesize
446
+ // answers undefined with no bridge endpoint by design (see
447
+ // shared/conversation-attachment.ts).
448
+ const conversationAttachment = synthesizeConversationAttachment(conversationChannelId, {
449
+ bridgeEndpoint: config.mcpBridgeEndpoint,
450
+ credential: attachmentCredential,
451
+ backendEndpoint: config.stigmerBackendEndpoint,
452
+ });
453
+ if (conversationAttachment) {
454
+ backfilledServers = injectSynthesizedAttachment(
455
+ backfilledServers, conversationAttachment, "conversation participation",
456
+ );
457
+ }
427
458
  resolvedMcpServers = { resolvedServers: backfilledServers };
428
459
  timing.mark("backfill_mcp");
429
460
 
@@ -743,8 +774,13 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
743
774
  ...(isPlanMode ? { permissions: planModePermissions } : {}),
744
775
  } as Parameters<typeof createDeepAgent>[0]);
745
776
 
746
- // Step 11: Prepare invocation input and config
747
- let userMessage = execution.spec!.message;
777
+ // Step 11: Prepare invocation input and config. The conversation catchup
778
+ // (cloud DD-006) rides the USER MESSAGE, not the system prompt — see
779
+ // composeUserMessage for the durability rationale (A27).
780
+ let userMessage = composeUserMessage(
781
+ execution.spec!.message,
782
+ readConversationCatchup(execution.spec!.conversationCatchup),
783
+ );
748
784
  if (outputSchema) {
749
785
  userMessage += `\n\n---\nIMPORTANT: When your analysis is complete, provide your findings as structured output matching the required schema. The system will capture your structured response automatically.`;
750
786
  }
@@ -49,7 +49,7 @@ import { WorkflowQueryController } from "@stigmer/protos/ai/stigmer/agentic/work
49
49
  import type { Workflow } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/api_pb";
50
50
  import { WorkflowInstanceQueryController } from "@stigmer/protos/ai/stigmer/agentic/workflowinstance/v1/query_pb";
51
51
  import type { WorkflowInstance } from "@stigmer/protos/ai/stigmer/agentic/workflowinstance/v1/api_pb";
52
- import { PlatformQueryController, GetRunnerScopedTokenInputSchema } from "@stigmer/protos/ai/stigmer/platform/v1/server_info_pb";
52
+ import { PlatformQueryController, GetRunnerScopedTokenInputSchema, TokenRenewalSchema } from "@stigmer/protos/ai/stigmer/platform/v1/server_info_pb";
53
53
  import { ChannelMessageQueryController } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_query_pb";
54
54
  import type { ChannelTemplate, MessagingChannel } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
55
55
  import { isEmbeddedRunnerToken } from "./token-claims.js";
@@ -94,12 +94,16 @@ export interface RunnerScopedToken {
94
94
  * `poolClaimSessionId` is the warm-pool attach exchange: a pool sandbox
95
95
  * presenting its pool_sandbox credential for the session it was claimed for
96
96
  * (the server authorizes against the claim record, not the caller's FGA
97
- * relations). The execution arms remain embedded_runner-only.
97
+ * relations). `renewal` is a live sandbox extending its own credential
98
+ * before expiry: no id is named because every mint parameter comes from the
99
+ * presented credential's verified claims (see sandbox-token-renewal.ts).
100
+ * The execution arms remain embedded_runner-only.
98
101
  */
99
102
  export type RunnerScopedTokenScope =
100
103
  | { agentExecutionId: string }
101
104
  | { workflowExecutionId: string }
102
- | { poolClaimSessionId: string };
105
+ | { poolClaimSessionId: string }
106
+ | { renewal: true };
103
107
 
104
108
  /**
105
109
  * Map the scope union onto the proto oneof init shape. The narrowing chain is
@@ -113,7 +117,10 @@ function toRunnerScopedTokenOneof(scope: RunnerScopedTokenScope) {
113
117
  if ("workflowExecutionId" in scope) {
114
118
  return { case: "workflowExecutionId", value: scope.workflowExecutionId } as const;
115
119
  }
116
- return { case: "poolClaim", value: { sessionId: scope.poolClaimSessionId } } as const;
120
+ if ("poolClaimSessionId" in scope) {
121
+ return { case: "poolClaim", value: { sessionId: scope.poolClaimSessionId } } as const;
122
+ }
123
+ return { case: "renewal", value: create(TokenRenewalSchema) } as const;
117
124
  }
118
125
 
119
126
  /**
package/src/main.ts CHANGED
@@ -213,6 +213,25 @@ async function runPoolMode(
213
213
  executionMode: config.mode,
214
214
  });
215
215
 
216
+ // Sandbox credential self-renewal (see sandbox-token-renewal.ts). Watches
217
+ // the env var because manager.updateToken keeps it in lockstep with the
218
+ // manager's internal ref: a blank member's pool_sandbox token parks the
219
+ // loop, the claim's updateToken swaps in a renewable session token, and
220
+ // from then on renewal applies fresh tokens back through the same
221
+ // updateToken (which also cascades to the proxy-credential coordinator).
222
+ const { startSandboxTokenRenewal } = await import("./sandbox-token-renewal.js");
223
+ const { StigmerClient } = await import("./client/stigmer-client.js");
224
+ const renewalClient = new StigmerClient({
225
+ endpoint: config.stigmerBackendEndpoint,
226
+ token: null,
227
+ });
228
+ const tokenRenewal = startSandboxTokenRenewal({
229
+ getToken: () => process.env.STIGMER_TOKEN ?? null,
230
+ renew: (currentToken) =>
231
+ renewalClient.getRunnerScopedToken({ renewal: true }, currentToken),
232
+ applyToken: (token) => manager.updateToken(token),
233
+ });
234
+
216
235
  let taskQueue: string;
217
236
  if (intent.kind === "pool-control") {
218
237
  registerPoolMemberContext({
@@ -277,6 +296,7 @@ async function runPoolMode(
277
296
  }
278
297
  shutdownRequested = true;
279
298
  console.warn(`[pool-member] Received ${signal}, shutting down gracefully...`);
299
+ tokenRenewal.stop();
280
300
  void manager.shutdown().then(resolve, (err) => {
281
301
  console.error("[pool-member] Shutdown failed:", err);
282
302
  resolve();
package/src/runner.ts CHANGED
@@ -114,6 +114,56 @@ export interface StigmerRunner {
114
114
  shutdown(): void;
115
115
  }
116
116
 
117
+ /**
118
+ * Wire up self-renewal for a static cloud sandbox's control-plane credential
119
+ * (see sandbox-token-renewal.ts for the model). The applied token reaches
120
+ * every consumer: activity gRPC clients read {@code tokenRef} per request,
121
+ * env-reading call sites (call-llm, registry-endpoint) read
122
+ * {@code process.env.STIGMER_TOKEN} per call, artifact storage resolves the
123
+ * ref per call, and the two Cursor SDK interceptors are updated directly —
124
+ * a static runner has no {@code RunnerTokenCoordinator} minting a separate
125
+ * proxy credential, so its x-stigmer-auth IS this token.
126
+ */
127
+ async function startStaticSandboxTokenRenewal(
128
+ config: Config,
129
+ tokenRef: { current: string | null },
130
+ ): Promise<{ stop(): void } | null> {
131
+ const { isRenewableSandboxToken, startSandboxTokenRenewal } = await import(
132
+ "./sandbox-token-renewal.js"
133
+ );
134
+ // Static mode's credential class never changes (it is baked into the pod's
135
+ // env), so a non-renewable credential — desktop/OSS/local — never starts
136
+ // the loop at all.
137
+ if (!isRenewableSandboxToken(tokenRef.current)) {
138
+ return null;
139
+ }
140
+
141
+ const { StigmerClient } = await import("./client/stigmer-client.js");
142
+ const { updateInterceptorToken } = await import(
143
+ "./activities/execute-cursor/fetch-interceptor.js"
144
+ );
145
+ const { updateHttp2InterceptorToken } = await import(
146
+ "./activities/execute-cursor/http2-interceptor.js"
147
+ );
148
+ const client = new StigmerClient({
149
+ endpoint: config.stigmerBackendEndpoint,
150
+ token: null,
151
+ tokenRef,
152
+ });
153
+
154
+ return startSandboxTokenRenewal({
155
+ getToken: () => tokenRef.current,
156
+ renew: (currentToken) =>
157
+ client.getRunnerScopedToken({ renewal: true }, currentToken),
158
+ applyToken: (token) => {
159
+ tokenRef.current = token;
160
+ process.env.STIGMER_TOKEN = token;
161
+ updateInterceptorToken(token);
162
+ updateHttp2InterceptorToken(token);
163
+ },
164
+ });
165
+ }
166
+
117
167
  /**
118
168
  * Create a Stigmer runner ready to poll a Temporal task queue.
119
169
  *
@@ -198,13 +248,24 @@ export async function createStigmerRunner(
198
248
  token: options.stigmerToken,
199
249
  stigmerEndpoint: baseConfig.stigmerBackendEndpoint,
200
250
  });
251
+ // The control-plane credential lives in a shared mutable ref (as in manager
252
+ // mode) so the sandbox-token renewal below can rotate it in-process:
253
+ // activity clients read the ref per request instead of pinning the boot
254
+ // token for the pod's whole life.
255
+ const tokenRef = { current: baseConfig.stigmerToken };
201
256
  const config: Config = {
202
257
  ...baseConfig,
203
258
  temporalAddress: coordinates.temporalAddress,
204
259
  temporalNamespace: coordinates.temporalNamespace,
260
+ stigmerTokenRef: tokenRef,
205
261
  };
206
262
  markBoot("bootstrap_resolved");
207
263
 
264
+ // Cloud sandbox credential self-renewal (no-op for desktop/OSS/local
265
+ // credentials — see the helper). Started before the worker so the first
266
+ // renewal point is scheduled even if the pod boots with a part-used token.
267
+ const tokenRenewal = await startStaticSandboxTokenRenewal(config, tokenRef);
268
+
208
269
  const { setExecutionContextRef } = await import(
209
270
  "./activities/execute-cursor/rejection-capture.js"
210
271
  );
@@ -242,6 +303,7 @@ export async function createStigmerRunner(
242
303
  console.log("Worker stopped");
243
304
  },
244
305
  shutdown() {
306
+ tokenRenewal?.stop();
245
307
  worker.shutdown();
246
308
  },
247
309
  };
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Self-renewal of a cloud sandbox's control-plane credential (STIGMER_TOKEN).
3
+ *
4
+ * A sandbox token is minted with a fixed TTL, but the sandbox it serves has
5
+ * no fixed lifetime: an active conversation extends a session sandbox
6
+ * indefinitely, and a long workflow run can outlast any TTL chosen at
7
+ * provisioning. Before this module, the only refresh path was the control
8
+ * plane's Secret-rewrite + pod restart — which the 2026-08-05 incident
9
+ * showed both sacrifices the turn that trips it (the old pod picks up the
10
+ * work holding the dead env token) and wipes an ephemeral sandbox's
11
+ * workspace. Renewal decouples credential lifetime from sandbox lifetime:
12
+ * the runner re-mints in-process before expiry via the getRunnerScopedToken
13
+ * `renewal` arm, and the server bounds renewability by the live sandbox
14
+ * record (a reaped sandbox's credential dies with it).
15
+ *
16
+ * Relationship to {@link createRunnerTokenCoordinator}
17
+ * (runner-token-coordinator.ts): the coordinator owns the PROXY credential
18
+ * (x-stigmer-auth) and re-mints it using the control-plane token; this
19
+ * module keeps that control-plane token itself fresh, so the two form one
20
+ * chain with no expiring root. They stay separate modules because their
21
+ * hosts differ — every proxy-mode runner has a coordinator, but only
22
+ * in-cluster sandbox runners (static cloud sandboxes and pool members) hold
23
+ * a renewable credential; the desktop's control-plane token is the user's
24
+ * own Auth0 token, refreshed by the host app.
25
+ *
26
+ * The loop re-reads the CURRENT token every cycle rather than binding to
27
+ * the boot credential, because the credential's class can change under it:
28
+ * a pool member boots with a non-renewable pool_sandbox token and swaps to
29
+ * a renewable session token at claim time (attach-session.ts). A
30
+ * non-renewable token parks the loop on a slow recheck instead of stopping
31
+ * it, so the swap is picked up without any coupling to the claim path.
32
+ */
33
+
34
+ /**
35
+ * Renew once the token has lived this fraction of its issued lifetime
36
+ * (anchored on iat/exp, the coordinator's 0.8 convention). Anchoring on the
37
+ * ISSUED lifetime matters: a fraction of the *remaining* lifetime re-arms at
38
+ * 80% of an ever-shrinking remainder and never reaches the renewal point.
39
+ */
40
+ const RENEW_AT_LIFETIME_FRACTION = 0.8;
41
+
42
+ /**
43
+ * Fallback safety margin before expiry for a token without an iat claim:
44
+ * renew this early, leaving room for two retry cycles.
45
+ */
46
+ const FALLBACK_RENEW_MARGIN_MS = 10 * 60_000;
47
+
48
+ /** Retry delay after a failed renewal — short enough to recover well before expiry. */
49
+ const RETRY_DELAY_MS = 60_000;
50
+
51
+ /** Recheck cadence while the current token is not a renewable class (pre-claim pool member). */
52
+ const RECHECK_DELAY_MS = 60_000;
53
+
54
+ /** Floor for any scheduled delay so a malformed expiry cannot spin a hot loop. */
55
+ const MIN_DELAY_MS = 5_000;
56
+
57
+ /** The token_type claims the renewal arm accepts (mirrors the server's gate). */
58
+ const RENEWABLE_TOKEN_TYPES = new Set(["sandbox", "workflow_sandbox"]);
59
+
60
+ export interface SandboxTokenRenewalOptions {
61
+ /** Reads the credential currently in effect (re-read every cycle; see module doc). */
62
+ readonly getToken: () => string | null | undefined;
63
+ /**
64
+ * Calls the getRunnerScopedToken `renewal` arm authenticated with
65
+ * {@code currentToken}. Returns undefined when the server minted nothing;
66
+ * the loop retries rather than crashing the runner.
67
+ */
68
+ readonly renew: (currentToken: string) => Promise<
69
+ { token: string; expiresInSeconds?: number } | undefined
70
+ >;
71
+ /** Applies a freshly minted credential to every sink the host wires (ref, env, interceptors). */
72
+ readonly applyToken: (token: string) => void;
73
+ /** Optional structured logger; defaults to console. */
74
+ readonly log?: Pick<typeof console, "log" | "warn">;
75
+ }
76
+
77
+ export interface SandboxTokenRenewal {
78
+ /** Stop the renewal timer. Idempotent. */
79
+ stop(): void;
80
+ }
81
+
82
+ /**
83
+ * Whether a token's decoded claims mark it as renewable through the
84
+ * `renewal` arm. Exported so a static-mode host can decide not to start
85
+ * the loop at all for a credential whose class can never change.
86
+ */
87
+ export function isRenewableSandboxToken(token: string | null | undefined): boolean {
88
+ const claims = decodeClaims(token);
89
+ return claims !== null
90
+ && RENEWABLE_TOKEN_TYPES.has(claims.tokenType ?? "")
91
+ && claims.expMs !== null;
92
+ }
93
+
94
+ /**
95
+ * Start the renewal loop. The first cycle runs immediately (it only reads
96
+ * the token and schedules; no network call unless the credential is already
97
+ * past its renewal point).
98
+ */
99
+ export function startSandboxTokenRenewal(
100
+ options: SandboxTokenRenewalOptions,
101
+ ): SandboxTokenRenewal {
102
+ const log = options.log ?? console;
103
+ let stopped = false;
104
+ let timer: ReturnType<typeof setTimeout> | null = null;
105
+
106
+ const schedule = (delayMs: number): void => {
107
+ if (stopped) {
108
+ return;
109
+ }
110
+ timer = setTimeout(() => {
111
+ void cycle();
112
+ }, Math.max(MIN_DELAY_MS, delayMs));
113
+ // Never keep the process alive solely for a token renewal.
114
+ timer.unref?.();
115
+ };
116
+
117
+ const cycle = async (): Promise<void> => {
118
+ if (stopped) {
119
+ return;
120
+ }
121
+ const token = options.getToken() ?? null;
122
+ const claims = decodeClaims(token);
123
+
124
+ if (token === null || claims === null
125
+ || !RENEWABLE_TOKEN_TYPES.has(claims.tokenType ?? "")) {
126
+ // Not (yet) a renewable credential — a pre-claim pool member, or a
127
+ // credential class this loop does not own. Park and look again.
128
+ schedule(RECHECK_DELAY_MS);
129
+ return;
130
+ }
131
+
132
+ if (claims.expMs === null) {
133
+ // A renewable-class token without an expiry is a mint-side anomaly;
134
+ // renewing "before expiry" is undefined, so park rather than spin.
135
+ log.warn("[sandbox-token-renewal] Credential carries no exp claim; parking");
136
+ schedule(RECHECK_DELAY_MS);
137
+ return;
138
+ }
139
+
140
+ const renewAtMs = claims.iatMs !== null
141
+ ? claims.iatMs + (claims.expMs - claims.iatMs) * RENEW_AT_LIFETIME_FRACTION
142
+ : claims.expMs - FALLBACK_RENEW_MARGIN_MS;
143
+ const delayMs = renewAtMs - Date.now();
144
+ if (delayMs > MIN_DELAY_MS) {
145
+ schedule(delayMs);
146
+ return;
147
+ }
148
+
149
+ // At or past the renewal point (possibly past expiry after a long pod
150
+ // pause — the attempt is still correct: the server decides).
151
+ try {
152
+ const renewed = await options.renew(token);
153
+ if (renewed) {
154
+ options.applyToken(renewed.token);
155
+ log.log("[sandbox-token-renewal] Sandbox credential renewed");
156
+ // Recompute from the fresh token (its own exp is authoritative).
157
+ schedule(MIN_DELAY_MS);
158
+ } else {
159
+ log.warn("[sandbox-token-renewal] Server minted no renewal; will retry");
160
+ schedule(RETRY_DELAY_MS);
161
+ }
162
+ } catch (err) {
163
+ log.warn(
164
+ "[sandbox-token-renewal] Renewal failed; will retry: " +
165
+ `${err instanceof Error ? err.message : err}`,
166
+ );
167
+ schedule(RETRY_DELAY_MS);
168
+ }
169
+ };
170
+
171
+ void cycle();
172
+
173
+ return {
174
+ stop(): void {
175
+ stopped = true;
176
+ if (timer) {
177
+ clearTimeout(timer);
178
+ timer = null;
179
+ }
180
+ },
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Decode the claims this module reads (token_type, iat, exp) from an
186
+ * unverified JWT. Decode-only on purpose: this is the runner's OWN
187
+ * credential, minted by the server it will present it back to —
188
+ * verification happens there.
189
+ */
190
+ function decodeClaims(
191
+ token: string | null | undefined,
192
+ ): { tokenType: string | undefined; iatMs: number | null; expMs: number | null } | null {
193
+ if (!token) {
194
+ return null;
195
+ }
196
+ const parts = token.split(".");
197
+ if (parts.length !== 3) {
198
+ return null;
199
+ }
200
+ try {
201
+ const payload = JSON.parse(
202
+ Buffer.from(parts[1], "base64url").toString("utf-8"),
203
+ ) as { token_type?: string; iat?: number; exp?: number };
204
+ return {
205
+ tokenType: payload.token_type,
206
+ iatMs: typeof payload.iat === "number" ? payload.iat * 1000 : null,
207
+ expMs: typeof payload.exp === "number" ? payload.exp * 1000 : null,
208
+ };
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
@@ -3,9 +3,9 @@
3
3
  * discovery with the never-throw failure posture, both connection
4
4
  * shapes, the structural approval-freedom the datastore attachment
5
5
  * pinned before it, and the prompt section's filter/order/cap rules
6
- * (DD-006 D6). The cross-repo pinned strings (slug, route, roster) are
7
- * guarded here and in the mcp-server integration test the
8
- * TOOL_CALL_LIMIT precedent.
6
+ * (DD-006 D6). The route is the cross-repo string, guarded here and in
7
+ * the mcp-server integration test (the TOOL_CALL_LIMIT precedent); the
8
+ * slug and roster are runner-internal and guarded here alone.
9
9
  */
10
10
 
11
11
  import { describe, expect, it, vi } from "vitest";
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The conversation participation attachment (channel-conversations
3
+ * DD-008 D-c, A14): the label-keyed attachment decision, the HTTP-only
4
+ * shape (the deliberate no-stdio divergence from both siblings), the
5
+ * structural approval-freedom, and the pinned strings. The route is the
6
+ * cross-repo string — pinned here and in the mcp-server's conversation
7
+ * integration test (the TOOL_CALL_LIMIT precedent); the label is pinned
8
+ * here and in the cloud's ChannelSessionBrokerTest (the sender-identity
9
+ * mirror-guard precedent).
10
+ */
11
+
12
+ import { describe, expect, it } from "vitest";
13
+
14
+ import { mergeApprovalPolicies, type ActiveLeases } from "../approval-policy.js";
15
+ import { needsBackfill } from "../connect-backfill.js";
16
+ import {
17
+ CHANNEL_ID_LABEL,
18
+ CONVERSATION_ATTACHMENT_SLUG,
19
+ CONVERSATION_ROUTE,
20
+ readChannelConversationId,
21
+ synthesizeConversationAttachment,
22
+ } from "../conversation-attachment.js";
23
+
24
+ const noLeases: ActiveLeases = {
25
+ global: false,
26
+ categories: new Set(),
27
+ servers: new Set(),
28
+ };
29
+
30
+ const cloudOptions = {
31
+ bridgeEndpoint: "https://mcp.stigmer.ai",
32
+ credential: "sandbox-token",
33
+ backendEndpoint: "http://localhost:7234",
34
+ };
35
+
36
+ describe("cross-repo and cross-file pinned strings", () => {
37
+ it("pins the label verbatim to the cloud's ChannelRuntimeConstants (mirror guard)", () => {
38
+ // Pinned to ChannelRuntimeConstants.CHANNEL_ID_METADATA_KEY in
39
+ // stigmer-cloud (ChannelSessionCreateScopeStep stamps it; the
40
+ // mirror-guard test lives in ChannelSessionBrokerTest). Drift
41
+ // degrades to honest absence — the escalation tool silently stops
42
+ // attaching — never worse; change BOTH sides together.
43
+ expect(CHANNEL_ID_LABEL).toBe("stigmer.ai/channel-id");
44
+ });
45
+
46
+ it("pins the attachment slug and the bridge route", () => {
47
+ // The slug is runner-internal (the resolved-server name and shadow
48
+ // key). The route is cross-repo: the mcp-server's conversation
49
+ // integration test pins CONVERSATION_ROUTE independently — a drift
50
+ // strands every synthesized attachment on a 404.
51
+ expect(CONVERSATION_ATTACHMENT_SLUG).toBe("stigmer-conversation");
52
+ expect(CONVERSATION_ROUTE).toBe("/conversation");
53
+ });
54
+ });
55
+
56
+ describe("readChannelConversationId", () => {
57
+ it("reads the serving channel id from the session labels", () => {
58
+ expect(readChannelConversationId({ [CHANNEL_ID_LABEL]: "agch_1" })).toBe("agch_1");
59
+ });
60
+
61
+ it("treats missing labels, a missing key, and blank values as absent", () => {
62
+ expect(readChannelConversationId(undefined)).toBeUndefined();
63
+ expect(readChannelConversationId({})).toBeUndefined();
64
+ expect(readChannelConversationId({ [CHANNEL_ID_LABEL]: "" })).toBeUndefined();
65
+ expect(readChannelConversationId({ [CHANNEL_ID_LABEL]: " " })).toBeUndefined();
66
+ });
67
+
68
+ it("ignores unrelated labels", () => {
69
+ expect(
70
+ readChannelConversationId({ "stigmer.ai/channel-conversation-key": "919000000001" }),
71
+ ).toBeUndefined();
72
+ });
73
+ });
74
+
75
+ describe("synthesizeConversationAttachment", () => {
76
+ it("returns undefined when the session serves no channel conversation", () => {
77
+ expect(synthesizeConversationAttachment(undefined, cloudOptions)).toBeUndefined();
78
+ });
79
+
80
+ it("returns undefined without a bridge endpoint — HTTP-only, no stdio fallback", () => {
81
+ // The deliberate divergence from both sibling attachments: escalate
82
+ // is cloud-only (OSS refuses FAILED_PRECONDITION) AND
83
+ // session-token-only (a stdio child's API key carries no session_id
84
+ // claim), so a stdio shape could only ever fail. Honest absence.
85
+ expect(
86
+ synthesizeConversationAttachment("agch_1", {
87
+ bridgeEndpoint: null,
88
+ credential: "sandbox-token",
89
+ backendEndpoint: "http://localhost:7234",
90
+ }),
91
+ ).toBeUndefined();
92
+ expect(
93
+ synthesizeConversationAttachment("agch_1", {
94
+ bridgeEndpoint: "",
95
+ credential: "sandbox-token",
96
+ backendEndpoint: "http://localhost:7234",
97
+ }),
98
+ ).toBeUndefined();
99
+ });
100
+
101
+ it("builds the HTTP shape against the bridge /conversation route with the credential", () => {
102
+ const attachment = synthesizeConversationAttachment("agch_1", {
103
+ ...cloudOptions,
104
+ bridgeEndpoint: "https://mcp.stigmer.ai/",
105
+ });
106
+
107
+ expect(attachment).toMatchObject({
108
+ slug: CONVERSATION_ATTACHMENT_SLUG,
109
+ connectionType: "http",
110
+ url: "https://mcp.stigmer.ai/conversation",
111
+ headers: { Authorization: "Bearer sandbox-token" },
112
+ });
113
+ });
114
+
115
+ it("omits the Authorization header without a credential", () => {
116
+ const attachment = synthesizeConversationAttachment("agch_1", {
117
+ ...cloudOptions,
118
+ credential: null,
119
+ });
120
+ expect(attachment?.headers).toBeUndefined();
121
+ });
122
+
123
+ it("is approval-free by construction: zero entries in the merged approval map", () => {
124
+ const attachment = synthesizeConversationAttachment("agch_1", cloudOptions)!;
125
+
126
+ // Channel surfaces run APPROVAL_MODE_UNATTENDED, where a gated tool
127
+ // resolves as skip-and-adapt — a gated escalation would never fire
128
+ // (DD-008's approval-free ruling, the DD-001 SD-3 structural bypass).
129
+ const merged = mergeApprovalPolicies([attachment], [], noLeases);
130
+ expect(merged.size).toBe(0);
131
+ });
132
+
133
+ it("is structurally immune to the connect backfill", () => {
134
+ const attachment = synthesizeConversationAttachment("agch_1", cloudOptions)!;
135
+ expect(attachment.discoveredCapabilitiesEmpty).toBe(false);
136
+ expect(needsBackfill(attachment)).toBe(false);
137
+ });
138
+ });