@botbuddy/cli 1.60.5 → 1.60.7

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.
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.60.5",
3
- "source_commit": "a49feea0a58695a606269a3e4ccd1f94afba7114",
4
- "source_sha256": "173f7953ea95f0facc3230dc993d4b64aab54037d8a89dfdafe044ad66063bb7",
2
+ "release_version": "1.60.7",
3
+ "source_commit": "6299d1f0f325871a33f5aa28bff8dc8029e7350c",
4
+ "source_sha256": "ac9f92aeaec3cf0cb18de06f1e16b0ee2c44c413ec7c84866f949909611098f1",
5
5
  "target": "darwin-arm64",
6
- "sha256": "a6b56a02eca424bcc5dc408b90f1d831456a241d04dcc59adc2210b071698e49"
6
+ "sha256": "1e24ee9c3fa9ac6447ff9f0c8096d80c4e2a98348d0e3b0277d406dfe8109e17"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.60.5",
3
- "source_commit": "a49feea0a58695a606269a3e4ccd1f94afba7114",
4
- "source_sha256": "173f7953ea95f0facc3230dc993d4b64aab54037d8a89dfdafe044ad66063bb7",
2
+ "release_version": "1.60.7",
3
+ "source_commit": "6299d1f0f325871a33f5aa28bff8dc8029e7350c",
4
+ "source_sha256": "ac9f92aeaec3cf0cb18de06f1e16b0ee2c44c413ec7c84866f949909611098f1",
5
5
  "target": "linux-x64",
6
- "sha256": "58a715446ff176ec2b65d583e4d6a6bf3ce81ab48dc0c030da52c61a019523b4"
6
+ "sha256": "68dfb56ddf1b4647dcd00644d88489fe2022b4136d72443e33f83a86ecb270bb"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.60.5",
4
- "source_commit": "a49feea0a58695a606269a3e4ccd1f94afba7114",
5
- "source_sha256": "173f7953ea95f0facc3230dc993d4b64aab54037d8a89dfdafe044ad66063bb7",
3
+ "release_version": "1.60.7",
4
+ "source_commit": "6299d1f0f325871a33f5aa28bff8dc8029e7350c",
5
+ "source_sha256": "ac9f92aeaec3cf0cb18de06f1e16b0ee2c44c413ec7c84866f949909611098f1",
6
6
  "targets": {
7
7
  "darwin-arm64": {
8
8
  "goos": "darwin",
9
9
  "goarch": "arm64",
10
10
  "binary": "daemon/bin/darwin-arm64/botbuddy-daemon",
11
- "sha256": "a6b56a02eca424bcc5dc408b90f1d831456a241d04dcc59adc2210b071698e49",
11
+ "sha256": "1e24ee9c3fa9ac6447ff9f0c8096d80c4e2a98348d0e3b0277d406dfe8109e17",
12
12
  "mode": "0755"
13
13
  },
14
14
  "linux-x64": {
15
15
  "goos": "linux",
16
16
  "goarch": "amd64",
17
17
  "binary": "daemon/bin/linux-x64/botbuddy-daemon",
18
- "sha256": "58a715446ff176ec2b65d583e4d6a6bf3ce81ab48dc0c030da52c61a019523b4",
18
+ "sha256": "68dfb56ddf1b4647dcd00644d88489fe2022b4136d72443e33f83a86ecb270bb",
19
19
  "mode": "0755"
20
20
  }
21
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.60.5",
3
+ "version": "1.60.7",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,9 @@
19
19
  "daemon/",
20
20
  "daemon-release/",
21
21
  "src/",
22
- "!src/**/*.test.mjs"
22
+ "!src/**/*.test.mjs",
23
+ "!src/staging-pool/test-helpers/",
24
+ "!src/staging-pool/fixtures/"
23
25
  ],
