@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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/call-agent.js +85 -10
- package/dist/activities/call-agent.js.map +1 -1
- package/dist/activities/execute-cursor/index.d.ts +12 -0
- package/dist/activities/execute-cursor/index.js +80 -10
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/model-pricing.d.ts +9 -0
- package/dist/activities/execute-cursor/model-pricing.js +19 -0
- package/dist/activities/execute-cursor/model-pricing.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +11 -0
- package/dist/activities/execute-cursor/prompt-builder.js +11 -0
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/service-tier.d.ts +68 -0
- package/dist/activities/execute-cursor/service-tier.js +187 -0
- package/dist/activities/execute-cursor/service-tier.js.map +1 -0
- package/dist/activities/execute-cursor/session-lifecycle.d.ts +16 -1
- package/dist/activities/execute-cursor/session-lifecycle.js +12 -4
- package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
- package/dist/activities/execute-cursor/usage-accumulator.d.ts +21 -1
- package/dist/activities/execute-cursor/usage-accumulator.js +23 -3
- package/dist/activities/execute-cursor/usage-accumulator.js.map +1 -1
- package/dist/activities/execute-deep-agent/mcp-gate.d.ts +28 -0
- package/dist/activities/execute-deep-agent/mcp-gate.js +22 -0
- package/dist/activities/execute-deep-agent/mcp-gate.js.map +1 -0
- package/dist/activities/execute-deep-agent/prompt-builder.d.ts +11 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js +16 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.js +30 -4
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +6 -1
- package/dist/client/stigmer-client.js +5 -2
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/main.js +18 -0
- package/dist/main.js.map +1 -1
- package/dist/runner.js +48 -0
- package/dist/runner.js.map +1 -1
- package/dist/sandbox-token-renewal.d.ts +65 -0
- package/dist/sandbox-token-renewal.js +169 -0
- package/dist/sandbox-token-renewal.js.map +1 -0
- package/dist/shared/artifact-storage.d.ts +17 -3
- package/dist/shared/artifact-storage.js +22 -4
- package/dist/shared/artifact-storage.js.map +1 -1
- package/dist/shared/channel-attachment.d.ts +3 -1
- package/dist/shared/channel-attachment.js +3 -1
- package/dist/shared/channel-attachment.js.map +1 -1
- package/dist/shared/conversation-attachment.d.ts +81 -0
- package/dist/shared/conversation-attachment.js +102 -0
- package/dist/shared/conversation-attachment.js.map +1 -0
- package/dist/shared/conversation-catchup.d.ts +33 -0
- package/dist/shared/conversation-catchup.js +53 -0
- package/dist/shared/conversation-catchup.js.map +1 -0
- package/dist/workflow-engine/loader.js +99 -2
- package/dist/workflow-engine/loader.js.map +1 -1
- package/dist/workflow-engine/tasks/call-agent.d.ts +0 -2
- package/dist/workflow-engine/tasks/call-agent.js +0 -2
- package/dist/workflow-engine/tasks/call-agent.js.map +1 -1
- package/dist/workflow-engine/types.d.ts +39 -7
- package/dist/workflow-engine/types.js.map +1 -1
- package/dist/workflows/call-agent-orchestrator.d.ts +3 -2
- package/dist/workflows/call-agent-orchestrator.js +8 -2
- package/dist/workflows/call-agent-orchestrator.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/sandbox-token-renewal.test.ts +174 -0
- package/src/activities/__tests__/call-agent-contracts.test.ts +4 -4
- package/src/activities/__tests__/call-agent.test.ts +219 -4
- package/src/activities/call-agent.ts +94 -10
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +79 -0
- package/src/activities/execute-cursor/__tests__/model-pricing.test.ts +20 -0
- package/src/activities/execute-cursor/__tests__/service-tier.test.ts +170 -0
- package/src/activities/execute-cursor/__tests__/usage-accumulator.test.ts +87 -1
- package/src/activities/execute-cursor/index.ts +111 -11
- package/src/activities/execute-cursor/model-pricing.ts +23 -0
- package/src/activities/execute-cursor/prompt-builder.ts +23 -0
- package/src/activities/execute-cursor/service-tier.ts +244 -0
- package/src/activities/execute-cursor/session-lifecycle.ts +33 -5
- package/src/activities/execute-cursor/usage-accumulator.ts +35 -3
- package/src/activities/execute-deep-agent/__tests__/mcp-gate.test.ts +42 -0
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +39 -1
- package/src/activities/execute-deep-agent/mcp-gate.ts +37 -0
- package/src/activities/execute-deep-agent/prompt-builder.ts +22 -2
- package/src/activities/execute-deep-agent/setup.ts +40 -4
- package/src/client/stigmer-client.ts +11 -4
- package/src/main.ts +20 -0
- package/src/runner.ts +62 -0
- package/src/sandbox-token-renewal.ts +212 -0
- package/src/shared/__tests__/channel-attachment.test.ts +3 -3
- package/src/shared/__tests__/conversation-attachment.test.ts +138 -0
- package/src/shared/__tests__/conversation-catchup.test.ts +70 -0
- package/src/shared/__tests__/synthesized-attachment.test.ts +120 -0
- package/src/shared/artifact-storage.ts +32 -7
- package/src/shared/channel-attachment.ts +3 -1
- package/src/shared/conversation-attachment.ts +115 -0
- package/src/shared/conversation-catchup.ts +60 -0
- package/src/workflow-engine/__tests__/golden-execution.test.ts +8 -8
- package/src/workflow-engine/__tests__/loader.test.ts +192 -7
- package/src/workflow-engine/__tests__/tasks/call-agent.test.ts +9 -9
- package/src/workflow-engine/loader.ts +113 -2
- package/src/workflow-engine/tasks/call-agent.ts +0 -2
- package/src/workflow-engine/types.ts +40 -7
- 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 (
|
|
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
|
-
|
|
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).
|
|
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
|
-
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
+
});
|