@botbuddy/cli 1.32.0 → 1.32.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.32.0",
3
+ "version": "1.32.2",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
Binary file
package/src/pw/args.mjs CHANGED
@@ -1,5 +1,26 @@
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"]);
4
+ // BOT-1682: the bb pw credential/session flags. They are accepted ONLY before the
5
+ // lane (consumed in run.mjs). Once one reaches planInvocation it is after the lane
6
+ // — the verb slot or the workload — which upstream @playwright/cli would reject
7
+ // with an "Unknown option" + help dump; catch it here and name the fix (move it
8
+ // before the lane) instead. `--tenant` after the lane also cannot be safely
9
+ // honored for session/lane auth, another reason to refuse rather than forward it.
10
+ export const PW_CREDENTIAL_FLAGS = ["--tenant", "--session-id", "--agent-session-token", "--agent-key", "--session-token"];
11
+ const credentialFlagOf = (token) => PW_CREDENTIAL_FLAGS.find((flag) => token === flag || String(token).startsWith(`${flag}=`)) ?? null;
12
+ function misplacedCredentialFlag(token) {
13
+ return new Error(`bb-pw: ${credentialFlagOf(token)} is a bb pw credential flag, not a Playwright argument — put it before the lane: bb-pw [--tenant <slug>] <lane> <verb> …`);
14
+ }
15
+ // BOT-1682: verbs that once existed under bb pw / an older @playwright/cli and now
16
+ // map to a differently-named upstream command. On these, hint the rename in one
17
+ // line instead of dumping ~110 lines of upstream usage. `console` and `status`
18
+ // are NOT here — they are live commands.
19
+ export const RETIRED_VERBS = { network: "use 'requests' (list) or 'request-headers <n>' / 'request <n>'" };
20
+ // BOT-1682: everything after a bare `--` is a positional Playwright argument (the
21
+ // upstream CLI treats `fill e1 -- --tenant` as literal text), so misplaced-flag
22
+ // detection must not look past it. Mirrors legacyAgentAliasHint's own `--` cutoff.
23
+ const beforeSeparator = (tokens) => { const sep = tokens.indexOf("--"); return sep === -1 ? tokens : tokens.slice(0, sep); };
3
24
  const secret = /^@ENV:(.+)$/;
