@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.
Files changed (101) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/call-http.js +12 -0
  3. package/dist/activities/call-http.js.map +1 -1
  4. package/dist/activities/call-llm.d.ts +18 -0
  5. package/dist/activities/call-llm.js +56 -2
  6. package/dist/activities/call-llm.js.map +1 -1
  7. package/dist/activities/execute-cursor/agent-session-cache.d.ts +72 -0
  8. package/dist/activities/execute-cursor/agent-session-cache.js +186 -0
  9. package/dist/activities/execute-cursor/agent-session-cache.js.map +1 -0
  10. package/dist/activities/execute-cursor/index.js +61 -28
  11. package/dist/activities/execute-cursor/index.js.map +1 -1
  12. package/dist/activities/execute-cursor/service-tier.d.ts +5 -15
  13. package/dist/activities/execute-cursor/service-tier.js +5 -21
  14. package/dist/activities/execute-cursor/service-tier.js.map +1 -1
  15. package/dist/activities/execute-cursor/skill-resolver.js +1 -1
  16. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  17. package/dist/activities/execute-deep-agent/setup.js +14 -0
  18. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  19. package/dist/activities/execute-deep-agent/shell-env.d.ts +8 -5
  20. package/dist/activities/execute-deep-agent/shell-env.js +10 -7
  21. package/dist/activities/execute-deep-agent/shell-env.js.map +1 -1
  22. package/dist/config.js +10 -5
  23. package/dist/config.js.map +1 -1
  24. package/dist/encryption/config.js +7 -2
  25. package/dist/encryption/config.js.map +1 -1
  26. package/dist/main.js +12 -6
  27. package/dist/main.js.map +1 -1
  28. package/dist/payload-codecs.js +2 -1
  29. package/dist/payload-codecs.js.map +1 -1
  30. package/dist/runner-manager.js +20 -7
  31. package/dist/runner-manager.js.map +1 -1
  32. package/dist/runner.js +19 -6
  33. package/dist/runner.js.map +1 -1
  34. package/dist/shared/fingerprint-secret.d.ts +3 -2
  35. package/dist/shared/fingerprint-secret.js +5 -3
  36. package/dist/shared/fingerprint-secret.js.map +1 -1
  37. package/dist/shared/llm-backend.js +8 -1
  38. package/dist/shared/llm-backend.js.map +1 -1
  39. package/dist/shared/model-client.d.ts +15 -0
  40. package/dist/shared/model-client.js +57 -13
  41. package/dist/shared/model-client.js.map +1 -1
  42. package/dist/shared/registry-endpoint.d.ts +5 -0
  43. package/dist/shared/registry-endpoint.js +7 -1
  44. package/dist/shared/registry-endpoint.js.map +1 -1
  45. package/dist/shared/runner-credential-keys.d.ts +26 -1
  46. package/dist/shared/runner-credential-keys.js +34 -1
  47. package/dist/shared/runner-credential-keys.js.map +1 -1
  48. package/dist/shared/runner-credential-store.d.ts +77 -0
  49. package/dist/shared/runner-credential-store.js +111 -0
  50. package/dist/shared/runner-credential-store.js.map +1 -0
  51. package/dist/shared/service-tier.d.ts +55 -0
  52. package/dist/shared/service-tier.js +67 -0
  53. package/dist/shared/service-tier.js.map +1 -0
  54. package/dist/shared/skill-writer.js +2 -2
  55. package/dist/shared/skill-writer.js.map +1 -1
  56. package/dist/shared/zip-extract.d.ts +10 -3
  57. package/dist/shared/zip-extract.js +10 -3
  58. package/dist/shared/zip-extract.js.map +1 -1
  59. package/dist/workflow-engine/tasks/call-function.d.ts +14 -0
  60. package/dist/workflow-engine/tasks/call-function.js +49 -5
  61. package/dist/workflow-engine/tasks/call-function.js.map +1 -1
  62. package/dist/workflow-engine/types.d.ts +6 -0
  63. package/dist/workflow-engine/types.js.map +1 -1
  64. package/dist/workflows/engine-core.js +36 -8
  65. package/dist/workflows/engine-core.js.map +1 -1
  66. package/package.json +2 -2
  67. package/src/activities/__tests__/call-http.test.ts +36 -0
  68. package/src/activities/__tests__/call-llm.test.ts +77 -0
  69. package/src/activities/call-http.ts +17 -0
  70. package/src/activities/call-llm.ts +78 -2
  71. package/src/activities/execute-cursor/__tests__/agent-session-cache.test.ts +220 -0
  72. package/src/activities/execute-cursor/__tests__/service-tier.test.ts +1 -1
  73. package/src/activities/execute-cursor/agent-session-cache.ts +229 -0
  74. package/src/activities/execute-cursor/index.ts +66 -20
  75. package/src/activities/execute-cursor/service-tier.ts +5 -29
  76. package/src/activities/execute-cursor/skill-resolver.ts +1 -1
  77. package/src/activities/execute-deep-agent/setup.ts +15 -0
  78. package/src/activities/execute-deep-agent/shell-env.ts +10 -7
  79. package/src/config.ts +10 -5
  80. package/src/encryption/config.ts +8 -2
  81. package/src/main.ts +16 -6
  82. package/src/payload-codecs.ts +2 -1
  83. package/src/runner-manager.ts +29 -6
  84. package/src/runner.ts +25 -6
  85. package/src/shared/__tests__/model-client.test.ts +99 -0
  86. package/src/shared/__tests__/runner-credential-store.test.ts +155 -0
  87. package/src/shared/__tests__/zip-extract.test.ts +46 -11
  88. package/src/shared/fingerprint-secret.ts +5 -3
  89. package/src/shared/llm-backend.ts +7 -1
  90. package/src/shared/model-client.ts +76 -13
  91. package/src/shared/registry-endpoint.ts +9 -1
  92. package/src/shared/runner-credential-keys.ts +36 -1
  93. package/src/shared/runner-credential-store.ts +115 -0
  94. package/src/shared/service-tier.ts +78 -0
  95. package/src/shared/skill-writer.ts +2 -2
  96. package/src/shared/zip-extract.ts +14 -7
  97. package/src/workflow-engine/__tests__/golden-execution.test.ts +20 -1
  98. package/src/workflow-engine/__tests__/tasks/call-function.test.ts +105 -0
  99. package/src/workflow-engine/tasks/call-function.ts +74 -13
  100. package/src/workflow-engine/types.ts +6 -0
  101. package/src/workflows/engine-core.ts +39 -8
@@ -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
- !process.env.STIGMER_PAYLOAD_ENCRYPTION_KEY
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
- if (token) {
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
- * 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.
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 = 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).toHaveLength(2);
16
- expect(entries[0]).toEqual({ path: "SKILL.md", content: "# My Skill" });
17
- expect(entries[1]).toEqual({ path: "references/schema.md", content: "# Schema\n\nTable definitions." });
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).toHaveLength(1);
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 (env-configured, else a stable
31
- * per-process random fallback). Memoized.
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 = process.env[ENV_VAR];
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
- env: NodeJS.ProcessEnv = process.env,
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;