@specific.dev/spectest 0.88.0 → 0.88.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/aws-sigv4.js +2 -2
- package/dist/components/emulate/service.js +3 -4
- package/dist/components/microsoft.d.ts +2 -2
- package/dist/components/microsoft.js +5 -6
- 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 +3 -3
- package/dist/index.js +1 -1
- package/dist/project-files.js +6 -6
- 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/emulate/entry.mjs +5 -7
- package/src/components/emulate/service.ts +3 -4
- package/src/components/microsoft.ts +7 -8
- package/src/components/replayFake.ts +16 -17
- package/src/daemon.ts +24 -20
- package/src/harness/protocol.ts +2 -0
- package/src/index.ts +3 -3
- package/src/project-files.ts +6 -6
- package/src/record-secrets.test.ts +54 -0
- package/src/record-secrets.ts +11 -32
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
|
|
@@ -26,11 +26,10 @@
|
|
|
26
26
|
// `entry.mjs` routes by Host header, applies the per-provider path table,
|
|
27
27
|
// and corrects the discovery document. It also re-issues every id_token
|
|
28
28
|
// with one RSA key it generates at boot, and serves that key at the
|
|
29
|
-
// provider's real JWKS URL.
|
|
29
|
+
// provider's real JWKS URL. This normalizes signing and issuer handling:
|
|
30
30
|
// the Google emulator signs HS256 and publishes an empty JWKS (so nothing
|
|
31
|
-
// can verify its token),
|
|
32
|
-
//
|
|
33
|
-
// handled in advance.
|
|
31
|
+
// can verify its token), and providers whose issuer differs from their
|
|
32
|
+
// base URL can declare it explicitly.
|
|
34
33
|
//
|
|
35
34
|
// The Dockerfile is deliberately CONSTANT — every provider package is
|
|
36
35
|
// installed whether or not this component is the one using it. Two reasons:
|
|
@@ -2,8 +2,8 @@ import type { ProviderOptions } from "./emulate/service.js";
|
|
|
2
2
|
export interface MicrosoftOptions extends ProviderOptions {
|
|
3
3
|
/**
|
|
4
4
|
* Directory (tenant) the app signs in against — whatever its authority
|
|
5
|
-
* URL uses.
|
|
6
|
-
*
|
|
5
|
+
* URL uses. Sets the seeded users' and client's tenant as well as the
|
|
6
|
+
* issuer and endpoint paths. Default `"common"`.
|
|
7
7
|
*/
|
|
8
8
|
tenantId?: string;
|
|
9
9
|
}
|
|
@@ -8,16 +8,15 @@
|
|
|
8
8
|
// widening what it serves is a table entry.
|
|
9
9
|
import { emulatorService, oauthClients, resolveUsers } from "./emulate/service.js";
|
|
10
10
|
function spec(users, opts) {
|
|
11
|
-
const
|
|
11
|
+
const tenantId = opts.tenantId || "common";
|
|
12
|
+
const authority = `https://login.microsoftonline.com/${tenantId}`;
|
|
12
13
|
return {
|
|
13
14
|
name: "microsoft",
|
|
14
15
|
module: "@emulators/microsoft",
|
|
15
16
|
pluginExport: "microsoftPlugin",
|
|
16
17
|
baseUrl: "https://login.microsoftonline.com",
|
|
17
18
|
hosts: ["login.microsoftonline.com", "graph.microsoft.com"],
|
|
18
|
-
//
|
|
19
|
-
// that checks the issuer against the authority it configured would
|
|
20
|
-
// reject that; the re-issue step corrects it.
|
|
19
|
+
// Keep the re-issued token aligned with discovery and the seeded tenant.
|
|
21
20
|
issuer: `${authority}/v2.0`,
|
|
22
21
|
discovery: {
|
|
23
22
|
issuer: `${authority}/v2.0`,
|
|
@@ -40,8 +39,8 @@ function spec(users, opts) {
|
|
|
40
39
|
scopes: ["openid", "email", "profile", "User.Read"],
|
|
41
40
|
},
|
|
42
41
|
seed: {
|
|
43
|
-
users: users.map((u) => ({ email: u.email, name: u.name })),
|
|
44
|
-
...oauthClients(opts.client),
|
|
42
|
+
users: users.map((u) => ({ email: u.email, name: u.name, tenant_id: tenantId })),
|
|
43
|
+
...oauthClients(opts.client, { tenant_id: tenantId }),
|
|
45
44
|
},
|
|
46
45
|
};
|
|
47
46
|
}
|
|
@@ -38,9 +38,9 @@ export interface InjectMatch {
|
|
|
38
38
|
* are SET on the egress forward — overwriting whatever the app sent (so app
|
|
39
39
|
* code can't smuggle a different value past the broker). Header VALUES may
|
|
40
40
|
* embed `{{secret:REF}}` tokens; each `REF` is resolved server-side from the
|
|
41
|
-
* project's Secrets store and
|
|
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
|
@@ -823,7 +823,7 @@ export type ServiceImage = {
|
|
|
823
823
|
* Runs in `defineEnvironment`, and in the daemon's runtime `startService`
|
|
824
824
|
* twin, inside the VM, where the repo sits at the project root. Existence
|
|
825
825
|
* checks go through the project-file resolver, so the same rules as
|
|
826
|
-
* `ctx.readProjectFile` apply (a `spectest/.
|
|
826
|
+
* `ctx.readProjectFile` apply (a `spectest/.specignore`d path is refused
|
|
827
827
|
* instead of read stale).
|
|
828
828
|
*/
|
|
829
829
|
export declare function validateServiceImage(serviceName: string, image: ServiceImage): ServiceImage;
|
|
@@ -1469,8 +1469,8 @@ export interface FakeDefinition<S = any, H extends Record<string, unknown> = Rec
|
|
|
1469
1469
|
* Internal. Platform secret references this fake needs at *record* time
|
|
1470
1470
|
* (set by {@link replayFake}). The daemon reports the union of these to
|
|
1471
1471
|
* the control plane, which resolves each via its `SecretResolver` and
|
|
1472
|
-
*
|
|
1473
|
-
* files, the config hash, or a cassette. Not part of the authoring
|
|
1472
|
+
* supplies them for the environment lifetime, including snapshots. Values
|
|
1473
|
+
* never enter project files, the config hash, or a cassette. Not part of the authoring
|
|
1474
1474
|
* surface; `JSON.stringify` ignores it (fakes never serialize to config).
|
|
1475
1475
|
*/
|
|
1476
1476
|
secretRefs?: readonly string[];
|
package/dist/index.js
CHANGED
|
@@ -253,7 +253,7 @@ const BUILD_TARGET_RE = /^[^\s]+$/;
|
|
|
253
253
|
* Runs in `defineEnvironment`, and in the daemon's runtime `startService`
|
|
254
254
|
* twin, inside the VM, where the repo sits at the project root. Existence
|
|
255
255
|
* checks go through the project-file resolver, so the same rules as
|
|
256
|
-
* `ctx.readProjectFile` apply (a `spectest/.
|
|
256
|
+
* `ctx.readProjectFile` apply (a `spectest/.specignore`d path is refused
|
|
257
257
|
* instead of read stale).
|
|
258
258
|
*/
|
|
259
259
|
export function validateServiceImage(serviceName, image) {
|
package/dist/project-files.js
CHANGED
|
@@ -12,14 +12,14 @@
|
|
|
12
12
|
// is correct for every file that is IN that hash. It can be behind for the two
|
|
13
13
|
// kinds of file the hash excludes: `spectest/tests/**` (excluded so a test-only
|
|
14
14
|
// edit keeps the fast start) and anything a project lists in
|
|
15
|
-
// `spectest/.
|
|
15
|
+
// `spectest/.specignore`.
|
|
16
16
|
//
|
|
17
17
|
// Hence the rule below: a path under `spectest/` resolves against the app copy
|
|
18
18
|
// first, because that copy is always current — this is what lets a fixture live
|
|
19
19
|
// in `spectest/tests/fixtures/` and still be found after it was added. Any
|
|
20
20
|
// other path resolves against /workspace, which the hash keeps current.
|
|
21
21
|
//
|
|
22
|
-
// The remaining hole is a file that `.
|
|
22
|
+
// The remaining hole is a file that `.specignore` excludes AND that sits outside
|
|
23
23
|
// `spectest/`: /workspace holds whatever the cold start uploaded, so a later
|
|
24
24
|
// edit is invisible. Nothing can repair those bytes at read time, so we refuse
|
|
25
25
|
// to read them instead of returning stale content. The control plane writes the
|
|
@@ -31,8 +31,8 @@ export const WORKSPACE = process.env.SPECTEST_WORKSPACE ?? "/workspace";
|
|
|
31
31
|
/** The app dir; the user's `spectest/` sits directly under it. */
|
|
32
32
|
export const APP_DIR = process.env.SPECTEST_APP_DIR ?? "/opt/spectest/app";
|
|
33
33
|
/** Paths (repo-relative) the warm-template hash skipped because of
|
|
34
|
-
* `spectest/.
|
|
35
|
-
* when the project ships no `.
|
|
34
|
+
* `spectest/.specignore`. Written by the control plane at env start; absent
|
|
35
|
+
* when the project ships no `.specignore`. */
|
|
36
36
|
const ENV_IGNORED_FILE = "/run/spectest-env-ignored.json";
|
|
37
37
|
let _envIgnored;
|
|
38
38
|
function envIgnored() {
|
|
@@ -44,7 +44,7 @@ function envIgnored() {
|
|
|
44
44
|
paths = raw.paths ?? [];
|
|
45
45
|
}
|
|
46
46
|
catch {
|
|
47
|
-
/* No file (no .
|
|
47
|
+
/* No file (no .specignore, or an older env) — nothing to refuse. */
|
|
48
48
|
}
|
|
49
49
|
_envIgnored = new Set(paths);
|
|
50
50
|
return _envIgnored;
|
|
@@ -90,7 +90,7 @@ export function resolveProjectPath(p, opts = {}) {
|
|
|
90
90
|
return app;
|
|
91
91
|
}
|
|
92
92
|
if (envIgnored().has(rel)) {
|
|
93
|
-
throw new Error(`project file "${p}" is excluded by
|
|
93
|
+
throw new Error(`project file "${p}" is excluded by the environment cache ignore rules, so the copy in the VM ` +
|
|
94
94
|
`is whatever a cold start uploaded and can be out of date. Move it under ` +
|
|
95
95
|
`spectest/tests/ (still cache-free, and always re-uploaded), or drop the pattern.`);
|
|
96
96
|
}
|
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
|
|
@@ -17,13 +17,11 @@
|
|
|
17
17
|
// origin. Real providers spread them over several hosts. We merge the
|
|
18
18
|
// real URLs in.
|
|
19
19
|
// * JWKS — we serve one RSA public key, for every provider.
|
|
20
|
-
// * id_token — we re-issue it: same claims, RS256, our key, and
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// And any future provider whose issuer does not match its base URL is
|
|
26
|
-
// covered in advance. No signature check is needed on the way in: the
|
|
20
|
+
// * id_token — we re-issue it: same claims, RS256, our key, and an
|
|
21
|
+
// explicit `iss` where the provider declares one. The Google emulator
|
|
22
|
+
// signs HS256 and serves an empty JWKS, so no client can verify its
|
|
23
|
+
// token. Providers whose issuer differs from their base URL can
|
|
24
|
+
// declare it explicitly. No signature check is needed on the way in: the
|
|
27
25
|
// token comes from an in-process function call, not from the network.
|
|
28
26
|
// * the account picker page — each button gets a `data-testid`, so a
|
|
29
27
|
// browser test has a stable locator.
|
|
@@ -26,11 +26,10 @@
|
|
|
26
26
|
// `entry.mjs` routes by Host header, applies the per-provider path table,
|
|
27
27
|
// and corrects the discovery document. It also re-issues every id_token
|
|
28
28
|
// with one RSA key it generates at boot, and serves that key at the
|
|
29
|
-
// provider's real JWKS URL.
|
|
29
|
+
// provider's real JWKS URL. This normalizes signing and issuer handling:
|
|
30
30
|
// the Google emulator signs HS256 and publishes an empty JWKS (so nothing
|
|
31
|
-
// can verify its token),
|
|
32
|
-
//
|
|
33
|
-
// handled in advance.
|
|
31
|
+
// can verify its token), and providers whose issuer differs from their
|
|
32
|
+
// base URL can declare it explicitly.
|
|
34
33
|
//
|
|
35
34
|
// The Dockerfile is deliberately CONSTANT — every provider package is
|
|
36
35
|
// installed whether or not this component is the one using it. Two reasons:
|
|
@@ -13,23 +13,22 @@ import { emulatorService, oauthClients, resolveUsers } from "./emulate/service.j
|
|
|
13
13
|
export interface MicrosoftOptions extends ProviderOptions {
|
|
14
14
|
/**
|
|
15
15
|
* Directory (tenant) the app signs in against — whatever its authority
|
|
16
|
-
* URL uses.
|
|
17
|
-
*
|
|
16
|
+
* URL uses. Sets the seeded users' and client's tenant as well as the
|
|
17
|
+
* issuer and endpoint paths. Default `"common"`.
|
|
18
18
|
*/
|
|
19
19
|
tenantId?: string;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function spec(users: ProviderUser[], opts: MicrosoftOptions): ProviderSpec {
|
|
23
|
-
const
|
|
23
|
+
const tenantId = opts.tenantId || "common";
|
|
24
|
+
const authority = `https://login.microsoftonline.com/${tenantId}`;
|
|
24
25
|
return {
|
|
25
26
|
name: "microsoft",
|
|
26
27
|
module: "@emulators/microsoft",
|
|
27
28
|
pluginExport: "microsoftPlugin",
|
|
28
29
|
baseUrl: "https://login.microsoftonline.com",
|
|
29
30
|
hosts: ["login.microsoftonline.com", "graph.microsoft.com"],
|
|
30
|
-
//
|
|
31
|
-
// that checks the issuer against the authority it configured would
|
|
32
|
-
// reject that; the re-issue step corrects it.
|
|
31
|
+
// Keep the re-issued token aligned with discovery and the seeded tenant.
|
|
33
32
|
issuer: `${authority}/v2.0`,
|
|
34
33
|
discovery: {
|
|
35
34
|
issuer: `${authority}/v2.0`,
|
|
@@ -52,8 +51,8 @@ function spec(users: ProviderUser[], opts: MicrosoftOptions): ProviderSpec {
|
|
|
52
51
|
scopes: ["openid", "email", "profile", "User.Read"],
|
|
53
52
|
},
|
|
54
53
|
seed: {
|
|
55
|
-
users: users.map((u) => ({ email: u.email, name: u.name })),
|
|
56
|
-
...oauthClients(opts.client),
|
|
54
|
+
users: users.map((u) => ({ email: u.email, name: u.name, tenant_id: tenantId })),
|
|
55
|
+
...oauthClients(opts.client, { tenant_id: tenantId }),
|
|
57
56
|
},
|
|
58
57
|
};
|
|
59
58
|
}
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// - RECORD (MITM): the request is forwarded to the REAL host and the
|
|
20
20
|
// request/response pair is captured (decoded, redacted), and the real
|
|
21
21
|
// response is returned to the app so a manual session behaves like
|
|
22
|
-
// production. This runs
|
|
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
|
@@ -1254,7 +1254,7 @@ const BUILD_TARGET_RE = /^[^\s]+$/;
|
|
|
1254
1254
|
* Runs in `defineEnvironment`, and in the daemon's runtime `startService`
|
|
1255
1255
|
* twin, inside the VM, where the repo sits at the project root. Existence
|
|
1256
1256
|
* checks go through the project-file resolver, so the same rules as
|
|
1257
|
-
* `ctx.readProjectFile` apply (a `spectest/.
|
|
1257
|
+
* `ctx.readProjectFile` apply (a `spectest/.specignore`d path is refused
|
|
1258
1258
|
* instead of read stale).
|
|
1259
1259
|
*/
|
|
1260
1260
|
export function validateServiceImage(serviceName: string, image: ServiceImage): ServiceImage {
|
|
@@ -2120,8 +2120,8 @@ export interface FakeDefinition<
|
|
|
2120
2120
|
* Internal. Platform secret references this fake needs at *record* time
|
|
2121
2121
|
* (set by {@link replayFake}). The daemon reports the union of these to
|
|
2122
2122
|
* the control plane, which resolves each via its `SecretResolver` and
|
|
2123
|
-
*
|
|
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[];
|
package/src/project-files.ts
CHANGED
|
@@ -12,14 +12,14 @@
|
|
|
12
12
|
// is correct for every file that is IN that hash. It can be behind for the two
|
|
13
13
|
// kinds of file the hash excludes: `spectest/tests/**` (excluded so a test-only
|
|
14
14
|
// edit keeps the fast start) and anything a project lists in
|
|
15
|
-
// `spectest/.
|
|
15
|
+
// `spectest/.specignore`.
|
|
16
16
|
//
|
|
17
17
|
// Hence the rule below: a path under `spectest/` resolves against the app copy
|
|
18
18
|
// first, because that copy is always current — this is what lets a fixture live
|
|
19
19
|
// in `spectest/tests/fixtures/` and still be found after it was added. Any
|
|
20
20
|
// other path resolves against /workspace, which the hash keeps current.
|
|
21
21
|
//
|
|
22
|
-
// The remaining hole is a file that `.
|
|
22
|
+
// The remaining hole is a file that `.specignore` excludes AND that sits outside
|
|
23
23
|
// `spectest/`: /workspace holds whatever the cold start uploaded, so a later
|
|
24
24
|
// edit is invisible. Nothing can repair those bytes at read time, so we refuse
|
|
25
25
|
// to read them instead of returning stale content. The control plane writes the
|
|
@@ -34,8 +34,8 @@ export const WORKSPACE = process.env.SPECTEST_WORKSPACE ?? "/workspace";
|
|
|
34
34
|
export const APP_DIR = process.env.SPECTEST_APP_DIR ?? "/opt/spectest/app";
|
|
35
35
|
|
|
36
36
|
/** Paths (repo-relative) the warm-template hash skipped because of
|
|
37
|
-
* `spectest/.
|
|
38
|
-
* when the project ships no `.
|
|
37
|
+
* `spectest/.specignore`. Written by the control plane at env start; absent
|
|
38
|
+
* when the project ships no `.specignore`. */
|
|
39
39
|
const ENV_IGNORED_FILE = "/run/spectest-env-ignored.json";
|
|
40
40
|
|
|
41
41
|
let _envIgnored: Set<string> | undefined;
|
|
@@ -46,7 +46,7 @@ function envIgnored(): Set<string> {
|
|
|
46
46
|
const raw = JSON.parse(readFileSync(ENV_IGNORED_FILE, "utf8")) as { paths?: string[] };
|
|
47
47
|
paths = raw.paths ?? [];
|
|
48
48
|
} catch {
|
|
49
|
-
/* No file (no .
|
|
49
|
+
/* No file (no .specignore, or an older env) — nothing to refuse. */
|
|
50
50
|
}
|
|
51
51
|
_envIgnored = new Set(paths);
|
|
52
52
|
return _envIgnored;
|
|
@@ -94,7 +94,7 @@ export function resolveProjectPath(p: string, opts: { record?: boolean } = {}):
|
|
|
94
94
|
}
|
|
95
95
|
if (envIgnored().has(rel)) {
|
|
96
96
|
throw new Error(
|
|
97
|
-
`project file "${p}" is excluded by
|
|
97
|
+
`project file "${p}" is excluded by the environment cache ignore rules, so the copy in the VM ` +
|
|
98
98
|
`is whatever a cold start uploaded and can be out of date. Move it under ` +
|
|
99
99
|
`spectest/tests/ (still cache-free, and always re-uploaded), or drop the pattern.`,
|
|
100
100
|
);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
test("environment secrets survive successful and failed evals and refresh as a complete set", async () => {
|
|
7
|
+
// Isolate daemon globals, eval instrumentation and app paths from other tests.
|
|
8
|
+
const root = await mkdtemp(join(tmpdir(), "spectest-secrets-"));
|
|
9
|
+
try {
|
|
10
|
+
const daemon = new URL("./daemon.ts", import.meta.url).pathname;
|
|
11
|
+
const secrets = new URL("./record-secrets.ts", import.meta.url).pathname;
|
|
12
|
+
const script = `
|
|
13
|
+
import { strict as assert } from "node:assert";
|
|
14
|
+
import { METHODS } from ${JSON.stringify(daemon)};
|
|
15
|
+
import { getRecordSecret } from ${JSON.stringify(secrets)};
|
|
16
|
+
const refs = await METHODS.recordSecretRefs({});
|
|
17
|
+
assert.equal(refs.environmentScoped, true);
|
|
18
|
+
await METHODS.setRecordSecrets({ secrets: { API_KEY: "first", REMOVED: "old" } });
|
|
19
|
+
// A request outside eval can use credentials immediately.
|
|
20
|
+
assert.equal(getRecordSecret("API_KEY"), "first");
|
|
21
|
+
const success = await METHODS.eval({ code: "export default 42;" });
|
|
22
|
+
assert.equal(success.ok, true, JSON.stringify(success));
|
|
23
|
+
assert.equal(getRecordSecret("API_KEY"), "first");
|
|
24
|
+
const failure = await METHODS.eval({ code: 'throw new Error("expected");' });
|
|
25
|
+
assert.equal(failure.ok, false);
|
|
26
|
+
assert.equal(getRecordSecret("API_KEY"), "first");
|
|
27
|
+
await assert.rejects(METHODS.setRecordSecrets({ secrets: { API_KEY: 123 } }));
|
|
28
|
+
assert.equal(getRecordSecret("API_KEY"), "first");
|
|
29
|
+
// Warm starts and manual forks replace inherited values, including deletions.
|
|
30
|
+
await METHODS.setRecordSecrets({ secrets: { API_KEY: "rotated" } });
|
|
31
|
+
assert.equal(getRecordSecret("API_KEY"), "rotated");
|
|
32
|
+
assert.equal(getRecordSecret("REMOVED"), undefined);
|
|
33
|
+
await METHODS.setRecordSecrets({ secrets: {} });
|
|
34
|
+
assert.equal(getRecordSecret("API_KEY"), undefined);
|
|
35
|
+
// A new SDK also works with a control plane that still sends eval secrets.
|
|
36
|
+
await METHODS.eval({ code: "export default 1;", secrets: { API_KEY: "legacy" } });
|
|
37
|
+
await METHODS.eval({ code: "export default 2;" });
|
|
38
|
+
assert.equal(getRecordSecret("API_KEY"), "legacy");
|
|
39
|
+
`;
|
|
40
|
+
const child = Bun.spawn([process.execPath, "--eval", script], {
|
|
41
|
+
env: { ...process.env, SPECTEST_APP_DIR: root },
|
|
42
|
+
stdout: "pipe",
|
|
43
|
+
stderr: "pipe",
|
|
44
|
+
});
|
|
45
|
+
const [code, stdout, stderr] = await Promise.all([
|
|
46
|
+
child.exited,
|
|
47
|
+
new Response(child.stdout).text(),
|
|
48
|
+
new Response(child.stderr).text(),
|
|
49
|
+
]);
|
|
50
|
+
expect(code, stdout + stderr).toBe(0);
|
|
51
|
+
} finally {
|
|
52
|
+
await rm(root, { recursive: true, force: true });
|
|
53
|
+
}
|
|
54
|
+
}, 20_000);
|
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
|
}
|