fix: retain unresolved browser secret declarations as input errors

This commit is contained in:
morluto
2026-10-24 03:04:11 +08:00
parent 661aef55a9
commit cbfc2bd9c9
13 changed files with 165 additions and 79 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ Navigation can run destination code before a newly discovered worker is rejected
that rejection does not roll back browser effects. REA removes its owned
future-document scripts, restores live frame methods, then disconnects.
Add secret declarations, storage seeds, and additional artifact or event
capture only when needed. Action, secret, and storage counts are not capped.
capture only when needed. Missing environment-backed secrets fail as input errors before browser acquisition, naming the declaration and required variable. An explicitly set empty string is a resolved value. Action, secret, and storage counts are not capped.
Duration, action, and navigation timeouts remain separate from CDP command
deadlines. `command_timeout_ms` (default 20000; omit it to keep that default
and leave it out of Evidence) bounds launch startup, the connect-over-CDP
+35 -23
View File
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
import { resolveScenarioSecrets } from "../../tests/fixtures/browserScenarioSecrets.js";
import { browserScenarioSchema } from "../domain/browserScenario.js";
import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "../domain/browserScenarioCaptureLimits.js";
@@ -85,7 +86,7 @@ const baseScenario = () => ({
describe("browser scenario secret redaction", () => {
it("matches percent-escape hex case without case-folding raw secrets", () => {
const secrets = BrowserScenarioSecrets.resolve(
const secrets = resolveScenarioSecrets(
browserScenarioSchema.parse(baseScenario()),
{
REA_TEST_SESSION: "a/b?",
@@ -93,7 +94,6 @@ describe("browser scenario secret redaction", () => {
REA_TEST_COOKIE: "cookie",
},
);
if (secrets === undefined) throw new Error("Expected resolved secrets");
expect(secrets.redact("logged a%2fb%3f, a%2Fb%3F and a%2fb%3F")).toBe(
"logged [REDACTED:session], [REDACTED:session] and [REDACTED:session]",
@@ -124,10 +124,34 @@ describe("browser scenario secret redaction", () => {
value_sha256: null,
});
});
it("does not resolve inherited environment properties as secret values", () => {
const scenario = browserScenarioSchema.parse(baseScenario());
const environment = Object.assign(
{ REA_TEST_PASSWORD: "password", REA_TEST_COOKIE: "cookie" },
Object.create({ REA_TEST_SESSION: "session" }) as object,
);
expect(BrowserScenarioSecrets.resolve(scenario, environment)).toMatchObject(
{
ok: false,
error: {
_tag: "AnalysisInputError",
issues: [
{
path: ["secrets", 0, "environment_variable"],
expected: {
environment_variable: "REA_TEST_SESSION",
secret_id: "session",
},
},
],
},
},
);
});
});
describe("storage value fingerprint encoding", () => {
const secrets = BrowserScenarioSecrets.resolve(
const secrets = resolveScenarioSecrets(
browserScenarioSchema.parse(baseScenario()),
{
REA_TEST_SESSION: "declared-session",
@@ -135,7 +159,6 @@ describe("storage value fingerprint encoding", () => {
REA_TEST_COOKIE: "declared-cookie",
},
);
if (secrets === undefined) throw new Error("Expected resolved secrets");
it.each(["", "ordinary", "\u0000", "\u0620", "\ufffd", "\ud83d\ude00"])(
"preserves the UTF-8 digest of well-formed %j",
@@ -242,16 +265,16 @@ describe("browserScenarioSchema", () => {
it("redacts overlapping secrets longest-first", () => {
const scenario = browserScenarioSchema.parse(baseScenario());
const secrets = BrowserScenarioSecrets.resolve(scenario, {
const secrets = resolveScenarioSecrets(scenario, {
REA_TEST_SESSION: "prefix",
REA_TEST_PASSWORD: "prefix-suffix",
REA_TEST_COOKIE: "cookie",
});
expect(secrets?.redact("prefix-suffix")).toBe("[REDACTED:login_input]");
expect(secrets.redact("prefix-suffix")).toBe("[REDACTED:login_input]");
});
it("does not leave a declared secret inside its redaction marker", () => {
const secrets = BrowserScenarioSecrets.resolve(
const secrets = resolveScenarioSecrets(
browserScenarioSchema.parse({
...baseScenario(),
secrets: [
@@ -269,11 +292,11 @@ describe("browserScenarioSchema", () => {
REA_TEST_SHORT_PASSWORD: "pass",
},
);
expect(secrets?.redact("seen pass in the page")).toBe(
expect(secrets.redact("seen pass in the page")).toBe(
"seen [REDACTED] in the page",
);
const bytes = BrowserScenarioSecrets.resolve(
const bytes = resolveScenarioSecrets(
browserScenarioSchema.parse({
...baseScenario(),
secrets: [
@@ -292,20 +315,9 @@ describe("browserScenarioSchema", () => {
REA_TEST_LONG_SECRET: "session-secret-value",
REA_TEST_PIN: "pass",
},
)?.redactBytes(Buffer.from("body session-secret-value tail"));
expect(bytes?.toString("utf8")).toBe("body [REDACTED] tail");
expect(bytes?.includes(Buffer.from("pass"))).toBe(false);
});
it("does not resolve inherited environment properties as secret values", () => {
const scenario = browserScenarioSchema.parse(baseScenario());
const environment = Object.assign(
{ REA_TEST_PASSWORD: "password", REA_TEST_COOKIE: "cookie" },
Object.create({ REA_TEST_SESSION: "session" }) as object,
);
expect(
BrowserScenarioSecrets.resolve(scenario, environment),
).toBeUndefined();
).redactBytes(Buffer.from("body session-secret-value tail"));
expect(bytes.toString("utf8")).toBe("body [REDACTED] tail");
expect(bytes.includes(Buffer.from("pass"))).toBe(false);
});
it("rejects unsupported actions", () => {
+24 -4
View File
@@ -1,3 +1,5 @@
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { err, ok, type Result } from "../domain/result.js";
import { createHash } from "node:crypto";
import type { BrowserScenario } from "../domain/browserScenario.js";
@@ -256,18 +258,36 @@ export class BrowserScenarioSecrets {
static resolve(
scenario: BrowserScenario,
environment: Readonly<Record<string, string | undefined>>,
): BrowserScenarioSecrets | undefined {
): Result<BrowserScenarioSecrets, AnalysisInputError> {
const values = new Map<string, string>();
for (const declaration of scenario.secrets) {
for (const [index, declaration] of scenario.secrets.entries()) {
const value = environment[declaration.environment_variable];
if (
!Object.hasOwn(environment, declaration.environment_variable) ||
typeof value !== "string"
)
return undefined;
return err(
new AnalysisInputError(
"capture_browser_scenario",
{
userMessage: `Set environment variable ${declaration.environment_variable} for browser scenario secret ${declaration.secret_id} in the environment that starts REA, then retry.`,
},
[
{
path: ["secrets", index, "environment_variable"],
reason: "missing_argument",
message: `Environment variable ${declaration.environment_variable} is not set for declared secret ${declaration.secret_id}.`,
expected: {
secret_id: declaration.secret_id,
environment_variable: declaration.environment_variable,
},
},
],
),
);
values.set(declaration.secret_id, value);
}
return new BrowserScenarioSecrets(values);
return ok(new BrowserScenarioSecrets(values));
}
value(source: BrowserScenarioValue): string {
@@ -2,7 +2,7 @@ import { expect, it } from "vitest";
import type { BrowserContext, Page } from "playwright-core";
import { browserScenarioSchema } from "../domain/browserScenario.js";
import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
import { resolveScenarioSecrets } from "../../tests/fixtures/browserScenarioSecrets.js";
import { capturePlaywrightStepArtifacts } from "./PlaywrightScenarioArtifacts.js";
const scenario = browserScenarioSchema.parse({
@@ -13,9 +13,7 @@ const scenario = browserScenarioSchema.parse({
start_url: { url: "https://app.example.test/" },
actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }],
});
const secrets = BrowserScenarioSecrets.resolve(scenario, {});
if (secrets === undefined)
throw new Error("Expected resolved scenario secrets");
const secrets = resolveScenarioSecrets(scenario, {});
it("returns a complete requested text artifact beyond the former inline ceiling", async () => {
const page = {
@@ -57,14 +55,9 @@ it("redacts declared secrets from current and historical URL artifacts", async (
],
capture: { at_end: ["url", "history"] },
});
const scenarioSecrets = BrowserScenarioSecrets.resolve(
scenarioWithSecretUrl,
{
REA_URL_TOKEN: secretValue,
},
);
if (scenarioSecrets === undefined)
throw new Error("Expected resolved URL secret");
const scenarioSecrets = resolveScenarioSecrets(scenarioWithSecretUrl, {
REA_URL_TOKEN: secretValue,
});
const query = new URLSearchParams([["token", secretValue]]).toString();
const url = `https://app.example.test/?${query}#${encodeURIComponent(secretValue)}`;
const page = {
@@ -121,11 +114,9 @@ it("redacts a declared secret used as a cookie or storage name", async () => {
{ secret_id: "storage_key", environment_variable: "REA_STORAGE_KEY" },
],
});
const scenarioSecrets = BrowserScenarioSecrets.resolve(scenarioWithSecret, {
const scenarioSecrets = resolveScenarioSecrets(scenarioWithSecret, {
REA_STORAGE_KEY: secretValue,
});
if (scenarioSecrets === undefined)
throw new Error("Expected resolved storage secret");
const page = {
url: () => "https://app.example.test/",
evaluate: async () => ({
@@ -3,7 +3,7 @@ import type { Page } from "playwright-core";
import { expect, it } from "vitest";
import { browserScenarioSchema } from "../domain/browserScenario.js";
import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
import { resolveScenarioSecrets } from "../../tests/fixtures/browserScenarioSecrets.js";
import { PlaywrightScenarioEvents } from "./PlaywrightScenarioEvents.js";
it("omits a request without a frame in a shared context", () => {
@@ -15,8 +15,7 @@ it("omits a request without a frame in a shared context", () => {
actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }],
capture: { events: ["network"] },
});
const secrets = BrowserScenarioSecrets.resolve(scenario, {});
if (secrets === undefined) throw new Error("Expected resolved secrets");
const secrets = resolveScenarioSecrets(scenario, {});
const events = new PlaywrightScenarioEvents({
page,
context: context as never,
@@ -50,8 +49,7 @@ it("detaches root and popup listeners when a scenario finishes", async () => {
actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }],
capture: { events: ["console", "popups"] },
});
const secrets = BrowserScenarioSecrets.resolve(scenario, {});
if (secrets === undefined) throw new Error("Expected resolved secrets");
const secrets = resolveScenarioSecrets(scenario, {});
const events = new PlaywrightScenarioEvents({
page,
context: context as never,
@@ -87,8 +85,7 @@ it("detaches worker and websocket listeners when a scenario finishes", async ()
actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }],
capture: { events: ["websockets", "workers"] },
});
const secrets = BrowserScenarioSecrets.resolve(scenario, {});
if (secrets === undefined) throw new Error("Expected resolved secrets");
const secrets = resolveScenarioSecrets(scenario, {});
const events = new PlaywrightScenarioEvents({
page,
context: context as never,
@@ -116,8 +113,7 @@ it("detaches owned-context popup discovery when a scenario finishes", async () =
actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }],
capture: { events: ["console"] },
});
const secrets = BrowserScenarioSecrets.resolve(scenario, {});
if (secrets === undefined) throw new Error("Expected resolved secrets");
const secrets = resolveScenarioSecrets(scenario, {});
const events = new PlaywrightScenarioEvents({
page,
context: context as never,
+7 -13
View File
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { BrowserContext, Page, Request, Response } from "playwright-core";
import { browserScenarioSchema } from "../domain/browserScenario.js";
import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
import { resolveScenarioSecrets } from "../../tests/fixtures/browserScenarioSecrets.js";
import { PlaywrightScenarioEvents } from "./PlaywrightScenarioEvents.js";
describe("declared secrets in scenario events", () => {
@@ -26,10 +26,9 @@ describe("declared secrets in scenario events", () => {
],
capture: { events: ["console", "page-errors", "websockets"] },
});
const secrets = BrowserScenarioSecrets.resolve(scenario, {
const secrets = resolveScenarioSecrets(scenario, {
REA_TEST_TOKEN: secret,
});
if (secrets === undefined) throw new Error("Expected resolved secrets");
const events = new PlaywrightScenarioEvents({
page,
enabled: new Set(["console", "page-errors", "websockets"]),
@@ -109,8 +108,7 @@ const captureNetworkBudgetFixture = async (
}),
},
});
const secrets = BrowserScenarioSecrets.resolve(scenario, {});
if (secrets === undefined) throw new Error("Expected resolved secrets");
const secrets = resolveScenarioSecrets(scenario, {});
const redactBytes = vi.spyOn(secrets, "redactBytes");
const capture = new PlaywrightScenarioEvents({
page,
@@ -216,8 +214,7 @@ describe("PlaywrightScenarioEvents", () => {
],
capture: { events: ["page-errors"] },
});
const secrets = BrowserScenarioSecrets.resolve(scenario, {});
if (secrets === undefined) throw new Error("Expected resolved secrets");
const secrets = resolveScenarioSecrets(scenario, {});
const events = new PlaywrightScenarioEvents({
page,
enabled: new Set(["page-errors"]),
@@ -266,8 +263,7 @@ describe("PlaywrightScenarioEvents", () => {
events: ["page-errors"],
},
});
const secrets = BrowserScenarioSecrets.resolve(scenario, {});
if (secrets === undefined) throw new Error("Expected resolved secrets");
const secrets = resolveScenarioSecrets(scenario, {});
const events = new PlaywrightScenarioEvents({
page,
enabled: new Set(["page-errors"]),
@@ -304,8 +300,7 @@ it("retains ordinary event volumes and reports event-count exhaustion", () => {
actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }],
capture: { events: ["page-errors"] },
});
const secrets = BrowserScenarioSecrets.resolve(scenario, {});
if (secrets === undefined) throw new Error("Expected resolved secrets");
const secrets = resolveScenarioSecrets(scenario, {});
const events = new PlaywrightScenarioEvents({
page,
enabled: new Set(["page-errors"]),
@@ -382,8 +377,7 @@ it("caps live descendant listeners and reuses capacity after closure", () => {
start_url: { url: "https://example.test" },
actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }],
});
const secrets = BrowserScenarioSecrets.resolve(scenario, {});
if (secrets === undefined) throw new Error("Expected resolved secrets");
const secrets = resolveScenarioSecrets(scenario, {});
const events = new PlaywrightScenarioEvents({
page,
enabled: new Set(["websockets"]),
@@ -1559,3 +1559,67 @@ it.each([false, true])(
}
},
);
it.each(["absent", "undefined", "inherited", "empty"] as const)(
"resolves declared environment secrets before browser acquisition: %s",
async (state) => {
const { page } = fixture();
Object.assign(page, { waitForTimeout: async () => undefined });
const environment: Record<string, string | undefined> = {
AVAILABLE_SECRET: "already-supplied-private-value",
};
if (state === "inherited")
Object.setPrototypeOf(environment, {
REQUIRED_SECRET: "inherited-value",
});
if (state === "undefined") environment.REQUIRED_SECRET = undefined;
if (state === "empty") environment.REQUIRED_SECRET = "";
const input = browserScenarioSchema.parse({
...scenario,
secrets: [
{ secret_id: "available", environment_variable: "AVAILABLE_SECRET" },
{ secret_id: "required", environment_variable: "REQUIRED_SECRET" },
],
});
const provider = new PlaywrightBrowserScenarioProvider(
(scenario, options) =>
PlaywrightScenarioSession.open(scenario, environment, options),
);
try {
const result = await provider.captureScenario(input, {});
if (state === "empty") {
if (!result.ok) throw result.error;
expect(mocks.openBrowser).toHaveBeenCalledOnce();
} else {
if (result.ok) throw new Error("Expected unresolved environment input");
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({
code: "invalid_request",
category: "invalid_input",
details: {
issues: [
{
path: ["secrets", 1, "environment_variable"],
reason: "missing_argument",
expected: {
secret_id: "required",
environment_variable: "REQUIRED_SECRET",
},
},
],
},
});
expect(projected.message).toContain(
"Set environment variable REQUIRED_SECRET",
);
expect(JSON.stringify(projected)).not.toContain(
environment.AVAILABLE_SECRET,
);
expect(result.error.cleanupIncomplete).toBe(false);
expect(mocks.openBrowser).not.toHaveBeenCalled();
}
} finally {
await provider.close();
}
},
);
+3 -3
View File
@@ -147,9 +147,9 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
},
): Promise<PlaywrightScenarioSession> {
throwIfScenarioCancelled(options.signal);
const secrets = BrowserScenarioSecrets.resolve(scenario, environment);
if (secrets === undefined)
throw new BrowserObservationError(OPERATION, "secret_unavailable");
const resolved = BrowserScenarioSecrets.resolve(scenario, environment);
if (!resolved.ok) throw resolved.error;
const secrets = resolved.value;
const startedAt = performance.now();
const openingCleanup = new Set<AnalysisCleanupLease>();
const opening = openPlaywrightScenarioBrowser(scenario, environment, {
@@ -3,7 +3,7 @@ import { EventEmitter } from "node:events";
import { expect, it } from "vitest";
import { browserScenarioSchema } from "../domain/browserScenario.js";
import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
import { resolveScenarioSecrets } from "../../tests/fixtures/browserScenarioSecrets.js";
import { PlaywrightScenarioStorage } from "./PlaywrightScenarioStorage.js";
class FakeCdp extends EventEmitter {
@@ -160,7 +160,7 @@ const createInitializedStorage = async (
const owner = await PlaywrightScenarioStorage.create(
{ context: context as never, page: page as never },
input,
BrowserScenarioSecrets.resolve(input, {})!,
resolveScenarioSecrets(input, {}),
options,
);
await owner?.initialize(page as never, context as never);
@@ -427,7 +427,7 @@ it("reports an unconfirmed stop when root initialization is still pending", asyn
const owner = await PlaywrightScenarioStorage.create(
{ context: contextApi as never, page: page as never },
input,
BrowserScenarioSecrets.resolve(input, {})!,
resolveScenarioSecrets(input, {}),
);
expect(owner).toBeDefined();
const initialization = owner?.initialize(page as never, contextApi as never);
@@ -471,7 +471,7 @@ it("does not create storage instrumentation when there are no storage seeds", as
await PlaywrightScenarioStorage.create(
{ context: contextApi as never, page: page as never },
empty,
BrowserScenarioSecrets.resolve(empty, {})!,
resolveScenarioSecrets(empty, {}),
),
).toBeUndefined();
expect(cdp.calls).toEqual([]);
@@ -486,7 +486,7 @@ it("detaches after setup cancellation even while script installation is pending"
const owner = await PlaywrightScenarioStorage.create(
{ context: contextApi as never, page: page as never },
input,
BrowserScenarioSecrets.resolve(input, {})!,
resolveScenarioSecrets(input, {}),
{ signal: controller.signal },
);
expect(owner).toBeDefined();
@@ -7,7 +7,7 @@ import {
type BrowserScenarioEvent,
} from "../../domain/browserScenarioCaptureValues.js";
import type { BrowserNetworkContentSelection } from "../../domain/browserNetworkEvidence.js";
import { BrowserScenarioSecrets } from "../BrowserScenarioSecrets.js";
import { resolveScenarioSecrets } from "../../../tests/fixtures/browserScenarioSecrets.js";
import {
PlaywrightScenarioNetwork,
type ScenarioNetworkRequest,
@@ -112,10 +112,9 @@ const harness = (
],
capture: { limits: limitsInput },
});
const secrets = BrowserScenarioSecrets.resolve(scenario, {
const secrets = resolveScenarioSecrets(scenario, {
FIXTURE_SECRET: secretValue,
});
if (secrets === undefined) throw new Error("Fixture secret was not resolved");
const events: BrowserScenarioEvent[] = [];
const omittedEventCounts = new Map<string, number>();
const collector = new PlaywrightScenarioNetwork({
-1
View File
@@ -207,7 +207,6 @@ const BROWSER_ERROR_CODES: Readonly<
cleanup_failed: "execution_failure",
cancelled: "cancelled",
timeout: "provider_timeout",
secret_unavailable: "unreadable_output",
};
const artifactOperationCode = (
+1 -2
View File
@@ -35,5 +35,4 @@ export type BrowserObservationFailureReason =
| "process_ownership_unavailable"
| "cleanup_failed"
| "cancelled"
| "timeout"
| "secret_unavailable";
| "timeout";
+12
View File
@@ -0,0 +1,12 @@
import type { BrowserScenario } from "../../src/domain/browserScenario.js";
import { BrowserScenarioSecrets } from "../../src/browser/BrowserScenarioSecrets.js";
/** Resolve valid fixture declarations, failing with the producer's typed input error. */
export const resolveScenarioSecrets = (
scenario: BrowserScenario,
environment: Readonly<Record<string, string | undefined>>,
): BrowserScenarioSecrets => {
const result = BrowserScenarioSecrets.resolve(scenario, environment);
if (!result.ok) throw result.error;
return result.value;
};