@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.32.0",
3
+ "version": "1.32.1",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
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;
@@ -1,4 +1,7 @@
1
1
  export const NAV = "navigate";
2
- const interaction = new Set(["click", "fill", "type", "select", "press", "drag", "hover", "dblclick", "check", "uncheck"]);
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
- function redact(value, secretValues = []) { return secretValues.reduce((text, secret) => secret ? text.split(secret).join("[redacted]") : text, String(value ?? "")); }
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
- const inspect = plan.mode === "socket" || (actionTypeFromMethod(plan.verb) === "interaction" && (plan.targetKind === "ref" || plan.fresh)); let code;
165
- 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) stdout.write(`${redact(result.text, plan.secretValues)}\n`); if (!result.ok) stderr.write(`${redact(plan.targetKind === "ref" && isStaleRefError(result.error) ? staleRefRemediation(plan.target) : result.error, plan.secretValues)}\n`); code = result.ok ? 0 : 1; } else code = await spawnExec(plan, env);
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
  }
@@ -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
- const quote = (value) => `'${String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
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 ?? ""));