@stigmer/runner 3.12.3 → 3.12.4
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-http.js +12 -0
- package/dist/activities/call-http.js.map +1 -1
- package/dist/activities/call-llm.d.ts +18 -0
- package/dist/activities/call-llm.js +56 -2
- package/dist/activities/call-llm.js.map +1 -1
- package/dist/activities/execute-cursor/agent-session-cache.d.ts +72 -0
- package/dist/activities/execute-cursor/agent-session-cache.js +186 -0
- package/dist/activities/execute-cursor/agent-session-cache.js.map +1 -0
- package/dist/activities/execute-cursor/index.js +61 -28
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/service-tier.d.ts +5 -15
- package/dist/activities/execute-cursor/service-tier.js +5 -21
- package/dist/activities/execute-cursor/service-tier.js.map +1 -1
- package/dist/activities/execute-cursor/skill-resolver.js +1 -1
- package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.js +14 -0
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/shell-env.d.ts +8 -5
- package/dist/activities/execute-deep-agent/shell-env.js +10 -7
- package/dist/activities/execute-deep-agent/shell-env.js.map +1 -1
- package/dist/config.js +10 -5
- package/dist/config.js.map +1 -1
- package/dist/encryption/config.js +7 -2
- package/dist/encryption/config.js.map +1 -1
- package/dist/main.js +12 -6
- package/dist/main.js.map +1 -1
- package/dist/payload-codecs.js +2 -1
- package/dist/payload-codecs.js.map +1 -1
- package/dist/runner-manager.js +20 -7
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.js +19 -6
- package/dist/runner.js.map +1 -1
- package/dist/shared/fingerprint-secret.d.ts +3 -2
- package/dist/shared/fingerprint-secret.js +5 -3
- package/dist/shared/fingerprint-secret.js.map +1 -1
- package/dist/shared/llm-backend.js +8 -1
- package/dist/shared/llm-backend.js.map +1 -1
- package/dist/shared/model-client.d.ts +15 -0
- package/dist/shared/model-client.js +57 -13
- package/dist/shared/model-client.js.map +1 -1
- package/dist/shared/registry-endpoint.d.ts +5 -0
- package/dist/shared/registry-endpoint.js +7 -1
- package/dist/shared/registry-endpoint.js.map +1 -1
- package/dist/shared/runner-credential-keys.d.ts +26 -1
- package/dist/shared/runner-credential-keys.js +34 -1
- package/dist/shared/runner-credential-keys.js.map +1 -1
- package/dist/shared/runner-credential-store.d.ts +77 -0
- package/dist/shared/runner-credential-store.js +111 -0
- package/dist/shared/runner-credential-store.js.map +1 -0
- package/dist/shared/service-tier.d.ts +55 -0
- package/dist/shared/service-tier.js +67 -0
- package/dist/shared/service-tier.js.map +1 -0
- package/dist/shared/skill-writer.js +2 -2
- package/dist/shared/skill-writer.js.map +1 -1
- package/dist/shared/zip-extract.d.ts +10 -3
- package/dist/shared/zip-extract.js +10 -3
- package/dist/shared/zip-extract.js.map +1 -1
- package/dist/workflow-engine/tasks/call-function.d.ts +14 -0
- package/dist/workflow-engine/tasks/call-function.js +49 -5
- package/dist/workflow-engine/tasks/call-function.js.map +1 -1
- package/dist/workflow-engine/types.d.ts +6 -0
- package/dist/workflow-engine/types.js.map +1 -1
- package/dist/workflows/engine-core.js +36 -8
- package/dist/workflows/engine-core.js.map +1 -1
- package/package.json +2 -2
- package/src/activities/__tests__/call-http.test.ts +36 -0
- package/src/activities/__tests__/call-llm.test.ts +77 -0
- package/src/activities/call-http.ts +17 -0
- package/src/activities/call-llm.ts +78 -2
- package/src/activities/execute-cursor/__tests__/agent-session-cache.test.ts +220 -0
- package/src/activities/execute-cursor/__tests__/service-tier.test.ts +1 -1
- package/src/activities/execute-cursor/agent-session-cache.ts +229 -0
- package/src/activities/execute-cursor/index.ts +66 -20
- package/src/activities/execute-cursor/service-tier.ts +5 -29
- package/src/activities/execute-cursor/skill-resolver.ts +1 -1
- package/src/activities/execute-deep-agent/setup.ts +15 -0
- package/src/activities/execute-deep-agent/shell-env.ts +10 -7
- package/src/config.ts +10 -5
- package/src/encryption/config.ts +8 -2
- package/src/main.ts +16 -6
- package/src/payload-codecs.ts +2 -1
- package/src/runner-manager.ts +29 -6
- package/src/runner.ts +25 -6
- package/src/shared/__tests__/model-client.test.ts +99 -0
- package/src/shared/__tests__/runner-credential-store.test.ts +155 -0
- package/src/shared/__tests__/zip-extract.test.ts +46 -11
- package/src/shared/fingerprint-secret.ts +5 -3
- package/src/shared/llm-backend.ts +7 -1
- package/src/shared/model-client.ts +76 -13
- package/src/shared/registry-endpoint.ts +9 -1
- package/src/shared/runner-credential-keys.ts +36 -1
- package/src/shared/runner-credential-store.ts +115 -0
- package/src/shared/service-tier.ts +78 -0
- package/src/shared/skill-writer.ts +2 -2
- package/src/shared/zip-extract.ts +14 -7
- package/src/workflow-engine/__tests__/golden-execution.test.ts +20 -1
- package/src/workflow-engine/__tests__/tasks/call-function.test.ts +105 -0
- package/src/workflow-engine/tasks/call-function.ts +74 -13
- package/src/workflow-engine/types.ts +6 -0
- package/src/workflows/engine-core.ts +39 -8
package/src/runner-manager.ts
CHANGED
|
@@ -32,6 +32,11 @@ import type { WorkerActivities } from "./worker.js";
|
|
|
32
32
|
import { resolveWorkflowSource, OTEL_WORKFLOW_INTERCEPTOR_MODULE } from "./workflow-source.js";
|
|
33
33
|
import { resolveRunnerBootstrap, refreshRunnerAccessToken } from "./bootstrap.js";
|
|
34
34
|
import { assertLlmBackendsPreflight } from "./preflight.js";
|
|
35
|
+
import {
|
|
36
|
+
captureRunnerSecrets,
|
|
37
|
+
getRunnerSecret,
|
|
38
|
+
setRunnerSecret,
|
|
39
|
+
} from "./shared/runner-credential-store.js";
|
|
35
40
|
import { createRunnerTokenCoordinator } from "./runner-token-coordinator.js";
|
|
36
41
|
// Per-task-queue in-flight activity tracking lives in ./in-flight.ts so the
|
|
37
42
|
// activity interceptor (no manager-closure handle) and unit tests can reach it.
|
|
@@ -214,6 +219,12 @@ export async function createStigmerRunnerManager(
|
|
|
214
219
|
): Promise<StigmerRunnerManager> {
|
|
215
220
|
validateManagerOptions(options);
|
|
216
221
|
|
|
222
|
+
// Take custody of runner secrets BEFORE anything else can read them from
|
|
223
|
+
// env — and before any agent code could spawn with them (#508). Runs here
|
|
224
|
+
// (not only in main.ts) because this factory is a public library boot door:
|
|
225
|
+
// in-process embedders like the desktop never execute main.ts.
|
|
226
|
+
captureRunnerSecrets();
|
|
227
|
+
|
|
217
228
|
const { registerStigmerDeepagentsProfiles } = await import(
|
|
218
229
|
"./activities/execute-deep-agent/deepagents-profiles.js"
|
|
219
230
|
);
|
|
@@ -365,7 +376,7 @@ export async function createStigmerRunnerManager(
|
|
|
365
376
|
if (
|
|
366
377
|
!bootstrap.payloadEncryption &&
|
|
367
378
|
bootstrap.runnerAccessToken &&
|
|
368
|
-
!
|
|
379
|
+
!getRunnerSecret("STIGMER_PAYLOAD_ENCRYPTION_KEY")
|
|
369
380
|
) {
|
|
370
381
|
console.warn(
|
|
371
382
|
"[runner-manager] Server minted a runner token but returned no payload " +
|
|
@@ -530,6 +541,13 @@ export async function createStigmerRunnerManager(
|
|
|
530
541
|
await removeManaged(
|
|
531
542
|
sessions, sessionId, SESSION_QUEUE_PREFIX + sessionId, "session",
|
|
532
543
|
);
|
|
544
|
+
// The session is done on this host — release its parked agent (and
|
|
545
|
+
// the executor + MCP subprocesses the lease pins) immediately rather
|
|
546
|
+
// than waiting out the idle TTL (#215).
|
|
547
|
+
const { evictSessionAgent } = await import(
|
|
548
|
+
"./activities/execute-cursor/agent-session-cache.js"
|
|
549
|
+
);
|
|
550
|
+
evictSessionAgent(sessionId);
|
|
533
551
|
},
|
|
534
552
|
|
|
535
553
|
activeSessions(): string[] {
|
|
@@ -585,12 +603,11 @@ export async function createStigmerRunnerManager(
|
|
|
585
603
|
// proxy credential follows it: only when no token has been minted (so the
|
|
586
604
|
// pre-mint lockstep is preserved), never once the runner owns a minted
|
|
587
605
|
// token. See runner-token-coordinator.ts and the staleness changelogs.
|
|
606
|
+
// The credential store is the second leg of the pair (it replaced the
|
|
607
|
+
// process.env.STIGMER_TOKEN write, #508): per-call readers like
|
|
608
|
+
// call-llm and the registry headers resolve the current token there.
|
|
588
609
|
tokenRef.current = token;
|
|
589
|
-
|
|
590
|
-
process.env.STIGMER_TOKEN = token;
|
|
591
|
-
} else {
|
|
592
|
-
delete process.env.STIGMER_TOKEN;
|
|
593
|
-
}
|
|
610
|
+
setRunnerSecret("STIGMER_TOKEN", token);
|
|
594
611
|
tokenCoordinator.onControlPlaneTokenChanged(token);
|
|
595
612
|
console.log("[runner-manager] Auth token updated");
|
|
596
613
|
},
|
|
@@ -629,6 +646,12 @@ export async function createStigmerRunnerManager(
|
|
|
629
646
|
}
|
|
630
647
|
|
|
631
648
|
await Promise.all(shutdownPromises);
|
|
649
|
+
// Workers are drained — release every parked session agent so their
|
|
650
|
+
// executor leases dispose (stdio MCP subprocesses die with them) (#215).
|
|
651
|
+
const { closeAllCachedAgents } = await import(
|
|
652
|
+
"./activities/execute-cursor/agent-session-cache.js"
|
|
653
|
+
);
|
|
654
|
+
closeAllCachedAgents();
|
|
632
655
|
sessions.clear();
|
|
633
656
|
workflowExecutions.clear();
|
|
634
657
|
poolControl = null;
|
package/src/runner.ts
CHANGED
|
@@ -20,6 +20,10 @@ import { DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS, DEFAULT_CURSOR_STREAM_STALL_TI
|
|
|
20
20
|
import type { WorkerActivities } from "./worker.js";
|
|
21
21
|
import { resolveRunnerBootstrap } from "./bootstrap.js";
|
|
22
22
|
import { assertLlmBackendsPreflight } from "./preflight.js";
|
|
23
|
+
import {
|
|
24
|
+
captureRunnerSecrets,
|
|
25
|
+
setRunnerSecret,
|
|
26
|
+
} from "./shared/runner-credential-store.js";
|
|
23
27
|
import { markBoot, emitRunnerBootTiming } from "./shared/cold-start-timing.js";
|
|
24
28
|
|
|
25
29
|
/**
|
|
@@ -118,11 +122,12 @@ export interface StigmerRunner {
|
|
|
118
122
|
* Wire up self-renewal for a static cloud sandbox's control-plane credential
|
|
119
123
|
* (see sandbox-token-renewal.ts for the model). The applied token reaches
|
|
120
124
|
* every consumer: activity gRPC clients read {@code tokenRef} per request,
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* ref per call, and the two
|
|
124
|
-
* a static runner has no
|
|
125
|
-
* proxy credential, so its
|
|
125
|
+
* per-call sites (call-llm, registry-endpoint headers) resolve it through
|
|
126
|
+
* the runner credential store (which replaced the process.env.STIGMER_TOKEN
|
|
127
|
+
* channel, #508), artifact storage resolves the ref per call, and the two
|
|
128
|
+
* Cursor SDK interceptors are updated directly — a static runner has no
|
|
129
|
+
* {@code RunnerTokenCoordinator} minting a separate proxy credential, so its
|
|
130
|
+
* x-stigmer-auth IS this token.
|
|
126
131
|
*/
|
|
127
132
|
async function startStaticSandboxTokenRenewal(
|
|
128
133
|
config: Config,
|
|
@@ -157,7 +162,9 @@ async function startStaticSandboxTokenRenewal(
|
|
|
157
162
|
client.getRunnerScopedToken({ renewal: true }, currentToken),
|
|
158
163
|
applyToken: (token) => {
|
|
159
164
|
tokenRef.current = token;
|
|
160
|
-
process.env.STIGMER_TOKEN
|
|
165
|
+
// Store write replaced the process.env.STIGMER_TOKEN write (#508) —
|
|
166
|
+
// same per-call readers (call-llm, registry headers), no env exposure.
|
|
167
|
+
setRunnerSecret("STIGMER_TOKEN", token);
|
|
161
168
|
updateInterceptorToken(token);
|
|
162
169
|
updateHttp2InterceptorToken(token);
|
|
163
170
|
},
|
|
@@ -190,6 +197,12 @@ export async function createStigmerRunner(
|
|
|
190
197
|
): Promise<StigmerRunner> {
|
|
191
198
|
validateOptions(options);
|
|
192
199
|
|
|
200
|
+
// Take custody of runner secrets BEFORE anything else can read them from
|
|
201
|
+
// env — and before any agent code could spawn with them (#508). Runs here
|
|
202
|
+
// (not only in main.ts) because this factory is a public library boot door
|
|
203
|
+
// for in-process embedders.
|
|
204
|
+
captureRunnerSecrets();
|
|
205
|
+
|
|
193
206
|
const { registerStigmerDeepagentsProfiles } = await import(
|
|
194
207
|
"./activities/execute-deep-agent/deepagents-profiles.js"
|
|
195
208
|
);
|
|
@@ -327,6 +340,12 @@ export async function createStigmerRunner(
|
|
|
327
340
|
emitRunnerBootTiming({ task_queue: config.taskQueue, mode: config.mode });
|
|
328
341
|
await worker.run();
|
|
329
342
|
console.log("Worker stopped");
|
|
343
|
+
// The worker has drained — release any parked session agent so its
|
|
344
|
+
// executor lease disposes with the process (#215).
|
|
345
|
+
const { closeAllCachedAgents } = await import(
|
|
346
|
+
"./activities/execute-cursor/agent-session-cache.js"
|
|
347
|
+
);
|
|
348
|
+
closeAllCachedAgents();
|
|
330
349
|
},
|
|
331
350
|
shutdown() {
|
|
332
351
|
tokenRenewal?.stop();
|
|
@@ -23,6 +23,7 @@ vi.mock("@langchain/openai", () => ({
|
|
|
23
23
|
|
|
24
24
|
import { buildChatModel } from "../model-client.js";
|
|
25
25
|
import { _resetRegistryCache } from "../model-registry.js";
|
|
26
|
+
import { ServiceTier } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
26
27
|
|
|
27
28
|
interface MockModel {
|
|
28
29
|
id: string;
|
|
@@ -306,6 +307,18 @@ describe("buildChatModel", () => {
|
|
|
306
307
|
expect(lastAnthropicArgs().apiKey).toBe("");
|
|
307
308
|
});
|
|
308
309
|
|
|
310
|
+
it("does NOT send service_tier on a backend adapter — tiers are Anthropic-first-party billing, and Vertex/Bedrock/Foundry have no tier dimension", async () => {
|
|
311
|
+
mockRegistryResponse([
|
|
312
|
+
{ id: "claude-sonnet-4.6", apiModelId: "claude-sonnet-4-6", provider: "anthropic" },
|
|
313
|
+
]);
|
|
314
|
+
|
|
315
|
+
await buildChatModel({ modelName: "claude-sonnet-4.6", serviceTier: ServiceTier.STANDARD });
|
|
316
|
+
|
|
317
|
+
const args = lastAnthropicArgs();
|
|
318
|
+
expect(typeof args.createClient).toBe("function");
|
|
319
|
+
expect(args).not.toHaveProperty("invocationKwargs");
|
|
320
|
+
});
|
|
321
|
+
|
|
309
322
|
it("yields to the proxy: a proxied call never consults the backend var", async () => {
|
|
310
323
|
mockRegistryResponse([
|
|
311
324
|
{ id: "claude-sonnet-4.6", apiModelId: "claude-sonnet-4-6", provider: "anthropic" },
|
|
@@ -359,4 +372,90 @@ describe("buildChatModel", () => {
|
|
|
359
372
|
await expect(buildChatModel({ modelName: "claude-sonnet-4.6" }))
|
|
360
373
|
.rejects.toThrow(/STIGMER_ANTHROPIC_BACKEND="verteks" is not a supported backend/);
|
|
361
374
|
});
|
|
375
|
+
|
|
376
|
+
describe("service tier (stigmer/stigmer#361)", () => {
|
|
377
|
+
// The #357 contract on the native harness: when the caller passes the
|
|
378
|
+
// execution's effective tier, every provider request pins it
|
|
379
|
+
// explicitly, so the provider ACCOUNT's default can never pick the
|
|
380
|
+
// price. Each provider has its own spelling; both are pinned here so
|
|
381
|
+
// a wrapper upgrade that moves the slot fails loudly.
|
|
382
|
+
|
|
383
|
+
it("OpenAI: STANDARD pins service_tier 'default' — never 'auto', which would re-open the account-default hole", async () => {
|
|
384
|
+
mockRegistryResponse([
|
|
385
|
+
{ id: "gpt-4.1", apiModelId: "gpt-4.1", provider: "openai" },
|
|
386
|
+
]);
|
|
387
|
+
|
|
388
|
+
await buildChatModel({ modelName: "gpt-4.1", serviceTier: ServiceTier.STANDARD });
|
|
389
|
+
|
|
390
|
+
const args = mockOpenAICtor.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
|
391
|
+
expect(args).toMatchObject({ service_tier: "default" });
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("OpenAI: FAST pins service_tier 'priority'", async () => {
|
|
395
|
+
mockRegistryResponse([
|
|
396
|
+
{ id: "gpt-4.1", apiModelId: "gpt-4.1", provider: "openai" },
|
|
397
|
+
]);
|
|
398
|
+
|
|
399
|
+
await buildChatModel({ modelName: "gpt-4.1", serviceTier: ServiceTier.FAST });
|
|
400
|
+
|
|
401
|
+
const args = mockOpenAICtor.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
|
402
|
+
expect(args).toMatchObject({ service_tier: "priority" });
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("Anthropic: STANDARD rides invocationKwargs as 'standard_only' — priority capacity is never consumed implicitly", async () => {
|
|
406
|
+
mockRegistryResponse([
|
|
407
|
+
{ id: "claude-haiku-4.5", apiModelId: "claude-haiku-4-5-20251001", provider: "anthropic" },
|
|
408
|
+
]);
|
|
409
|
+
|
|
410
|
+
await buildChatModel({ modelName: "claude-haiku-4.5", serviceTier: ServiceTier.STANDARD });
|
|
411
|
+
|
|
412
|
+
expect(lastAnthropicArgs()).toMatchObject({
|
|
413
|
+
invocationKwargs: { service_tier: "standard_only" },
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("Anthropic: FAST rides invocationKwargs as 'auto' — use purchased priority capacity when available", async () => {
|
|
418
|
+
mockRegistryResponse([
|
|
419
|
+
{ id: "claude-haiku-4.5", apiModelId: "claude-haiku-4-5-20251001", provider: "anthropic" },
|
|
420
|
+
]);
|
|
421
|
+
|
|
422
|
+
await buildChatModel({ modelName: "claude-haiku-4.5", serviceTier: ServiceTier.FAST });
|
|
423
|
+
|
|
424
|
+
expect(lastAnthropicArgs()).toMatchObject({
|
|
425
|
+
invocationKwargs: { service_tier: "auto" },
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("an omitted tier sends NO tier parameter on either provider — platform-internal utility calls stay untiered", async () => {
|
|
430
|
+
mockRegistryResponse([
|
|
431
|
+
{ id: "gpt-4.1", apiModelId: "gpt-4.1", provider: "openai" },
|
|
432
|
+
]);
|
|
433
|
+
await buildChatModel({ modelName: "gpt-4.1" });
|
|
434
|
+
const openAiArgs = mockOpenAICtor.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
|
435
|
+
expect(openAiArgs).not.toHaveProperty("service_tier");
|
|
436
|
+
|
|
437
|
+
mockRegistryResponse([
|
|
438
|
+
{ id: "claude-haiku-4.5", apiModelId: "claude-haiku-4-5-20251001", provider: "anthropic" },
|
|
439
|
+
]);
|
|
440
|
+
await buildChatModel({ modelName: "claude-haiku-4.5" });
|
|
441
|
+
expect(lastAnthropicArgs()).not.toHaveProperty("invocationKwargs");
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("the tier rides the proxy path too — the request body parameter is what the proxy meters", async () => {
|
|
445
|
+
mockRegistryResponse([
|
|
446
|
+
{ id: "claude-haiku-4.5", apiModelId: "claude-haiku-4-5-20251001", provider: "anthropic" },
|
|
447
|
+
]);
|
|
448
|
+
|
|
449
|
+
await buildChatModel({
|
|
450
|
+
modelName: "claude-haiku-4.5",
|
|
451
|
+
proxyEndpoint: "https://api.stigmer.ai",
|
|
452
|
+
stigmerToken: "token",
|
|
453
|
+
serviceTier: ServiceTier.STANDARD,
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
expect(lastAnthropicArgs()).toMatchObject({
|
|
457
|
+
invocationKwargs: { service_tier: "standard_only" },
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
});
|
|
362
461
|
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
captureRunnerSecrets,
|
|
4
|
+
getRunnerSecret,
|
|
5
|
+
setRunnerSecret,
|
|
6
|
+
runnerSecretsEnvView,
|
|
7
|
+
resetRunnerSecretsForTests,
|
|
8
|
+
} from "../runner-credential-store.js";
|
|
9
|
+
import {
|
|
10
|
+
RUNNER_CREDENTIAL_ENV_KEYS,
|
|
11
|
+
RUNNER_ENCRYPTION_ENV_KEYS,
|
|
12
|
+
RUNNER_SECRET_ENV_KEYS,
|
|
13
|
+
} from "../runner-credential-keys.js";
|
|
14
|
+
|
|
15
|
+
/** Snapshot/restore of every env slot these tests touch. */
|
|
16
|
+
const savedEnv = new Map<string, string | undefined>();
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
resetRunnerSecretsForTests();
|
|
20
|
+
for (const key of RUNNER_SECRET_ENV_KEYS) {
|
|
21
|
+
savedEnv.set(key, process.env[key]);
|
|
22
|
+
delete process.env[key];
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
resetRunnerSecretsForTests();
|
|
28
|
+
for (const [key, value] of savedEnv) {
|
|
29
|
+
if (value === undefined) {
|
|
30
|
+
delete process.env[key];
|
|
31
|
+
} else {
|
|
32
|
+
process.env[key] = value;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
savedEnv.clear();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("captureRunnerSecrets", () => {
|
|
39
|
+
it("moves every listed secret out of process.env and into the store", () => {
|
|
40
|
+
// Plant a value for every name in the source-of-truth list, so a key
|
|
41
|
+
// added to runner-credential-keys.ts is covered here automatically —
|
|
42
|
+
// the #385 test idiom, applied to custody.
|
|
43
|
+
for (const key of RUNNER_SECRET_ENV_KEYS) {
|
|
44
|
+
process.env[key] = `boot-${key}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
captureRunnerSecrets();
|
|
48
|
+
|
|
49
|
+
for (const key of RUNNER_SECRET_ENV_KEYS) {
|
|
50
|
+
expect(
|
|
51
|
+
process.env[key],
|
|
52
|
+
`'${key}' must not remain in process.env after capture — anything ` +
|
|
53
|
+
`left there is readable by every agent shell command (issue #508)`,
|
|
54
|
+
).toBeUndefined();
|
|
55
|
+
expect(getRunnerSecret(key)).toBe(`boot-${key}`);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("is idempotent — a second capture cannot re-freeze rotated values", () => {
|
|
60
|
+
process.env.STIGMER_TOKEN = "boot-token";
|
|
61
|
+
captureRunnerSecrets();
|
|
62
|
+
setRunnerSecret("STIGMER_TOKEN", "rotated-token");
|
|
63
|
+
|
|
64
|
+
// A late (buggy or racing) boot-door call must be a no-op.
|
|
65
|
+
process.env.STIGMER_TOKEN = "stale-replant";
|
|
66
|
+
captureRunnerSecrets();
|
|
67
|
+
|
|
68
|
+
expect(getRunnerSecret("STIGMER_TOKEN")).toBe("rotated-token");
|
|
69
|
+
// The replant stays in env (capture did not consume it) — the live-env
|
|
70
|
+
// fallback below deliberately does NOT apply when the store holds a
|
|
71
|
+
// value, so rotation always wins.
|
|
72
|
+
expect(process.env.STIGMER_TOKEN).toBe("stale-replant");
|
|
73
|
+
delete process.env.STIGMER_TOKEN;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("scrubs the encryption keys, not only the #385 credentials (issue #508)", () => {
|
|
77
|
+
// Pins the #508 scope widening by name. The parameterized test above
|
|
78
|
+
// would pass even if the combined list regressed to the 8 credentials;
|
|
79
|
+
// this one cannot.
|
|
80
|
+
for (const key of [
|
|
81
|
+
"STIGMER_PAYLOAD_ENCRYPTION_KEY",
|
|
82
|
+
"STIGMER_PAYLOAD_ENCRYPTION_SECONDARY_KEY",
|
|
83
|
+
]) {
|
|
84
|
+
expect(RUNNER_ENCRYPTION_ENV_KEYS, `'${key}' must be a captured secret`).toContain(key);
|
|
85
|
+
}
|
|
86
|
+
for (const key of [...RUNNER_CREDENTIAL_ENV_KEYS, ...RUNNER_ENCRYPTION_ENV_KEYS]) {
|
|
87
|
+
expect(RUNNER_SECRET_ENV_KEYS, `'${key}' must be in the scrub list`).toContain(key);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("getRunnerSecret", () => {
|
|
93
|
+
it("falls back to a live process.env value the capture never saw", () => {
|
|
94
|
+
captureRunnerSecrets();
|
|
95
|
+
// Custody rule 2: a value planted after boot behaves like the env read
|
|
96
|
+
// the store replaced (test/embedder compatibility) — production only
|
|
97
|
+
// sets these at process start, so this path is dead there.
|
|
98
|
+
process.env.STIGMER_TOKEN = "late-planted";
|
|
99
|
+
|
|
100
|
+
expect(getRunnerSecret("STIGMER_TOKEN")).toBe("late-planted");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns undefined for an absent secret", () => {
|
|
104
|
+
captureRunnerSecrets();
|
|
105
|
+
expect(getRunnerSecret("STIGMER_TOKEN")).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("setRunnerSecret", () => {
|
|
110
|
+
it("carries the rotation channel: set updates, null clears", () => {
|
|
111
|
+
captureRunnerSecrets();
|
|
112
|
+
|
|
113
|
+
setRunnerSecret("STIGMER_TOKEN", "minted-1");
|
|
114
|
+
expect(getRunnerSecret("STIGMER_TOKEN")).toBe("minted-1");
|
|
115
|
+
|
|
116
|
+
setRunnerSecret("STIGMER_TOKEN", "minted-2");
|
|
117
|
+
expect(getRunnerSecret("STIGMER_TOKEN")).toBe("minted-2");
|
|
118
|
+
|
|
119
|
+
setRunnerSecret("STIGMER_TOKEN", null);
|
|
120
|
+
expect(getRunnerSecret("STIGMER_TOKEN")).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("never lets a rotated value sit in process.env, even if a writer beats the boot capture", () => {
|
|
124
|
+
process.env.STIGMER_TOKEN = "boot-token";
|
|
125
|
+
|
|
126
|
+
// No captureRunnerSecrets() call yet — the writer forces it.
|
|
127
|
+
setRunnerSecret("STIGMER_TOKEN", "rotated-early");
|
|
128
|
+
|
|
129
|
+
expect(process.env.STIGMER_TOKEN).toBeUndefined();
|
|
130
|
+
expect(getRunnerSecret("STIGMER_TOKEN")).toBe("rotated-early");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("runnerSecretsEnvView", () => {
|
|
135
|
+
it("merges captured secrets over process.env without writing them back", () => {
|
|
136
|
+
process.env.STIGMER_TOKEN = "boot-token";
|
|
137
|
+
captureRunnerSecrets();
|
|
138
|
+
|
|
139
|
+
const view = runnerSecretsEnvView();
|
|
140
|
+
|
|
141
|
+
expect(view.STIGMER_TOKEN).toBe("boot-token");
|
|
142
|
+
expect(view.PATH).toBe(process.env.PATH);
|
|
143
|
+
expect(
|
|
144
|
+
process.env.STIGMER_TOKEN,
|
|
145
|
+
"building the view must not re-plant secrets into process.env",
|
|
146
|
+
).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("reflects rotation", () => {
|
|
150
|
+
captureRunnerSecrets();
|
|
151
|
+
setRunnerSecret("STIGMER_TOKEN", "rotated");
|
|
152
|
+
|
|
153
|
+
expect(runnerSecretsEnvView().STIGMER_TOKEN).toBe("rotated");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { extractZipFileEntries } from "../zip-extract.js";
|
|
2
|
+
import { extractZipFileEntries, type ZipFileEntry } from "../zip-extract.js";
|
|
3
3
|
import { buildZip } from "../../__test-utils__/zip-fixtures.js";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Decode entries for text-content assertions. The production contract is
|
|
7
|
+
* bytes (issue #683); decoding belongs to the tests that want to assert on
|
|
8
|
+
* human-readable fixtures, not to the module under test.
|
|
9
|
+
*/
|
|
10
|
+
function decoded(entries: ZipFileEntry[]): { path: string; content: string }[] {
|
|
11
|
+
return entries.map((e) => ({ path: e.path, content: new TextDecoder().decode(e.content) }));
|
|
12
|
+
}
|
|
13
|
+
|
|
5
14
|
// ─── extractZipFileEntries ───────────────────────────────────────────────
|
|
6
15
|
|
|
7
16
|
describe("extractZipFileEntries", () => {
|
|
@@ -12,9 +21,10 @@ describe("extractZipFileEntries", () => {
|
|
|
12
21
|
]);
|
|
13
22
|
|
|
14
23
|
const entries = await extractZipFileEntries(zip);
|
|
15
|
-
expect(entries).
|
|
16
|
-
|
|
17
|
-
|
|
24
|
+
expect(decoded(entries)).toEqual([
|
|
25
|
+
{ path: "SKILL.md", content: "# My Skill" },
|
|
26
|
+
{ path: "references/schema.md", content: "# Schema\n\nTable definitions." },
|
|
27
|
+
]);
|
|
18
28
|
});
|
|
19
29
|
|
|
20
30
|
it("extracts deflated files", async () => {
|
|
@@ -22,8 +32,7 @@ describe("extractZipFileEntries", () => {
|
|
|
22
32
|
const zip = buildZip([{ name: "notes.txt", content, method: "deflated" }]);
|
|
23
33
|
|
|
24
34
|
const entries = await extractZipFileEntries(zip);
|
|
25
|
-
expect(entries).
|
|
26
|
-
expect(entries[0]).toEqual({ path: "notes.txt", content });
|
|
35
|
+
expect(decoded(entries)).toEqual([{ path: "notes.txt", content }]);
|
|
27
36
|
});
|
|
28
37
|
|
|
29
38
|
it("skips directory entries", async () => {
|
|
@@ -123,6 +132,32 @@ describe("extractZipFileEntries", () => {
|
|
|
123
132
|
expect(entries).toHaveLength(1);
|
|
124
133
|
});
|
|
125
134
|
|
|
135
|
+
// ── Binary safety (issue #683) ──────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
it("round-trips a stored binary entry byte-identically", async () => {
|
|
138
|
+
// PNG magic followed by bytes that are not valid UTF-8 (0x89 alone, a
|
|
139
|
+
// lone continuation byte, an unpaired lead byte). A decode/encode
|
|
140
|
+
// round-trip replaces these with U+FFFD — the corruption this pins.
|
|
141
|
+
const binary = new Uint8Array([
|
|
142
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe, 0x00, 0x80, 0xc3,
|
|
143
|
+
]);
|
|
144
|
+
const zip = buildZip([{ name: "references/diagram.png", content: binary }]);
|
|
145
|
+
|
|
146
|
+
const entries = await extractZipFileEntries(zip);
|
|
147
|
+
expect(entries).toHaveLength(1);
|
|
148
|
+
expect(new Uint8Array(entries[0].content)).toEqual(binary);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("round-trips a deflated binary entry byte-identically", async () => {
|
|
152
|
+
const binary = new Uint8Array(256);
|
|
153
|
+
for (let i = 0; i < 256; i++) binary[i] = i; // every byte value once
|
|
154
|
+
const zip = buildZip([{ name: "assets/font.woff2", content: binary, method: "deflated" }]);
|
|
155
|
+
|
|
156
|
+
const entries = await extractZipFileEntries(zip);
|
|
157
|
+
expect(entries).toHaveLength(1);
|
|
158
|
+
expect(new Uint8Array(entries[0].content)).toEqual(binary);
|
|
159
|
+
});
|
|
160
|
+
|
|
126
161
|
// ── Streaming entries (issue #450) ─────────────────────────────────────
|
|
127
162
|
|
|
128
163
|
it("extracts Go-default streaming archives (deflated, data descriptors)", async () => {
|
|
@@ -132,7 +167,7 @@ describe("extractZipFileEntries", () => {
|
|
|
132
167
|
]);
|
|
133
168
|
|
|
134
169
|
const entries = await extractZipFileEntries(zip);
|
|
135
|
-
expect(entries).toEqual([
|
|
170
|
+
expect(decoded(entries)).toEqual([
|
|
136
171
|
{ path: "SKILL.md", content: "# Streamed Skill" },
|
|
137
172
|
{ path: "references/notes.md", content: "streamed notes" },
|
|
138
173
|
]);
|
|
@@ -151,7 +186,7 @@ describe("extractZipFileEntries", () => {
|
|
|
151
186
|
]);
|
|
152
187
|
|
|
153
188
|
const entries = await extractZipFileEntries(zip);
|
|
154
|
-
expect(entries).toEqual([
|
|
189
|
+
expect(decoded(entries)).toEqual([
|
|
155
190
|
{ path: "poison.md", content: poisoned },
|
|
156
191
|
{ path: "after.md", content: "the entry after the poisoned one" },
|
|
157
192
|
]);
|
|
@@ -162,7 +197,7 @@ describe("extractZipFileEntries", () => {
|
|
|
162
197
|
const zip = buildZip([{ name: "cd-sizes.txt", content, streaming: true }]);
|
|
163
198
|
|
|
164
199
|
const entries = await extractZipFileEntries(zip);
|
|
165
|
-
expect(entries).toEqual([{ path: "cd-sizes.txt", content }]);
|
|
200
|
+
expect(decoded(entries)).toEqual([{ path: "cd-sizes.txt", content }]);
|
|
166
201
|
});
|
|
167
202
|
|
|
168
203
|
// ── Central directory edge cases ───────────────────────────────────────
|
|
@@ -173,7 +208,7 @@ describe("extractZipFileEntries", () => {
|
|
|
173
208
|
});
|
|
174
209
|
|
|
175
210
|
const entries = await extractZipFileEntries(zip);
|
|
176
|
-
expect(entries).toEqual([{ path: "a.txt", content: "aaa" }]);
|
|
211
|
+
expect(decoded(entries)).toEqual([{ path: "a.txt", content: "aaa" }]);
|
|
177
212
|
});
|
|
178
213
|
|
|
179
214
|
it("is not fooled by EOCD signature bytes inside the archive comment", async () => {
|
|
@@ -185,7 +220,7 @@ describe("extractZipFileEntries", () => {
|
|
|
185
220
|
});
|
|
186
221
|
|
|
187
222
|
const entries = await extractZipFileEntries(zip);
|
|
188
|
-
expect(entries).toEqual([{ path: "a.txt", content: "aaa" }]);
|
|
223
|
+
expect(decoded(entries)).toEqual([{ path: "a.txt", content: "aaa" }]);
|
|
189
224
|
});
|
|
190
225
|
|
|
191
226
|
it("returns empty array when the central directory is missing", async () => {
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { randomBytes, type BinaryLike } from "node:crypto";
|
|
23
|
+
import { getRunnerSecret } from "./runner-credential-store.js";
|
|
23
24
|
|
|
24
25
|
const ENV_VAR = "STIGMER_RUNNER_HITL_SECRET";
|
|
25
26
|
|
|
@@ -27,13 +28,14 @@ let cached: Buffer | undefined;
|
|
|
27
28
|
let warned = false;
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
|
-
* Return the runner's HITL master secret (
|
|
31
|
-
*
|
|
31
|
+
* Return the runner's HITL master secret (operator-configured via the
|
|
32
|
+
* {@link ENV_VAR} env var, resolved through the credential store since the
|
|
33
|
+
* #508 boot capture; else a stable per-process random fallback). Memoized.
|
|
32
34
|
*/
|
|
33
35
|
export function getRunnerHitlMasterSecret(): BinaryLike {
|
|
34
36
|
if (cached) return cached;
|
|
35
37
|
|
|
36
|
-
const fromEnv =
|
|
38
|
+
const fromEnv = getRunnerSecret(ENV_VAR);
|
|
37
39
|
if (fromEnv && fromEnv.length > 0) {
|
|
38
40
|
cached = Buffer.from(fromEnv, "utf-8");
|
|
39
41
|
return cached;
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { LlmProvider } from "./llm-proxy.js";
|
|
18
|
+
import { runnerSecretsEnvView } from "./runner-credential-store.js";
|
|
18
19
|
|
|
19
20
|
// ─── Backend selection ───────────────────────────────────────────────────────
|
|
20
21
|
|
|
@@ -345,7 +346,12 @@ export function checkFoundryPrerequisites(
|
|
|
345
346
|
*/
|
|
346
347
|
export function checkDirectCredentials(
|
|
347
348
|
provider: LlmProvider,
|
|
348
|
-
|
|
349
|
+
// Credential keys live in the runner credential store after the boot
|
|
350
|
+
// capture (#508), so the default is the store view, not bare process.env.
|
|
351
|
+
// The other checks in this module keep the process.env default: they read
|
|
352
|
+
// deployment CONFIG (backend selection, regions), which is deliberately
|
|
353
|
+
// not captured.
|
|
354
|
+
env: NodeJS.ProcessEnv = runnerSecretsEnvView(),
|
|
349
355
|
): string | null {
|
|
350
356
|
if (provider === "openai") {
|
|
351
357
|
if (env.OPENAI_API_KEY?.trim()) return null;
|