@stigmer/runner 3.6.0 → 3.7.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.js +39 -5
- 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/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/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/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__/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 +49 -7
- package/src/activities/execute-cursor/model-pricing.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/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/artifact-storage.ts +32 -7
- 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
|
@@ -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
|
+
}
|
|
@@ -89,11 +89,23 @@ export class LocalArtifactStorage implements ArtifactStorage {
|
|
|
89
89
|
|
|
90
90
|
export class ProxyArtifactStorage implements ArtifactStorage {
|
|
91
91
|
private readonly baseUrl: string;
|
|
92
|
-
private readonly
|
|
92
|
+
private readonly authTokenSource: ProxyAuthTokenSource;
|
|
93
93
|
|
|
94
|
-
constructor(proxyEndpoint: string, authToken:
|
|
94
|
+
constructor(proxyEndpoint: string, authToken: ProxyAuthTokenSource) {
|
|
95
95
|
this.baseUrl = `${proxyEndpoint.replace(/\/+$/, "")}/v1/proxy/artifacts`;
|
|
96
|
-
this.
|
|
96
|
+
this.authTokenSource = authToken;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve the credential per call rather than pinning the boot token: a
|
|
101
|
+
* cloud sandbox's control-plane token rotates in place (see
|
|
102
|
+
* sandbox-token-renewal.ts), and this storage lives for the pod's whole
|
|
103
|
+
* life — a captured string would silently 401 after the first rotation.
|
|
104
|
+
*/
|
|
105
|
+
private get authToken(): string {
|
|
106
|
+
return typeof this.authTokenSource === "string"
|
|
107
|
+
? this.authTokenSource
|
|
108
|
+
: (this.authTokenSource.current ?? "");
|
|
97
109
|
}
|
|
98
110
|
|
|
99
111
|
async upload(key: string, content: Buffer, contentType?: string): Promise<string> {
|
|
@@ -214,12 +226,18 @@ export class ProxyArtifactStorage implements ArtifactStorage {
|
|
|
214
226
|
|
|
215
227
|
// ── Factory ──────────────────────────────────────────────────────────
|
|
216
228
|
|
|
229
|
+
/**
|
|
230
|
+
* The proxy credential, either fixed (a caller-supplied string) or live (a
|
|
231
|
+
* shared mutable ref, read per call — the sandbox token-renewal posture).
|
|
232
|
+
*/
|
|
233
|
+
export type ProxyAuthTokenSource = string | { readonly current: string | null };
|
|
234
|
+
|
|
217
235
|
export interface ArtifactStorageConfig {
|
|
218
236
|
readonly type: ArtifactStorageType;
|
|
219
237
|
readonly localPath: string;
|
|
220
238
|
readonly localServeUrl: string;
|
|
221
239
|
readonly proxyEndpoint: string | null;
|
|
222
|
-
readonly proxyAuthToken:
|
|
240
|
+
readonly proxyAuthToken: ProxyAuthTokenSource | null;
|
|
223
241
|
}
|
|
224
242
|
|
|
225
243
|
export function loadArtifactStorageConfig(config: Config): ArtifactStorageConfig {
|
|
@@ -239,7 +257,11 @@ export function loadArtifactStorageConfig(config: Config): ArtifactStorageConfig
|
|
|
239
257
|
localPath: process.env.LOCAL_ARTIFACT_PATH ?? "/var/stigmer/artifacts",
|
|
240
258
|
localServeUrl: process.env.LOCAL_ARTIFACT_SERVE_URL ?? "http://localhost:7235",
|
|
241
259
|
proxyEndpoint: type === "proxy" ? (config.proxyEndpoint ?? null) : null,
|
|
242
|
-
|
|
260
|
+
// Prefer the live ref: renewal rotates the token in place and uploads
|
|
261
|
+
// must present the current credential, not the boot one.
|
|
262
|
+
proxyAuthToken: type === "proxy"
|
|
263
|
+
? (config.stigmerTokenRef ?? config.stigmerToken ?? null)
|
|
264
|
+
: null,
|
|
243
265
|
};
|
|
244
266
|
}
|
|
245
267
|
|
|
@@ -248,10 +270,13 @@ export function createArtifactStorage(cfg: ArtifactStorageConfig): ArtifactStora
|
|
|
248
270
|
if (!cfg.proxyEndpoint) {
|
|
249
271
|
throw new Error("Proxy artifact storage requires STIGMER_PROXY_ENDPOINT");
|
|
250
272
|
}
|
|
251
|
-
|
|
273
|
+
const tokenAtBoot = typeof cfg.proxyAuthToken === "string"
|
|
274
|
+
? cfg.proxyAuthToken
|
|
275
|
+
: cfg.proxyAuthToken?.current;
|
|
276
|
+
if (!tokenAtBoot) {
|
|
252
277
|
throw new Error("Proxy artifact storage requires STIGMER_TOKEN");
|
|
253
278
|
}
|
|
254
|
-
return new ProxyArtifactStorage(cfg.proxyEndpoint, cfg.proxyAuthToken);
|
|
279
|
+
return new ProxyArtifactStorage(cfg.proxyEndpoint, cfg.proxyAuthToken!);
|
|
255
280
|
}
|
|
256
281
|
|
|
257
282
|
return new LocalArtifactStorage(cfg.localPath, cfg.localServeUrl);
|
|
@@ -404,9 +404,9 @@ describe("Golden Execution — Tier 1d: Advanced Tasks", () => {
|
|
|
404
404
|
expect(config.message).not.toContain("${ $context");
|
|
405
405
|
expect(config.message).not.toContain("${ $input");
|
|
406
406
|
expect(config.env).toEqual({ GITHUB_TOKEN: "${.secrets.GITHUB_TOKEN}" });
|
|
407
|
-
expect(config.
|
|
408
|
-
expect(config.
|
|
409
|
-
expect(config.
|
|
407
|
+
expect(config.run_config?.model_name).toBe("claude-3-5-sonnet");
|
|
408
|
+
expect(config.run_config?.max_cost_usd).toBe(0.75);
|
|
409
|
+
expect(config.run_config?.service_tier).toBe("SERVICE_TIER_STANDARD");
|
|
410
410
|
expect(config.harness).toBe("HARNESS_NATIVE");
|
|
411
411
|
expect(config.output?.schema).toBeDefined();
|
|
412
412
|
expect(config.output?.schema.required).toContain("severity");
|
|
@@ -428,7 +428,6 @@ describe("Golden Execution — Tier 1d: Advanced Tasks", () => {
|
|
|
428
428
|
" with:",
|
|
429
429
|
' agent: "stigmer/code-reviewer"',
|
|
430
430
|
' message: "Review this"',
|
|
431
|
-
' org: "stigmer"',
|
|
432
431
|
].join("\n");
|
|
433
432
|
|
|
434
433
|
const model = loadWorkflowFromYaml(crossOrgYaml);
|
|
@@ -444,7 +443,6 @@ describe("Golden Execution — Tier 1d: Advanced Tasks", () => {
|
|
|
444
443
|
expect(mockCallAgent).toHaveBeenCalledOnce();
|
|
445
444
|
const [config] = mockCallAgent.mock.calls[0];
|
|
446
445
|
expect(config.agent).toBe("stigmer/code-reviewer");
|
|
447
|
-
expect(config.org).toBe("stigmer");
|
|
448
446
|
expect(config.message).toBe("Review this");
|
|
449
447
|
});
|
|
450
448
|
|
|
@@ -1048,9 +1046,11 @@ describe("Golden Execution — Structured Output Pipeline", () => {
|
|
|
1048
1046
|
expect(config.message).toContain("Date: 2026-05-26");
|
|
1049
1047
|
expect(config.message).not.toContain("${ $env");
|
|
1050
1048
|
|
|
1051
|
-
//
|
|
1052
|
-
|
|
1053
|
-
|
|
1049
|
+
// Run config and harness preserved. The tier arrives canonical from the
|
|
1050
|
+
// golden's "fast" shorthand — the loader mapping riding a full workflow
|
|
1051
|
+
// load (#357).
|
|
1052
|
+
expect(config.run_config?.model_name).toBe("claude-sonnet-4");
|
|
1053
|
+
expect(config.run_config?.service_tier).toBe("SERVICE_TIER_FAST");
|
|
1054
1054
|
expect(config.harness).toBe("HARNESS_CURSOR");
|
|
1055
1055
|
expect(metadata.taskName).toBe("analyze_player_data");
|
|
1056
1056
|
|