24
26
  "keywords": [
25
27
  "mcp",
package/src/commands.mjs CHANGED
@@ -25,6 +25,7 @@ import { VERSION } from "./version.mjs";
25
25
  import { setupMcpKey, revokeMcpKey, resolveMcpConfigKey, resolveEnvVarName, DEFAULT_MCP_ENV_VAR, McpKeyError, McpKeyStoreError } from "./mcp-key.mjs";
26
26
  import { readAgentBinding } from "./wait-profile.mjs";
27
27
  import { runPw } from "./pw/run.mjs";
28
+ import { symptomHelp as stagingPoolSymptomHelp } from "./staging-pool/errors.mjs";
28
29
  import { maybeWarnStale, cmdUpdate } from "./update-check.mjs";
29
30
  import { createTelemetryOutbox } from "./telemetry-outbox.mjs";
30
31
  import { attestTelemetryCredential, loadTelemetryIdentity, loadTelemetryLocation } from "./telemetry-config.mjs";
@@ -123,6 +124,8 @@ export async function run(argv, {
123
124
  case "test-ingest": return cmdTestIngest(args);
124
125
  case "wait": return runWait(args);
125
126
  case "pw": return runPw(args);
127
+ // BOT-2084: the human-installed staging session pool broker.
128
+ case "staging-pool": return (await import("./staging-pool/cli.mjs")).runStagingPool(args);
126
129
  case "mcp": return cmdMcp(args);
127
130
  case "resources": return callTool("list_resources");
128
131
  case "agents": return callTool("list_agents");
@@ -269,6 +272,16 @@ ${bold("BROWSER LANES")}
269
272
  with --environment <env> or BB_PW_ENV)
270
273
  pw --help Show lane usage
271
274
 
275
+ ${bold("STAGING SIGN-IN")} (BOT-2084)
276
+ pw --environment staging <lane> auth --persona <alias>
277
+ Agents: sign a claimed lane in to staging as a seeded
278
+ persona, no password (${cyan("bb pw auth --help")})
279
+ staging-pool status [--check]
280
+ Pool health: one reason, who acts, the next command
281
+ staging-pool install|tick|mint-one|uninstall
282
+ Human only, from Terminal.app (${cyan("bb staging-pool --help")})
283
+ help symptom staging-pool Every refusal code with its cause and next command
284
+
272
285
  ${bold("OTHER")}
273
286
  locks -m [--host name] Reserve typed local resources, including Playwright MCP lanes
274
287
  evidence publish <file> Publish a local evidence statement → botbuddy/ready check-run
@@ -421,6 +434,13 @@ async function cmdToolHelp(args) {
421
434
  : `Discovery failed: ${res.status} ${res.statusText}`);
422
435
  }
423
436
  console.log(formatDiscovery(await res.json(), { tool }));
437
+ if (!tool) console.log(toolCatalogFooter());
438
+ }
439
+
440
+ // BOT-2084: local CLI commands are not MCP tools, so the catalog names where
441
+ // their help lives (kept to a few lines: the discovery help has a size budget).
442
+ export function toolCatalogFooter() {
443
+ return `\nLocal CLI commands (not MCP tools): ${cyan("bb pw auth --help")} staging sign-in for a lane · ${cyan("bb staging-pool --help")} the human-installed session pool · ${cyan("bb help symptom staging-pool")} their error codes.`;
424
444
  }
425
445
 
426
446
  // Keep the most common lifecycle recoveries local: an agent must be able to
@@ -451,6 +471,8 @@ ${bold("WAITING")}
451
471
 
452
472
  ${cyan("telemetry-unavailable")} A test launch has no test-run ID or wait command.
453
473
  ${cyan("command-run-stuck")} A local command receipt and remote lifecycle disagree.
474
+ ${cyan("staging-pool")} Every bb staging-pool / bb pw auth error code (BOT-2084);
475
+ ${cyan("bb help symptom <code>")} for one, e.g. pool_empty.
454
476
 
455
477
  Choose the symptom printed by the command. If neither applies, preserve the
456
478
  original command result and receipt, then report the exact error and command ID.`;
@@ -506,6 +528,12 @@ ${bold("Boundary")}
506
528
  Command-run status is agent-owned. A different agent may be denied access; that
507
529
  denial is not evidence that the run is missing or terminal.`;
508
530
 
531
+
532
+ // BOT-2084: the staging session pool's error codes (cli/src/staging-pool/errors.mjs).
533
+ if (topic.startsWith("symptom ")) {
534
+ const local = stagingPoolSymptomHelp(topic.slice("symptom ".length));
535
+ if (local) return local;
536
+ }
509
537
  return null;
510
538
  }
511
539
 
package/src/pw/args.mjs CHANGED
@@ -61,6 +61,23 @@ export function planInvocation(argv, env = process.env) {
61
61
  if (argv.length !== 3 || !["pass", "fail"].includes(argv[2])) throw new Error("bb-pw: usage: bb-pw <lane> verdict pass|fail");
62
62
  return { scope: "lane", lane, session: `lane-${lane}`, verb, mode: "verdict", verdict: argv[2] };
63
63
  }
