@botbuddy/cli 1.32.0 → 1.32.1
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/package.json +1 -1
- package/src/pw/args.mjs +15 -0
- package/src/pw/readiness.mjs +4 -1
- package/src/pw/run.mjs +25 -4
- package/src/pw/targets.mjs +10 -1
- package/src/pw/translate.mjs +411 -0
package/package.json
CHANGED
package/src/pw/args.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { TARGET_VERBS, parseTargetFlags, hasTargetFlags, buildLocatorTarget, classifyTarget, isSnapshotRef } from "./targets.mjs";
|
|
2
|
+
import { TRANSLATE_VERBS, planTranslateVerb, MAX_EXEC_ARG } from "./translate.mjs";
|
|
2
3
|
export const GLOBAL_VERBS = new Set(["list", "close-all", "kill-all", "reap"]);
|
|
3
4
|
const secret = /^@ENV:(.+)$/;
|
|
4
5
|
export function resolveRef(value, env = process.env) {
|
|
@@ -16,6 +17,20 @@ export function planInvocation(argv, env = process.env) {
|
|
|
16
17
|
if (!validLane(lane) || !verb) throw new Error(`bb-pw: usage: lane must be a positive integer and include a verb`);
|
|
17
18
|
if (verb === "status") return { scope: "lane", lane, session: `lane-${lane}`, verb, mode: "status" };
|
|
18
19
|
const rest = argv.slice(2); let forwarded = rest, target = null, fresh = false;
|
|
20
|
+
// BOT-1702: bb-pw OWNS upload/route/unroute and translates them to upstream
|
|
21
|
+
// `run-code` (see translate.mjs). A resolved `@ENV:` secret in any value routes
|
|
22
|
+
// the generated code through the socket path so it never lands in argv, and is
|
|
23
|
+
// tracked for redaction — mirroring the sibling verbs' guarantee.
|
|
24
|
+
if (TRANSLATE_VERBS.has(verb)) {
|
|
25
|
+
const { code, secretValues } = planTranslateVerb(verb, rest, { env, resolveRef });
|
|
26
|
+
// Use the daemon socket (JSON, no argv limit) for a resolved secret OR a
|
|
27
|
+
// payload too large for a single child-process argument (Codex R2 P2: a big
|
|
28
|
+
// --inline/body would otherwise fail with E2BIG). Measure UTF-8 BYTES, not
|
|
29
|
+
// UTF-16 code units — the kernel's per-arg limit is on encoded bytes, so a
|
|
30
|
+
// multibyte body (e.g. emoji/CJK) could otherwise slip past (Codex R3 P2).
|
|
31
|
+
const sensitive = secretValues.length > 0 || Buffer.byteLength(code, "utf8") > MAX_EXEC_ARG;
|
|
32
|
+
return { scope: "lane", lane, session: `lane-${lane}`, verb, mode: sensitive ? "socket" : "exec", execArgv: sensitive ? null : [`-s=lane-${lane}`, "run-code", code], socketArgs: sensitive ? ["run-code", code] : null, telemetryUrl: null, secretValues, rollup: false, target: null, targetKind: null, fresh: false, translate: true };
|
|
33
|
+
}
|
|
19
34
|
if (TARGET_VERBS.has(verb)) {
|
|
20
35
|
const parsed = parseTargetFlags(rest); fresh = parsed.fresh;
|
|
21
36
|
forwarded = hasTargetFlags(parsed.flags) ? [buildLocatorTarget(parsed.flags), ...parsed.rest] : parsed.rest;
|
package/src/pw/readiness.mjs
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export const NAV = "navigate";
|
|
2
|
-
|
|
2
|
+
// `upload` mutates the page (it sets the input's files), so it counts as an
|
|
3
|
+
// interaction — otherwise session telemetry would omit every upload (BOT-1702
|
|
4
|
+
// Codex R27 P2).
|
|
5
|
+
const interaction = new Set(["click", "fill", "type", "select", "press", "drag", "hover", "dblclick", "check", "uncheck", "upload"]);
|
|
3
6
|
export function actionTypeFromMethod(method) { const value = String(method); return ["goto", "open", "go-back", "go-forward", "reload"].includes(value) ? NAV : value === "screenshot" ? "screenshot" : value === "snapshot" ? "snapshot" : interaction.has(value) ? "interaction" : "other"; }
|
|
4
7
|
export function deriveSession(events, { meta = {} } = {}) { const ordered = [...events].sort((a,b) => a.ts-b.ts), type = (name) => ordered.filter((event) => actionTypeFromMethod(event.method) === name); const nav = type(NAV); return { ...meta, started_at: ordered[0]?.ts ?? null, ended_at: ordered.at(-1)?.ts ?? null, duration_ms: ordered.length ? ordered.at(-1).ts - ordered[0].ts : 0, active_ms: 0, idle_threshold_ms: 60000, navigations: nav.length, distinct_routes: [...new Set(nav.map((event) => { try { return new URL(event.url).pathname; } catch { return String(event.url).split("?")[0]; } }))], screenshots: type("screenshot").length, snapshots: type("snapshot").length, interactions: type("interaction").length, actions_total: ordered.length }; }
|
package/src/pw/run.mjs
CHANGED
|
@@ -13,8 +13,19 @@ import { clearAgentState, isRejectedCachedMcpSession, resolveAgentSessionCredent
|
|
|
13
13
|
// server-side, so the lane name bb-pw builds/matches/prints is the one the lock
|
|
14
14
|
// kernel actually stored ("jonos-mbp:8", not "Jonos-MBP.localdomain:8").
|
|
15
15
|
const hostFor = (env) => canonicalizeHostString(env.PLAYWRIGHT_MCP_HOST || env.HOSTNAME || os.hostname());
|
|
16
|
-
function help(out) { out.write("Usage: pw [--tenant <slug>] [--session-id <id>] [--agent-session-token <token>] <lane> <verb> [args…]\n\nAliases: bb-pw <lane> <verb> [args…] · botbuddy pw <lane> <verb> [args…]\n (all three drive the same lock-gated Playwright lane)\n\n--session-id <id> accept a lane held by this arming-session agent id (from\n register_agent); defaults to $BOTBUDDY_SESSION_ID, then the\n id saved by `botbuddy register`.\n--agent-session-token <token> session credential (env $BOTBUDDY_AGENT_SESSION_TOKEN);\n --agent-key / --session-token are one-release legacy aliases.\n--tenant <slug> override the worktree .botbuddy-agent.json tenant when falling\n back to the .mcp.json ($BOTBUDDY_MCP_KEY) credential.\n"); }
|
|
17
|
-
|
|
16
|
+
function help(out) { out.write("Usage: pw [--tenant <slug>] [--session-id <id>] [--agent-session-token <token>] <lane> <verb> [args…]\n\nAliases: bb-pw <lane> <verb> [args…] · botbuddy pw <lane> <verb> [args…]\n (all three drive the same lock-gated Playwright lane)\n\n--session-id <id> accept a lane held by this arming-session agent id (from\n register_agent); defaults to $BOTBUDDY_SESSION_ID, then the\n id saved by `botbuddy register`.\n--agent-session-token <token> session credential (env $BOTBUDDY_AGENT_SESSION_TOKEN);\n --agent-key / --session-token are one-release legacy aliases.\n--tenant <slug> override the worktree .botbuddy-agent.json tenant when falling\n back to the .mcp.json ($BOTBUDDY_MCP_KEY) credential.\n\nFile upload (BOT-1702) — works on a hidden <input type=file>:\n bb-pw <lane> upload <selector> <path...>\n bb-pw <lane> upload <selector> --inline name=<n>,mime=<m>,size=<bytes>\n bb-pw <lane> upload <selector> --inline name=<n>,mime=<m>,bytes=@base64:<b64>\n bb-pw <lane> upload <selector> --inline name=<n>,mime=<m>,text=<str>\n --inline repeats; builds synthetic files in memory (no workstation FS).\n <selector> also accepts --testid/--role/--label/--text like click/fill.\n\nNetwork mocking (BOT-1702) — routes persist on the lane across invocations:\n bb-pw <lane> route <url-glob> stall hold the request pending\n (screenshot the loading state)\n bb-pw <lane> route <url-glob> abort [--error <code>] fail the request (default: failed)\n bb-pw <lane> route <url-glob> fulfill --status <n> [--content-type <ct>]\n [--body <str|@base64:..|@file:PATH>]\n bb-pw <lane> unroute [<url-glob>] release one route, or all\n bb-pw <lane> route-list list the routes owned on this lane\n (offline mode already exists upstream: bb-pw <lane> network-state-set offline)\n"); }
|
|
17
|
+
// Redact LONGEST secrets first: replacing a short value that is a substring of a
|
|
18
|
+
// longer secret (e.g. a filename that also appears inside the payload's base64)
|
|
19
|
+
// would otherwise break the longer match and leave the remainder recoverable
|
|
20
|
+
// (Codex R11 P1).
|
|
21
|
+
export function redact(value, secretValues = []) { return [...new Set(secretValues.filter(Boolean))].sort((a, b) => b.length - a.length).reduce((text, secret) => text.split(secret).join("[redacted]"), String(value ?? "")); }
|
|
22
|
+
// bb-pw OWNS the translate verbs, so their generated run-code is an internal
|
|
23
|
+
// implementation detail. When a secret is embedded in it, @playwright/cli echoes
|
|
24
|
+
// the source back ("### Ran Playwright code") in forms our value-based redaction
|
|
25
|
+
// cannot always match (JSON- vs single-quote-escaped, normalized, base64…). When
|
|
26
|
+
// any secret is in play we therefore DROP the echoed code block entirely — the
|
|
27
|
+
// robust fix for the whole class — while still redacting the rest (Codex R11).
|
|
28
|
+
export const stripCodeEcho = (text) => String(text ?? "").replace(/### Ran Playwright code\n```[\s\S]*?\n```\n?/g, "");
|
|
18
29
|
// BOT-1488: the register_agent identity for this machine, persisted by
|
|
19
30
|
// `botbuddy register` into ~/.botbuddy/config.json. This is the SESSION agent
|
|
20
31
|
// that acquire_resources binds a lane to — distinct from the tenant-bound
|
|
@@ -161,7 +172,17 @@ async function runPwInner(argv, deps = {}) {
|
|
|
161
172
|
const effHost = auth.canonicalHost ?? host;
|
|
162
173
|
if (plan.mode === "status") { const lock = auth.coordinator?.status ? await auth.coordinator.status({ host: effHost, slot: plan.lane }) : null; stdout.write(JSON.stringify({ lane: plan.lane, session: plan.session, host: effHost, lock, spooled_events: telemetry.count(plan.lane) }, null, 2) + "\n"); return 0; }
|
|
163
174
|
if (actionTypeFromMethod(plan.verb) !== "other") telemetry.append(plan.lane, { ts: Date.now(), method: plan.verb, url: actionTypeFromMethod(plan.verb) === NAV ? plan.telemetryUrl : null });
|
|
164
|
-
|
|
165
|
-
|
|
175
|
+
// Translate verbs ALWAYS run via the socket (even a small, non-secret one), so
|
|
176
|
+
// run.mjs — not an inherited-stdio spawnExec — owns their output and can strip
|
|
177
|
+
// the internal code echo; socketRun falls back to execArgv.slice(1) as its args
|
|
178
|
+
// (Codex R26 P2).
|
|
179
|
+
const inspect = plan.mode === "socket" || plan.translate || (actionTypeFromMethod(plan.verb) === "interaction" && (plan.targetKind === "ref" || plan.fresh)); let code;
|
|
180
|
+
// bb-pw's translate verbs (upload/route/...) generate internal run-code; that
|
|
181
|
+
// echoed "### Ran Playwright code" block is never useful to the caller and can
|
|
182
|
+
// be large (a 100KB+ base64 payload) or carry a secret, so strip it for every
|
|
183
|
+
// translated command — not only secret ones (Codex R25 P2) — while still
|
|
184
|
+
// redacting any tracked secret from the rest.
|
|
185
|
+
const suppressEcho = !!plan.translate || !!plan.secretValues?.length;
|
|
186
|
+
if (inspect) { if (plan.fresh) await socketRun({ ...plan, socketArgs: ["snapshot"] }, env).catch(() => {}); const result = await socketRun({ ...plan, socketArgs: plan.socketArgs ?? plan.execArgv.slice(1) }, env); if (result.text) { const text = suppressEcho ? stripCodeEcho(result.text) : result.text; stdout.write(`${redact(text, plan.secretValues)}\n`); } if (!result.ok) { let errText = plan.targetKind === "ref" && isStaleRefError(result.error) ? staleRefRemediation(plan.target) : result.error; if (suppressEcho) errText = stripCodeEcho(errText); stderr.write(`${redact(errText, plan.secretValues)}\n`); } code = result.ok ? 0 : 1; } else code = await spawnExec(plan, env);
|
|
166
187
|
if (plan.rollup && code === 0) await telemetry.rollup({ lane: plan.lane, coordinator: auth.coordinator, host: effHost }); return code;
|
|
167
188
|
}
|
package/src/pw/targets.mjs
CHANGED
|
@@ -3,7 +3,12 @@ export const TARGET_VERBS = new Set(["click", "dblclick", "fill", "hover", "chec
|
|
|
3
3
|
export const isSnapshotRef = (value) => REF.test(String(value ?? ""));
|
|
4
4
|
export const classifyTarget = (value) => /^getBy[A-Z]/.test(String(value ?? "")) ? "locator" : isSnapshotRef(value) ? "ref" : "selector";
|
|
5
5
|
const BASE = ["role", "placeholder", "text", "testid", "label", "title", "alt"];
|
|
6
|
-
|
|
6
|
+
// Produce a VALID single-quoted JS string literal. Besides backslash and the
|
|
7
|
+
// quote, escape newline/CR — an unescaped newline in a single-quoted literal is a
|
|
8
|
+
// syntax error, which would break the run-code that embeds a locator built from a
|
|
9
|
+
// multiline flag value (BOT-1702 Codex R24 P2). (U+2028/U+2029 are legal in
|
|
10
|
+
// string literals since ES2019, so they need no escaping.)
|
|
11
|
+
const quote = (value) => "'" + String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r") + "'";
|
|
7
12
|
export function parseTargetFlags(args) {
|
|
8
13
|
const flags = {}, rest = []; let fresh = false;
|
|
9
14
|
for (let index = 0; index < args.length; index += 1) {
|
|
@@ -15,6 +20,10 @@ export function parseTargetFlags(args) {
|
|
|
15
20
|
if (![...BASE, "name"].includes(name)) { rest.push(args[index]); continue; }
|
|
16
21
|
const value = inline ?? args[++index];
|
|
17
22
|
if (value === undefined) throw new Error(`bb-pw: --${name} needs a value`);
|
|
23
|
+
// Reject a repeated locator flag rather than silently keeping the last value,
|
|
24
|
+
// so a concatenated command can't target a different element while reporting
|
|
25
|
+
// success (BOT-1702 Codex R22 P2).
|
|
26
|
+
if (name in flags) throw new Error(`bb-pw: duplicate flag --${name}`);
|
|
18
27
|
flags[name] = value;
|
|
19
28
|
}
|
|
20
29
|
return { flags, rest, fresh };
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { parseTargetFlags, hasTargetFlags, buildLocatorTarget } from "./targets.mjs";
|
|
5
|
+
|
|
6
|
+
// BOT-1702: bb-pw OWNS the `upload`/`route`/`unroute` verbs and translates them
|
|
7
|
+
// to upstream `@playwright/cli run-code`, whose function body runs against the
|
|
8
|
+
// lane daemon's persistent page. This keeps the surface in our control (no MS
|
|
9
|
+
// fork) and lets route handlers persist across invocations, so
|
|
10
|
+
// `route <glob> stall` → `goto` → `screenshot` → `unroute` works. Driver:
|
|
11
|
+
// a Supply Guard acceptance flow (its policy-document upload UI).
|
|
12
|
+
export const TRANSLATE_VERBS = new Set(["upload", "route", "unroute", "route-list"]);
|
|
13
|
+
const ROUTE_ACTIONS = new Set(["stall", "abort", "fulfill"]);
|
|
14
|
+
// Force the socket transport (JSON over the daemon socket, no argv limit) once
|
|
15
|
+
// the generated run-code exceeds this, instead of passing it as one child-process
|
|
16
|
+
// argument. Linux caps a single argv entry at ~128 KiB (MAX_ARG_STRLEN), but
|
|
17
|
+
// Windows caps the WHOLE command line at 32,767 chars — so pick the limit by
|
|
18
|
+
// platform, well under each cap, or a large `--inline`/fulfill body would fail in
|
|
19
|
+
// spawn() (Codex R2/R23 P2).
|
|
20
|
+
export const MAX_EXEC_ARG = process.platform === "win32" ? 8_000 : 100_000;
|
|
21
|
+
// A JS string/number literal safe to embed in generated code. JSON.stringify
|
|
22
|
+
// emits a valid double-quoted JS string (or a bare number), so it doubles as our
|
|
23
|
+
// escaper for selectors, globs, bodies and base64 blobs.
|
|
24
|
+
const j = (value) => JSON.stringify(value);
|
|
25
|
+
|
|
26
|
+
// Node's base64 decoder is permissive: it silently drops invalid chars AND
|
|
27
|
+
// normalizes non-canonical padding bits (e.g. "AB==" → "AA=="), so a malformed
|
|
28
|
+
// payload would decode to different bytes than the caller supplied (Codex R4/R6
|
|
29
|
+
// P2). Accept only input that round-trips to itself, i.e. canonical standard
|
|
30
|
+
// base64.
|
|
31
|
+
function decodeBase64(text, ctx) {
|
|
32
|
+
const s = String(text);
|
|
33
|
+
const buf = Buffer.from(s, "base64");
|
|
34
|
+
if (buf.toString("base64") !== s)
|
|
35
|
+
throw new Error(`bb-pw: ${ctx}: invalid base64 (expected canonical standard padded base64)`);
|
|
36
|
+
return buf;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A fulfill body is embedded as a JS string literal, so it must be exact UTF-8.
|
|
40
|
+
// Reject bytes that don't round-trip (e.g. a base64/file payload with byte 0xff),
|
|
41
|
+
// rather than silently corrupting them via `toString("utf8")` → U+FFFD (Codex R19
|
|
42
|
+
// P2). Binary response bodies are out of scope.
|
|
43
|
+
function utf8Body(buf, ctx) {
|
|
44
|
+
const str = buf.toString("utf8");
|
|
45
|
+
if (!Buffer.from(str, "utf8").equals(buf))
|
|
46
|
+
throw new Error(`bb-pw: ${ctx}: body is not valid UTF-8 (binary fulfill bodies are not supported — provide text/JSON)`);
|
|
47
|
+
return str;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The finite set Playwright's Route.abort accepts; anything else asserts in
|
|
51
|
+
// Chromium at request time, so validate at plan time (Codex R6 P2).
|
|
52
|
+
const ABORT_ERRORS = new Set(["aborted", "accessdenied", "addressunreachable", "blockedbyclient", "blockedbyresponse", "connectionaborted", "connectionclosed", "connectionfailed", "connectionrefused", "connectionreset", "internetdisconnected", "namenotresolved", "timedout", "failed"]);
|
|
53
|
+
|
|
54
|
+
// Parse one `--inline name=<n>,mime=<m>,(size=<bytes>|bytes=@base64:<b64>|text=<str>)`
|
|
55
|
+
// spec into an in-memory file descriptor. Never touches the workstation FS, so an
|
|
56
|
+
// agent can craft a 0-byte or spoofed-bytes file without staging one on disk.
|
|
57
|
+
export function parseInline(spec) {
|
|
58
|
+
const s = String(spec);
|
|
59
|
+
// The content source (size=/bytes=/text=) is the LAST field and its value runs
|
|
60
|
+
// to end-of-string, so a text/CSV/query-string payload may contain commas —
|
|
61
|
+
// even a literal `,name=`/`,size=` — without being mis-split (Codex R8 P2).
|
|
62
|
+
const src = /(?:^|,)(size|bytes|text)=/.exec(s);
|
|
63
|
+
if (!src) throw new Error("bb-pw: --inline needs one of size=<bytes>, bytes=@base64:<b64>, or text=<str> as its last field");
|
|
64
|
+
const srcKey = src[1];
|
|
65
|
+
const srcStart = src.index + (src[0].length - (srcKey.length + 1));
|
|
66
|
+
const srcValue = s.slice(srcStart + srcKey.length + 1);
|
|
67
|
+
// The head (everything before the source) holds name=/mime=; those values never
|
|
68
|
+
// contain commas, so a plain comma split is safe here.
|
|
69
|
+
const kv = {};
|
|
70
|
+
const head = s.slice(0, srcStart).replace(/,$/, "");
|
|
71
|
+
if (head) for (const part of head.split(",")) {
|
|
72
|
+
const eq = part.indexOf("=");
|
|
73
|
+
if (eq < 0) throw new Error(`bb-pw: --inline expects key=value pairs, got "${part}"`);
|
|
74
|
+
const key = part.slice(0, eq).trim();
|
|
75
|
+
// Only name/mime are valid before the content source; reject an unknown or
|
|
76
|
+
// misspelled field (e.g. `filename=`) or a duplicate rather than ignoring it
|
|
77
|
+
// and uploading with different metadata than the caller intended (Codex R20).
|
|
78
|
+
if (key !== "name" && key !== "mime") throw new Error(`bb-pw: --inline: unknown field "${key}" (allowed: name, mime, and one of size/bytes/text)`);
|
|
79
|
+
if (key in kv) throw new Error(`bb-pw: --inline: duplicate field "${key}"`);
|
|
80
|
+
kv[key] = part.slice(eq + 1);
|
|
81
|
+
}
|
|
82
|
+
if (!kv.name) throw new Error("bb-pw: --inline needs name=<filename> before the content source");
|
|
83
|
+
if (!kv.mime) throw new Error("bb-pw: --inline needs mime=<type> before the content source");
|
|
84
|
+
let buffer;
|
|
85
|
+
if (srcKey === "size") {
|
|
86
|
+
const n = Number(srcValue);
|
|
87
|
+
if (!/^\d+$/.test(srcValue) || !Number.isInteger(n) || n < 0) throw new Error(`bb-pw: --inline size must be a non-negative integer, got "${srcValue}"`);
|
|
88
|
+
buffer = Buffer.alloc(n);
|
|
89
|
+
} else if (srcKey === "bytes") {
|
|
90
|
+
const match = /^@base64:(.*)$/s.exec(srcValue);
|
|
91
|
+
if (!match) throw new Error("bb-pw: --inline bytes= must be @base64:<data>");
|
|
92
|
+
buffer = decodeBase64(match[1], "--inline bytes");
|
|
93
|
+
} else {
|
|
94
|
+
buffer = Buffer.from(srcValue, "utf8");
|
|
95
|
+
}
|
|
96
|
+
// Bytes are carried as base64 and decoded in the BROWSER via `atob` (see
|
|
97
|
+
// buildUploadCode): base64 is ~1.33× vs ~3-4× for a JS number array, keeping the
|
|
98
|
+
// generated arg small (Codex R2 P2), and the browser has `atob` where the
|
|
99
|
+
// run-code VM lacks Node `Buffer` and Playwright's Buffer-only FilePayload.
|
|
100
|
+
return { name: kv.name, mimeType: kv.mime, base64: buffer.toString("base64") };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// `selectorExpr` is a full page accessor expression, e.g. `page.locator("#x")` or
|
|
104
|
+
// `page.getByTestId('x')`. `files` is either [{path}] (real disk paths) or
|
|
105
|
+
// [{name,mimeType,bytes}] (synthetic in-memory). Both paths work on a hidden
|
|
106
|
+
// <input type=file>:
|
|
107
|
+
// - real paths → Playwright `setInputFiles([...paths])` (native chooser path).
|
|
108
|
+
// - synthetic → built as File objects in the BROWSER via DataTransfer and
|
|
109
|
+
// assigned to `input.files`, dispatching input+change. This is fully hermetic
|
|
110
|
+
// (no workstation FS) and avoids Playwright's Buffer-only FilePayload, which
|
|
111
|
+
// is unreachable from the sandboxed run-code context (no Node Buffer there).
|
|
112
|
+
export function buildUploadCode(selectorExpr, files) {
|
|
113
|
+
const synthetic = files.length > 0 && files[0].path === undefined;
|
|
114
|
+
if (!synthetic) {
|
|
115
|
+
const items = files.map((file) => j(file.path));
|
|
116
|
+
return `async page => { await ${selectorExpr}.setInputFiles([${items.join(", ")}]); }`;
|
|
117
|
+
}
|
|
118
|
+
const payload = files.map((file) => ({ name: file.name, mime: file.mimeType, b64: file.base64 }));
|
|
119
|
+
// Enforce the same constraints Playwright's setInputFiles applies, which this
|
|
120
|
+
// synthetic path would otherwise bypass — allowing browser states a real user
|
|
121
|
+
// cannot reach (Codex R13 P2): the target must be an <input type=file>, and
|
|
122
|
+
// multiple files require a `multiple` input.
|
|
123
|
+
return `async page => { await ${selectorExpr}.evaluate((el, files) => { if (el instanceof HTMLLabelElement && el.control) el = el.control; if (!(el instanceof HTMLInputElement) || el.type !== "file") throw new Error("bb-pw upload: target is not an <input type=file>"); if (el.webkitdirectory) throw new Error("bb-pw upload: cannot assign synthetic files to a directory (webkitdirectory) input"); if (files.length > 1 && !el.multiple) throw new Error("bb-pw upload: multiple files require an <input multiple>"); const dt = new DataTransfer(); for (const f of files) { const bin = atob(f.b64); const u8 = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); dt.items.add(new File([u8], f.name, { type: f.mime })); } el.files = dt.files; el.dispatchEvent(new Event("input", { bubbles: true, composed: true })); el.dispatchEvent(new Event("change", { bubbles: true, composed: true })); }, ${JSON.stringify(payload)}); }`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// bb-pw's routes are registered via `page.route` inside the run-code VM, which
|
|
127
|
+
// bypasses upstream's route registry — so upstream `route-list` would report "No
|
|
128
|
+
// active routes" and a leftover stall/fulfill mock would be invisible (Codex R2
|
|
129
|
+
// P2). We therefore track owned routes on `page.__bbPwRoutes` (the daemon reuses
|
|
130
|
+
// one page object across invocations) and OWN `route-list`/`unroute` so they read
|
|
131
|
+
// and prune that registry.
|
|
132
|
+
// Bookkeeping key = a non-reversible digest of the RESOLVED glob. It mirrors
|
|
133
|
+
// Playwright's page.unroute (which matches on the resolved pattern), so two routes
|
|
134
|
+
// installed under the same `@ENV:` ref but DIFFERENT resolved values get distinct
|
|
135
|
+
// keys and are pruned independently (Codex R4 P2). It is a hash, so it never
|
|
136
|
+
// leaks a secret glob into the registry that route-list prints (Codex R3 P1).
|
|
137
|
+
const matchKey = (resolvedGlob) => createHash("sha256").update(String(resolvedGlob)).digest("hex").slice(0, 16);
|
|
138
|
+
// `displayGlob` is what route-list shows — the raw arg (e.g. `@ENV:SIGNED_URL`),
|
|
139
|
+
// never the resolved value.
|
|
140
|
+
const trackRoute = (displayGlob, action, key) => `(page.__bbPwRoutes = page.__bbPwRoutes || []).push({ glob: ${j(displayGlob)}, action: ${j(action)}, key: ${j(key)} });`;
|
|
141
|
+
|
|
142
|
+
export function buildRouteCode(glob, action, opts = {}, displayGlob = glob) {
|
|
143
|
+
const g = j(glob), key = matchKey(glob);
|
|
144
|
+
const track = trackRoute(displayGlob, action, key);
|
|
145
|
+
if (action === "stall")
|
|
146
|
+
// Capture each intercepted route (never resolving it) so the matched request
|
|
147
|
+
// hangs pending — a screenshot captures the loading state — and unroute can
|
|
148
|
+
// later release it (Codex R3 P2), instead of leaving it hung forever.
|
|
149
|
+
return `async page => { await page.route(${g}, route => { (page.__bbPwPending = page.__bbPwPending || []).push({ key: ${j(key)}, route }); }); ${track} }`;
|
|
150
|
+
if (action === "abort")
|
|
151
|
+
return `async page => { await page.route(${g}, route => route.abort(${j(opts.error || "failed")})); ${track} }`;
|
|
152
|
+
if (action === "fulfill") {
|
|
153
|
+
const fields = [];
|
|
154
|
+
if (opts.status !== undefined) fields.push(`status: ${Number(opts.status)}`);
|
|
155
|
+
if (opts.contentType !== undefined) fields.push(`contentType: ${j(opts.contentType)}`);
|
|
156
|
+
// Body is ALWAYS a plain string literal. @base64:/@file: are decoded at plan
|
|
157
|
+
// time (Node, where Buffer exists) — the generated handler runs in the
|
|
158
|
+
// @playwright/cli run-code VM, which exposes no Node Buffer, so a
|
|
159
|
+
// `Buffer.from(...)` here would throw ReferenceError on the first request.
|
|
160
|
+
if (opts.body !== undefined) fields.push(`body: ${j(opts.body)}`);
|
|
161
|
+
return `async page => { await page.route(${g}, route => route.fulfill({ ${fields.join(", ")} })); ${track} }`;
|
|
162
|
+
}
|
|
163
|
+
throw new Error(`bb-pw: route action must be stall|abort|fulfill, got "${action}"`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// On unroute, also RELEASE any requests already caught by a stall handler —
|
|
167
|
+
// page.unroute only stops future interceptions and would leave the pending
|
|
168
|
+
// request hanging (Codex R3 P2) — and prune only the records whose resolved key
|
|
169
|
+
// matches, so a colliding display ref keeps its other route (R4 P2). For a
|
|
170
|
+
// SPECIFIC unroute, release via route.fallback() so any broader still-registered
|
|
171
|
+
// mock in the chain still runs (route.continue() would go straight to the network
|
|
172
|
+
// and bypass it); for clear-all there is no remaining chain, so continue()
|
|
173
|
+
// directly (Codex R23 P2).
|
|
174
|
+
export function buildUnrouteCode(glob, displayGlob = glob) {
|
|
175
|
+
// Only null/undefined means "clear all"; an (already-rejected) empty string
|
|
176
|
+
// must never reach the unrouteAll branch (Codex R5 P2).
|
|
177
|
+
if (glob != null) {
|
|
178
|
+
const g = j(glob), key = j(matchKey(glob));
|
|
179
|
+
return `async page => { await page.unroute(${g}); for (const p of (page.__bbPwPending || []).filter(p => p.key === ${key})) { try { await p.route.fallback(); } catch {} } if (page.__bbPwPending) page.__bbPwPending = page.__bbPwPending.filter(p => p.key !== ${key}); if (page.__bbPwRoutes) page.__bbPwRoutes = page.__bbPwRoutes.filter(r => r.key !== ${key}); }`;
|
|
180
|
+
}
|
|
181
|
+
return `async page => { await page.unrouteAll(); for (const p of (page.__bbPwPending || [])) { try { await p.route.continue(); } catch {} } page.__bbPwPending = []; page.__bbPwRoutes = []; }`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function buildRouteListCode() {
|
|
185
|
+
// Return the ARRAY directly (glob + action only; the internal match key stays
|
|
186
|
+
// out of the output). @playwright/cli's run-code wrapper JSON.stringifies the
|
|
187
|
+
// result once — stringifying here too would emit a JSON string, leaving
|
|
188
|
+
// automation a string after one parse (Codex R25 P2).
|
|
189
|
+
return `async page => { return (page.__bbPwRoutes || []).map(r => ({ glob: r.glob, action: r.action })); }`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Allowed flags per route action; anything else is a typo or an incompatible
|
|
193
|
+
// option and must be rejected rather than silently ignored (Codex R3 P2).
|
|
194
|
+
const ROUTE_FLAGS = { stall: [], abort: ["error"], fulfill: ["status", "content-type", "body"] };
|
|
195
|
+
|
|
196
|
+
function parseSimpleFlags(args) {
|
|
197
|
+
const flags = {};
|
|
198
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
199
|
+
const match = /^--([a-z][a-z-]*)(?:=(.*))?$/s.exec(String(args[index]));
|
|
200
|
+
if (!match) throw new Error(`bb-pw: unexpected argument "${args[index]}"`);
|
|
201
|
+
const value = match[2] !== undefined ? match[2] : args[index += 1];
|
|
202
|
+
if (value === undefined) throw new Error(`bb-pw: --${match[1]} needs a value`);
|
|
203
|
+
// Reject a repeated flag rather than silently keeping the last value, so a
|
|
204
|
+
// concatenated command can't run against a different mock than intended
|
|
205
|
+
// (Codex R21 P2).
|
|
206
|
+
if (match[1] in flags) throw new Error(`bb-pw: duplicate flag --${match[1]}`);
|
|
207
|
+
flags[match[1]] = value;
|
|
208
|
+
}
|
|
209
|
+
return flags;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function planUpload(rest, rr, markSecret, resolveMeta) {
|
|
213
|
+
const { flags, rest: remainder } = parseTargetFlags(rest);
|
|
214
|
+
let selectorExpr, fileArgs;
|
|
215
|
+
if (hasTargetFlags(flags)) {
|
|
216
|
+
// Resolve `@ENV:` in each locator flag value (not the boolean `exact`), so a
|
|
217
|
+
// secret/ref in a target flag behaves like any other value (Codex R1 P2).
|
|
218
|
+
const resolved = Object.fromEntries(Object.entries(flags).map(([key, value]) => [key, key === "exact" ? value : rr(value)]));
|
|
219
|
+
selectorExpr = `page.${buildLocatorTarget(resolved)}`;
|
|
220
|
+
fileArgs = remainder;
|
|
221
|
+
} else {
|
|
222
|
+
const selector = remainder[0];
|
|
223
|
+
if (selector === undefined || String(selector).startsWith("--"))
|
|
224
|
+
throw new Error("bb-pw: upload needs a <selector> (or --testid/--role/--label/--text)");
|
|
225
|
+
selectorExpr = `page.locator(${j(rr(selector))})`;
|
|
226
|
+
fileArgs = remainder.slice(1);
|
|
227
|
+
}
|
|
228
|
+
// Parse one --inline spec. If the spec itself came from a secret ref
|
|
229
|
+
// (`--inline @ENV:SPEC`), parseInline SPLITS and TRANSFORMS it, so the whole
|
|
230
|
+
// recorded secret no longer matches the substrings that reach errors, nor the
|
|
231
|
+
// base64 payload embedded in the echoed code. Mark the derived name/mime/base64
|
|
232
|
+
// sensitive, and on an invalid secret spec throw a value-free error so no
|
|
233
|
+
// fragment leaks (Codex R10 P1).
|
|
234
|
+
const parseInlineArg = (spec) => {
|
|
235
|
+
const { value: resolved, secret: specWasSecret } = resolveMeta(spec);
|
|
236
|
+
let file;
|
|
237
|
+
try { file = parseInline(resolved); }
|
|
238
|
+
catch (error) { throw specWasSecret ? new Error("bb-pw: --inline spec from a secret ref is invalid (value redacted)") : error; }
|
|
239
|
+
if (specWasSecret) { markSecret(file.name); markSecret(file.mimeType); markSecret(file.base64); }
|
|
240
|
+
return file;
|
|
241
|
+
};
|
|
242
|
+
const paths = [], inlines = [];
|
|
243
|
+
for (let index = 0; index < fileArgs.length; index += 1) {
|
|
244
|
+
const token = String(fileArgs[index]);
|
|
245
|
+
if (token === "--inline") {
|
|
246
|
+
const spec = fileArgs[index += 1];
|
|
247
|
+
if (spec === undefined) throw new Error("bb-pw: --inline needs a value");
|
|
248
|
+
inlines.push(parseInlineArg(spec));
|
|
249
|
+
} else if (token.startsWith("--inline=")) {
|
|
250
|
+
inlines.push(parseInlineArg(token.slice("--inline=".length)));
|
|
251
|
+
} else if (token.startsWith("--")) {
|
|
252
|
+
throw new Error(`bb-pw: upload: unexpected flag "${token}"`);
|
|
253
|
+
} else {
|
|
254
|
+
paths.push(fileArgs[index]);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (inlines.length && paths.length)
|
|
258
|
+
throw new Error("bb-pw: upload takes either real <path...> OR --inline synthetic files, not both");
|
|
259
|
+
if (!inlines.length && !paths.length)
|
|
260
|
+
throw new Error("bb-pw: upload needs at least one <path> or --inline file");
|
|
261
|
+
const files = inlines.length ? inlines : paths.map((path) => {
|
|
262
|
+
const { value, secret } = resolveMeta(path);
|
|
263
|
+
const abs = resolve(value);
|
|
264
|
+
// If the path came from a secret ref, normalization may change its spelling;
|
|
265
|
+
// mark the resolved absolute form sensitive too (Codex R11/R14 P2).
|
|
266
|
+
if (secret) markSecret(abs);
|
|
267
|
+
return { path: abs };
|
|
268
|
+
});
|
|
269
|
+
return buildUploadCode(selectorExpr, files);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function planRoute(rest, rr, markSecret, resolveMeta) {
|
|
273
|
+
const rawGlob = rest[0];
|
|
274
|
+
if (rawGlob === undefined || rawGlob === "" || String(rawGlob).startsWith("--"))
|
|
275
|
+
throw new Error("bb-pw: route needs a <url-glob> pattern");
|
|
276
|
+
const action = rest[1];
|
|
277
|
+
if (action === undefined || String(action).startsWith("--"))
|
|
278
|
+
throw new Error("bb-pw: route needs an action: stall|abort|fulfill");
|
|
279
|
+
if (!ROUTE_ACTIONS.has(action))
|
|
280
|
+
throw new Error(`bb-pw: route action must be stall|abort|fulfill, got "${action}"`);
|
|
281
|
+
const flags = parseSimpleFlags(rest.slice(2));
|
|
282
|
+
const allowed = ROUTE_FLAGS[action];
|
|
283
|
+
const unknown = Object.keys(flags).filter((flag) => !allowed.includes(flag));
|
|
284
|
+
if (unknown.length)
|
|
285
|
+
throw new Error(`bb-pw: route ${action}: unsupported flag(s) ${unknown.map((f) => `--${f}`).join(", ")}. Allowed: ${allowed.length ? allowed.map((f) => `--${f}`).join(", ") : "none"}`);
|
|
286
|
+
// displayGlob is the raw arg (safe: a secret ref stays "@ENV:X"); g is resolved.
|
|
287
|
+
const g = rr(rawGlob), displayGlob = rawGlob;
|
|
288
|
+
if (action === "stall") return buildRouteCode(g, "stall", {}, displayGlob);
|
|
289
|
+
if (action === "abort") {
|
|
290
|
+
let error;
|
|
291
|
+
if (flags.error !== undefined) {
|
|
292
|
+
error = rr(flags.error);
|
|
293
|
+
if (!ABORT_ERRORS.has(error)) throw new Error(`bb-pw: route abort --error "${error}" is not a valid code. One of: ${[...ABORT_ERRORS].join(", ")}`);
|
|
294
|
+
}
|
|
295
|
+
return buildRouteCode(g, "abort", { error }, displayGlob);
|
|
296
|
+
}
|
|
297
|
+
const opts = {};
|
|
298
|
+
// --status is required for fulfill (contract: `fulfill --status <n>`); omitting
|
|
299
|
+
// it would silently default to 200 and turn a negative-path mock into a success
|
|
300
|
+
// (Codex R6 P2).
|
|
301
|
+
if (flags.status === undefined) throw new Error("bb-pw: route fulfill requires --status <n>");
|
|
302
|
+
{
|
|
303
|
+
// Resolve @ENV: like the other values, then validate — an unresolved ref
|
|
304
|
+
// would become status: NaN in the handler (Codex R2 P2). Require a valid HTTP
|
|
305
|
+
// status (100-599): Playwright evaluates `status || 200`, so 0 (and other
|
|
306
|
+
// out-of-range values) would silently return 200 (Codex R7 P2).
|
|
307
|
+
const status = rr(flags.status);
|
|
308
|
+
if (!/^\d+$/.test(String(status)) || Number(status) < 100 || Number(status) > 599)
|
|
309
|
+
throw new Error(`bb-pw: route fulfill --status must be a valid HTTP status (100-599), got "${status}"`);
|
|
310
|
+
opts.status = status;
|
|
311
|
+
}
|
|
312
|
+
if (flags["content-type"] !== undefined) opts.contentType = rr(flags["content-type"]);
|
|
313
|
+
if (flags.body !== undefined) {
|
|
314
|
+
// Resolve @ENV: FIRST, then interpret @base64:/@file: on the RESOLVED value —
|
|
315
|
+
// so `--body @ENV:BODY` where BODY is `@base64:…`/`@file:…` is decoded/loaded,
|
|
316
|
+
// not fulfilled with the literal marker (Codex R16 P2).
|
|
317
|
+
const { value: resolvedBody, secret: bodyWasSecret } = resolveMeta(flags.body);
|
|
318
|
+
const base64 = /^@base64:(.*)$/s.exec(resolvedBody);
|
|
319
|
+
const file = /^@file:(.*)$/s.exec(resolvedBody);
|
|
320
|
+
// Decode to a UTF-8 string at plan time so the generated handler embeds a
|
|
321
|
+
// plain string (no Buffer in the run-code VM). Fulfill bodies are text/JSON
|
|
322
|
+
// mock responses; binary bodies are out of scope.
|
|
323
|
+
if (base64) opts.body = utf8Body(decodeBase64(base64[1], "route fulfill --body @base64"), "route fulfill --body @base64");
|
|
324
|
+
else if (file) {
|
|
325
|
+
// When the @file: path itself came from a secret ref, mark the EXTRACTED
|
|
326
|
+
// path sensitive before reading, so a filesystem error (ENOENT etc.) that
|
|
327
|
+
// contains the bare path is redacted by the planner-error path (Codex R17
|
|
328
|
+
// P2). @file: contents are always sensitive — mark them so the body takes
|
|
329
|
+
// the socket path and is redacted, never emitted into argv/logs (R8 P2).
|
|
330
|
+
if (bodyWasSecret) markSecret(file[1]);
|
|
331
|
+
opts.body = markSecret(utf8Body(readFileSync(file[1]), "route fulfill --body @file"));
|
|
332
|
+
}
|
|
333
|
+
else opts.body = resolvedBody;
|
|
334
|
+
// If the ref itself was secret, the decoded/loaded body inherits provenance.
|
|
335
|
+
if (bodyWasSecret) markSecret(opts.body);
|
|
336
|
+
}
|
|
337
|
+
return buildRouteCode(g, "fulfill", opts, displayGlob);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function planUnroute(rest, rr) {
|
|
341
|
+
// Reject flag-shaped args: a typo like `unroute --glob=x` must NOT silently
|
|
342
|
+
// fall through to clearing every route (Codex R4 P2).
|
|
343
|
+
const flags = rest.filter((arg) => String(arg).startsWith("--"));
|
|
344
|
+
if (flags.length) throw new Error(`bb-pw: unroute takes no flags, got ${flags.join(", ")}`);
|
|
345
|
+
const positionals = rest.filter((arg) => !String(arg).startsWith("--"));
|
|
346
|
+
if (positionals.length > 1) throw new Error("bb-pw: unroute takes at most one <url-glob> (omit to clear all)");
|
|
347
|
+
const rawGlob = positionals[0];
|
|
348
|
+
if (rawGlob === undefined) return buildUnrouteCode(null, null);
|
|
349
|
+
// Fail closed on an explicitly empty glob (e.g. an unset "$GLOB"): it must not
|
|
350
|
+
// silently clear every route (Codex R5 P2).
|
|
351
|
+
if (rawGlob === "") throw new Error("bb-pw: unroute given an empty <url-glob>; omit the argument to clear all routes");
|
|
352
|
+
return buildUnrouteCode(rr(rawGlob), rawGlob);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function planRouteList(rest) {
|
|
356
|
+
// route-list takes no operands; reject a stray glob/flag rather than silently
|
|
357
|
+
// ignoring it and returning the full registry (Codex R18 P2).
|
|
358
|
+
if (rest.length) throw new Error(`bb-pw: route-list takes no arguments, got ${rest.join(" ")}`);
|
|
359
|
+
return buildRouteListCode();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Build the generated run-code for a translate verb. `resolveRef` is passed in
|
|
363
|
+
// from args.mjs (avoids an import cycle) so `@ENV:` refs resolve the same way as
|
|
364
|
+
// for every other verb; any resolved secret is collected so the caller can route
|
|
365
|
+
// through the socket path and redact it from output.
|
|
366
|
+
export function planTranslateVerb(verb, rest, { env, resolveRef }) {
|
|
367
|
+
const secretValues = [];
|
|
368
|
+
// Track a secret in BOTH the raw form (as it may appear in a browser result)
|
|
369
|
+
// AND the JSON-escaped inner form (as it appears embedded via JSON.stringify in
|
|
370
|
+
// the generated code that @playwright/cli echoes back), so redaction catches a
|
|
371
|
+
// secret containing quotes/backslashes/newlines too (Codex R8 P2).
|
|
372
|
+
const pushSecret = (value) => {
|
|
373
|
+
const raw = String(value);
|
|
374
|
+
if (raw && !secretValues.includes(raw)) secretValues.push(raw);
|
|
375
|
+
const escaped = JSON.stringify(raw).slice(1, -1);
|
|
376
|
+
if (escaped !== raw && !secretValues.includes(escaped)) secretValues.push(escaped);
|
|
377
|
+
};
|
|
378
|
+
// Resolve @ENV: and report provenance. Provenance comes from resolveRef().secret
|
|
379
|
+
// directly — NOT from growth of the (deduplicated) secretValues, which would
|
|
380
|
+
// wrongly read false when the same secret was already tracked (Codex R14 P2).
|
|
381
|
+
const resolveMeta = (value) => {
|
|
382
|
+
const resolved = resolveRef(value, env);
|
|
383
|
+
if (resolved.secret) pushSecret(resolved.value);
|
|
384
|
+
return { value: String(resolved.value), secret: !!resolved.secret };
|
|
385
|
+
};
|
|
386
|
+
const rr = (value) => resolveMeta(value).value;
|
|
387
|
+
// Mark a value sensitive so it forces the socket transport and is redacted from
|
|
388
|
+
// output — used for content loaded from disk (@file:), which was never on the
|
|
389
|
+
// command line and must not be newly exposed in argv/logs (Codex R8 P2).
|
|
390
|
+
const markSecret = (value) => { pushSecret(value); return value; };
|
|
391
|
+
let code;
|
|
392
|
+
try {
|
|
393
|
+
code = verb === "upload" ? planUpload(rest, rr, markSecret, resolveMeta)
|
|
394
|
+
: verb === "route" ? planRoute(rest, rr, markSecret, resolveMeta)
|
|
395
|
+
: verb === "unroute" ? planUnroute(rest, rr)
|
|
396
|
+
: verb === "route-list" ? planRouteList(rest)
|
|
397
|
+
: (() => { throw new Error(`bb-pw: unknown translate verb ${verb}`); })();
|
|
398
|
+
} catch (error) {
|
|
399
|
+
// A planning error may interpolate a resolved @ENV: value (e.g. an invalid
|
|
400
|
+
// --status/--error). runPwInner writes the thrown message straight to stderr,
|
|
401
|
+
// before secretValues is applied — so redact it here, using the secrets rr()
|
|
402
|
+
// has already collected (Codex R9 P2).
|
|
403
|
+
throw new Error(redactSecrets(error?.message ?? String(error), secretValues));
|
|
404
|
+
}
|
|
405
|
+
return { code, secretValues };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Dedupe + longest-first, matching run.mjs redact(): a short secret that is a
|
|
409
|
+
// substring of a longer one must not be replaced first, or the longer match
|
|
410
|
+
// breaks and leaks its remainder (Codex R12 P1).
|
|
411
|
+
const redactSecrets = (text, secrets) => [...new Set(secrets.filter(Boolean))].sort((a, b) => b.length - a.length).reduce((acc, secret) => acc.split(secret).join("[redacted]"), String(text ?? ""));
|