@specific.dev/spectest 0.88.1 → 0.88.3

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/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # Spectest SDK
2
+
3
+ Spectest runs your application and its dependencies in isolated environments.
4
+ Tests can fork a parent's live state, including memory and files, then drive
5
+ user flows with recorded assertions and graphical replays.
6
+
7
+ Install the SDK in your project's `spectest/` directory:
8
+
9
+ ```sh
10
+ mkdir -p spectest
11
+ npm install --prefix spectest --save-exact @specific.dev/spectest@0.88.3
12
+ ```
13
+
14
+ Install the [Spectest CLI](https://github.com/specific-dev/spectest/releases/latest)
15
+ and run `spectest docs /installation` for project setup and authentication.
16
+ `spectest docs` contains the complete offline authoring guide.
17
+
18
+ ## Electron apps
19
+
20
+ Use `electron()` to build and run a real Linux Electron app under Xvfb, with
21
+ its main process, preload, IPC and renderer. Spectest supplies the container
22
+ setup; no custom Dockerfile is needed.
23
+
24
+ Create `spectest/index.ts`:
25
+
26
+ ```ts
27
+ import { defineEnvironment } from "@specific.dev/spectest";
28
+ import { electron } from "@specific.dev/spectest/components";
29
+
30
+ export const env = defineEnvironment({
31
+ name: "desktop-app",
32
+ services: { app: electron() },
33
+ });
34
+
35
+ export default env.project();
36
+ ```
37
+
38
+ Create `spectest/tests/notes.ts`, using your app's labels:
39
+
40
+ ```ts
41
+ import { expect } from "@specific.dev/spectest";
42
+ import { env } from "../index";
43
+
44
+ env.test("Save a note", async (ctx) => {
45
+ const app = await ctx.desktop(ctx.svc.app);
46
+ await app.getByRole("textbox", { name: "New note" }).fill("Hello desktop");
47
+ await app.getByRole("button", { name: "Add note", exact: true }).click();
48
+ await expect(app.getByRole("listitem")).toHaveText(["Hello desktop"]);
49
+ });
50
+ ```
51
+
52
+ Run `spectest test .` from the project root. The dashboard records desktop
53
+ interactions and replays the app with window chrome derived from its Electron
54
+ configuration. Child tests using `dependsOn` inherit the live app state,
55
+ including unsaved input, in their own isolated environment.
56
+
57
+ The app must declare Electron in `package.json`. Defaults are `npm ci` with a
58
+ package lock (otherwise `npm install`), `npm run build`, and the package's
59
+ `main` entry. Use `appDir`, `installCommand`, `buildCommand`, `entry`, and
60
+ `nodeVersion` to customize the build. `env` supplies runtime variables;
61
+ `dependsOn` waits for backend services in the same environment.
62
+
63
+ Applications need Linux-compatible dependencies and binaries. Native OS dialogs
64
+ and macOS-specific APIs are outside this basic support. Replays capture the
65
+ renderer DOM from acquisition onward, not native OS surfaces.
66
+
67
+ Run `spectest docs /components/electron` for the full configuration reference,
68
+ backend setup, session behavior, and replay scope (CLI 0.2.124 or later).
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
@@ -0,0 +1,242 @@
1
+ // The `socialAuth()` container's entry point.
2
+ //
3
+ // It runs one emulator per configured provider (the `@emulators/*` packages
4
+ // from vercel-labs/emulate) and puts all of them behind ONE port, routed by
5
+ // `Host` header. That is what lets the component answer at the providers'
6
+ // real endpoints: the daemon owns DNS and TLS for those names and proxies
7
+ // every one of them here.
8
+ //
9
+ // The engine is generic. Everything provider-specific — which hosts to
10
+ // claim, which paths differ from the real ones, which discovery values to
11
+ // correct — arrives as JSON in SPECTEST_AUTH_CONFIG, built by
12
+ // `../social-auth.ts`. Add a provider there, not here.
13
+ //
14
+ // Four things happen to a response on the way out:
15
+ //
16
+ // * discovery — the emulator advertises every endpoint on its own single
17
+ // origin. Real providers spread them over several hosts. We merge the
18
+ // real URLs in.
19
+ // * JWKS — we serve one RSA public key, for every provider.
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
25
+ // token comes from an in-process function call, not from the network.
26
+ // * the account picker page — each button gets a `data-testid`, so a
27
+ // browser test has a stable locator.
28
+ //
29
+ // Every request is logged. Per-case service logs reach the dashboard, so
30
+ // the whole OAuth exchange is readable on the case page with no helper API.
31
+
32
+ import { createServer } from "@emulators/core";
33
+ import { SignJWT, decodeJwt, exportJWK } from "jose";
34
+
35
+ const CONFIG = JSON.parse(process.env.SPECTEST_AUTH_CONFIG ?? "{}");
36
+ const PORT = Number(CONFIG.port ?? 4000);
37
+ /** Key id published in the JWKS and stamped on every re-issued id_token. */
38
+ const KID = "spectest-social-auth";
39
+
40
+ // ──────────────────────────────────────────────────────────────────────────
41
+ // Signing key
42
+ //
43
+ // Generated once, at boot — which is before the warm-template snapshot, so
44
+ // every fork of this environment holds the same key and a token minted in a
45
+ // parent stays valid in a child. Never generate it later: after a fork the
46
+ // guest RNG is frozen, and sibling forks would produce identical keys.
47
+ // ──────────────────────────────────────────────────────────────────────────
48
+
49
+ const KEY_PAIR = await crypto.subtle.generateKey(
50
+ {
51
+ name: "RSASSA-PKCS1-v1_5",
52
+ modulusLength: 2048,
53
+ publicExponent: new Uint8Array([1, 0, 1]),
54
+ hash: "SHA-256",
55
+ },
56
+ true,
57
+ ["sign", "verify"],
58
+ );
59
+ const JWKS = {
60
+ keys: [{ ...(await exportJWK(KEY_PAIR.publicKey)), kid: KID, use: "sig", alg: "RS256" }],
61
+ };
62
+
63
+ // ──────────────────────────────────────────────────────────────────────────
64
+ // Providers
65
+ // ──────────────────────────────────────────────────────────────────────────
66
+
67
+ /** host → runtime record. One emulator can claim several hosts. */
68
+ const BY_HOST = new Map();
69
+
70
+ for (const spec of CONFIG.providers ?? []) {
71
+ const mod = await import(spec.module);
72
+ const plugin = mod[spec.pluginExport];
73
+ if (!plugin) {
74
+ throw new Error(
75
+ `social-auth: ${spec.module} has no export ${spec.pluginExport}`,
76
+ );
77
+ }
78
+
79
+ // `port` only feeds the emulator's default base URL, which we always
80
+ // override — nothing listens on it. Each emulator is a fetch handler.
81
+ const { app, store, webhooks } = createServer(plugin, {
82
+ port: PORT,
83
+ baseUrl: spec.baseUrl,
84
+ fallbackUser: spec.fallbackUser,
85
+ });
86
+ plugin.seed?.(store, spec.baseUrl);
87
+ if (spec.seed && mod.seedFromConfig) {
88
+ mod.seedFromConfig(store, spec.baseUrl, spec.seed, webhooks);
89
+ }
90
+
91
+ const runtime = {
92
+ name: spec.name,
93
+ baseUrl: spec.baseUrl,
94
+ oidc: spec.oidc !== false,
95
+ issuer: spec.issuer,
96
+ discovery: spec.discovery ?? {},
97
+ jwksPath: spec.jwksPath ? new RegExp(spec.jwksPath) : null,
98
+ aliases: spec.aliases,
99
+ rewrites: (spec.rewrites ?? []).map((r) => ({ re: new RegExp(r.from), to: r.to })),
100
+ fetch: app.fetch,
101
+ };
102
+ for (const host of spec.hosts) BY_HOST.set(host.toLowerCase(), runtime);
103
+ console.log(`[social-auth] ${spec.name} → ${spec.hosts.join(", ")}`);
104
+ }
105
+
106
+ // ──────────────────────────────────────────────────────────────────────────
107
+ // Response rewriting
108
+ // ──────────────────────────────────────────────────────────────────────────
109
+
110
+ const DISCOVERY_RE = /\/\.well-known\/openid-configuration$/;
111
+
112
+ /** Correct the discovery document: real hosts per endpoint, and RS256,
113
+ * which is what we actually sign with after `reissueIdToken`. */
114
+ async function patchDiscovery(res, provider) {
115
+ const doc = await res.json();
116
+ const patched = {
117
+ ...doc,
118
+ ...provider.discovery,
119
+ id_token_signing_alg_values_supported: ["RS256"],
120
+ };
121
+ return json(patched, res.status);
122
+ }
123
+
124
+ /** Re-issue the id_token on a token response. Claims pass through
125
+ * unchanged except `iss`, which only a provider that cannot derive it
126
+ * from its base URL overrides. */
127
+ async function patchTokenResponse(res, provider) {
128
+ const body = await res.json();
129
+ if (typeof body.id_token !== "string") return json(body, res.status);
130
+
131
+ const claims = decodeJwt(body.id_token);
132
+ if (provider.issuer) claims.iss = provider.issuer;
133
+ body.id_token = await new SignJWT(claims)
134
+ .setProtectedHeader({ alg: "RS256", kid: KID, typ: "JWT" })
135
+ .sign(KEY_PAIR.privateKey);
136
+ return json(body, res.status);
137
+ }
138
+
139
+ /** Give every account button on the picker page a stable test id, and mark
140
+ * the page itself. The hidden field that identifies the account is named
141
+ * differently per provider, and does not always hold the address — GitHub
142
+ * identifies an account by its login — so `aliases` maps it back. The test
143
+ * id is the account's email on every provider. */
144
+ async function patchPickerPage(res, provider) {
145
+ let html = await res.text();
146
+ html = html.replace(/<form class="user-form"[\s\S]*?<\/form>/g, (form) => {
147
+ const id = /<input type="hidden" name="(?:email|login|sub|username|user_ref)" value="([^"]*)"/.exec(form);
148
+ if (!id) return form;
149
+ const account = provider.aliases?.[id[1]] ?? id[1];
150
+ return form.replace(
151
+ "<button type=\"submit\"",
152
+ `<button type="submit" data-testid="spectest-user-${account}"`,
153
+ );
154
+ });
155
+ html = html.replace("<body", '<body data-spectest-signin="1"');
156
+ return new Response(html, { status: res.status, headers: res.headers });
157
+ }
158
+
159
+ function json(value, status) {
160
+ return new Response(JSON.stringify(value), {
161
+ status,
162
+ headers: { "content-type": "application/json" },
163
+ });
164
+ }
165
+
166
+ // ──────────────────────────────────────────────────────────────────────────
167
+ // Server
168
+ // ──────────────────────────────────────────────────────────────────────────
169
+
170
+ Bun.serve({
171
+ port: PORT,
172
+ // The default 10s kills nothing here, but an authorize page fetched by a
173
+ // cold browser can be slower than it looks. Ingress uses the same margin.
174
+ idleTimeout: 60,
175
+ async fetch(req) {
176
+ const url = new URL(req.url);
177
+
178
+ // Answered on any Host, because the ready check probes the container's
179
+ // own IP and never sends a provider name.
180
+ if (url.pathname === "/healthz") return new Response("ok\n");
181
+
182
+ // The daemon reverse-proxies us and rewrites `Host` to the service-net
183
+ // name, so the provider the client asked for only survives in
184
+ // `X-Forwarded-Host`. Read that first; `Host` covers a direct call.
185
+ const host = (req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? "")
186
+ .toLowerCase()
187
+ .replace(/:\d+$/, "");
188
+ const provider = BY_HOST.get(host);
189
+ if (!provider) {
190
+ return new Response(
191
+ `spectest social-auth: no provider claims Host=${JSON.stringify(host)}\n` +
192
+ `claimed hosts: ${[...BY_HOST.keys()].join(", ")}\n`,
193
+ { status: 404, headers: { "content-type": "text/plain" } },
194
+ );
195
+ }
196
+
197
+ // Real path → the path this emulator serves it on. Identity by default,
198
+ // which is also what carries the emulator's own internal endpoints (the
199
+ // picker's POST target) through untouched.
200
+ let path = url.pathname;
201
+ for (const rule of provider.rewrites) {
202
+ if (rule.re.test(path)) {
203
+ path = path.replace(rule.re, rule.to);
204
+ break;
205
+ }
206
+ }
207
+
208
+ let res;
209
+ if (provider.oidc && provider.jwksPath?.test(path)) {
210
+ // Our key, never the emulator's — we re-sign every id_token.
211
+ res = json(JWKS, 200);
212
+ } else {
213
+ const body =
214
+ req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
215
+ // Restore the Host the client used, so an emulator that reads it sees
216
+ // the provider rather than our container.
217
+ const headers = new Headers(req.headers);
218
+ headers.set("host", host);
219
+ const inner = new Request(new URL(path + url.search, provider.baseUrl), {
220
+ method: req.method,
221
+ headers,
222
+ body,
223
+ });
224
+ res = await provider.fetch(inner);
225
+
226
+ const type = res.headers.get("content-type") ?? "";
227
+ if (type.includes("json")) {
228
+ if (DISCOVERY_RE.test(path)) res = await patchDiscovery(res, provider);
229
+ else if (provider.oidc) res = await patchTokenResponse(res, provider);
230
+ } else if (type.includes("text/html")) {
231
+ res = await patchPickerPage(res, provider);
232
+ }
233
+ }
234
+
235
+ console.log(
236
+ `[social-auth] ${provider.name} ${req.method} ${host}${url.pathname} → ${res.status}`,
237
+ );
238
+ return res;
239
+ },
240
+ });
241
+
242
+ console.log(`[social-auth] listening on :${PORT}`);
@@ -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
@@ -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[];
@@ -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.1",
3
+ "version": "0.88.3",
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
@@ -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
@@ -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[];
@@ -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
  }