64
+ // BOT-2084: `auth --persona <slug>` is a LOCAL wrapper verb (never forwarded to
65
+ // Playwright). Wrapper flags stay before the lane; only --persona follows it.
66
+ if (verb === "auth") {
67
+ const authArgs = argv.slice(2);
68
+ const strayAuth = authArgs.find((value) => credentialFlagOf(value));
69
+ if (strayAuth) throw misplacedCredentialFlag(strayAuth);
70
+ const usage = "bb-pw: usage: bb pw --environment staging <lane> auth --persona <slug>";
71
+ let persona = null;
72
+ for (let i = 0; i < authArgs.length; i++) {
73
+ const token = String(authArgs[i]);
74
+ if (token === "--persona" && persona === null && authArgs[i + 1] !== undefined && !String(authArgs[i + 1]).startsWith("--")) { persona = String(authArgs[++i]); continue; }
75
+ if (token.startsWith("--persona=") && persona === null && token.length > "--persona=".length) { persona = token.slice("--persona=".length); continue; }
76
+ throw new Error(`${usage} (unexpected '${token}')`);
77
+ }
78
+ if (!persona) throw new Error(`${usage} (--persona is required)`);
79
+ return { scope: "lane", lane, session: `lane-${lane}`, verb, mode: "auth", persona };
80
+ }
64
81
  if (verb === "status") {
65
82
  // `status` has no workload, so — like the global verbs — there is no `--`
66
83
  // exemption: a credential flag in ANY position (incl. after `--`) is misplaced
@@ -18,7 +18,11 @@ 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 }; }, agentId };
21
+ return { kind,
22
+ // BOT-2084: the agent this credential authenticates as (whoami), for checks that
23
+ // need the exact holder when no local agent id is known (an exported session token).
24
+ async whoami() { const identity = await call("whoami", {}); return typeof identity?.agent?.id === "string" ? identity.agent.id : null; },
25
+ 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
26
  }
23
27
 
24
28
  // BOT-1572/1582: authenticate lock verification with a per-session `bb_agent_` token —
package/src/pw/run.mjs CHANGED
@@ -10,11 +10,12 @@ import { loadConfig, getConfig } from "../config.mjs";
10
10
  import { VERSION } from "../version.mjs";
11
11
  import { readAgentSessionTokenEnv, AGENT_KEY_RE, legacyAgentAliasHint } from "../agent-key.mjs";
12
12
  import { clearAgentState, isRejectedCachedMcpSession, resolveAgentSessionCredential } from "../agent-session.mjs";
13
+ import * as stagingAuth from "./staging-auth.mjs";
13
14
  // BOT-1488: canonicalize the raw hostname the SAME way acquire_resources does
14
15
  // server-side, so the lane name bb-pw builds/matches/prints is the one the lock
15
16
  // kernel actually stored ("jonos-mbp:8", not "Jonos-MBP.localdomain:8").
16
17
  const hostFor = (env) => canonicalizeHostString(env.PLAYWRIGHT_MCP_HOST || env.HOSTNAME || os.hostname());
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"); }
18
+ 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\nStaging sign-in (BOT-2084) — agents sign a lane in to staging without a password:\n bb pw --environment staging <lane> auth --persona <alias>\n Installs a session from the human-installed staging session pool into this lane only.\n Prerequisites, example, output and every refusal: bb pw auth --help\n bb setup / bb doctor restore YOUR BotBuddy agent session; they never sign in to the\n application under test.\n\nSaved browser state (state-save / state-load) is forwarded to Playwright. State files are\nSECRET-BEARING and are not a staging sign-in path: use auth above.\n"); }
18
19
  // Redact LONGEST secrets first: replacing a short value that is a substring of a
19
20
  // longer secret (e.g. a filename that also appears inside the payload's base64)
20
21
  // would otherwise break the longer match and leave the remainder recoverable
@@ -67,7 +68,7 @@ async function gate({ env, host, lane, deps }) {
67
68
  coordinator = createSessionTokenCoordinator({ token, fetchImpl: deps.fetch });
68
69
  }
69
70
  if (coordinator.kind === "unverified") return { allowed: false, message: "bb-pw: BotBuddy credential is unusable. Re-register the agent, or set BB_PW_NO_LOCK=1 for local-only work." };
