@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 +68 -0
- package/dist/aws-sigv4.js +2 -2
- package/dist/components/emulate/entry.mjs +242 -0
- package/dist/components/replayFake.d.ts +4 -4
- package/dist/components/replayFake.js +12 -13
- package/dist/daemon.js +24 -16
- package/dist/harness/protocol.d.ts +1 -1
- package/dist/harness/protocol.js +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/record-secrets.d.ts +3 -8
- package/dist/record-secrets.js +11 -32
- package/package.json +1 -1
- package/src/aws-sigv4.ts +2 -2
- package/src/components/replayFake.ts +16 -17
- package/src/daemon.ts +24 -20
- package/src/harness/protocol.ts +2 -0
- package/src/index.ts +2 -2
- package/src/record-secrets.test.ts +54 -0
- package/src/record-secrets.ts +11 -32
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
|
|
8
|
-
//
|
|
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
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
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 (
|
|
55
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
5635
|
-
|
|
5636
|
-
|
|
5637
|
-
|
|
5638
|
-
|
|
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>;
|
package/dist/harness/protocol.js
CHANGED
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
|
-
*
|
|
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/record-secrets.d.ts
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
/** Replace the
|
|
2
|
-
|
|
3
|
-
|
|
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;
|
package/dist/record-secrets.js
CHANGED
|
@@ -1,39 +1,18 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
/**
|
|
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
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
|
|
8
|
-
//
|
|
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
|
|
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
|
|
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
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
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 (
|
|
55
|
-
//
|
|
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
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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 {
|
|
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
|
|
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
|
-
|
|
6748
|
-
|
|
6749
|
-
|
|
6750
|
-
|
|
6751
|
-
|
|
6752
|
-
|
|
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
|
/**
|
package/src/harness/protocol.ts
CHANGED
|
@@ -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
|
-
*
|
|
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);
|
package/src/record-secrets.ts
CHANGED
|
@@ -1,41 +1,20 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
}
|