@specific.dev/spectest 0.115.0 → 0.116.0

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.
@@ -55,19 +55,14 @@
55
55
  // lifetime and redacted). Region/service are inferred from the request. See
56
56
  // `../aws-sigv4.ts`.
57
57
 
58
- import { createSocket } from "node:dgram";
59
58
  import { createHash } from "node:crypto";
60
59
  import { existsSync, readFileSync } from "node:fs";
61
- import { request as httpsRequest } from "node:https";
62
60
  import path from "node:path";
63
- import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
64
- import * as dnsPacket from "dns-packet";
65
61
 
66
62
  import type { FakeContext, FakeDefinition } from "../index.js";
67
63
  import { resolveReplayMode } from "../replay-mode.js";
68
64
  import { getWebSocketLifetime } from "../harness/fake-websocket-lifetime.js";
69
65
  import { reportBackgroundFailure } from "../harness/background-failures.js";
70
- import { getRecordSecret } from "../record-secrets.js";
71
66
  import {
72
67
  recordWebSocket, replayWebSocket, validateWebSocketKey, validateWebSocketRecording,
73
68
  websocketMatcher, type WebSocketKey, type CassetteWebSocket, type WebSocketSession,
@@ -76,71 +71,15 @@ export type { WebSocketKey, ReplayWebSocketMessage, CassetteWebSocket, CassetteW
76
71
  import { replaySignature, type ReplayKey } from "./replay-key.js";
77
72
  export type { JsonValue, MatchKey, ReplayRequest, ReplayKey } from "./replay-key.js";
78
73
  import {
79
- inferSigV4Scope,
80
- signAwsV4Request,
81
- type SigV4Credentials,
82
- } from "../aws-sigv4.js";
74
+ STRIP_FORWARD_HEADERS, isAmzParam, collectSecretRefs, ruleMatches, brokerHeaders,
75
+ resolveExternalA, forwardHttp, type ResolvedSecret, type InjectRule, type SignConfig,
76
+ } from "../fake-upstream.js";
77
+ export type { StringMatch, InjectMatch, InjectRule, AwsSigV4Sign, SignConfig } from "../fake-upstream.js";
83
78
 
84
79
  // ────────────────────────────────────────────────────────────────────────
85
80
  // Options + cassette format
86
81
  // ────────────────────────────────────────────────────────────────────────
87
82
 
88
- /** Match one request dimension. A bare string is an exact match; the
89
- * object forms cover prefix and regex matching. */
90
- export type StringMatch =
91
- | string
92
- | { exact: string }
93
- | { startsWith: string }
94
- | { regex: string };
95
-
96
- /** Request matchers for a credential-injection rule. A rule applies only
97
- * when EVERY specified dimension matches; omit `match` to always apply. */
98
- export interface InjectMatch {
99
- /** Match the request path (no query). */
100
- path?: StringMatch;
101
- /** HTTP method(s) — any one matching satisfies it. */
102
- method?: string | string[];
103
- /** Query-param matchers (ANDed); each key's value(s) must match. */
104
- query?: Record<string, StringMatch>;
105
- /** Header matchers (ANDed; header names case-insensitive). */
106
- headers?: Record<string, StringMatch>;
107
- }
108
-
109
- /** One credential-brokering rule. When it matches a request, these headers
110
- * are SET on the egress forward — overwriting whatever the app sent (so app
111
- * code can't smuggle a different value past the broker). Header VALUES may
112
- * embed `{{secret:REF}}` tokens; each `REF` is resolved server-side from the
113
- * project's Secrets store and supplied for the environment lifetime. Values
114
- * never enter project files, the warm-cache hash, or a cassette (redaction
115
- * fails closed). */
116
- export interface InjectRule {
117
- match?: InjectMatch;
118
- headers: Record<string, string>;
119
- }
120
-
121
- /** SigV4 request-signing broker. Unlike `inject` (which SETs a static header
122
- * value), this RE-SIGNS the outbound request: the app signs with throwaway
123
- * dummy credentials, we strip that signature and re-sign the exact outbound
124
- * request with the real credentials — the only way to broker AWS auth, whose
125
- * signature is a keyed HMAC over the whole request, not a static token.
126
- *
127
- * The three fields are **secret refs, resolved directly** from the project's
128
- * Secrets store (NOT `{{secret:}}` templates), with the same lifetime and
129
- * fail-closed redaction as `inject`. Region + service are inferred from the
130
- * incoming request (its credential scope, else the host); the app never
131
- * configures them. */
132
- export interface AwsSigV4Sign {
133
- type: "awsSigv4";
134
- /** Secret ref for the AWS access key id. */
135
- accessKeyId: string;
136
- /** Secret ref for the AWS secret access key. */
137
- secretAccessKey: string;
138
- /** Optional secret ref for a session token (temporary credentials). */
139
- sessionToken?: string;
140
- }
141
-
142
- export type SignConfig = AwsSigV4Sign;
143
-
144
83
  /** A normal fake context with explicit delegation to recording/replay. */
145
84
  export interface ReplayFakeContext extends FakeContext {
146
85
  /** Record against the real upstream in record mode, or consume a matching
@@ -319,45 +258,6 @@ const SENSITIVE_HEADERS = new Set([
319
258
  "x-amz-security-token",
320
259
  ]);
321
260
 
322
- /** Hop-by-hop + body-framing headers never forwarded / never replayed
323
- * (we decompress on the way through, so the encoding/length no longer
324
- * describe the bytes the app receives). */
325
- const STRIP_FORWARD_HEADERS = new Set([
326
- "host",
327
- "connection",
328
- "keep-alive",
329
- "proxy-authenticate",
330
- "proxy-authorization",
331
- "te",
332
- "trailers",
333
- "transfer-encoding",
334
- "upgrade",
335
- "content-length",
336
- "content-encoding",
337
- // We force `accept-encoding: identity` on the forward and decompress any
338
- // residual encoding, so the app never sees a stale accept-encoding.
339
- "accept-encoding",
340
- ]);
341
-
342
- /** External resolver used to find the REAL upstream IP, bypassing the in-VM
343
- * resolver (which points the faked host back at the daemon). */
344
- const EXTERNAL_DNS = process.env.SPECTEST_UPSTREAM_DNS ?? "1.1.1.1";
345
- const EXTERNAL_DNS_PORT = Number(process.env.SPECTEST_UPSTREAM_PORT ?? "53");
346
-
347
- /** SigV4 puts per-request signing params (`X-Amz-Signature`, `X-Amz-Date`, …)
348
- * in the query for presigned URLs — they change every call and would poison
349
- * query matching, so they're dropped from both the match key and the forward
350
- * when signing is active. */
351
- function isAmzParam(key: string): boolean {
352
- return key.toLowerCase().startsWith("x-amz-");
353
- }
354
-
355
- function stripAmzParams(search: URLSearchParams): URLSearchParams {
356
- const out = new URLSearchParams();
357
- for (const [k, v] of search) if (!isAmzParam(k)) out.append(k, v);
358
- return out;
359
- }
360
-
361
261
  function normalizeQuery(search: URLSearchParams, stripAmz: boolean): Record<string, string[]> {
362
262
  const out: Record<string, string[]> = {};
363
263
  for (const key of [...search.keys()].sort()) {
@@ -449,13 +349,6 @@ function decodeBody(bytes: Uint8Array): { body: string; bodyEncoding: "utf8" | "
449
349
  }
450
350
  }
451
351
 
452
- /** One resolved secret value and the ref it came from (for the redaction
453
- * placeholder). */
454
- interface ResolvedSecret {
455
- ref: string;
456
- value: string;
457
- }
458
-
459
352
  function makeRedactor(
460
353
  secrets: ResolvedSecret[],
461
354
  patterns: (string | RegExp)[] | undefined,
@@ -477,209 +370,6 @@ function makeRedactor(
477
370
  };
478
371
  }
479
372
 
480
- // ────────────────────────────────────────────────────────────────────────
481
- // Credential brokering: matchers + secret-token resolution
482
- // ────────────────────────────────────────────────────────────────────────
483
-
484
- /** `{{secret:REF}}` template token embedded in an injected header value. */
485
- const SECRET_TOKEN = /\{\{secret:([A-Za-z0-9_]+)\}\}/g;
486
-
487
- /** Every secret ref referenced by any inject rule's header templates plus the
488
- * SigV4 signing credentials (direct refs, not templates). */
489
- function collectSecretRefs(rules: InjectRule[], sign: SignConfig | undefined): string[] {
490
- const refs = new Set<string>();
491
- for (const rule of rules) {
492
- for (const value of Object.values(rule.headers)) {
493
- for (const m of value.matchAll(SECRET_TOKEN)) refs.add(m[1]);
494
- }
495
- }
496
- if (sign?.type === "awsSigv4") {
497
- refs.add(sign.accessKeyId);
498
- refs.add(sign.secretAccessKey);
499
- if (sign.sessionToken) refs.add(sign.sessionToken);
500
- }
501
- return [...refs];
502
- }
503
-
504
- function matchString(m: StringMatch, value: string): boolean {
505
- if (typeof m === "string") return value === m;
506
- if ("exact" in m) return value === m.exact;
507
- if ("startsWith" in m) return value.startsWith(m.startsWith);
508
- if ("regex" in m) return new RegExp(m.regex).test(value);
509
- return false;
510
- }
511
-
512
- interface IncomingRequest {
513
- method: string;
514
- path: string;
515
- query: URLSearchParams;
516
- headers: Headers;
517
- }
518
-
519
- /** True if every dimension the matcher specifies is satisfied (AND across
520
- * dimensions). No `match` → always true. */
521
- function ruleMatches(match: InjectMatch | undefined, req: IncomingRequest): boolean {
522
- if (!match) return true;
523
- if (match.path !== undefined && !matchString(match.path, req.path)) return false;
524
- if (match.method !== undefined) {
525
- const methods = (Array.isArray(match.method) ? match.method : [match.method]).map((s) =>
526
- s.toUpperCase(),
527
- );
528
- if (!methods.includes(req.method.toUpperCase())) return false;
529
- }
530
- if (match.query) {
531
- for (const [k, m] of Object.entries(match.query)) {
532
- if (!req.query.getAll(k).some((v) => matchString(m, v))) return false;
533
- }
534
- }
535
- if (match.headers) {
536
- for (const [k, m] of Object.entries(match.headers)) {
537
- const v = req.headers.get(k.toLowerCase());
538
- if (v === null || !matchString(m, v)) return false;
539
- }
540
- }
541
- return true;
542
- }
543
-
544
- interface BrokeredHeaders {
545
- /** Lowercased header → resolved value, to SET on the forward. */
546
- headers: Record<string, string>;
547
- /** Resolved secrets (for redaction). */
548
- secrets: ResolvedSecret[];
549
- /** Refs referenced but not supplied by the control plane. */
550
- missing: string[];
551
- }
552
-
553
- /** Resolve a rule's header templates, substituting every `{{secret:REF}}`
554
- * with the environment credential. Collects resolved secrets (for redaction)
555
- * and any refs the control plane didn't supply (fail loud). */
556
- function brokerHeaders(rule: InjectRule): BrokeredHeaders {
557
- const headers: Record<string, string> = {};
558
- const secrets: ResolvedSecret[] = [];
559
- const missing: string[] = [];
560
- const seen = new Set<string>();
561
- for (const [name, template] of Object.entries(rule.headers)) {
562
- headers[name.toLowerCase()] = template.replace(SECRET_TOKEN, (_full, ref: string) => {
563
- const value = getRecordSecret(ref);
564
- if (value === undefined) {
565
- missing.push(ref);
566
- return "";
567
- }
568
- if (!seen.has(ref)) {
569
- seen.add(ref);
570
- secrets.push({ ref, value });
571
- }
572
- return value;
573
- });
574
- }
575
- return { headers, secrets, missing };
576
- }
577
-
578
- // ────────────────────────────────────────────────────────────────────────
579
- // Record-mode egress: resolve the real host externally, then MITM-forward
580
- // ────────────────────────────────────────────────────────────────────────
581
-
582
- /** Resolve a host's A records against an EXTERNAL DNS server, bypassing the
583
- * in-VM resolver (which would answer with the daemon's own gateway for a
584
- * faked host). Mirrors spectest-resolver's own upstream-forward transport
585
- * (dns-packet over UDP) so it works identically in-VM. */
586
- function resolveExternalA(host: string): Promise<string[]> {
587
- const query = dnsPacket.encode({
588
- type: "query",
589
- id: 1,
590
- flags: dnsPacket.RECURSION_DESIRED,
591
- questions: [{ type: "A", name: host }],
592
- });
593
- return new Promise((resolve) => {
594
- const sock = createSocket("udp4");
595
- let done = false;
596
- const finish = (ips: string[]): void => {
597
- if (done) return;
598
- done = true;
599
- try {
600
- sock.close();
601
- } catch {
602
- // ignore
603
- }
604
- resolve(ips);
605
- };
606
- sock.on("message", (msg) => {
607
- try {
608
- const res = dnsPacket.decode(msg);
609
- const ips = (res.answers ?? []).flatMap((a) =>
610
- a.type === "A" && typeof a.data === "string" && a.data.length > 0 ? [a.data] : [],
611
- );
612
- finish(ips);
613
- } catch {
614
- finish([]);
615
- }
616
- });
617
- sock.on("error", () => finish([]));
618
- sock.send(query, EXTERNAL_DNS_PORT, EXTERNAL_DNS, (err) => {
619
- if (err) finish([]);
620
- });
621
- setTimeout(() => finish([]), 3_000);
622
- });
623
- }
624
-
625
- interface UpstreamResponse {
626
- status: number;
627
- headers: Record<string, string>;
628
- /** Decompressed body bytes. */
629
- bytes: Uint8Array;
630
- }
631
-
632
- /** Open a real HTTPS connection to `ip` but with SNI + `Host` = `host`, so
633
- * the public certificate validates against the real hostname (the in-VM CA
634
- * is not involved on this leg). Decompresses the response so the stored and
635
- * returned bytes are the plain payload. */
636
- function forwardToRealUpstream(args: {
637
- ip: string;
638
- host: string;
639
- method: string;
640
- pathWithQuery: string;
641
- headers: Record<string, string>;
642
- body: Uint8Array;
643
- }): Promise<UpstreamResponse> {
644
- return new Promise((resolve, reject) => {
645
- const req = httpsRequest(
646
- {
647
- host: args.ip,
648
- port: 443,
649
- method: args.method,
650
- path: args.pathWithQuery,
651
- servername: args.host,
652
- headers: { ...args.headers, host: args.host, "accept-encoding": "identity" },
653
- },
654
- (res) => {
655
- const chunks: Buffer[] = [];
656
- res.on("data", (c: Buffer) => chunks.push(c));
657
- res.on("end", () => {
658
- let bytes: Uint8Array = Buffer.concat(chunks);
659
- const enc = String(res.headers["content-encoding"] ?? "").toLowerCase();
660
- try {
661
- if (enc.includes("br")) bytes = brotliDecompressSync(bytes);
662
- else if (enc.includes("gzip")) bytes = gunzipSync(bytes);
663
- else if (enc.includes("deflate")) bytes = inflateSync(bytes);
664
- } catch {
665
- // Leave the bytes as-is if decompression fails.
666
- }
667
- const headers: Record<string, string> = {};
668
- for (const [k, v] of Object.entries(res.headers)) {
669
- if (typeof v === "string") headers[k.toLowerCase()] = v;
670
- else if (Array.isArray(v)) headers[k.toLowerCase()] = v.join(", ");
671
- }
672
- resolve({ status: res.statusCode ?? 0, headers, bytes });
673
- });
674
- res.on("error", reject);
675
- },
676
- );
677
- req.on("error", reject);
678
- if (args.body.length > 0) req.write(Buffer.from(args.body));
679
- req.end();
680
- });
681
- }
682
-
683
373
  // ────────────────────────────────────────────────────────────────────────
684
374
  // replayFake
685
375
  // ────────────────────────────────────────────────────────────────────────
@@ -756,157 +446,16 @@ export function replayFake<S = undefined, H extends Record<string, unknown> = {}
756
446
  // ── RECORD (MITM) ───────────────────────────────────────────────────
757
447
  async function record(
758
448
  req: Request,
759
- url: URL,
760
449
  reqLike: CassetteRequest,
761
450
  bodyBytes: Uint8Array,
762
451
  state: CassetteState,
763
452
  ): Promise<Response> {
764
- // Credential brokering (static header inject): first matching rule wins.
765
- // Resolve its `{{secret:REF}}` tokens from the environment secret store.
766
- const rule = injectRules.find((r) =>
767
- ruleMatches(r.match, {
768
- method: reqLike.method,
769
- path: reqLike.path,
770
- query: url.searchParams,
771
- headers: req.headers,
772
- }),
773
- );
774
- const brokered = rule ? brokerHeaders(rule) : { headers: {}, secrets: [], missing: [] };
775
-
776
- // SigV4 signing: resolve the credential secret refs (direct refs, not
777
- // `{{secret:}}` templates). Missing required refs fail loud alongside any
778
- // missing inject refs.
779
- let signCreds: SigV4Credentials | undefined;
780
- const signMissing: string[] = [];
781
- if (opts.sign?.type === "awsSigv4") {
782
- const s = opts.sign;
783
- const accessKeyId = getRecordSecret(s.accessKeyId);
784
- const secretAccessKey = getRecordSecret(s.secretAccessKey);
785
- const sessionToken = s.sessionToken ? getRecordSecret(s.sessionToken) : undefined;
786
- if (accessKeyId === undefined) signMissing.push(s.accessKeyId);
787
- if (secretAccessKey === undefined) signMissing.push(s.secretAccessKey);
788
- if (s.sessionToken && sessionToken === undefined) signMissing.push(s.sessionToken);
789
- if (accessKeyId !== undefined && secretAccessKey !== undefined) {
790
- signCreds = { accessKeyId, secretAccessKey, sessionToken };
791
- }
792
- }
793
-
794
- const missing = [...brokered.missing, ...signMissing];
795
- if (missing.length > 0) {
796
- const refs = [...new Set(missing)];
797
- return new Response(
798
- `replayFake(${opts.name}): secret(s) ${JSON.stringify(refs)} were not supplied ` +
799
- `(configure them on the project's Secrets page, then start a new environment or fork).\n`,
800
- { status: 599, headers: { "content-type": "text/plain" } },
801
- );
802
- }
803
-
804
- // Resolve the REAL host's IP via an external resolver so we don't loop
805
- // back into the daemon (the in-VM resolver answers our own gateway for
806
- // this faked name). See the module header.
807
- const ips = await resolveExternalA(host);
808
- if (ips.length === 0) {
809
- return new Response(
810
- `replayFake(${opts.name}): could not resolve real upstream ${JSON.stringify(host)} ` +
811
- `via external DNS ${EXTERNAL_DNS} (is the VM online?).\n`,
812
- { status: 599, headers: { "content-type": "text/plain" } },
813
- );
814
- }
815
-
816
- // Forward headers: drop hop-by-hop/encoding, then SET the brokered
817
- // headers — overwriting whatever the app sent (so the credential can't
818
- // be smuggled or spoofed by app code). The real values only ever appear
819
- // on this outbound wire.
820
- const fwdHeaders: Record<string, string> = {};
821
- req.headers.forEach((v, k) => {
822
- if (!STRIP_FORWARD_HEADERS.has(k.toLowerCase())) fwdHeaders[k.toLowerCase()] = v;
823
- });
824
- for (const [k, v] of Object.entries(brokered.headers)) fwdHeaders[k] = v;
825
-
826
- // SigV4 re-signing: strip the app's dummy signature and re-sign the exact
827
- // outbound request (method/path/query/signed-headers/body-hash) with the
828
- // brokered credentials, so `authorization`/`x-amz-date`/`x-amz-content-sha256`
829
- // are internally consistent.
830
- const signSecrets: ResolvedSecret[] = [];
831
- let wireQuery = url.searchParams.toString();
832
- if (opts.sign?.type === "awsSigv4" && signCreds) {
833
- const scope = inferSigV4Scope({
834
- authorization: req.headers.get("authorization"),
835
- credentialParam: url.searchParams.get("X-Amz-Credential"),
836
- host,
837
- });
838
- if (!scope) {
839
- return new Response(
840
- `replayFake(${opts.name}): could not infer the AWS region/service to sign ` +
841
- `${reqLike.method} ${reqLike.path} (no SigV4 Credential on the request, and host ` +
842
- `${JSON.stringify(host)} is not a <service>.<region>.amazonaws.com name).\n`,
843
- { status: 599, headers: { "content-type": "text/plain" } },
844
- );
845
- }
846
- // Drop the app's own (dummy-signed) signing headers before re-signing.
847
- for (const h of ["authorization", "x-amz-date", "x-amz-content-sha256", "x-amz-security-token"]) {
848
- delete fwdHeaders[h];
849
- }
850
- // Fold the app's remaining `x-amz-*`/content-type headers (e.g.
851
- // `x-amz-target`) into the signature; they stay on the outbound wire.
852
- const additionalHeaders: Record<string, string> = {};
853
- for (const [k, v] of Object.entries(fwdHeaders)) {
854
- if (
855
- k === "content-type" ||
856
- (k.startsWith("x-amz-") &&
857
- k !== "x-amz-date" &&
858
- k !== "x-amz-content-sha256" &&
859
- k !== "x-amz-security-token")
860
- ) {
861
- additionalHeaders[k] = v;
862
- }
863
- }
864
- const forwardParams = stripAmzParams(url.searchParams);
865
- const signed = signAwsV4Request({
866
- method: reqLike.method,
867
- path: reqLike.path,
868
- query: forwardParams,
869
- host,
870
- body: bodyBytes,
871
- credentials: signCreds,
872
- scope,
873
- additionalHeaders,
874
- });
875
- for (const [k, v] of Object.entries(signed.headers)) fwdHeaders[k] = v;
876
- // Send the canonical query we signed (URLSearchParams' `+`-for-space
877
- // encoding would diverge from the signed `%20`).
878
- wireQuery = signed.canonicalQuery;
879
- signSecrets.push(
880
- { ref: opts.sign.accessKeyId, value: signCreds.accessKeyId },
881
- { ref: opts.sign.secretAccessKey, value: signCreds.secretAccessKey },
882
- );
883
- if (signCreds.sessionToken && opts.sign.sessionToken) {
884
- signSecrets.push({ ref: opts.sign.sessionToken, value: signCreds.sessionToken });
885
- }
886
- }
887
-
888
- const pathWithQuery = reqLike.path + (wireQuery ? `?${wireQuery}` : "");
889
-
890
- let upstream: UpstreamResponse;
891
- try {
892
- upstream = await forwardToRealUpstream({
893
- ip: ips[0],
894
- host,
895
- method: reqLike.method,
896
- pathWithQuery,
897
- headers: fwdHeaders,
898
- body: bodyBytes,
899
- });
900
- } catch (err) {
901
- return new Response(
902
- `replayFake(${opts.name}): forward to real ${host} (${ips[0]}) failed: ${(err as Error).message}\n`,
903
- { status: 599, headers: { "content-type": "text/plain" } },
904
- );
905
- }
453
+ const forwarded = await forwardHttp(req, host, opts, bodyBytes, `replayFake(${opts.name})`);
454
+ if (forwarded instanceof Response) return forwarded;
455
+ const { upstream, secrets: redactSecrets } = forwarded;
906
456
 
907
457
  // Decode + redact for storage; return the real (un-redacted) bytes to
908
458
  // the app so the manual session behaves like production.
909
- const redactSecrets = [...brokered.secrets, ...signSecrets];
910
459
  const redact = makeRedactor(redactSecrets, opts.redactPatterns);
911
460
  const decoded = decodeBody(upstream.bytes);
912
461
  const respHeaders: Record<string, string> = {};
@@ -998,7 +547,7 @@ export function replayFake<S = undefined, H extends Record<string, unknown> = {}
998
547
  };
999
548
 
1000
549
  if (resolveMode() === "replay") return replay(reqLike, state);
1001
- return record(req, url, reqLike, bodyBytes, state);
550
+ return record(req, reqLike, bodyBytes, state);
1002
551
  };
1003
552
 
1004
553
  const websocket: NonNullable<FakeDefinition<CassetteState>["websocket"]> = async (req, state) => {
package/src/daemon.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { collectSecretRefs } from "./fake-upstream.js";
1
2
  import { phoneScope, phoneRecordingChunk, releasePhoneRecording, type PhoneRecording } from "./phone.js";
2
3
  import { runWithBackgroundFailures } from "./harness/background-failures.js";
3
4
  import { generateHostCert as mintHostCert } from "./harness/certificate.js";
@@ -6177,6 +6178,7 @@ export function harnessMethods(state: RouteState): MethodTable {
6177
6178
  // new method. Check the loaded project, including after warm restore.
6178
6179
  setupInteractive: typeof loaded?.project.interactive?.seed === "function",
6179
6180
  replayEnvironmentMode: true,
6181
+ inFlightTest: state.inFlightTest !== null,
6180
6182
  }),
6181
6183
 
6182
6184
  // Live bootstrap progress, polled by the control plane during
@@ -6275,13 +6277,14 @@ export function harnessMethods(state: RouteState): MethodTable {
6275
6277
  groups: groupsMetadata(requireLoaded().project.tests),
6276
6278
  }),
6277
6279
 
6278
- // Union of platform secret refs the loaded fakes declare (replayFake's
6280
+ // Union of platform secret refs the loaded fakes declare (live options and replayFake's
6279
6281
  // `secretRefs`). The control plane resolves these server-side and pushes
6280
6282
  // the values for the environment lifetime. Empty if nothing's loaded.
6281
6283
  recordSecretRefs: async () => {
6282
6284
  const refs = new Set<string>();
6283
6285
  for (const fake of FAKES.values()) {
6284
6286
  for (const ref of fake.def.secretRefs ?? []) refs.add(ref);
6287
+ for (const ref of collectSecretRefs(fake.def.live?.inject ?? [], fake.def.live?.sign)) refs.add(ref);
6285
6288
  }
6286
6289
  return { refs: [...refs], environmentScoped: true };
6287
6290
  },