@specific.dev/spectest 0.88.0 → 0.88.2

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/aws-sigv4.js CHANGED
@@ -4,8 +4,8 @@
4
4
  // egress forward. The app under test signs with throwaway dummy credentials
5
5
  // (any AWS SDK refuses to build a request without *some* credential); the
6
6
  // broker strips that dummy signature and re-signs the exact outbound request
7
- // with the real credentials brokered eval-scoped from the platform Secrets
8
- // store. Because signing happens at forward time over the real outbound
7
+ // with real credentials supplied by the platform for the environment lifetime.
8
+ // Because signing happens at forward time over the real outbound
9
9
  // canonical request, `x-amz-date` / `x-amz-content-sha256` / `authorization`
10
10
  // are always internally consistent — which is exactly what static header
11
11
  // injection cannot achieve for SigV4 (the signature is a keyed HMAC over the
@@ -26,11 +26,10 @@
26
26
  // `entry.mjs` routes by Host header, applies the per-provider path table,
27
27
  // and corrects the discovery document. It also re-issues every id_token
28
28
  // with one RSA key it generates at boot, and serves that key at the
29
- // provider's real JWKS URL. That single step covers three separate faults:
29
+ // provider's real JWKS URL. This normalizes signing and issuer handling:
30
30
  // the Google emulator signs HS256 and publishes an empty JWKS (so nothing
31
- // can verify its token), the Microsoft one cannot put the tenant in its
32
- // issuer, and any future provider whose issuer is not its base URL is
33
- // handled in advance.
31
+ // can verify its token), and providers whose issuer differs from their
32
+ // base URL can declare it explicitly.
34
33
  //
35
34
  // The Dockerfile is deliberately CONSTANT — every provider package is
36
35
  // installed whether or not this component is the one using it. Two reasons:
@@ -2,8 +2,8 @@ import type { ProviderOptions } from "./emulate/service.js";
2
2
  export interface MicrosoftOptions extends ProviderOptions {
3
3
  /**
4
4
  * Directory (tenant) the app signs in against — whatever its authority
5
- * URL uses. It appears in the issuer and in every endpoint path, so a
6
- * client that checks the issuer sees what it expects. Default `"common"`.
5
+ * URL uses. Sets the seeded users' and client's tenant as well as the
6
+ * issuer and endpoint paths. Default `"common"`.
7
7
  */
8
8
  tenantId?: string;
9
9
  }
@@ -8,16 +8,15 @@
8
8
  // widening what it serves is a table entry.
9
9
  import { emulatorService, oauthClients, resolveUsers } from "./emulate/service.js";