4
25
  export function resolveRef(value, env = process.env) {
5
26
  const match = secret.exec(String(value));
@@ -11,17 +32,71 @@ export const normaliseLane = (token) => String(token).replace(/^agent-0*/, "").r
11
32
  function validLane(lane) { return /^[1-9]\d*$/.test(lane); }
12
33
  export function planInvocation(argv, env = process.env) {
13
34
  if (!argv.length) throw new Error("bb-pw: usage: bb-pw <lane> <verb> [args…] (or: bb-pw <reap|list|close-all|kill-all>)");
14
- if (GLOBAL_VERBS.has(argv[0])) return argv[0] === "reap" ? { scope: "global", verb: "reap", mode: "reap" } : { scope: "global", verb: argv[0], mode: "exec", execArgv: argv };
35
+ if (GLOBAL_VERBS.has(argv[0])) {
36
+ // BOT-1682: a credential flag after a global verb (`bb-pw list --tenant …`)
37
+ // has no lane to sit before, and would otherwise be forwarded to upstream —
38
+ // which rejects it with its own "Unknown option" dump. Refuse it here too.
39
+ // Global verbs (list/close-all/kill-all/reap) take NO positional workload, so
40
+ // unlike lane verbs there is no `--` exemption — a credential-shaped token in
41
+ // ANY position (incl. after `--`) is misplaced, never a literal value that
42
+ // upstream would accept (Codex: `list -- --tenant` otherwise reaches upstream,
43
+ // which errors "too many arguments").
44
+ const strayGlobal = argv.slice(1).find((value) => credentialFlagOf(value));
45
+ if (strayGlobal) throw misplacedCredentialFlag(strayGlobal);
46
+ return argv[0] === "reap" ? { scope: "global", verb: "reap", mode: "reap" } : { scope: "global", verb: argv[0], mode: "exec", execArgv: argv };
47
+ }
15
48
  const lane = normaliseLane(argv[0]), verb = argv[1];
16
49
  if (!validLane(lane) || !verb) throw new Error(`bb-pw: usage: lane must be a positive integer and include a verb`);
17
- if (verb === "status") return { scope: "lane", lane, session: `lane-${lane}`, verb, mode: "status" };
50
+ // BOT-1682: a credential flag in the verb slot (`<lane> --tenant …`) or a
51
+ // retired verb — caught before status/target handling so neither leaks upstream.
52
+ if (credentialFlagOf(verb)) throw misplacedCredentialFlag(verb);
53
+ if (Object.hasOwn(RETIRED_VERBS, verb)) throw new Error(`bb-pw: '${verb}' was retired — ${RETIRED_VERBS[verb]}`);
54
+ if (verb === "status") {
55
+ // `status` has no workload, so — like the global verbs — there is no `--`
56
+ // exemption: a credential flag in ANY position (incl. after `--`) is misplaced
57
+ // and must be refused, or `status -- --tenant` would return success while
58
+ // silently ignoring it and still leaving the id readable downstream (Codex).
59
+ const strayStatus = argv.slice(2).find((value) => credentialFlagOf(value));
60
+ if (strayStatus) throw misplacedCredentialFlag(strayStatus);
61
+ return { scope: "lane", lane, session: `lane-${lane}`, verb, mode: "status" };
62
+ }
18
63
  const rest = argv.slice(2); let forwarded = rest, target = null, fresh = false;
64
+ // BOT-1702: bb-pw OWNS upload/route/unroute and translates them to upstream
65
+ // `run-code` (see translate.mjs). A resolved `@ENV:` secret in any value routes
66
+ // the generated code through the socket path so it never lands in argv, and is
67
+ // tracked for redaction — mirroring the sibling verbs' guarantee.
68
+ if (TRANSLATE_VERBS.has(verb)) {
69
+ // BOT-1682: run the misplaced-credential-flag guard BEFORE dispatching a
70
+ // translated verb (upload/route/unroute/route-list), so `route … stall --tenant …`
71
+ // gets the one-line "put it before the lane" refusal rather than an unrelated
72
+ // translate-parse error (Codex). But a translated verb's parsers consume the
73
+ // token AFTER an option as that option's VALUE (`upload --label --tenant`,
74
+ // `route … fulfill --body --session-token`), so a credential-shaped token whose
75
+ // predecessor is an option (`--x`) is a legitimate value and is left alone;
76
+ // only one in a non-value position is misplaced (Codex R17). `--` still exempts.
77
+ const scanT = beforeSeparator(rest);
78
+ const strayTranslate = scanT.find((value, i) => credentialFlagOf(value) && !(i > 0 && /^--[a-z]/.test(String(scanT[i - 1]))));
79
+ if (strayTranslate) throw misplacedCredentialFlag(strayTranslate);
80
+ const { code, secretValues } = planTranslateVerb(verb, rest, { env, resolveRef });
81
+ // Use the daemon socket (JSON, no argv limit) for a resolved secret OR a
82
+ // payload too large for a single child-process argument (Codex R2 P2: a big
83
+ // --inline/body would otherwise fail with E2BIG). Measure UTF-8 BYTES, not
84
+ // UTF-16 code units — the kernel's per-arg limit is on encoded bytes, so a
85
+ // multibyte body (e.g. emoji/CJK) could otherwise slip past (Codex R3 P2).
86
+ const sensitive = secretValues.length > 0 || Buffer.byteLength(code, "utf8") > MAX_EXEC_ARG;
87
+ 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 };
88
+ }
19
89
  if (TARGET_VERBS.has(verb)) {
20
90
  const parsed = parseTargetFlags(rest); fresh = parsed.fresh;
21
91
  forwarded = hasTargetFlags(parsed.flags) ? [buildLocatorTarget(parsed.flags), ...parsed.rest] : parsed.rest;
22
92
  target = forwarded.find((value) => !String(value).startsWith("--")) ?? null;
23
93
  if (fresh && target && isSnapshotRef(target)) throw new Error("bb-pw: --fresh cannot target a snapshot ref; use a stable locator");
24
94
  }
95
+ // BOT-1682: a credential flag surviving into the workload (`<lane> <verb> …
96
+ // --tenant …`) is misplaced — target-flag VALUES have already been consumed by
97
+ // parseTargetFlags, so only a genuine standalone credential-flag token remains.
98
+ const stray = beforeSeparator(forwarded).find((value) => credentialFlagOf(value));
99
+ if (stray) throw misplacedCredentialFlag(stray);
25
100
  const resolved = forwarded.map((value) => resolveRef(value, env));
26
101
  const sensitive = resolved.some((value) => value.secret);
27
102
  return { scope: "lane", lane, session: `lane-${lane}`, verb, mode: sensitive ? "socket" : "exec", execArgv: sensitive ? null : [`-s=lane-${lane}`, verb, ...forwarded], socketArgs: sensitive ? [verb, ...resolved.map((value) => value.value)] : null, telemetryUrl: ["goto", "open", "go-back", "go-forward", "reload"].includes(verb) ? (resolved[0]?.secret ? resolved[0].ref : forwarded[0] ?? null) : null, secretValues: resolved.filter((value) => value.secret).map((value) => String(value.value)), rollup: verb === "close", target, targetKind: target === null ? null : classifyTarget(target), fresh };
@@ -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
@@ -1,5 +1,5 @@
1
1
  import os from "node:os";
2
- import { planInvocation } from "./args.mjs";
2
+ import { planInvocation, GLOBAL_VERBS } from "./args.mjs";
3
3
  import { actionTypeFromMethod, NAV } from "./readiness.mjs";
4
4
  import { isStaleRefError, staleRefRemediation } from "./targets.mjs";
5
5
  import { createSessionTokenCoordinator } from "./coordinator.mjs";
@@ -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> accepted before the lane but currently INERT — the tenant\n comes from your resolved credential, so bb pw does not act on\n this flag for session/lane auth yet (server-validated tenant\n selection is tracked in BOT-1732).\n\nCredential flags (--tenant / --session-id / --agent-session-token) go BEFORE the\nlane. Placed after the lane they are refused with guidance (they are not\nPlaywright arguments, and a --tenant after the lane cannot be safely honored).\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
@@ -103,14 +114,58 @@ export async function runPw(argv, deps = {}) {
103
114
  // while-loop in runPwInner). Everything after the first non-option (the lane) is a
104
115
  // Playwright workload argument and is never a BotBuddy credential.
105
116
  const PW_LEADING_FLAGS = ["--tenant", "--session-id", "--agent-session-token", "--agent-key", "--session-token"];
117
+ const selfHealErrorLine = (error) => `bb-pw: ${error?.message ?? "agent session unavailable"}. ${error?.code === "client_key_required" ? "Run `bb login` then retry." : "Run `bb doctor --fix` then retry."}\n`;
118
+ // The leading credential options, NORMALIZED to split `flag value` pairs so an
119
+ // inline `--agent-key=<token>` is recognized by legacyAgentAliasHint (which
120
+ // matches exact flag tokens) exactly like the split form. Credential flags are
121
+ // only accepted BEFORE the lane (BOT-1682 P1: `--tenant` after the lane cannot be
122
+ // safely honored for session/lane auth and is refused, see planInvocation), so
123
+ // this leading scan is the whole surface the alias hint needs to consider.
106
124
  function leadingPwOptions(args) {
107
125
  const lead = [];
108
- for (let i = 0; i < args.length && PW_LEADING_FLAGS.includes(args[i]); i += 2) {
109
- lead.push(args[i]);
110
- if (i + 1 < args.length) lead.push(args[i + 1]);
126
+ let rest = [...args];
127
+ let match;
128
+ while ((match = matchWrapperFlag(rest[0]))) {
129
+ lead.push(match.flag);
130
+ if (match.inline !== null) { lead.push(match.inline); rest = rest.slice(1); }
131
+ else { if (rest[1] === undefined) break; lead.push(rest[1]); rest = rest.slice(2); }
111
132
  }
112
133
  return lead;
113
134
  }
135
+ // BOT-1682: match a leading credential flag in either `--flag value` or
136
+ // `--flag=value` form. Returns the base flag + its inline value (null when the
137
+ // value is the NEXT token), or null when the token is not a credential flag.
138
+ function matchWrapperFlag(token) {
139
+ for (const flag of PW_LEADING_FLAGS) {
140
+ if (token === flag) return { flag, inline: null };
141
+ if (typeof token === "string" && token.startsWith(`${flag}=`)) return { flag, inline: token.slice(flag.length + 1) };
142
+ }
143
+ return null;
144
+ }
145
+ const applyWrapperFlag = (deps, flag, value) => flag === "--tenant"
146
+ ? { ...deps, tenant: value }
147
+ : ["--agent-session-token", "--agent-key", "--session-token"].includes(flag)
148
+ ? { ...deps, sessionToken: value }
149
+ : { ...deps, sessionId: value };
150
+ // Consume a run of credential flags from `list` starting at `start`, folding each
151
+ // into deps. Returns { deps, rest } on success, or { error: flag } when a flag
152
+ // has no value. `rest` is the tokens after the consumed run.
153
+ function consumeWrapperFlags(list, deps, start = 0) {
154
+ let rest = list.slice(start);
155
+ let match;
156
+ while ((match = matchWrapperFlag(rest[0]))) {
157
+ let value;
158
+ if (match.inline !== null) { value = match.inline; rest = rest.slice(1); }
159
+ else { if (rest[1] === undefined) return { error: match.flag }; value = rest[1]; rest = rest.slice(2); }
160
+ // An empty value (`--flag=` or `--flag ""`) is a usage error, not a silent
161
+ // no-op: an empty session token would make `!deps.sessionToken` true and fall
162
+ // back to another credential, executing under a different identity (Codex P2).
163
+ // Matches the old `if (!args[1])` guard this helper replaced.
164
+ if (value === "") return { error: match.flag };
165
+ deps = applyWrapperFlag(deps, match.flag, value);
166
+ }
167
+ return { deps, rest };
168
+ }
114
169
 
115
170
  async function runPwInner(argv, deps = {}) {
116
171
  const env = deps.env ?? process.env, stdout = deps.stdout ?? process.stdout, stderr = deps.stderr ?? process.stderr; let args = [...argv]; if (["--help", "-h"].includes(args[0])) { help(stdout); return 0; }
@@ -126,7 +181,14 @@ async function runPwInner(argv, deps = {}) {
126
181
  // session identity alongside --session-id, so a token-armed session need not
127
182
  // pass an id. Holder matching still rides on the server's owner_is_caller and
128
183
  // the resolved agent ids (gate()).
129
- while (["--tenant", "--session-id", "--agent-session-token", "--agent-key", "--session-token"].includes(args[0])) { const flag = args[0]; if (!args[1]) { stderr.write(`bb-pw: ${flag} needs a value\n`); return 2; } deps = flag === "--tenant" ? { ...deps, tenant: args[1] } : ["--agent-session-token", "--agent-key", "--session-token"].includes(flag) ? { ...deps, sessionToken: args[1] } : { ...deps, sessionId: args[1] }; args = args.slice(2); }
184
+ // Credential flags are accepted ONLY as LEADING options (before the lane). A
185
+ // `--tenant` after the lane cannot be safely honored — bb pw would authenticate
186
+ // with the resolved session/key regardless of the named tenant, so a tenant B
187
+ // request could run under tenant A's cached session (Codex P1). Anything
188
+ // credential-flag-shaped still present once we reach the verb is refused by
189
+ // planInvocation with guidance to move it before the lane; a real, server-pinned
190
+ // `--tenant` lane fallback is BOT-1732.
191
+ { const consumed = consumeWrapperFlags(args, deps, 0); if (consumed.error) { stderr.write(`bb-pw: ${consumed.error} needs a value\n`); return 2; } deps = consumed.deps; args = consumed.rest; }
130
192
  let plan; try { plan = planInvocation(args, env); } catch (error) { stderr.write(`${error.message}\n`); return 2; }
131
193
  const { spawnExec, socketRun } = deps.daemon ?? await import("./daemon.mjs");
132
194
  if (plan.scope === "global") { if (plan.mode === "reap") { await (deps.reap ?? (await import("./reap.mjs")).reap)({ env, stdout }); return 0; } if (env.BB_PW_NO_LOCK !== "1") { stderr.write("bb-pw: close-all and kill-all require BB_PW_NO_LOCK=1 because they can affect lanes you do not own.\n"); return 3; } return spawnExec(plan, env); }
@@ -135,12 +197,11 @@ async function runPwInner(argv, deps = {}) {
135
197
  let managedCredential = null;
136
198
  if (env.BB_PW_NO_LOCK !== "1" && !deps.sessionToken && !readAgentSessionTokenEnv(env) && (!deps.coordinator || deps.resolveCredential)) {
137
199
  try {
138
- const credential = await (deps.resolveCredential ?? resolveAgentSessionCredential)({ argv, env, cwd: deps.cwd ?? process.cwd() });
200
+ const credential = await (deps.resolveCredential ?? resolveAgentSessionCredential)({ argv, env, cwd: deps.cwd ?? process.cwd(), sessionId: deps.sessionId ?? env.BOTBUDDY_SESSION_ID ?? null, flagToken: null });
139
201
  managedCredential = credential;
140
202
  deps = { ...deps, sessionToken: credential.token, sessionId: deps.sessionId ?? credential.sessionId ?? null };
141
203
  } catch (error) {
142
- stderr.write(`bb-pw: ${error?.message ?? "agent session unavailable"}. ${error?.code === "client_key_required" ? "Run `bb login` then retry." : "Run `bb doctor --fix` then retry."}\n`);
143
- return 3;
204
+ stderr.write(selfHealErrorLine(error)); return 3;
144
205
  }
145
206
  }
146
207
  const telemetry = deps.telemetry ?? (await import("./telemetry.mjs")).makeTelemetry({ env }); const host = deps.host ?? hostFor(env);
@@ -150,9 +211,17 @@ async function runPwInner(argv, deps = {}) {
150
211
  // allowed:false with NO errorCode and must never rotate the session (Codex round-5).
151
212
  if (!auth.allowed && !deps.coordinator && auth.errorCode != null && isRejectedCachedMcpSession({ auth: true, code: auth.errorCode }, managedCredential?.source, 0)) {
152
213
  await clearAgentState(deps.cwd ?? process.cwd());
153
- const credential = await (deps.resolveCredential ?? resolveAgentSessionCredential)({ argv, env, cwd: deps.cwd ?? process.cwd() });
154
- managedCredential = credential;
155
- deps = { ...deps, sessionToken: credential.token, sessionId: credential.sessionId ?? deps.sessionId ?? null };
214
+ try {
215
+ const credential = await (deps.resolveCredential ?? resolveAgentSessionCredential)({ argv, env, cwd: deps.cwd ?? process.cwd(), sessionId: deps.sessionId ?? env.BOTBUDDY_SESSION_ID ?? null, flagToken: null });
216
+ managedCredential = credential;
217
+ // Keep an explicit --session-id holder authoritative over the freshly minted
218
+ // one, same precedence as the initial resolution (Codex).
219
+ deps = { ...deps, sessionToken: credential.token, sessionId: deps.sessionId ?? credential.sessionId ?? null };
220
+ } catch (error) {
221
+ // BOT-1682: the renewal after a revoked cache can also fail self-heal; fail
222
+ // closed with a clear message rather than letting the error escape to exit 1.
223
+ stderr.write(selfHealErrorLine(error)); return 3;
224
+ }
156
225
  auth = await gate({ env, host, lane: plan.lane, deps });
157
226
  }
158
227
  if (!auth.allowed) { stderr.write(`${auth.message}\n`); return 3; }
@@ -161,7 +230,17 @@ async function runPwInner(argv, deps = {}) {
161
230
  const effHost = auth.canonicalHost ?? host;
162
231
  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
232
  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);
233
+ // Translate verbs ALWAYS run via the socket (even a small, non-secret one), so
234
+ // run.mjs not an inherited-stdio spawnExec owns their output and can strip
235
+ // the internal code echo; socketRun falls back to execArgv.slice(1) as its args
236
+ // (Codex R26 P2).
237
+ const inspect = plan.mode === "socket" || plan.translate || (actionTypeFromMethod(plan.verb) === "interaction" && (plan.targetKind === "ref" || plan.fresh)); let code;
238
+ // bb-pw's translate verbs (upload/route/...) generate internal run-code; that
239
+ // echoed "### Ran Playwright code" block is never useful to the caller and can
240
+ // be large (a 100KB+ base64 payload) or carry a secret, so strip it for every
241
+ // translated command — not only secret ones (Codex R25 P2) — while still
242
+ // redacting any tracked secret from the rest.
243
+ const suppressEcho = !!plan.translate || !!plan.secretValues?.length;
244
+ 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
245
  if (plan.rollup && code === 0) await telemetry.rollup({ lane: plan.lane, coordinator: auth.coordinator, host: effHost }); return code;
167
246
  }
@@ -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 ?? ""));