70
- const selfAgentIds = new Set([sessionAgentId, registeredAgentId, coordinator.agentId].filter(Boolean));
71
+ const selfAgentIds = new Set([sessionAgentId, registeredAgentId, coordinator.agentId, deps.credentialAgentId].filter(Boolean));
71
72
  const callerId = coordinator.agentId ?? sessionAgentId ?? registeredAgentId ?? "botbuddy";
72
73
  const laneName = `playwright_lane:${host}:${lane}`;
73
74
  try {
@@ -85,15 +86,20 @@ async function gate({ env, host, lane, deps }) {
85
86
  // the holder to the caller's owner (BOT-1490 owner_is_caller — server-authoritative,
86
87
  // closing the shared-hostname gap without trusting a local file). The local paths
87
88
  // stay as fallbacks for older servers that do not send owner_is_caller.
88
- if (status.held && (selfAgentIds.has(status.heldBy) || status.ownerIsCaller === true)) return { allowed: true, coordinator, canonicalHost };
89
+ // BOT-2084: report HOW the holder matched. `auth` (staging session issuance)
90
+ // requires the exact agent ("self"), never only a same-owner match ("owner").
91
+ // callerIds/holder let staging-auth cleanup touch only what this caller installed.
92
+ const callerIds = [...selfAgentIds];
93
+ if (status.held && selfAgentIds.has(status.heldBy)) return { allowed: true, coordinator, canonicalHost, holderMatch: "self", holder: status.heldBy, callerIds };
94
+ if (status.held && status.ownerIsCaller === true) return { allowed: true, coordinator, canonicalHost, holderMatch: "owner", holder: status.heldBy ?? null, callerIds };
89
95
  // Distinguish an expired/free lane from one another operator holds so the
90
96
  // operator knows whether to re-acquire vs. wait for a handover (BOT-660). Name it
91
97
  // by the server's canonical host so a not-held message matches the dashboard even
92
98
  // when local normalization cannot reach the alias-collapsed key (BOT-1490 AC-5).
93
- if (!status.held) return { allowed: false, message: `bb-pw: lane lock ${foundLaneName} is not held (caller ${callerId}). Acquire it via BotBuddy first, or set BB_PW_NO_LOCK=1 for local-only work.` };
99
+ if (!status.held) return { allowed: false, reason: "not_held", canonicalHost, callerIds, coordinator, message: `bb-pw: lane lock ${foundLaneName} is not held (caller ${callerId}). Acquire it via BotBuddy first, or set BB_PW_NO_LOCK=1 for local-only work.` };
94
100
  const holder = status.heldBy ?? "unknown";
95
101
  const holderName = status.heldByName ? ` (${status.heldByName})` : "";
96
- return { allowed: false, message: `bb-pw: lane lock ${foundLaneName} is held by ${holder}${holderName}, not you (caller ${callerId}). Acquire it first or set BB_PW_NO_LOCK=1 for local-only work.` };
102
+ return { allowed: false, reason: "held_by_other", canonicalHost, callerIds, coordinator, message: `bb-pw: lane lock ${foundLaneName} is held by ${holder}${holderName}, not you (caller ${callerId}). Acquire it first or set BB_PW_NO_LOCK=1 for local-only work.` };
97
103
  } catch (error) {
98
104
  return { allowed: false, errorCode: error?.code ?? null, message: `bb-pw: could not verify lane lock ${laneName} (${error.message}). Set BB_PW_NO_LOCK=1 for local-only work.` };
99
105
  }
@@ -183,8 +189,23 @@ function consumeWrapperFlags(list, deps, start = 0) {
183
189
  return { deps, rest };
184
190
  }
185
191
 
192
+ // The non-secret values a staging-auth refusal names in its next command.
193
+ const authVars = (plan, deps = {}) => {
194
+ const cwd = deps.cwd ?? process.cwd();
195
+ return { lane: plan.lane, persona: plan.persona, cwd, ticketFlag: stagingAuth.ticketFlagFor(cwd) };
196
+ };
197
+
198
+ // `bb pw auth --help` / `bb pw [flags] <lane> auth --help`: auth's own help, before
199
+ // any credential, lock or daemon call.
200
+ function wantsAuthHelp(args) {
201
+ const at = args.indexOf("auth");
202
+ if (at === -1 || !(at === 0 || /^[0-9]+$/.test(args[at - 1]))) return false;
203
+ return args.slice(at + 1).some((token) => token === "--help" || token === "-h");
204
+ }
205
+
186
206
  async function runPwInner(argv, deps = {}) {
187
207
  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; }
208
+ if (wantsAuthHelp(args)) { stdout.write(`${(await import("../staging-pool/help.mjs")).PW_AUTH_HELP}\n`); return 0; }
188
209
  if (["--version", "-v"].includes(args[0])) { stdout.write(`${versionLine()}\n`); return 0; }
189
210
  // BOT-1701 (AC-6): one stderr nudge for a tier-3 legacy flag/env alias. bb pw
190
211
  // only recognizes credential flags as LEADING options (the while-loop below);
@@ -206,6 +227,14 @@ async function runPwInner(argv, deps = {}) {
206
227
  // `--tenant` lane fallback is BOT-1732.
207
228
  { 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; }
208
229
  let plan; try { plan = planInvocation(args, env); } catch (error) { stderr.write(`${error.message}\n`); return 2; }
230
+ // BOT-2084 (#1068 run.mjs:110): auth only ever issues a STAGING session into a
231
+ // lock-gated lane, so refuse any other resolved environment — and the unlocked
232
+ // local mode — before any credential, lock or issuer call.
233
+ if (plan.mode === "auth") {
234
+ const authEnvironment = resolveEnvironment({ flag: deps.environment ?? null, env });
235
+ if (authEnvironment.error || authEnvironment.environment !== "staging") return stagingAuth.refuse({ stdout, stderr }, "auth_requires_staging_environment", 2, {}, authVars(plan, deps));
236
+ if (env.BB_PW_NO_LOCK === "1") return stagingAuth.refuse({ stdout, stderr }, "auth_requires_lane_lock", 3, {}, authVars(plan, deps));
237
+ }
209
238
  const { spawnExec, socketRun } = deps.daemon ?? await import("./daemon.mjs");
210
239
  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); }
