@botbuddy/cli 1.32.1 → 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.1",
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,6 +1,26 @@
1
1
  import { TARGET_VERBS, parseTargetFlags, hasTargetFlags, buildLocatorTarget, classifyTarget, isSnapshotRef } from "./targets.mjs";
2
2
  import { TRANSLATE_VERBS, planTranslateVerb, MAX_EXEC_ARG } from "./translate.mjs";
3
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); };
4
24
  const secret = /^@ENV:(.+)$/;
5
25
  export function resolveRef(value, env = process.env) {
6
26
  const match = secret.exec(String(value));
@@ -12,16 +32,51 @@ export const normaliseLane = (token) => String(token).replace(/^agent-0*/, "").r
12
32
  function validLane(lane) { return /^[1-9]\d*$/.test(lane); }
13
33
  export function planInvocation(argv, env = process.env) {
14
34
  if (!argv.length) throw new Error("bb-pw: usage: bb-pw <lane> <verb> [args…] (or: bb-pw <reap|list|close-all|kill-all>)");
15
- 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
+ }
16
48
  const lane = normaliseLane(argv[0]), verb = argv[1];
17
49
  if (!validLane(lane) || !verb) throw new Error(`bb-pw: usage: lane must be a positive integer and include a verb`);
18
- 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
+ }
19
63
  const rest = argv.slice(2); let forwarded = rest, target = null, fresh = false;
20
64
  // BOT-1702: bb-pw OWNS upload/route/unroute and translates them to upstream
21
65
  // `run-code` (see translate.mjs). A resolved `@ENV:` secret in any value routes
22
66
  // the generated code through the socket path so it never lands in argv, and is
23
67
  // tracked for redaction — mirroring the sibling verbs' guarantee.
24
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);
25
80
  const { code, secretValues } = planTranslateVerb(verb, rest, { env, resolveRef });
26
81
  // Use the daemon socket (JSON, no argv limit) for a resolved secret OR a
27
82
  // payload too large for a single child-process argument (Codex R2 P2: a big
@@ -37,6 +92,11 @@ export function planInvocation(argv, env = process.env) {
37
92
  target = forwarded.find((value) => !String(value).startsWith("--")) ?? null;
38
93
  if (fresh && target && isSnapshotRef(target)) throw new Error("bb-pw: --fresh cannot target a snapshot ref; use a stable locator");
39
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);
40
100
  const resolved = forwarded.map((value) => resolveRef(value, env));
41
101
  const sensitive = resolved.some((value) => value.secret);
42
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 };
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,7 +13,7 @@ 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\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"); }
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
17
  // Redact LONGEST secrets first: replacing a short value that is a substring of a
18
18
  // longer secret (e.g. a filename that also appears inside the payload's base64)
19
19
  // would otherwise break the longer match and leave the remainder recoverable
@@ -114,14 +114,58 @@ export async function runPw(argv, deps = {}) {
114
114
  // while-loop in runPwInner). Everything after the first non-option (the lane) is a
115
115
  // Playwright workload argument and is never a BotBuddy credential.
116
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.
117
124
  function leadingPwOptions(args) {
118
125
  const lead = [];
119
- for (let i = 0; i < args.length && PW_LEADING_FLAGS.includes(args[i]); i += 2) {
120
- lead.push(args[i]);
121
- 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); }
122
132
  }
123
133
  return lead;
124
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
+ }
125
169
 
126
170
  async function runPwInner(argv, deps = {}) {
127
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; }
@@ -137,7 +181,14 @@ async function runPwInner(argv, deps = {}) {
137
181
  // session identity alongside --session-id, so a token-armed session need not
138
182
  // pass an id. Holder matching still rides on the server's owner_is_caller and
139
183
  // the resolved agent ids (gate()).
140
- 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; }
141
192
  let plan; try { plan = planInvocation(args, env); } catch (error) { stderr.write(`${error.message}\n`); return 2; }
142
193
  const { spawnExec, socketRun } = deps.daemon ?? await import("./daemon.mjs");
143
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); }
@@ -146,12 +197,11 @@ async function runPwInner(argv, deps = {}) {
146
197
  let managedCredential = null;
147
198
  if (env.BB_PW_NO_LOCK !== "1" && !deps.sessionToken && !readAgentSessionTokenEnv(env) && (!deps.coordinator || deps.resolveCredential)) {
148
199
  try {
149
- 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 });
150
201
  managedCredential = credential;
151
202
  deps = { ...deps, sessionToken: credential.token, sessionId: deps.sessionId ?? credential.sessionId ?? null };
152
203
  } catch (error) {
153
- 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`);
154
- return 3;
204
+ stderr.write(selfHealErrorLine(error)); return 3;
155
205
  }
156
206
  }
157
207
  const telemetry = deps.telemetry ?? (await import("./telemetry.mjs")).makeTelemetry({ env }); const host = deps.host ?? hostFor(env);
@@ -161,9 +211,17 @@ async function runPwInner(argv, deps = {}) {
161
211
  // allowed:false with NO errorCode and must never rotate the session (Codex round-5).
162
212
  if (!auth.allowed && !deps.coordinator && auth.errorCode != null && isRejectedCachedMcpSession({ auth: true, code: auth.errorCode }, managedCredential?.source, 0)) {
163
213
  await clearAgentState(deps.cwd ?? process.cwd());
164
- const credential = await (deps.resolveCredential ?? resolveAgentSessionCredential)({ argv, env, cwd: deps.cwd ?? process.cwd() });
165
- managedCredential = credential;
166
- 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
+ }
167
225
  auth = await gate({ env, host, lane: plan.lane, deps });
168
226
  }
169
227
  if (!auth.allowed) { stderr.write(`${auth.message}\n`); return 3; }