@botbuddy/cli 1.34.4 → 1.35.0
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/commands.mjs +3 -0
- package/src/pw/args.mjs +14 -4
- package/src/pw/coordinator.mjs +2 -2
- package/src/pw/heartbeat.mjs +31 -0
- package/src/pw/run.mjs +79 -8
- package/src/pw/session.mjs +997 -0
- package/src/pw/telemetry.mjs +111 -10
- package/src/telemetry-config.mjs +9 -4
- package/src/telemetry-delivery.mjs +20 -2
- package/src/telemetry-outbox.mjs +32 -6
package/package.json
CHANGED
package/src/commands.mjs
CHANGED
|
@@ -223,6 +223,9 @@ ${bold("AGENT WAITS")}
|
|
|
223
223
|
${bold("BROWSER LANES")}
|
|
224
224
|
pw <lane> <verb> [args…] Drive a lock-gated Playwright lane
|
|
225
225
|
(also the short ${cyan("pw")} binary, or ${cyan("bb-pw")} alias)
|
|
226
|
+
pw <lane> verdict pass|fail Record the session's acceptance verdict
|
|
227
|
+
(sessions report live timing/results; start one
|
|
228
|
+
with --environment <env> or BB_PW_ENV)
|
|
226
229
|
pw --help Show lane usage
|
|
227
230
|
|
|
228
231
|
${bold("OTHER")}
|
package/src/pw/args.mjs
CHANGED
|
@@ -7,10 +7,14 @@ export const GLOBAL_VERBS = new Set(["list", "close-all", "kill-all", "reap"]);
|
|
|
7
7
|
// with an "Unknown option" + help dump; catch it here and name the fix (move it
|
|
8
8
|
// before the lane) instead. `--tenant` after the lane also cannot be safely
|
|
9
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"];
|
|
10
|
+
export const PW_CREDENTIAL_FLAGS = ["--tenant", "--session-id", "--agent-session-token", "--agent-key", "--session-token", "--environment"];
|
|
11
|
+
// BOT-1318: the one lane bound, shared with the server validator
|
|
12
|
+
// (supabase/functions/_shared/executionLifecycle.ts MAX_BROWSER_LANE; a contract
|
|
13
|
+
// test pins them equal). An out-of-range lane is refused before anything runs.
|
|
14
|
+
export const MAX_PW_LANE = 9999;
|
|
11
15
|
const credentialFlagOf = (token) => PW_CREDENTIAL_FLAGS.find((flag) => token === flag || String(token).startsWith(`${flag}=`)) ?? null;
|
|
12
16
|
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> …`);
|
|
17
|
+
return new Error(`bb-pw: ${credentialFlagOf(token)} is a bb pw ${credentialFlagOf(token) === "--environment" ? "wrapper" : "credential"} flag, not a Playwright argument — put it before the lane: bb-pw [--tenant <slug>] [--environment <env>] <lane> <verb> …`);
|
|
14
18
|
}
|
|
15
19
|
// BOT-1682: verbs that once existed under bb pw / an older @playwright/cli and now
|
|
16
20
|
// map to a differently-named upstream command. On these, hint the rename in one
|
|
@@ -29,7 +33,7 @@ export function resolveRef(value, env = process.env) {
|
|
|
29
33
|
return { value: env[match[1]], ref: value, secret: true };
|
|
30
34
|
}
|
|
31
35
|
export const normaliseLane = (token) => String(token).replace(/^agent-0*/, "").replace(/^lane-/, "");
|
|
32
|
-
function validLane(lane) { return /^[1-9]\d*$/.test(lane); }
|
|
36
|
+
function validLane(lane) { return /^[1-9]\d*$/.test(lane) && Number(lane) <= MAX_PW_LANE; }
|
|
33
37
|
export function planInvocation(argv, env = process.env) {
|
|
34
38
|
if (!argv.length) throw new Error("bb-pw: usage: bb-pw <lane> <verb> [args…] (or: bb-pw <reap|list|close-all|kill-all>)");
|
|
35
39
|
if (GLOBAL_VERBS.has(argv[0])) {
|
|
@@ -46,11 +50,17 @@ export function planInvocation(argv, env = process.env) {
|
|
|
46
50
|
return argv[0] === "reap" ? { scope: "global", verb: "reap", mode: "reap" } : { scope: "global", verb: argv[0], mode: "exec", execArgv: argv };
|
|
47
51
|
}
|
|
48
52
|
const lane = normaliseLane(argv[0]), verb = argv[1];
|
|
49
|
-
if (!validLane(lane) || !verb) throw new Error(`bb-pw: usage: lane must be
|
|
53
|
+
if (!validLane(lane) || !verb) throw new Error(`bb-pw: usage: lane must be an integer from 1 to ${MAX_PW_LANE} and include a verb`);
|
|
50
54
|
// BOT-1682: a credential flag in the verb slot (`<lane> --tenant …`) or a
|
|
51
55
|
// retired verb — caught before status/target handling so neither leaks upstream.
|
|
52
56
|
if (credentialFlagOf(verb)) throw misplacedCredentialFlag(verb);
|
|
53
57
|
if (Object.hasOwn(RETIRED_VERBS, verb)) throw new Error(`bb-pw: '${verb}' was retired — ${RETIRED_VERBS[verb]}`);
|
|
58
|
+
// BOT-1318: `verdict pass|fail` is a LOCAL wrapper verb — it records the
|
|
59
|
+
// session's acceptance verdict and is never forwarded to Playwright.
|
|
60
|
+
if (verb === "verdict") {
|
|
61
|
+
if (argv.length !== 3 || !["pass", "fail"].includes(argv[2])) throw new Error("bb-pw: usage: bb-pw <lane> verdict pass|fail");
|
|
62
|
+
return { scope: "lane", lane, session: `lane-${lane}`, verb, mode: "verdict", verdict: argv[2] };
|
|
63
|
+
}
|
|
54
64
|
if (verb === "status") {
|
|
55
65
|
// `status` has no workload, so — like the global verbs — there is no `--`
|
|
56
66
|
// exemption: a credential flag in ANY position (incl. after `--`) is misplaced
|
package/src/pw/coordinator.mjs
CHANGED
|
@@ -18,12 +18,12 @@ function mcpCaller(authHeader, fetchImpl) {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
function laneCoordinator(kind, call, agentId) {
|
|
21
|
-
return { kind, async status({ host, slot }) { const result = await call("list_resources", { host, subtype: "playwright_lane" }); const list = Array.isArray(result) ? result : result.resources ?? []; const resource = list.find((item) => item.name === `playwright_lane:${host}:${slot}` || String(item.slot) === String(slot)); /* BOT-1490: canonical_host is a TOP-LEVEL field the server echoes even on an empty page (not-held lane); owner_is_caller is per-row. Older servers send neither → null. */ const canonicalHost = (result && !Array.isArray(result) ? result.canonical_host : null) ?? null; return resource ? { held: resource.status !== "free", heldBy: resource.owner_agent_id ?? null, heldByName: resource.agents?.name ?? null, host: resource.host ?? null, name: resource.name ?? null, slot: resource.slot ?? null, canonicalHost, ownerIsCaller: resource.owner_is_caller ?? null } : { held: false, heldBy: null, heldByName: null, host: null, name: null, slot: null, canonicalHost, ownerIsCaller: null }; },
|
|
21
|
+
return { kind, async status({ host, slot }) { const result = await call("list_resources", { host, subtype: "playwright_lane" }); const list = Array.isArray(result) ? result : result.resources ?? []; const resource = list.find((item) => item.name === `playwright_lane:${host}:${slot}` || String(item.slot) === String(slot)); /* BOT-1490: canonical_host is a TOP-LEVEL field the server echoes even on an empty page (not-held lane); owner_is_caller is per-row. Older servers send neither → null. */ const canonicalHost = (result && !Array.isArray(result) ? result.canonical_host : null) ?? null; return resource ? { held: resource.status !== "free", heldBy: resource.owner_agent_id ?? null, heldByName: resource.agents?.name ?? null, host: resource.host ?? null, name: resource.name ?? null, slot: resource.slot ?? null, canonicalHost, ownerIsCaller: resource.owner_is_caller ?? null } : { held: false, heldBy: null, heldByName: null, host: null, name: null, slot: null, canonicalHost, ownerIsCaller: null }; }, agentId };
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
// BOT-1572/1582: authenticate lock verification with a per-session `bb_agent_` token —
|
|
25
25
|
// the server resolves it to the session agent (mcp-server authenticateAgent), so
|
|
26
|
-
// `list_resources
|
|
26
|
+
// `list_resources` runs AS the session, and holder matching
|
|
27
27
|
// rides on the server's `owner_is_caller`. No machine profile is required.
|
|
28
28
|
export function createSessionTokenCoordinator({ token, fetchImpl = fetch } = {}) {
|
|
29
29
|
if (!token) return { kind: "unverified" };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// BOT-1318 — the browser-session-owned heartbeat. Spawned detached by bb pw once
|
|
3
|
+
// a tracked lane browser is open; it keeps the execution fresh (≤20 s ticks)
|
|
4
|
+
// against BOT-1317's 90 s reaper while the browser idles, terminalizes
|
|
5
|
+
// `crashed` when it observes the lane's Playwright daemon die, and exits once
|
|
6
|
+
// the session closes. It carries no secret: the credential is re-resolved from
|
|
7
|
+
// the worktree binding (else its cached session, never a fresh mint), exactly as
|
|
8
|
+
// the foreground command resolves it.
|
|
9
|
+
import { pathToFileURL } from "node:url";
|
|
10
|
+
import { resolveSession } from "./daemon.mjs";
|
|
11
|
+
import { resolveBrowserTelemetry } from "./telemetry.mjs";
|
|
12
|
+
import { runHeartbeatLoop } from "./session.mjs";
|
|
13
|
+
|
|
14
|
+
export async function main(argv = process.argv.slice(2), env = process.env) {
|
|
15
|
+
let info;
|
|
16
|
+
try { info = JSON.parse(argv[0] ?? ""); } catch { return 2; }
|
|
17
|
+
if (!info?.source_run_id || !info?.lane || !info?.host || !info?.cwd) return 2;
|
|
18
|
+
const resolved = await resolveBrowserTelemetry({ env, cwd: info.cwd });
|
|
19
|
+
if (!resolved.tracked) return 0;
|
|
20
|
+
const tracker = await resolved.resolver.forHeartbeat(info);
|
|
21
|
+
const daemonAlive = async (state) => {
|
|
22
|
+
const { session } = await resolveSession(`lane-${info.lane}`, env);
|
|
23
|
+
return Boolean(session) && (!state?.daemon_generation || session.socketPath === state.daemon_generation);
|
|
24
|
+
};
|
|
25
|
+
await runHeartbeatLoop({ tracker, sourceRunId: info.source_run_id, daemonAlive });
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
30
|
+
main().then((code) => { process.exitCode = code; }, () => { process.exitCode = 1; });
|
|
31
|
+
}
|
package/src/pw/run.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { actionTypeFromMethod, NAV } from "./readiness.mjs";
|
|
|
4
4
|
import { isStaleRefError, staleRefRemediation } from "./targets.mjs";
|
|
5
5
|
import { createSessionTokenCoordinator } from "./coordinator.mjs";
|
|
6
6
|
import { canonicalizeHostString } from "./host.mjs";
|
|
7
|
+
import { resolveEnvironment } from "./session.mjs";
|
|
7
8
|
import { resolveAgentBinding } from "../wait-profile.mjs";
|
|
8
9
|
import { loadConfig, getConfig } from "../config.mjs";
|
|
9
10
|
import { VERSION } from "../version.mjs";
|
|
@@ -13,7 +14,7 @@ import { clearAgentState, isRejectedCachedMcpSession, resolveAgentSessionCredent
|
|
|
13
14
|
// server-side, so the lane name bb-pw builds/matches/prints is the one the lock
|
|
14
15
|
// kernel actually stored ("jonos-mbp:8", not "Jonos-MBP.localdomain:8").
|
|
15
16
|
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> 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
|
|
17
|
+
function help(out) { out.write("Usage: pw [--tenant <slug>] [--session-id <id>] [--agent-session-token <token>] [--environment <env>] <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\n--environment <local|preview|staging|production> the environment a TRACKED\n browser session is exercising (env $BB_PW_ENV when absent; the\n flag wins). Required when a session starts in a repository\n with .botbuddy/config.json; persisted for the session, and a\n later disagreeing value is refused.\n\nBrowser-acceptance telemetry (BOT-1318): each lane browser session (open →\nclose) is reported once as a browser_acceptance execution with wall/active/\ncommand time, per-action results and a verdict. Every action's result is\nuploaded before the command returns; offline, events queue locally and drain\nlater. Values you type/fill, secrets, query strings and raw output are never\nrecorded. BB_PW_NO_LOCK=1 stays local and untracked.\n bb-pw <lane> verdict pass|fail record the acceptance verdict (local; never\n sent to Playwright). Without it a session\n closes as not_asserted.\n bb-pw <lane> status shows browser_session: source_run_id,\n execution_id, delivery, environment, verdict.\n\nCredential flags (--tenant / --session-id / --agent-session-token) and\n--environment go BEFORE the lane. 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
18
|
// Redact LONGEST secrets first: replacing a short value that is a substring of a
|
|
18
19
|
// longer secret (e.g. a filename that also appears inside the payload's base64)
|
|
19
20
|
// would otherwise break the longer match and leave the remainder recoverable
|
|
@@ -101,6 +102,19 @@ async function gate({ env, host, lane, deps }) {
|
|
|
101
102
|
// pre-BOT-1488 refusal and was indistinguishable from a code regression. Every
|
|
102
103
|
// non-zero exit now names the bundled version, and --version exists at all, so
|
|
103
104
|
// an agent can tell "old binary" from "real bug" without reading the source.
|
|
105
|
+
// BOT-1318: a queued (undelivered) lifecycle event is degradation, never a
|
|
106
|
+
// change to the browser command's exit code or output.
|
|
107
|
+
function reportDegradation(stderr, delivery) {
|
|
108
|
+
if (delivery?.server_ended) {
|
|
109
|
+
const next = delivery.rehomed ? `this command's result${delivery.rehomed > 1 ? ` and ${delivery.rehomed - 1} earlier undelivered action(s) were` : " was"} recorded in a new tracked session` : "the next command starts a new tracked session";
|
|
110
|
+
stderr.write(`bb-pw: BotBuddy already ended this session as ${delivery.server_ended} (no heartbeat reached it within its grace window, e.g. while this machine slept); ${next}.\n`);
|
|
111
|
+
}
|
|
112
|
+
if (delivery?.heartbeat_failed) stderr.write(`bb-pw: telemetry degraded — the session heartbeat could not start; if this browser idles longer than BotBuddy's grace window it is recorded as abandoned. The next bb pw command on this lane retries it.\n`);
|
|
113
|
+
if (delivery?.persist_failed) stderr.write(`bb-pw: telemetry degraded — the browser command ran, but its result could not be durably recorded (${delivery.persist_failed}); this session's report is missing that action. Check disk space and permissions under the bb pw telemetry directory.\n`);
|
|
114
|
+
if (delivery?.queued) stderr.write(`bb-pw: telemetry degraded — ${delivery.queued} lifecycle event(s) queued locally; they are delivered on the next action, heartbeat or close.\n`);
|
|
115
|
+
// A permanent (contract) rejection is quarantined, never retried — say so.
|
|
116
|
+
if (delivery?.quarantined) stderr.write(`bb-pw: telemetry degraded — ${delivery.quarantined} lifecycle event(s) were rejected by BotBuddy and quarantined locally (not retried); this session's report is incomplete. Upgrade @botbuddy/cli, or inspect the outbox quarantine file.\n`);
|
|
117
|
+
}
|
|
104
118
|
const versionLine = () => `bb-pw (@botbuddy/cli v${VERSION})`;
|
|
105
119
|
export async function runPw(argv, deps = {}) {
|
|
106
120
|
const stderr = deps.stderr ?? process.stderr;
|
|
@@ -113,7 +127,7 @@ export async function runPw(argv, deps = {}) {
|
|
|
113
127
|
// The leading option/value pairs bb pw consumes before the lane/verb (mirrors the
|
|
114
128
|
// while-loop in runPwInner). Everything after the first non-option (the lane) is a
|
|
115
129
|
// Playwright workload argument and is never a BotBuddy credential.
|
|
116
|
-
const PW_LEADING_FLAGS = ["--tenant", "--session-id", "--agent-session-token", "--agent-key", "--session-token"];
|
|
130
|
+
const PW_LEADING_FLAGS = ["--tenant", "--session-id", "--agent-session-token", "--agent-key", "--session-token", "--environment"];
|
|
117
131
|
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
132
|
// The leading credential options, NORMALIZED to split `flag value` pairs so an
|
|
119
133
|
// inline `--agent-key=<token>` is recognized by legacyAgentAliasHint (which
|
|
@@ -144,6 +158,8 @@ function matchWrapperFlag(token) {
|
|
|
144
158
|
}
|
|
145
159
|
const applyWrapperFlag = (deps, flag, value) => flag === "--tenant"
|
|
146
160
|
? { ...deps, tenant: value }
|
|
161
|
+
: flag === "--environment"
|
|
162
|
+
? { ...deps, environment: value }
|
|
147
163
|
: ["--agent-session-token", "--agent-key", "--session-token"].includes(flag)
|
|
148
164
|
? { ...deps, sessionToken: value }
|
|
149
165
|
: { ...deps, sessionId: value };
|
|
@@ -204,7 +220,7 @@ async function runPwInner(argv, deps = {}) {
|
|
|
204
220
|
stderr.write(selfHealErrorLine(error)); return 3;
|
|
205
221
|
}
|
|
206
222
|
}
|
|
207
|
-
const
|
|
223
|
+
const host = deps.host ?? hostFor(env);
|
|
208
224
|
let auth = await gate({ env, host, lane: plan.lane, deps });
|
|
209
225
|
// Only an ACTUAL auth rejection carries an errorCode (the coordinator surfaces
|
|
210
226
|
// the MCP auth code). A plain lock denial (lane held by another agent) returns
|
|
@@ -228,19 +244,74 @@ async function runPwInner(argv, deps = {}) {
|
|
|
228
244
|
// Report the host the lock service actually stored (alias-collapsed), so the
|
|
229
245
|
// status view and lane telemetry match the server, not the local string (AC-4).
|
|
230
246
|
const effHost = auth.canonicalHost ?? host;
|
|
231
|
-
|
|
232
|
-
|
|
247
|
+
// BOT-1318: resolve the lane's browser-acceptance tracker AFTER the gate (a
|
|
248
|
+
// refused/unverified lane writes no lifecycle) and never for unlocked local mode.
|
|
249
|
+
let tracker = null;
|
|
250
|
+
if (env.BB_PW_NO_LOCK !== "1") {
|
|
251
|
+
const telemetry = deps.telemetry ?? (await import("./telemetry.mjs")).makeTelemetry({ env, cwd: deps.cwd ?? process.cwd(), sessionCredential: deps.sessionToken ?? readAgentSessionTokenEnv(env) ?? null });
|
|
252
|
+
const resolved = await telemetry.resolve({ host: effHost, lane: plan.lane });
|
|
253
|
+
if (resolved?.error) { stderr.write(`${resolved.error}\n`); return 2; }
|
|
254
|
+
tracker = resolved?.tracked ? resolved.tracker : null;
|
|
255
|
+
}
|
|
256
|
+
// BOT-1318: a tracked session's environment comes from --environment, then
|
|
257
|
+
// BB_PW_ENV; an invalid value is refused before any tracked action or write —
|
|
258
|
+
// and before status too, so wrapper configuration is never silently accepted.
|
|
259
|
+
const envResolution = resolveEnvironment({ flag: deps.environment ?? null, env });
|
|
260
|
+
if (tracker && envResolution.error) { stderr.write(`${envResolution.error}\n`); return 2; }
|
|
261
|
+
const environment = envResolution.environment ?? null;
|
|
262
|
+
if (plan.mode === "status") {
|
|
263
|
+
const lock = auth.coordinator?.status ? await auth.coordinator.status({ host: effHost, slot: plan.lane }) : null;
|
|
264
|
+
const browserSession = tracker ? await tracker.status() : { tracked: false, active: false };
|
|
265
|
+
if (tracker && environment && browserSession.active && browserSession.environment && browserSession.environment !== environment) {
|
|
266
|
+
stderr.write(`bb-pw: lane ${plan.lane}'s open browser session is tracked as environment "${browserSession.environment}", not "${environment}".\n`);
|
|
267
|
+
return 2;
|
|
268
|
+
}
|
|
269
|
+
stdout.write(JSON.stringify({ lane: plan.lane, session: plan.session, host: effHost, lock, browser_session: browserSession }, null, 2) + "\n");
|
|
270
|
+
return 0;
|
|
271
|
+
}
|
|
272
|
+
if (plan.mode === "verdict") {
|
|
273
|
+
if (!tracker) { stderr.write("bb-pw: verdict needs a tracked browser session (a repository with .botbuddy/config.json and a lock-gated lane).\n"); return 2; }
|
|
274
|
+
const result = await tracker.setVerdict(plan.verdict, { environment });
|
|
275
|
+
if (result.error) { stderr.write(`${result.error}\n`); return 2; }
|
|
276
|
+
reportDegradation(stderr, result.delivery); return 0;
|
|
277
|
+
}
|
|
233
278
|
// Translate verbs ALWAYS run via the socket (even a small, non-secret one), so
|
|
234
279
|
// run.mjs — not an inherited-stdio spawnExec — owns their output and can strip
|
|
235
280
|
// the internal code echo; socketRun falls back to execArgv.slice(1) as its args
|
|
236
281
|
// (Codex R26 P2).
|
|
237
|
-
const inspect = plan.mode === "socket" || plan.translate || (actionTypeFromMethod(plan.verb) === "interaction" && (plan.targetKind === "ref" || plan.fresh));
|
|
282
|
+
const inspect = plan.mode === "socket" || plan.translate || (actionTypeFromMethod(plan.verb) === "interaction" && (plan.targetKind === "ref" || plan.fresh));
|
|
238
283
|
// bb-pw's translate verbs (upload/route/...) generate internal run-code; that
|
|
239
284
|
// echoed "### Ran Playwright code" block is never useful to the caller and can
|
|
240
285
|
// be large (a 100KB+ base64 payload) or carry a secret, so strip it for every
|
|
241
286
|
// translated command — not only secret ones (Codex R25 P2) — while still
|
|
242
287
|
// redacting any tracked secret from the rest.
|
|
243
288
|
const suppressEcho = !!plan.translate || !!plan.secretValues?.length;
|
|
244
|
-
|
|
245
|
-
|
|
289
|
+
// `errorText` is inspected only to classify a failure; it is never persisted.
|
|
290
|
+
const execute = async () => {
|
|
291
|
+
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`); } return { code: result.ok ? 0 : 1, errorText: result.ok ? null : String(result.error ?? "") }; }
|
|
292
|
+
const code = await spawnExec(plan, env); return { code, errorText: code === 0 ? null : `exit ${code}` };
|
|
293
|
+
};
|
|
294
|
+
if (!tracker) return (await execute()).code;
|
|
295
|
+
const probe = async () => { const resolver = deps.daemon?.resolveSession ?? (await import("./daemon.mjs")).resolveSession; const { session } = await resolver(plan.session, env); return { alive: Boolean(session), generation: session?.socketPath ?? null }; };
|
|
296
|
+
if (plan.verb === "close") {
|
|
297
|
+
const closed = await tracker.close({ environment, probe, run: execute });
|
|
298
|
+
if (closed.error) { stderr.write(`${closed.error}\n`); return 2; }
|
|
299
|
+
reportDegradation(stderr, closed.delivery); return closed.code;
|
|
300
|
+
}
|
|
301
|
+
const handle = await tracker.begin({ verb: plan.verb, category: actionTypeFromMethod(plan.verb), route: plan.telemetryUrl ?? null, environment, probe });
|
|
302
|
+
if (handle.error) { stderr.write(`${handle.error}\n`); return handle.exitCode ?? 2; }
|
|
303
|
+
if (!handle.tracked) return (await execute()).code;
|
|
304
|
+
// A signal during a tracked action terminalizes the session `canceled` (once)
|
|
305
|
+
// before the process exits with the conventional signal code.
|
|
306
|
+
const signals = deps.signalEmitter ?? process;
|
|
307
|
+
const exit = deps.exit ?? ((code) => process.exit(code));
|
|
308
|
+
const onSignal = (signal) => { handle.cancel(signal).finally(() => exit(signal === "SIGINT" ? 130 : 143)); };
|
|
309
|
+
signals.once("SIGINT", onSignal); signals.once("SIGTERM", onSignal);
|
|
310
|
+
let result;
|
|
311
|
+
try { result = await execute(); }
|
|
312
|
+
finally { signals.removeListener("SIGINT", onSignal); signals.removeListener("SIGTERM", onSignal); }
|
|
313
|
+
const evidenceKind = plan.verb === "screenshot" ? "screenshot" : plan.verb === "tracing-stop" ? "trace" : null;
|
|
314
|
+
const delivery = await handle.finish({ ok: result.code === 0, errorText: result.errorText, exitCode: result.code, evidenceKind });
|
|
315
|
+
reportDegradation(stderr, delivery);
|
|
316
|
+
return result.code;
|
|
246
317
|
}
|