@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 +1 -1
- package/src/agent-session.mjs +0 -0
- package/src/pw/args.mjs +62 -2
- package/src/pw/run.mjs +70 -12
package/package.json
CHANGED
package/src/agent-session.mjs
CHANGED
|
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]))
|
|
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
|
-
|
|
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>
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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; }
|