211
240
  // Local-only lanes explicitly opt out of lock ownership, so they also need no
@@ -215,7 +244,7 @@ async function runPwInner(argv, deps = {}) {
215
244
  try {
216
245
  const credential = await (deps.resolveCredential ?? resolveAgentSessionCredential)({ argv, env, cwd: deps.cwd ?? process.cwd(), sessionId: deps.sessionId ?? env.BOTBUDDY_SESSION_ID ?? null, flagToken: null });
217
246
  managedCredential = credential;
218
- deps = { ...deps, sessionToken: credential.token, sessionId: deps.sessionId ?? credential.sessionId ?? null };
247
+ deps = { ...deps, sessionToken: credential.token, sessionId: deps.sessionId ?? credential.sessionId ?? null, credentialAgentId: credential.agentId ?? null };
219
248
  } catch (error) {
220
249
  stderr.write(selfHealErrorLine(error)); return 3;
221
250
  }
@@ -232,7 +261,7 @@ async function runPwInner(argv, deps = {}) {
232
261
  managedCredential = credential;
233
262
  // Keep an explicit --session-id holder authoritative over the freshly minted
234
263
  // one, same precedence as the initial resolution (Codex).
235
- deps = { ...deps, sessionToken: credential.token, sessionId: deps.sessionId ?? credential.sessionId ?? null };
264
+ deps = { ...deps, sessionToken: credential.token, sessionId: deps.sessionId ?? credential.sessionId ?? null, credentialAgentId: credential.agentId ?? null };
236
265
  } catch (error) {
237
266
  // BOT-1682: the renewal after a revoked cache can also fail self-heal; fail
238
267
  // closed with a clear message rather than letting the error escape to exit 1.
@@ -240,7 +269,21 @@ async function runPwInner(argv, deps = {}) {
240
269
  }
241
270
  auth = await gate({ env, host, lane: plan.lane, deps });
242
271
  }
243
- if (!auth.allowed) { stderr.write(`${auth.message}\n`); return 3; }
272
+ // BOT-2084 staging-auth hooks share the lane's daemon and the gate-verified
273
+ // credential; the trust bridge decides how that credential reaches Staging.
274
+ const daemonModule = deps.daemon?.resolveSession ? null : await import("./daemon.mjs");
275
+ const authCtx = { plan, host: auth.canonicalHost ?? host, env, deps, stdout, stderr, socketRun, resolveSession: deps.daemon?.resolveSession ?? daemonModule.resolveSession,
276
+ holder: auth.holder ?? null, callerIds: auth.callerIds ?? [],
277
+ // An exported session token carries no local agent id: ask the server who it is.
278
+ resolveCallerId: typeof auth.coordinator?.whoami === "function" ? () => auth.coordinator.whoami() : null,
279
+ credential: { token: deps.sessionToken ?? readAgentSessionTokenEnv(env) ?? null, agentId: deps.credentialAgentId ?? null, sessionId: deps.sessionId ?? null } };
280
+ if (!auth.allowed) {
281
+ // A lane that is provably no longer ours (not a transient verify error):
282
+ // clear the session we installed and revoke its custody (AC-3).
283
+ if (auth.reason === "not_held" || auth.reason === "held_by_other") await stagingAuth.onLaneAuthorityLost(authCtx).catch(() => {});
284
+ if (plan.mode === "auth" && (auth.reason === "not_held" || auth.reason === "held_by_other")) return stagingAuth.refuse({ stdout, stderr }, "lane_not_claimed", 3, { detail: auth.message }, authVars(plan, deps));
285
+ stderr.write(`${auth.message}\n`); return 3;
286
+ }
244
287
  // Report the host the lock service actually stored (alias-collapsed), so the
245
288
  // status view and lane telemetry match the server, not the local string (AC-4).
246
289
  const effHost = auth.canonicalHost ?? host;
@@ -259,6 +302,19 @@ async function runPwInner(argv, deps = {}) {
259
302
  const envResolution = resolveEnvironment({ flag: deps.environment ?? null, env });
260
303
  if (tracker && envResolution.error) { stderr.write(`${envResolution.error}\n`); return 2; }
261
304
  const environment = envResolution.environment ?? null;
305
+ if (plan.mode === "auth") {
306
+ let holderMatch = auth.holderMatch;
307
+ if (holderMatch === "owner" && auth.holder && authCtx.resolveCallerId) {
308
+ const me = await authCtx.resolveCallerId().catch(() => null);
309
+ if (me && me === auth.holder) { holderMatch = "self"; authCtx.callerIds = [...authCtx.callerIds, me]; }
310
+ }
311
+ if (holderMatch !== "self") return stagingAuth.refuse({ stdout, stderr }, "exact_lane_holder_required", 3, {}, authVars(plan, deps));
312
+ if (tracker) {
313
+ const browserSession = await tracker.status();
314
+ if (browserSession.active && browserSession.environment && browserSession.environment !== "staging") return stagingAuth.refuse({ stdout, stderr }, "browser_session_not_staging", 2, {}, authVars(plan, deps));
315
+ }
316
+ return stagingAuth.runStagingAuth(authCtx);
317
+ }
262
318
  if (plan.mode === "status") {
263
319
  const lock = auth.coordinator?.status ? await auth.coordinator.status({ host: effHost, slot: plan.lane }) : null;
264
320
  const browserSession = tracker ? await tracker.status() : { tracked: false, active: false };
@@ -275,6 +331,9 @@ async function runPwInner(argv, deps = {}) {
275
331
  if (result.error) { stderr.write(`${result.error}\n`); return 2; }
276
332
  reportDegradation(stderr, result.delivery); return 0;
277
333
  }
334
+ // BOT-2084: crash cleanup and renewal-before-sweep for a lane carrying a
335
+ // staging custody; a failed renewal stops the command (AC-3).
336
+ { const stop = await stagingAuth.beforeLaneCommand(authCtx); if (stop !== null) return stop; }
278
337
  // Translate verbs ALWAYS run via the socket (even a small, non-secret one), so
279
338
  // run.mjs — not an inherited-stdio spawnExec — owns their output and can strip
280
339
  // the internal code echo; socketRun falls back to execArgv.slice(1) as its args
@@ -291,10 +350,15 @@ async function runPwInner(argv, deps = {}) {
291
350
  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
351
  const code = await spawnExec(plan, env); return { code, errorText: code === 0 ? null : `exit ${code}` };
293
352
  };
294
- if (!tracker) return (await execute()).code;
353
+ if (!tracker) {
354
+ const result = await execute();
355
+ if (plan.verb === "close") await stagingAuth.afterLaneClose(authCtx);
356
+ return result.code;
357
+ }
295
358
  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
359
  if (plan.verb === "close") {
297
360
  const closed = await tracker.close({ environment, probe, run: execute });
361
+ await stagingAuth.afterLaneClose(authCtx);
298
362
  if (closed.error) { stderr.write(`${closed.error}\n`); return 2; }
299
363
  reportDegradation(stderr, closed.delivery); return closed.code;
300
364
  }