10
10
  function spec(users, opts) {
11
- const authority = `https://login.microsoftonline.com/${opts.tenantId || "common"}`;
11
+ const tenantId = opts.tenantId || "common";
12
+ const authority = `https://login.microsoftonline.com/${tenantId}`;
12
13
  return {
13
14
  name: "microsoft",
14
15
  module: "@emulators/microsoft",
15
16
  pluginExport: "microsoftPlugin",
16
17
  baseUrl: "https://login.microsoftonline.com",
17
18
  hosts: ["login.microsoftonline.com", "graph.microsoft.com"],
18
- // The emulator has no tenant, so its issuer is the bare host. A client
19
- // that checks the issuer against the authority it configured would
20
- // reject that; the re-issue step corrects it.
19
+ // Keep the re-issued token aligned with discovery and the seeded tenant.
21
20
  issuer: `${authority}/v2.0`,
22
21
  discovery: {
23
22
  issuer: `${authority}/v2.0`,
@@ -40,8 +39,8 @@ function spec(users, opts) {
40
39
  scopes: ["openid", "email", "profile", "User.Read"],
41
40
  },
42
41
  seed: {
43
- users: users.map((u) => ({ email: u.email, name: u.name })),
44
- ...oauthClients(opts.client),
42
+ users: users.map((u) => ({ email: u.email, name: u.name, tenant_id: tenantId })),
43
+ ...oauthClients(opts.client, { tenant_id: tenantId }),
45
44
  },
46
45
  };
47
46
  }
@@ -38,9 +38,9 @@ export interface InjectMatch {
38
38
  * are SET on the egress forward — overwriting whatever the app sent (so app
39
39
  * code can't smuggle a different value past the broker). Header VALUES may
40
40
  * embed `{{secret:REF}}` tokens; each `REF` is resolved server-side from the
41
- * project's Secrets store and pushed eval-scoped — the real value never
42
- * enters project code, the
43
- * tarball, the warm-cache hash, or a cassette (it's redacted, fail-closed). */
41
+ * project's Secrets store and supplied for the environment lifetime. Values
42
+ * never enter project files, the warm-cache hash, or a cassette (redaction
43
+ * fails closed). */
44
44
  export interface InjectRule {
45
45
  match?: InjectMatch;
46
46
  headers: Record<string, string>;
@@ -52,7 +52,7 @@ export interface InjectRule {
52
52
  * signature is a keyed HMAC over the whole request, not a static token.
53
53
  *
54
54
  * The three fields are **secret refs, resolved directly** from the project's
55
- * Secrets store (NOT `{{secret:}}` templates) — same eval-scoped push and
55
+ * Secrets store (NOT `{{secret:}}` templates), with the same lifetime and
56
56
  * fail-closed redaction as `inject`. Region + service are inferred from the
57
57
  * incoming request (its credential scope, else the host); the app never
58
58
  * configures them. */
@@ -19,7 +19,7 @@
19
19
  // - RECORD (MITM): the request is forwarded to the REAL host and the
20
20
  // request/response pair is captured (decoded, redacted), and the real
21
21
  // response is returned to the app so a manual session behaves like
22
- // production. This runs under `spectest env eval` against a manual env.
22
+ // production. This runs for all interactions with a manual environment.
23
23
  //
24
24
  // Because the fake's hostname IS the real host, the in-VM resolver points
25
25
  // that name at the daemon — so a naive `fetch("https://api.stripe.com")`
@@ -33,26 +33,25 @@
33
33
  //
34
34
  // Mode is chosen by `isRecording()`: it is true only inside an active
35
35
  // recorder (a `spectest test` case), false in eval/manual. So `auto`
36
- // (the default) replays under test and records under eval — no new
36
+ // (the default) replays under test and records during manual use — no new
37
37
  // control-plane mode flag. `mode: "record" | "replay"` overrides it.
38
38
  //
39
39
  // Credential brokering (per-fake): `inject` rules set
40
40
  // headers on the egress forward — overwriting whatever the app sent — so
41
41
  // app code never holds the credential. Header values embed `{{secret:REF}}`
42
42
  // tokens; each REF is resolved server-side from the platform Secrets store
43
- // and pushed eval-scoped from the control plane (via {@link getRecordSecret}).
44
- // The real value lives only on the outbound wire to the real upstream and
45
- // is redacted from the cassette (fail-closed) — it never enters project
46
- // code, the tarball, the warm-cache hash, or any snapshot a hermetic run
47
- // could fork.
43
+ // and supplied for the environment lifetime (via {@link getRecordSecret}).
44
+ // Values live in harness memory (including snapshots) and on the outbound
45
+ // wire. They are redacted from cassettes (fail-closed) and never enter
46
+ // project files or the warm-cache hash.
48
47
  //
49
48
  // AWS SigV4 (`sign: { type: "awsSigv4", ... }`): static header injection
50
49
  // can't broker AWS auth — the `Authorization` value is a keyed HMAC over the
51
50
  // entire request, not a static token. So the forward instead RE-SIGNS: the
52
51
  // app signs with throwaway dummy creds (any AWS SDK refuses to build a request
53
52
  // with none), we strip that signature and re-sign the exact outbound request
54
- // with the real credentials (also direct secret refs, same eval-scoped push +
55
- // redaction). Region/service are inferred from the request. See
53
+ // with the real credentials (direct secret refs, supplied for the environment
54
+ // lifetime and redacted). Region/service are inferred from the request. See
56
55
  // `../aws-sigv4.ts`.
57
56
  import { createSocket } from "node:dgram";
58
57
  import { createHash } from "node:crypto";
@@ -308,7 +307,7 @@ function ruleMatches(match, req) {
308
307
  return true;
309
308
  }
310
309
  /** Resolve a rule's header templates, substituting every `{{secret:REF}}`
311
- * with the eval-scoped value. Collects resolved secrets (for redaction)
310
+ * with the environment credential. Collects resolved secrets (for redaction)
312
311
  * and any refs the control plane didn't supply (fail loud). */
313
312
  function brokerHeaders(rule) {
314
313
  const headers = {};
@@ -438,7 +437,7 @@ export function replayFake(opts) {
438
437
  const match = defaultMatch(opts.match);
439
438
  const injectRules = opts.inject ?? [];
440
439
  // Refs every header template references — the control plane resolves these
441
- // server-side and pushes them eval-scoped (see the daemon's
440
+ // server-side and pushes them for the environment lifetime (see the daemon's
442
441
  // /record-secret-refs endpoint, which reads `def.secretRefs`).
443
442
  const secretRefs = collectSecretRefs(injectRules, opts.sign);
444
443
  const resolveMode = () => {
@@ -477,7 +476,7 @@ export function replayFake(opts) {
477
476
  // ── RECORD (MITM) ───────────────────────────────────────────────────
478
477
  async function record(req, url, reqLike, bodyBytes, state) {
479
478
  // Credential brokering (static header inject): first matching rule wins.
480
- // Resolve its `{{secret:REF}}` tokens from the eval-scoped store.
479
+ // Resolve its `{{secret:REF}}` tokens from the environment secret store.
481
480
  const rule = injectRules.find((r) => ruleMatches(r.match, {
482
481
  method: reqLike.method,
483
482
  path: reqLike.path,
@@ -509,7 +508,7 @@ export function replayFake(opts) {
509
508
  if (missing.length > 0) {
510
509
  const refs = [...new Set(missing)];
511
510
  return new Response(`replayFake(${opts.name}): secret(s) ${JSON.stringify(refs)} were not supplied ` +
512
- `(configure them on the project's Secrets page, and record via \`spectest env eval\`).\n`, { status: 599, headers: { "content-type": "text/plain" } });
511
+ `(configure them on the project's Secrets page, then start a new environment or fork).\n`, { status: 599, headers: { "content-type": "text/plain" } });
513
512
  }
514
513
  // Resolve the REAL host's IP via an external resolver so we don't loop
515
514
  // back into the daemon (the in-VM resolver answers our own gateway for
package/dist/daemon.js CHANGED
@@ -57,7 +57,7 @@ import { startTlsTerminator } from "./harness/tls-terminator.js";
57
57
  import { runContainerArgs } from "./harness/container-run.js";
58
58
  import { BUILDKIT_CACHE_DIR, BUILDKIT_CACHE_UNION, IMAGE_CACHE_MANIFEST, imageCachePathsSync, isOnImageCache, mergeCacheIndex, } from "./harness/image-cache.js";
59
59
  import { assertAbsolute, certificateHostnames, defaultKeyMode, expandServiceToken, isNoopChown, mountFlag, needsIdTables, numericId, resolveChownIds, } from "./harness/file-mounts.js";
60
- import { conflict, notFound, requireString, } from "./harness/methods.js";
60
+ import { badRequest, conflict, notFound, requireString, } from "./harness/methods.js";
61
61
  import { openTerminal } from "./terminal.js";
62
62
  // `ctx.mcp(url)`. One client per call, no registry: an authenticated
63
63
  // client is passed to descendants as a test's return value.
@@ -65,7 +65,7 @@ import { openMcp } from "./mcp.js";
65
65
  import { readAnnotation } from "./annotate.js";
66
66
  import { isRecording, recordEmail, recordEnv, recordExec, recordFake, recordStep, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
67
67
  import { deepUnwrap, readRaw, wrap } from "./inspect.js";
68
- import { clearRecordSecrets, setRecordSecrets } from "./record-secrets.js";
68
+ import { setRecordSecrets } from "./record-secrets.js";
69
69
  import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
70
70
  function namedServices(cfg) {
71
71
  return Object.entries(cfg.services).map(([name, def]) => ({ name, ...def }));
@@ -5030,13 +5030,8 @@ function explainEvalExportError(code, message) {
5030
5030
  " return out;\n" +
5031
5031
  " })();\n");
5032
5032
  }
5033
- async function evalCode(code, secrets) {
5033
+ async function evalCode(code) {
5034
5034
  const start = Date.now();
5035
- // Eval-scoped secret channel for record-mode fakes — set before the
5036
- // snippet runs, cleared in the `finally` below so a secret never
5037
- // persists into daemon memory (and thus into a forkable snapshot) past
5038
- // the eval that supplied it. See record-secrets.ts.
5039
- setRecordSecrets(secrets);
5040
5035
  const chunks = [];
5041
5036
  const origStdout = process.stdout.write.bind(process.stdout);
5042
5037
  const origStderr = process.stderr.write.bind(process.stderr);
@@ -5204,7 +5199,6 @@ async function evalCode(code, secrets) {
5204
5199
  };
5205
5200
  }
5206
5201
  finally {
5207
- clearRecordSecrets();
5208
5202
  fetchScope.active = false;
5209
5203
  restoreFetch();
5210
5204
  restoreConsole();
@@ -5515,6 +5509,14 @@ function loadedSummary(l) {
5515
5509
  * cutover.
5516
5510
  */
5517
5511
  export function harnessMethods(state) {
5512
+ const configureSecrets = (params) => {
5513
+ const secrets = params.secrets;
5514
+ if (!secrets || typeof secrets !== "object" || Array.isArray(secrets) ||
5515
+ Object.values(secrets).some(value => typeof value !== "string")) {
5516
+ throw badRequest("secrets must be an object of string values");
5517
+ }
5518
+ setRecordSecrets(secrets);
5519
+ };
5518
5520
  return {
5519
5521
  health: async () => ({ ok: true, capabilities: ["prepared-images-v1"] }),
5520
5522
  // Live bootstrap progress, polled by the control plane during
@@ -5606,14 +5608,18 @@ export function harnessMethods(state) {
5606
5608
  }),
5607
5609
  // Union of platform secret refs the loaded fakes declare (replayFake's
5608
5610
  // `secretRefs`). The control plane resolves these server-side and pushes
5609
- // the values on the eval path only. Empty if nothing's loaded.
5611
+ // the values for the environment lifetime. Empty if nothing's loaded.
5610
5612
  recordSecretRefs: async () => {
5611
5613
  const refs = new Set();
5612
5614
  for (const fake of FAKES.values()) {
5613
5615
  for (const ref of fake.def.secretRefs ?? [])
5614
5616
  refs.add(ref);
5615
5617
  }
5616
- return { refs: [...refs] };
5618
+ return { refs: [...refs], environmentScoped: true };
5619
+ },
5620
+ setRecordSecrets: async (params) => {
5621
+ configureSecrets(params);
5622
+ return { ok: true };
5617
5623
  },
5618
5624
  prepareImages: async (params) => exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => {
5619
5625
  const restore = captureConsole(BOOT_LOG_SINK);
@@ -5631,11 +5637,13 @@ export function harnessMethods(state) {
5631
5637
  projectSetup: async () => exclusive(state, "inFlightProjectSetup", "project-setup already in progress", () => runProjectSetup()),
5632
5638
  eval: async (params) => {
5633
5639
  const code = requireString(params, "code");
5634
- // `secrets` are eval-scoped: the control plane resolves the loaded
5635
- // project's declared `replayFake` refs and pushes the values here on
5636
- // the eval path only. Never present on the test path.
5637
- const secrets = params.secrets;
5638
- return exclusive(state, "inFlightTest", "a test or eval is already running", () => evalCode(code, secrets));
5640
+ return exclusive(state, "inFlightTest", "a test or eval is already running", () => {
5641
+ // Compatibility with older control planes: adopt their supplied values,
5642
+ // but keep them for subsequent browser/background requests as well.
5643
+ if (params.secrets !== undefined)
5644
+ configureSecrets(params);
5645
+ return evalCode(code);
5646
+ });
5639
5647
  },
5640
5648
  /**
5641
5649
  * Snapshot each service's log output produced during env bring-up
@@ -23,7 +23,7 @@
23
23
  /** Protocol version this build speaks. Must match `PROTOCOL_VERSION` in protocol.rs. */
24
24
  export declare const PROTOCOL_VERSION = 1;
25
25
  /** Methods either side can send. */
26
- export type Method = "hello" | "load" | "loadTests" | "fingerprint" | "bootstrap" | "run" | "eval" | "teardown" | "shutdown" | "createArtifact" | "completeArtifact" | "progress" | "step";
26
+ export type Method = "hello" | "load" | "loadTests" | "fingerprint" | "bootstrap" | "run" | "eval" | "setRecordSecrets" | "teardown" | "shutdown" | "createArtifact" | "completeArtifact" | "progress" | "step";
27
27
  /** Methods this build knows how to dispatch. A method outside this set still
28
28
  * *parses* — it is answered with an error, never dropped. */
29
29
  export declare const KNOWN_METHODS: ReadonlySet<string>;
@@ -32,6 +32,7 @@ export const KNOWN_METHODS = new Set([
32
32
  "bootstrap",
33
33
  "run",
34
34
  "eval",
35
+ "setRecordSecrets",
35
36
  "teardown",
36
37
  "shutdown",
37
38
  "createArtifact",
package/dist/index.d.ts CHANGED
@@ -823,7 +823,7 @@ export type ServiceImage = {
823
823
  * Runs in `defineEnvironment`, and in the daemon's runtime `startService`
824
824
  * twin, inside the VM, where the repo sits at the project root. Existence
825
825
  * checks go through the project-file resolver, so the same rules as
826
- * `ctx.readProjectFile` apply (a `spectest/.envignore`d path is refused
826
+ * `ctx.readProjectFile` apply (a `spectest/.specignore`d path is refused
827
827
  * instead of read stale).
828
828
  */
829
829
  export declare function validateServiceImage(serviceName: string, image: ServiceImage): ServiceImage;
@@ -1469,8 +1469,8 @@ export interface FakeDefinition<S = any, H extends Record<string, unknown> = Rec
1469
1469
  * Internal. Platform secret references this fake needs at *record* time
1470
1470
  * (set by {@link replayFake}). The daemon reports the union of these to
1471
1471
  * the control plane, which resolves each via its `SecretResolver` and
1472
- * pushes the values on the eval path only — they never enter project
1473
- * files, the config hash, or a cassette. Not part of the authoring
1472
+ * supplies them for the environment lifetime, including snapshots. Values
1473
+ * never enter project files, the config hash, or a cassette. Not part of the authoring
1474
1474
  * surface; `JSON.stringify` ignores it (fakes never serialize to config).
1475
1475
  */
1476
1476
  secretRefs?: readonly string[];
package/dist/index.js CHANGED
@@ -253,7 +253,7 @@ const BUILD_TARGET_RE = /^[^\s]+$/;
253
253
  * Runs in `defineEnvironment`, and in the daemon's runtime `startService`
254
254
  * twin, inside the VM, where the repo sits at the project root. Existence
255
255
  * checks go through the project-file resolver, so the same rules as
256
- * `ctx.readProjectFile` apply (a `spectest/.envignore`d path is refused
256
+ * `ctx.readProjectFile` apply (a `spectest/.specignore`d path is refused
257
257
  * instead of read stale).
258
258
  */
259
259
  export function validateServiceImage(serviceName, image) {
@@ -12,14 +12,14 @@
12
12
  // is correct for every file that is IN that hash. It can be behind for the two
13
13
  // kinds of file the hash excludes: `spectest/tests/**` (excluded so a test-only
14
14
  // edit keeps the fast start) and anything a project lists in
15
- // `spectest/.envignore`.
15
+ // `spectest/.specignore`.
16
16
  //
17
17
  // Hence the rule below: a path under `spectest/` resolves against the app copy
18
18
  // first, because that copy is always current — this is what lets a fixture live
19
19
  // in `spectest/tests/fixtures/` and still be found after it was added. Any
20
20
  // other path resolves against /workspace, which the hash keeps current.
21
21
  //
22
- // The remaining hole is a file that `.envignore` excludes AND that sits outside
22
+ // The remaining hole is a file that `.specignore` excludes AND that sits outside
23
23
  // `spectest/`: /workspace holds whatever the cold start uploaded, so a later
24
24
  // edit is invisible. Nothing can repair those bytes at read time, so we refuse
25
25
  // to read them instead of returning stale content. The control plane writes the
@@ -31,8 +31,8 @@ export const WORKSPACE = process.env.SPECTEST_WORKSPACE ?? "/workspace";
31
31
  /** The app dir; the user's `spectest/` sits directly under it. */
32
32
  export const APP_DIR = process.env.SPECTEST_APP_DIR ?? "/opt/spectest/app";
33
33
  /** Paths (repo-relative) the warm-template hash skipped because of
34
- * `spectest/.envignore`. Written by the control plane at env start; absent
35
- * when the project ships no `.envignore`. */
34
+ * `spectest/.specignore`. Written by the control plane at env start; absent
35
+ * when the project ships no `.specignore`. */
36
36
  const ENV_IGNORED_FILE = "/run/spectest-env-ignored.json";
37
37
  let _envIgnored;
38
38
  function envIgnored() {
@@ -44,7 +44,7 @@ function envIgnored() {
44
44
  paths = raw.paths ?? [];
45
45
  }
46
46
  catch {
47
- /* No file (no .envignore, or an older env) — nothing to refuse. */
47
+ /* No file (no .specignore, or an older env) — nothing to refuse. */
48
48
  }
49
49
  _envIgnored = new Set(paths);
50
50
  return _envIgnored;
@@ -90,7 +90,7 @@ export function resolveProjectPath(p, opts = {}) {
90
90
  return app;
91
91
  }
92
92
  if (envIgnored().has(rel)) {
93
- throw new Error(`project file "${p}" is excluded by spectest/.envignore, so the copy in the VM ` +
93
+ throw new Error(`project file "${p}" is excluded by the environment cache ignore rules, so the copy in the VM ` +
94
94
  `is whatever a cold start uploaded and can be out of date. Move it under ` +
95
95
  `spectest/tests/ (still cache-free, and always re-uploaded), or drop the pattern.`);
96
96
  }
@@ -1,9 +1,4 @@
1
- /** Replace the eval-scoped secret set. Called by the daemon at the start
2
- * of each `/eval` with the values the control plane resolved. */
3
- export declare function setRecordSecrets(secrets: Record<string, string> | undefined): void;
4
- /** Drop all secrets. Called from the eval's `finally` so nothing survives
5
- * past the eval that supplied them. */
6
- export declare function clearRecordSecrets(): void;
7
- /** Resolve a secret by its platform `ref`. `undefined` if the control
8
- * plane didn't supply it (ref not configured, or not on the eval path). */
1
+ /** Replace the complete environment secret set, including removing stale refs. */
2
+ export declare function setRecordSecrets(secrets: Record<string, string>): void;
3
+ /** Resolve a declared project secret for an outbound recording request. */
9
4
  export declare function getRecordSecret(ref: string): string | undefined;
@@ -1,39 +1,18 @@
1
- // Eval-scoped secret channel for record-mode fakes (see
2
- // `components/replayFake.ts`).
3
- //
4
- // The control plane resolves a platform secret (e.g. `STRIPE_API_KEY`)
5
- // server-side and pushes it into the daemon on the `/eval` request body
6
- // only — never on the `run_tests` path, never into the project tarball,
7
- // the config hash, or any cassette. The daemon stashes the resolved
8
- // values here for exactly the duration of one eval (set before the
9
- // snippet runs, cleared in the eval's `finally`) and the record-mode fake
10
- // forwarder reads them via {@link getRecordSecret}.
11
- //
12
- // Eval-scoped is load-bearing: daemon memory survives snapshot/fork, so a
13
- // long-lived secrets map could ride a warm/post-test snapshot into a
14
- // forkable state a hermetic `spectest test` could observe. Clearing per
15
- // eval means a secret never persists into anything a replay run can reach.
16
- // This module is shared (one instance per daemon process) so the daemon
17
- // writes and the component reads the same map.
1
+ // Environment-lifetime credentials for record-mode fakes. The control plane
2
+ // resolves declared references from the owning project's Secrets store before
3
+ // service startup and refreshes them on warm starts and interactive forks.
4
+ // They persist across evals, background requests and memory snapshots, just
5
+ // like other environment state. Test forks inherit their parent's credentials;
6
+ // replay mode still uses cassettes and never forwards upstream.
7
+ // Values stay out of project files, config hashes and recorded cassettes.
18
8
  const SECRETS = new Map();
19
- /** Replace the eval-scoped secret set. Called by the daemon at the start
20
- * of each `/eval` with the values the control plane resolved. */
9
+ /** Replace the complete environment secret set, including removing stale refs. */
21
10
  export function setRecordSecrets(secrets) {
22
11
  SECRETS.clear();
23
- if (!secrets)
24
- return;
25
- for (const [ref, value] of Object.entries(secrets)) {
26
- if (typeof value === "string")
27
- SECRETS.set(ref, value);
28
- }
12
+ for (const [ref, value] of Object.entries(secrets))
13
+ SECRETS.set(ref, value);
29
14
  }
30
- /** Drop all secrets. Called from the eval's `finally` so nothing survives
31
- * past the eval that supplied them. */
32
- export function clearRecordSecrets() {
33
- SECRETS.clear();
34
- }
35
- /** Resolve a secret by its platform `ref`. `undefined` if the control
36
- * plane didn't supply it (ref not configured, or not on the eval path). */
15
+ /** Resolve a declared project secret for an outbound recording request. */
37
16
  export function getRecordSecret(ref) {
38
17
  return SECRETS.get(ref);
39
18
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.88.0",
3
+ "version": "0.88.2",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/aws-sigv4.ts CHANGED
@@ -4,8 +4,8 @@
4
4
  // egress forward. The app under test signs with throwaway dummy credentials
5
5
  // (any AWS SDK refuses to build a request without *some* credential); the
6
6
  // broker strips that dummy signature and re-signs the exact outbound request
7
- // with the real credentials brokered eval-scoped from the platform Secrets
8
- // store. Because signing happens at forward time over the real outbound
7
+ // with real credentials supplied by the platform for the environment lifetime.
8
+ // Because signing happens at forward time over the real outbound
9
9
  // canonical request, `x-amz-date` / `x-amz-content-sha256` / `authorization`
10
10
  // are always internally consistent — which is exactly what static header
11
11
  // injection cannot achieve for SigV4 (the signature is a keyed HMAC over the
@@ -17,13 +17,11 @@
17
17
  // origin. Real providers spread them over several hosts. We merge the
18
18
  // real URLs in.
19
19
  // * JWKS — we serve one RSA public key, for every provider.
20
- // * id_token — we re-issue it: same claims, RS256, our key, and a
21
- // corrected `iss` where the emulator cannot know it. This is one step
22
- // that fixes three faults. The Google emulator signs HS256 and serves
23
- // an empty JWKS, so no client can verify its token. The Microsoft one
24
- // cannot put the tenant in the issuer, because it does not know it.
25
- // And any future provider whose issuer does not match its base URL is
26
- // covered in advance. No signature check is needed on the way in: the
20
+ // * id_token — we re-issue it: same claims, RS256, our key, and an
21
+ // explicit `iss` where the provider declares one. The Google emulator
22
+ // signs HS256 and serves an empty JWKS, so no client can verify its
23
+ // token. Providers whose issuer differs from their base URL can
24
+ // declare it explicitly. No signature check is needed on the way in: the
27
25
  // token comes from an in-process function call, not from the network.
28
26
  // * the account picker page — each button gets a `data-testid`, so a
29
27
  // browser test has a stable locator.
@@ -26,11 +26,10 @@
26
26
  // `entry.mjs` routes by Host header, applies the per-provider path table,
27
27
  // and corrects the discovery document. It also re-issues every id_token
28
28
  // with one RSA key it generates at boot, and serves that key at the
29
- // provider's real JWKS URL. That single step covers three separate faults:
29
+ // provider's real JWKS URL. This normalizes signing and issuer handling:
30
30
  // the Google emulator signs HS256 and publishes an empty JWKS (so nothing
31
- // can verify its token), the Microsoft one cannot put the tenant in its
32
- // issuer, and any future provider whose issuer is not its base URL is
33
- // handled in advance.
31
+ // can verify its token), and providers whose issuer differs from their
32
+ // base URL can declare it explicitly.
34
33
  //
35
34
  // The Dockerfile is deliberately CONSTANT — every provider package is
36
35
  // installed whether or not this component is the one using it. Two reasons:
@@ -13,23 +13,22 @@ import { emulatorService, oauthClients, resolveUsers } from "./emulate/service.j
13
13
  export interface MicrosoftOptions extends ProviderOptions {
14
14
  /**
15
15
  * Directory (tenant) the app signs in against — whatever its authority
16
- * URL uses. It appears in the issuer and in every endpoint path, so a
17
- * client that checks the issuer sees what it expects. Default `"common"`.
16
+ * URL uses. Sets the seeded users' and client's tenant as well as the
17
+ * issuer and endpoint paths. Default `"common"`.
18
18
  */
19
19
  tenantId?: string;
20
20
  }
21
21
 
22
22
  function spec(users: ProviderUser[], opts: MicrosoftOptions): ProviderSpec {
23
- const authority = `https://login.microsoftonline.com/${opts.tenantId || "common"}`;
23
+ const tenantId = opts.tenantId || "common";
24
+ const authority = `https://login.microsoftonline.com/${tenantId}`;
24
25
  return {
25
26
  name: "microsoft",
26
27
  module: "@emulators/microsoft",
27
28
  pluginExport: "microsoftPlugin",
28
29
  baseUrl: "https://login.microsoftonline.com",
29
30
  hosts: ["login.microsoftonline.com", "graph.microsoft.com"],
30
- // The emulator has no tenant, so its issuer is the bare host. A client
31
- // that checks the issuer against the authority it configured would
32
- // reject that; the re-issue step corrects it.
31
+ // Keep the re-issued token aligned with discovery and the seeded tenant.
33
32
  issuer: `${authority}/v2.0`,
34
33
  discovery: {
35
34
  issuer: `${authority}/v2.0`,
@@ -52,8 +51,8 @@ function spec(users: ProviderUser[], opts: MicrosoftOptions): ProviderSpec {
52
51
  scopes: ["openid", "email", "profile", "User.Read"],
53
52
  },
54
53
  seed: {
55
- users: users.map((u) => ({ email: u.email, name: u.name })),
56
- ...oauthClients(opts.client),
54
+ users: users.map((u) => ({ email: u.email, name: u.name, tenant_id: tenantId })),
55
+ ...oauthClients(opts.client, { tenant_id: tenantId }),
57
56
  },
58
57
  };
59
58
  }
@@ -19,7 +19,7 @@
19
19
  // - RECORD (MITM): the request is forwarded to the REAL host and the
20
20
  // request/response pair is captured (decoded, redacted), and the real
21
21
  // response is returned to the app so a manual session behaves like
22
- // production. This runs under `spectest env eval` against a manual env.
22
+ // production. This runs for all interactions with a manual environment.
23
23
  //
24
24
  // Because the fake's hostname IS the real host, the in-VM resolver points
25
25
  // that name at the daemon — so a naive `fetch("https://api.stripe.com")`
@@ -33,26 +33,25 @@
33
33
  //
34
34
  // Mode is chosen by `isRecording()`: it is true only inside an active
35
35
  // recorder (a `spectest test` case), false in eval/manual. So `auto`
36
- // (the default) replays under test and records under eval — no new
36
+ // (the default) replays under test and records during manual use — no new
37
37
  // control-plane mode flag. `mode: "record" | "replay"` overrides it.
38
38
  //
39
39
  // Credential brokering (per-fake): `inject` rules set
40
40
  // headers on the egress forward — overwriting whatever the app sent — so
41
41
  // app code never holds the credential. Header values embed `{{secret:REF}}`
42
42
  // tokens; each REF is resolved server-side from the platform Secrets store
43
- // and pushed eval-scoped from the control plane (via {@link getRecordSecret}).
44
- // The real value lives only on the outbound wire to the real upstream and
45
- // is redacted from the cassette (fail-closed) — it never enters project
46
- // code, the tarball, the warm-cache hash, or any snapshot a hermetic run
47
- // could fork.
43
+ // and supplied for the environment lifetime (via {@link getRecordSecret}).
44
+ // Values live in harness memory (including snapshots) and on the outbound
45
+ // wire. They are redacted from cassettes (fail-closed) and never enter
46
+ // project files or the warm-cache hash.
48
47
  //
49
48
  // AWS SigV4 (`sign: { type: "awsSigv4", ... }`): static header injection
50
49
  // can't broker AWS auth — the `Authorization` value is a keyed HMAC over the
51
50
  // entire request, not a static token. So the forward instead RE-SIGNS: the
52
51
  // app signs with throwaway dummy creds (any AWS SDK refuses to build a request
53
52
  // with none), we strip that signature and re-sign the exact outbound request
54
- // with the real credentials (also direct secret refs, same eval-scoped push +
55
- // redaction). Region/service are inferred from the request. See
53
+ // with the real credentials (direct secret refs, supplied for the environment
54
+ // lifetime and redacted). Region/service are inferred from the request. See
56
55
  // `../aws-sigv4.ts`.
57
56
 
58
57
  import { createSocket } from "node:dgram";
@@ -116,9 +115,9 @@ export interface InjectMatch {
116
115
  * are SET on the egress forward — overwriting whatever the app sent (so app
117
116
  * code can't smuggle a different value past the broker). Header VALUES may
118
117
  * embed `{{secret:REF}}` tokens; each `REF` is resolved server-side from the
119
- * project's Secrets store and pushed eval-scoped — the real value never
120
- * enters project code, the
121
- * tarball, the warm-cache hash, or a cassette (it's redacted, fail-closed). */
118
+ * project's Secrets store and supplied for the environment lifetime. Values
119
+ * never enter project files, the warm-cache hash, or a cassette (redaction
120
+ * fails closed). */
122
121
  export interface InjectRule {
123
122
  match?: InjectMatch;
124
123
  headers: Record<string, string>;
@@ -131,7 +130,7 @@ export interface InjectRule {
131
130
  * signature is a keyed HMAC over the whole request, not a static token.
132
131
  *
133
132
  * The three fields are **secret refs, resolved directly** from the project's
134
- * Secrets store (NOT `{{secret:}}` templates) — same eval-scoped push and
133
+ * Secrets store (NOT `{{secret:}}` templates), with the same lifetime and
135
134
  * fail-closed redaction as `inject`. Region + service are inferred from the
136
135
  * incoming request (its credential scope, else the host); the app never
137
136
  * configures them. */
@@ -531,7 +530,7 @@ interface BrokeredHeaders {
531
530
  }
532
531
 
533
532
  /** Resolve a rule's header templates, substituting every `{{secret:REF}}`
534
- * with the eval-scoped value. Collects resolved secrets (for redaction)
533
+ * with the environment credential. Collects resolved secrets (for redaction)
535
534
  * and any refs the control plane didn't supply (fail loud). */
536
535
  function brokerHeaders(rule: InjectRule): BrokeredHeaders {
537
536
  const headers: Record<string, string> = {};
@@ -675,7 +674,7 @@ export function replayFake(
675
674
  const match = defaultMatch(opts.match);
676
675
  const injectRules = opts.inject ?? [];
677
676
  // Refs every header template references — the control plane resolves these
678
- // server-side and pushes them eval-scoped (see the daemon's
677
+ // server-side and pushes them for the environment lifetime (see the daemon's
679
678
  // /record-secret-refs endpoint, which reads `def.secretRefs`).
680
679
  const secretRefs = collectSecretRefs(injectRules, opts.sign);
681
680
 
@@ -722,7 +721,7 @@ export function replayFake(
722
721
  state: CassetteState,
723
722
  ): Promise<Response> {
724
723
  // Credential brokering (static header inject): first matching rule wins.
725
- // Resolve its `{{secret:REF}}` tokens from the eval-scoped store.
724
+ // Resolve its `{{secret:REF}}` tokens from the environment secret store.
726
725
  const rule = injectRules.find((r) =>
727
726
  ruleMatches(r.match, {
728
727
  method: reqLike.method,
@@ -756,7 +755,7 @@ export function replayFake(
756
755
  const refs = [...new Set(missing)];
757
756
  return new Response(
758
757
  `replayFake(${opts.name}): secret(s) ${JSON.stringify(refs)} were not supplied ` +
759
- `(configure them on the project's Secrets page, and record via \`spectest env eval\`).\n`,
758
+ `(configure them on the project's Secrets page, then start a new environment or fork).\n`,
760
759
  { status: 599, headers: { "content-type": "text/plain" } },
761
760
  );
762
761
  }
package/src/daemon.ts CHANGED
@@ -168,6 +168,7 @@ import {
168
168
  resolveChownIds,
169
169
  } from "./harness/file-mounts.js";
170
170
  import {
171
+ badRequest,
171
172
  codeOf,
172
173
  conflict,
173
174
  notFound,
@@ -201,7 +202,7 @@ import {
201
202
  } from "./recorder.js";
202
203
  import { deepUnwrap, readRaw, wrap } from "./inspect.js";
203
204
  import type { Wrapped } from "./inspect.js";
204
- import { clearRecordSecrets, setRecordSecrets } from "./record-secrets.js";
205
+ import { setRecordSecrets } from "./record-secrets.js";
205
206
  import { generateId } from "./ids.js";
206
207
  import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
207
208
 
@@ -6029,16 +6030,8 @@ function explainEvalExportError(code: string, message: string): string {
6029
6030
  );
6030
6031
  }
6031
6032
 
6032
- async function evalCode(
6033
- code: string,
6034
- secrets?: Record<string, string>,
6035
- ): Promise<EvalResult> {
6033
+ async function evalCode(code: string): Promise<EvalResult> {
6036
6034
  const start = Date.now();
6037
- // Eval-scoped secret channel for record-mode fakes — set before the
6038
- // snippet runs, cleared in the `finally` below so a secret never
6039
- // persists into daemon memory (and thus into a forkable snapshot) past
6040
- // the eval that supplied it. See record-secrets.ts.
6041
- setRecordSecrets(secrets);
6042
6035
  const chunks: string[] = [];
6043
6036
  const origStdout = process.stdout.write.bind(process.stdout);
6044
6037
  const origStderr = process.stderr.write.bind(process.stderr);
@@ -6233,7 +6226,6 @@ async function evalCode(
6233
6226
  error: { message: explainEvalExportError(code, message), stack: e.stack },
6234
6227
  };
6235
6228
  } finally {
6236
- clearRecordSecrets();
6237
6229
  fetchScope.active = false;
6238
6230
  restoreFetch();
6239
6231
  restoreConsole();
@@ -6609,6 +6601,14 @@ function loadedSummary(l: ReturnType<typeof requireLoaded>) {
6609
6601
  * cutover.
6610
6602
  */
6611
6603
  export function harnessMethods(state: RouteState): MethodTable {
6604
+ const configureSecrets = (params: Record<string, unknown>) => {
6605
+ const secrets = params.secrets;
6606
+ if (!secrets || typeof secrets !== "object" || Array.isArray(secrets) ||
6607
+ Object.values(secrets).some(value => typeof value !== "string")) {
6608
+ throw badRequest("secrets must be an object of string values");
6609
+ }
6610
+ setRecordSecrets(secrets as Record<string, string>);
6611
+ };
6612
6612
  return {
6613
6613
  health: async () => ({ ok: true, capabilities: ["prepared-images-v1"] }),
6614
6614
 
@@ -6710,13 +6710,18 @@ export function harnessMethods(state: RouteState): MethodTable {
6710
6710
 
6711
6711
  // Union of platform secret refs the loaded fakes declare (replayFake's
6712
6712
  // `secretRefs`). The control plane resolves these server-side and pushes
6713
- // the values on the eval path only. Empty if nothing's loaded.
6713
+ // the values for the environment lifetime. Empty if nothing's loaded.
6714
6714
  recordSecretRefs: async () => {
6715
6715
  const refs = new Set<string>();
6716
6716
  for (const fake of FAKES.values()) {
6717
6717
  for (const ref of fake.def.secretRefs ?? []) refs.add(ref);
6718
6718
  }
6719
- return { refs: [...refs] };
6719
+ return { refs: [...refs], environmentScoped: true };
6720
+ },
6721
+
6722
+ setRecordSecrets: async (params) => {
6723
+ configureSecrets(params);
6724
+ return { ok: true };
6720
6725
  },
6721
6726
 
6722
6727
  prepareImages: async (params) =>
@@ -6744,13 +6749,12 @@ export function harnessMethods(state: RouteState): MethodTable {
6744
6749
 
6745
6750
  eval: async (params) => {
6746
6751
  const code = requireString(params, "code");
6747
- // `secrets` are eval-scoped: the control plane resolves the loaded
6748
- // project's declared `replayFake` refs and pushes the values here on
6749
- // the eval path only. Never present on the test path.
6750
- const secrets = params.secrets as Record<string, string> | undefined;
6751
- return exclusive(state, "inFlightTest", "a test or eval is already running", () =>
6752
- evalCode(code, secrets),
6753
- );
6752
+ return exclusive(state, "inFlightTest", "a test or eval is already running", () => {
6753
+ // Compatibility with older control planes: adopt their supplied values,
6754
+ // but keep them for subsequent browser/background requests as well.
6755
+ if (params.secrets !== undefined) configureSecrets(params);
6756
+ return evalCode(code);
6757
+ });
6754
6758
  },
6755
6759
 
6756
6760
  /**
@@ -34,6 +34,7 @@ export type Method =
34
34
  | "bootstrap"
35
35
  | "run"
36
36
  | "eval"
37
+ | "setRecordSecrets"
37
38
  | "teardown"
38
39
  | "shutdown"
39
40
  // up: harness → supervisor
@@ -52,6 +53,7 @@ export const KNOWN_METHODS: ReadonlySet<string> = new Set<Method>([
52
53
  "bootstrap",
53
54
  "run",
54
55
  "eval",
56
+ "setRecordSecrets",
55
57
  "teardown",
56
58
  "shutdown",
57
59
  "createArtifact",
package/src/index.ts CHANGED
@@ -1254,7 +1254,7 @@ const BUILD_TARGET_RE = /^[^\s]+$/;
1254
1254
  * Runs in `defineEnvironment`, and in the daemon's runtime `startService`
1255
1255
  * twin, inside the VM, where the repo sits at the project root. Existence
1256
1256
  * checks go through the project-file resolver, so the same rules as
1257
- * `ctx.readProjectFile` apply (a `spectest/.envignore`d path is refused
1257
+ * `ctx.readProjectFile` apply (a `spectest/.specignore`d path is refused
1258
1258
  * instead of read stale).
1259
1259
  */
1260
1260
  export function validateServiceImage(serviceName: string, image: ServiceImage): ServiceImage {
@@ -2120,8 +2120,8 @@ export interface FakeDefinition<
2120
2120
  * Internal. Platform secret references this fake needs at *record* time
2121
2121
  * (set by {@link replayFake}). The daemon reports the union of these to
2122
2122
  * the control plane, which resolves each via its `SecretResolver` and
2123
- * pushes the values on the eval path only — they never enter project
2124
- * files, the config hash, or a cassette. Not part of the authoring
2123
+ * supplies them for the environment lifetime, including snapshots. Values
2124
+ * never enter project files, the config hash, or a cassette. Not part of the authoring
2125
2125
  * surface; `JSON.stringify` ignores it (fakes never serialize to config).
2126
2126
  */
2127
2127
  secretRefs?: readonly string[];
@@ -12,14 +12,14 @@
12
12
  // is correct for every file that is IN that hash. It can be behind for the two
13
13
  // kinds of file the hash excludes: `spectest/tests/**` (excluded so a test-only
14
14
  // edit keeps the fast start) and anything a project lists in
15
- // `spectest/.envignore`.
15
+ // `spectest/.specignore`.
16
16
  //
17
17
  // Hence the rule below: a path under `spectest/` resolves against the app copy
18
18
  // first, because that copy is always current — this is what lets a fixture live
19
19
  // in `spectest/tests/fixtures/` and still be found after it was added. Any
20
20
  // other path resolves against /workspace, which the hash keeps current.
21
21
  //
22
- // The remaining hole is a file that `.envignore` excludes AND that sits outside
22
+ // The remaining hole is a file that `.specignore` excludes AND that sits outside
23
23
  // `spectest/`: /workspace holds whatever the cold start uploaded, so a later
24
24
  // edit is invisible. Nothing can repair those bytes at read time, so we refuse
25
25
  // to read them instead of returning stale content. The control plane writes the
@@ -34,8 +34,8 @@ export const WORKSPACE = process.env.SPECTEST_WORKSPACE ?? "/workspace";
34
34
  export const APP_DIR = process.env.SPECTEST_APP_DIR ?? "/opt/spectest/app";
35
35
 
36
36
  /** Paths (repo-relative) the warm-template hash skipped because of
37
- * `spectest/.envignore`. Written by the control plane at env start; absent
38
- * when the project ships no `.envignore`. */
37
+ * `spectest/.specignore`. Written by the control plane at env start; absent
38
+ * when the project ships no `.specignore`. */
39
39
  const ENV_IGNORED_FILE = "/run/spectest-env-ignored.json";
40
40
 
41
41
  let _envIgnored: Set<string> | undefined;
@@ -46,7 +46,7 @@ function envIgnored(): Set<string> {
46
46
  const raw = JSON.parse(readFileSync(ENV_IGNORED_FILE, "utf8")) as { paths?: string[] };
47
47
  paths = raw.paths ?? [];
48
48
  } catch {
49
- /* No file (no .envignore, or an older env) — nothing to refuse. */
49
+ /* No file (no .specignore, or an older env) — nothing to refuse. */
50
50
  }
51
51
  _envIgnored = new Set(paths);
52
52
  return _envIgnored;
@@ -94,7 +94,7 @@ export function resolveProjectPath(p: string, opts: { record?: boolean } = {}):
94
94
  }
95
95
  if (envIgnored().has(rel)) {
96
96
  throw new Error(
97
- `project file "${p}" is excluded by spectest/.envignore, so the copy in the VM ` +
97
+ `project file "${p}" is excluded by the environment cache ignore rules, so the copy in the VM ` +
98
98
  `is whatever a cold start uploaded and can be out of date. Move it under ` +
99
99
  `spectest/tests/ (still cache-free, and always re-uploaded), or drop the pattern.`,
100
100
  );
@@ -0,0 +1,54 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ test("environment secrets survive successful and failed evals and refresh as a complete set", async () => {
7
+ // Isolate daemon globals, eval instrumentation and app paths from other tests.
8
+ const root = await mkdtemp(join(tmpdir(), "spectest-secrets-"));
9
+ try {
10
+ const daemon = new URL("./daemon.ts", import.meta.url).pathname;
11
+ const secrets = new URL("./record-secrets.ts", import.meta.url).pathname;
12
+ const script = `
13
+ import { strict as assert } from "node:assert";
14
+ import { METHODS } from ${JSON.stringify(daemon)};
15
+ import { getRecordSecret } from ${JSON.stringify(secrets)};
16
+ const refs = await METHODS.recordSecretRefs({});
17
+ assert.equal(refs.environmentScoped, true);
18
+ await METHODS.setRecordSecrets({ secrets: { API_KEY: "first", REMOVED: "old" } });
19
+ // A request outside eval can use credentials immediately.
20
+ assert.equal(getRecordSecret("API_KEY"), "first");
21
+ const success = await METHODS.eval({ code: "export default 42;" });
22
+ assert.equal(success.ok, true, JSON.stringify(success));
23
+ assert.equal(getRecordSecret("API_KEY"), "first");
24
+ const failure = await METHODS.eval({ code: 'throw new Error("expected");' });
25
+ assert.equal(failure.ok, false);
26
+ assert.equal(getRecordSecret("API_KEY"), "first");
27
+ await assert.rejects(METHODS.setRecordSecrets({ secrets: { API_KEY: 123 } }));
28
+ assert.equal(getRecordSecret("API_KEY"), "first");
29
+ // Warm starts and manual forks replace inherited values, including deletions.
30
+ await METHODS.setRecordSecrets({ secrets: { API_KEY: "rotated" } });
31
+ assert.equal(getRecordSecret("API_KEY"), "rotated");
32
+ assert.equal(getRecordSecret("REMOVED"), undefined);
33
+ await METHODS.setRecordSecrets({ secrets: {} });
34
+ assert.equal(getRecordSecret("API_KEY"), undefined);
35
+ // A new SDK also works with a control plane that still sends eval secrets.
36
+ await METHODS.eval({ code: "export default 1;", secrets: { API_KEY: "legacy" } });
37
+ await METHODS.eval({ code: "export default 2;" });
38
+ assert.equal(getRecordSecret("API_KEY"), "legacy");
39
+ `;
40
+ const child = Bun.spawn([process.execPath, "--eval", script], {
41
+ env: { ...process.env, SPECTEST_APP_DIR: root },
42
+ stdout: "pipe",
43
+ stderr: "pipe",
44
+ });
45
+ const [code, stdout, stderr] = await Promise.all([
46
+ child.exited,
47
+ new Response(child.stdout).text(),
48
+ new Response(child.stderr).text(),
49
+ ]);
50
+ expect(code, stdout + stderr).toBe(0);
51
+ } finally {
52
+ await rm(root, { recursive: true, force: true });
53
+ }
54
+ }, 20_000);
@@ -1,41 +1,20 @@
1
- // Eval-scoped secret channel for record-mode fakes (see
2
- // `components/replayFake.ts`).
3
- //
4
- // The control plane resolves a platform secret (e.g. `STRIPE_API_KEY`)
5
- // server-side and pushes it into the daemon on the `/eval` request body
6
- // only — never on the `run_tests` path, never into the project tarball,
7
- // the config hash, or any cassette. The daemon stashes the resolved
8
- // values here for exactly the duration of one eval (set before the
9
- // snippet runs, cleared in the eval's `finally`) and the record-mode fake
10
- // forwarder reads them via {@link getRecordSecret}.
11
- //
12
- // Eval-scoped is load-bearing: daemon memory survives snapshot/fork, so a
13
- // long-lived secrets map could ride a warm/post-test snapshot into a
14
- // forkable state a hermetic `spectest test` could observe. Clearing per
15
- // eval means a secret never persists into anything a replay run can reach.
16
- // This module is shared (one instance per daemon process) so the daemon
17
- // writes and the component reads the same map.
1
+ // Environment-lifetime credentials for record-mode fakes. The control plane
2
+ // resolves declared references from the owning project's Secrets store before
3
+ // service startup and refreshes them on warm starts and interactive forks.
4
+ // They persist across evals, background requests and memory snapshots, just
5
+ // like other environment state. Test forks inherit their parent's credentials;
6
+ // replay mode still uses cassettes and never forwards upstream.
7
+ // Values stay out of project files, config hashes and recorded cassettes.
18
8
 
19
9
  const SECRETS = new Map<string, string>();
20
10
 
21
- /** Replace the eval-scoped secret set. Called by the daemon at the start
22
- * of each `/eval` with the values the control plane resolved. */
23
- export function setRecordSecrets(secrets: Record<string, string> | undefined): void {
11
+ /** Replace the complete environment secret set, including removing stale refs. */
12
+ export function setRecordSecrets(secrets: Record<string, string>): void {
24
13
  SECRETS.clear();
25
- if (!secrets) return;
26
- for (const [ref, value] of Object.entries(secrets)) {
27
- if (typeof value === "string") SECRETS.set(ref, value);
28
- }
14
+ for (const [ref, value] of Object.entries(secrets)) SECRETS.set(ref, value);
29
15
  }
30
16
 
31
- /** Drop all secrets. Called from the eval's `finally` so nothing survives
32
- * past the eval that supplied them. */
33
- export function clearRecordSecrets(): void {
34
- SECRETS.clear();
35
- }
36
-
37
- /** Resolve a secret by its platform `ref`. `undefined` if the control
38
- * plane didn't supply it (ref not configured, or not on the eval path). */
17
+ /** Resolve a declared project secret for an outbound recording request. */
39
18
  export function getRecordSecret(ref: string): string | undefined {
40
19
  return SECRETS.get(ref);
41
20
  }