@botbuddy/cli 1.32.1 → 1.32.3

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.3",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,11 +1,14 @@
1
1
  // BOT-1649 — diagnose and repair a worktree's short-lived agent credential
2
2
  // without ever printing the token unless the operator explicitly requests an
3
3
  // export snippet for the current shell.
4
+ import { hostname } from "node:os";
4
5
  import { readAgentBinding } from "./wait-profile.mjs";
5
- import { resolveOwnerToken } from "./cli-credentials.mjs";
6
+ import { resolveOwnerToken, CLIENT_KEY_SERVICE, OWNER_TOKEN_SERVICE } from "./cli-credentials.mjs";
7
+ import { readKeychainSecret } from "./agent-credential-store.mjs";
6
8
  import { getConfig } from "./config.mjs";
7
9
  import { isAgentStateFresh, readAgentState } from "./agent-state.mjs";
8
10
  import { selfHealAgentSession } from "./agent-session.mjs";
11
+ import { machineUuid } from "./machine-id.mjs";
9
12
 
10
13
  export function parseDoctorArgs(argv = []) {
11
14
  const parsed = { fix: false, printExports: false, json: false, errors: [] };
@@ -29,54 +32,134 @@ function shellQuote(value) {
29
32
  * `expired` (not `available`) so `--fix` does not try to repair the cache and the
30
33
  * verdict points at `bb login` instead of a bogus binding repair.
31
34
  */
32
- function clientKeyStatus(owner, nowMs) {
35
+ function clientKeyStatus(owner, nowMs, readFailed = false) {
36
+ // BOT-1750 (Codex R5 P2): a credential-store READ FAILURE (locked/erroring
37
+ // Keychain) is distinct from an absent key — reporting it as `missing` would send
38
+ // the operator to `bb login` when the real remedy is to unlock the store.
39
+ if (readFailed) return { status: "unavailable" };
33
40
  if (!owner?.token) return { status: "missing" };
34
41
  if (typeof owner.expiresAt === "number" && owner.expiresAt <= nowMs) return { status: "expired" };
35
42
  return { status: "available" };
36
43
  }
37
44
 
45
+ /**
46
+ * BOT-1750 (Codex R3/R5 P2): the recovery for a missing/unreadable repair
47
+ * prerequisite, in priority order — a credential-store FAILURE first (unlock it,
48
+ * not `bb login`), then an absent/expired key, then a missing binding. Returns null
49
+ * when every prerequisite is satisfied.
50
+ */
51
+ function prerequisiteRecovery(report) {
52
+ if (report.client_key.status === "unavailable") {
53
+ return "the client key could not be read (locked or failing credential store); unlock it, then run `bb doctor --fix`";
54
+ }
55
+ if (report.client_key.status === "missing" || report.client_key.status === "expired") {
56
+ return "run `bb login`, then `bb doctor --fix`";
57
+ }
58
+ if (report.binding.status === "missing") {
59
+ return "add a valid .botbuddy-agent.json tenant binding, then run `bb doctor --fix`";
60
+ }
61
+ return null;
62
+ }
63
+
64
+ /**
65
+ * BOT-1750 (Codex R6 P2): recovery for a self-heal that THREW during the repair pass
66
+ * (the Keychain can lock/error between the first probe and the register read). A
67
+ * credential-store failure must point at unlocking the store — not `bb login` or a
68
+ * binding repair — matching prerequisiteRecovery's wording.
69
+ */
70
+ function repairFailureRecovery(code) {
71
+ if (code === "client_key_unavailable") {
72
+ return "the client key could not be read (locked or failing credential store); unlock it, then run `bb doctor --fix`";
73
+ }
74
+ // BOT-1750 (Codex R9 P2): `session_mint_unauthorized` means a locally-unexpired
75
+ // client key was revoked/rejected server-side — only a fresh login replaces it, so
76
+ // point at `bb login`, not a binding repair (the binding is valid).
77
+ if (code === "client_key_required" || code === "session_mint_unauthorized") return "run `bb login`, then `bb doctor --fix`";
78
+ // BOT-1750 (Codex R10 P2): a transport/server mint failure (or a malformed mint
79
+ // response) is not a binding problem — the binding was verified available before
80
+ // repair began. Point at retrying rather than editing a valid .botbuddy-agent.json.
81
+ if (code === "session_mint_failed" || code === "session_mint_invalid") return "the BotBuddy service could not mint a session (transient/server error); retry `bb doctor --fix`";
82
+ return "repair .botbuddy-agent.json, then run `bb doctor --fix`";
83
+ }
84
+
85
+ /**
86
+ * BOT-1750 (AC-4): fold the server's stack-lease capability verdict (from a mint)
87
+ * into the report. `established` clears the line; anything else records the reason
88
+ * and points the operator at the missing attestation.
89
+ */
90
+ function applyStackLeaseCapability(report, capability) {
91
+ if (!capability || typeof capability.status !== "string") return;
92
+ report.stack_lease.capability = capability.status;
93
+ if (capability.reason) report.stack_lease.reason = capability.reason;
94
+ if (capability.status !== "established" && !report.recovery) {
95
+ report.recovery = `stack leases need a device-consented Helper on this machine: ${capability.reason ?? "worktree not attested"}`;
96
+ }
97
+ }
98
+
38
99
  /** Inspect a worktree credential. Only `exports` ever contains the secret. */
39
100
  export async function doctorAgentAuth({
40
101
  cwd = process.cwd(), env = process.env, fix = false, printExports = false,
41
102
  readState = readAgentState, isFresh = isAgentStateFresh, now = () => Date.now(),
42
- binding = readAgentBinding, ownerToken = () => resolveOwnerToken({ getConfig }),
103
+ binding = readAgentBinding,
104
+ // BOT-1750 (Codex R5 P2): read the client key STRICTLY here too, so doctor's own
105
+ // probe distinguishes a locked/failing credential store (throws → `unavailable`)
106
+ // from a genuinely absent key (`missing`) — the strict read must not live only
107
+ // inside selfHeal, which the `--fix` gate skips when it reports the key missing.
108
+ ownerToken = () => resolveOwnerToken({
109
+ getConfig,
110
+ keychainRead: () => readKeychainSecret(CLIENT_KEY_SERVICE, { strict: true }),
111
+ keychainReadLegacy: () => readKeychainSecret(OWNER_TOKEN_SERVICE, { strict: true }),
112
+ }),
43
113
  selfHeal = selfHealAgentSession,
114
+ hostFn = hostname, machineUuidFn = machineUuid,
44
115
  } = {}) {
45
- const [state, bound, owner] = await Promise.all([
46
- readState(cwd), binding(cwd).catch(() => null), ownerToken().catch(() => null),
116
+ let owner = null;
117
+ let ownerReadFailed = false;
118
+ const [state, bound] = await Promise.all([
119
+ readState(cwd), binding(cwd).catch(() => null),
47
120
  ]);
121
+ try { owner = await ownerToken(); } catch { ownerReadFailed = true; }
48
122
  const fresh = Boolean(state && isFresh(state, new Date()));
49
123
  const report = {
50
124
  schema_version: 1,
51
125
  worktree: cwd,
52
126
  binding: bound?.tenant ? { status: "available", tenant: bound.tenant } : { status: "missing" },
53
- client_key: clientKeyStatus(owner, now()),
127
+ client_key: clientKeyStatus(owner, now(), ownerReadFailed),
54
128
  session: fresh
55
129
  ? { status: "fresh", agent_id: state.agent_id, session_id: state.session_id, expires_at: state.expires_at }
56
130
  : { status: state ? "stale" : "missing" },
131
+ // BOT-1750 (AC-3/AC-4): the worktree's stack-lease readiness. `machine_id_readable`
132
+ // only reports that a hardware id could be read (true on almost any host) — it is
133
+ // NOT attestation (Codex P2): a machine is only attested when a device-consented
134
+ // Helper backs it, which the server's `capability` verdict reflects. The verdict
135
+ // is known only after a register call, so a plain `bb doctor` reports `unverified`
136
+ // and `--fix` re-registers to (re)establish it (`established` | `unattested`).
137
+ stack_lease: {
138
+ host: hostFn(),
139
+ machine_id_readable: Boolean(machineUuidFn()),
140
+ capability: "unverified",
141
+ },
57
142
  };
58
143
  let credential = fresh
59
144
  ? { token: state.agent_session_token, sessionId: state.session_id, agentId: state.agent_id, source: "cache" }
60
145
  : null;
61
- if (!fresh && fix && report.binding.status === "available" && report.client_key.status === "available") {
146
+ // BOT-1750 (Codex P1): repair on `--fix` even when the cache is FRESH otherwise
147
+ // `bb doctor --fix` (the remedy the stack refusal points at) would reuse a fresh but
148
+ // never-attested session and never re-establish the capability. `force` makes the
149
+ // self-heal run a registration/attestation round regardless of cache freshness.
150
+ if (fix && report.binding.status === "available" && report.client_key.status === "available") {
62
151
  try {
63
- credential = await selfHeal({ cwd, env });
152
+ credential = await selfHeal({ cwd, env, force: true });
64
153
  report.session = { status: credential.source === "minted" ? "minted" : credential.source, agent_id: credential.agentId ?? null, session_id: credential.sessionId ?? null };
154
+ applyStackLeaseCapability(report, credential.stackLeaseCapability);
65
155
  } catch (error) {
66
156
  report.session = { status: "repair_failed", error: error?.code ?? "agent_session_unavailable" };
67
- report.recovery = error?.code === "client_key_required"
68
- ? "run `bb login`, then `bb doctor --fix`"
69
- : "repair .botbuddy-agent.json, then run `bb doctor --fix`";
157
+ report.recovery = repairFailureRecovery(error?.code);
70
158
  return { exitCode: 3, report, exports: null };
71
159
  }
72
160
  }
73
161
  if (!credential) {
74
- const needsLogin = report.client_key.status === "missing" || report.client_key.status === "expired";
75
- report.recovery = needsLogin
76
- ? "run `bb login`, then `bb doctor --fix`"
77
- : report.binding.status === "missing"
78
- ? "add a valid .botbuddy-agent.json tenant binding, then run `bb doctor --fix`"
79
- : "run `bb doctor --fix` to mint a replacement session";
162
+ report.recovery = prerequisiteRecovery(report) ?? "run `bb doctor --fix` to mint a replacement session";
80
163
  return { exitCode: 3, report, exports: null };
81
164
  }
82
165
  const exports = printExports
@@ -85,9 +168,31 @@ export async function doctorAgentAuth({
85
168
  ...(credential.sessionId ? [`export BOTBUDDY_SESSION_ID=${shellQuote(credential.sessionId)}`] : []),
86
169
  ].join("\n")
87
170
  : null;
171
+ // BOT-1750 (Codex R3 P2): when `--fix` could not even attempt a repair because a
172
+ // prerequisite is missing (no tenant binding, or no/expired client key) yet a fresh
173
+ // cached session left `credential` populated, report THAT prerequisite — not the
174
+ // Helper/capability message below, since registration never ran and the real remedy
175
+ // is `bb login` or restoring the binding.
176
+ if (fix && (report.binding.status !== "available" || report.client_key.status !== "available")) {
177
+ report.recovery = prerequisiteRecovery(report) ?? "run `bb doctor --fix` to mint a replacement session";
178
+ return { exitCode: 3, report, exports };
179
+ }
180
+ // BOT-1750 (Codex R2 P1): `bb doctor --fix` is the advertised remedy for a stack-
181
+ // lease refusal, so it must not report success while the worktree's capability is
182
+ // still not `established` — including an `unattested` verdict or an omitted verdict
183
+ // from an older server (which leaves it `unverified`). The session is still minted
184
+ // and usable for wait/run/pw, but the exit fails closed so the lease-refusal remedy
185
+ // never falsely reports fixed. A plain `bb doctor` (no --fix) stays exit 0: it is a
186
+ // read-only report that has not attempted to establish anything.
187
+ if (fix && report.stack_lease.capability !== "established") {
188
+ if (!report.recovery) {
189
+ report.recovery = "session minted, but this worktree's stack-lease capability is not established — no device-consented Helper attests this machine, so `bb stack` will still be refused.";
190
+ }
191
+ return { exitCode: 3, report, exports };
192
+ }
88
193
  return { exitCode: 0, report, exports };
89
194
  }
90
195
 
91
196
  export function doctorHelp() {
92
- return "bb doctor [--fix] [--print-exports] [--json]\n\nChecks the client key, repo binding, and cached agent session. --fix mints a replacement session; --print-exports prints its canonical shell exports.";
197
+ return "bb doctor [--fix] [--print-exports] [--json]\n\nChecks the client key, repo binding, cached agent session, and this worktree's stack-lease capability (registered host + attested machine). --fix mints a replacement session and (re)establishes the stack-lease capability; --print-exports prints its canonical shell exports.";
93
198
  }
Binary file
package/src/commands.mjs CHANGED
@@ -227,6 +227,10 @@ export async function cmdDoctor(args, { log = (line) => console.log(line), error
227
227
  log(`binding: ${result.report.binding.status}${result.report.binding.tenant ? ` (${result.report.binding.tenant})` : ""}`);
228
228
  log(`client key: ${result.report.client_key.status}`);
229
229
  log(`agent session: ${result.report.session.status}`);
230
+ if (result.report.stack_lease) {
231
+ const sl = result.report.stack_lease;
232
+ log(`stack lease: ${sl.capability} (host ${sl.host}, machine id ${sl.machine_id_readable ? "readable" : "unavailable"})${sl.reason ? ` — ${sl.reason}` : ""}`);
233
+ }
230
234
  if (result.report.recovery) log(`recovery: ${result.report.recovery}`);
231
235
  }
232
236
  if (result.exports) log(result.exports);
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; }
package/src/stack.mjs CHANGED
@@ -30,7 +30,9 @@ import { randomUUID } from "crypto";
30
30
  import { callToolJson } from "./api.mjs";
31
31
  import { SERVER_URL, getConfig } from "./config.mjs";
32
32
  import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
33
- import { AGENT_KEY_RE, readAgentKeyEnv } from "./agent-key.mjs";
33
+ import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
34
+ import { resolveAgentSessionCredential } from "./agent-session.mjs";
35
+ import { clearAgentState } from "./agent-state.mjs";
34
36
  import { runDockerCommand, runDockerWorkflow, ADMITTED_DOCKER_VALIDATIONS } from "./docker-hygiene.mjs";
35
37
  import { projectIdFromConfig } from "./stack-file-lock.mjs";
36
38
  import { machineUuid } from "./machine-id.mjs";
@@ -347,30 +349,176 @@ export function parseSupabaseStatus(text) {
347
349
 
348
350
  // ── runtime (network / process) ──────────────────────────────────────────────
349
351
 
350
- // BOT-1599: a stack lease is a host-bound operation. When a session token is
351
- // present it must win over the durable OAuth client credential, otherwise the
352
- // lease RPC resolves the hostless/stale OAuth agent rather than the agent that
353
- // registered this worktree and machine attestation. The MCP server accepts the
354
- // session token through the same agent-key header used by bb-pw.
352
+ // BOT-1750: a stack lease is a host-bound operation, so it must resolve its
353
+ // session credential through the SAME shared resolver `bb wait`/`run`/`pw` use
354
+ // exported token, then the fresh per-worktree `.botbuddy-agent.json` cache, then
355
+ // ONE self-heal that re-establishes this worktree's host + machine attestation.
356
+ // That is what makes the lease resolve to the agent row carrying `host`, instead
357
+ // of the hostless tier-2 MCP key that produced `AGENT_HOST_REQUIRED` (BOT-1599
358
+ // fixed only the case where the token was already exported in the environment).
355
359
  //
356
- // Without a session token, retain the BOT-1520 Keychain-backed OAuth/MCP-key
357
- // fallback for operator commands and legacy callers.
358
- export async function stackAuthHeader() {
359
- const sessionToken = readAgentKeyEnv();
360
- if (sessionToken) return AGENT_KEY_RE.test(sessionToken) ? { "x-agent-api-key": sessionToken } : null;
361
- const agentKey = await resolveAgentKey();
362
- const owner = await resolveOwnerToken({ getConfig });
360
+ // When the resolver has nothing to work with (no client key or no tenant binding
361
+ // to self-heal from — e.g. an operator running `stack status` with only a tier-2
362
+ // MCP key), fall through to the BOT-1520 Keychain-backed OAuth/MCP-key path. That
363
+ // credential is valid for operator commands but is NOT the lease path; the lease
364
+ // RPC will refuse it with an actionable AGENT_HOST_REQUIRED message (see cmdUp).
365
+ export async function stackAuthHeader({
366
+ argv = process.argv.slice(2),
367
+ env = process.env,
368
+ cwd = process.cwd(),
369
+ resolveCredential = resolveAgentSessionCredential,
370
+ resolveAgentKeyFn = resolveAgentKey,
371
+ resolveOwnerTokenFn = () => resolveOwnerToken({ getConfig }),
372
+ } = {}) {
373
+ let credential = null;
374
+ try {
375
+ credential = await resolveCredential({ argv, env, cwd });
376
+ } catch (error) {
377
+ // BOT-1750 (Codex P2): only the "nothing to self-heal from" cases fall through
378
+ // to the operator credential path — no durable client key, or no tenant binding.
379
+ // A transport/server/tenant/malformed self-heal failure must FAIL CLOSED, not
380
+ // silently switch principals: for `up` that would turn a real error into a
381
+ // misleading host-registration refusal, and for owner-scoped `status`/`touch`/
382
+ // `done` it would run as a different agent that cannot see the original lease.
383
+ if (error?.code !== "client_key_required" && error?.code !== "tenant_binding_required") throw error;
384
+ credential = null;
385
+ }
386
+ if (credential) {
387
+ // BOT-1750 (Codex R3 P2): a resolver that RETURNED a credential must yield a
388
+ // well-formed session token. A nonempty-but-malformed token (a corrupt cache or
389
+ // an incompatible server response) must FAIL CLOSED — not skip the session header
390
+ // and silently authenticate as the OAuth/MCP-key principal, which is the exact
391
+ // principal switch the catch above guards against.
392
+ if (typeof credential.token === "string" && AGENT_KEY_RE.test(credential.token)) {
393
+ return withSource({ "x-agent-api-key": credential.token }, credential.source);
394
+ }
395
+ throw Object.assign(new Error(`resolved agent session token has an unexpected shape (source: ${credential.source ?? "unknown"}); re-run \`bb doctor --fix\``), { code: "session_token_malformed" });
396
+ }
397
+ // Only reached when a missing prerequisite (no client key / no tenant binding)
398
+ // routed us here — the operator credential path for status/touch/done.
399
+ const agentKey = await resolveAgentKeyFn();
400
+ const owner = await resolveOwnerTokenFn();
363
401
  if (owner) {
364
402
  if (owner.expiresAt && Date.now() >= owner.expiresAt) {
365
- if (agentKey) return { Authorization: `Bearer ${agentKey}`, "x-agent-api-key": agentKey };
403
+ if (agentKey) return withSource({ Authorization: `Bearer ${agentKey}`, "x-agent-api-key": agentKey }, "operator");
366
404
  return null;
367
405
  }
368
- return { Authorization: `Bearer ${owner.token}`, "x-agent-api-key": agentKey || "" };
406
+ return withSource({ Authorization: `Bearer ${owner.token}`, "x-agent-api-key": agentKey || "" }, "operator");
369
407
  }
370
- if (agentKey) return { Authorization: `Bearer ${agentKey}`, "x-agent-api-key": agentKey };
408
+ if (agentKey) return withSource({ Authorization: `Bearer ${agentKey}`, "x-agent-api-key": agentKey }, "operator");
371
409
  return null;
372
410
  }
373
411
 
412
+ // BOT-1750 (Codex R7 P2): carry the credential SOURCE on the returned headers as a
413
+ // NON-enumerable property, so callers can clear+self-heal+retry once on a
414
+ // server-rejected cached session (parity with run/test/pw/wait) without the source
415
+ // ever leaking into the spread fetch headers.
416
+ function withSource(headers, source) {
417
+ Object.defineProperty(headers, "__source", { value: source ?? null, enumerable: false });
418
+ return headers;
419
+ }
420
+
421
+ /**
422
+ * BOT-1750 (Codex R7 P2): a locally-fresh cache whose token was revoked server-side
423
+ * (e.g. a concurrent re-registration rotated the session) is rejected at the lease
424
+ * RPC. Mirror the other consumers: when the FIRST auth came from the per-worktree
425
+ * cache and the RPC returns an auth rejection, clear the cache and self-heal once,
426
+ * then let the caller retry with the fresh headers. Returns the fresh headers, or
427
+ * null when no retry applies (not cache-sourced, or the refresh could not authenticate).
428
+ */
429
+ export async function refreshRejectedCacheAuth(auth, {
430
+ cwd = process.cwd(), authProvider = stackAuthHeader, clear = clearAgentState,
431
+ } = {}) {
432
+ if (auth?.__source !== "cache") return null;
433
+ await clear(cwd);
434
+ const fresh = await authProvider();
435
+ // BOT-1750 (Codex R8 P2): retry ONLY with a genuinely self-healed session. If the
436
+ // client key or binding vanished after clearing the cache, authProvider falls
437
+ // through to the OAuth/MCP operator credential — a DIFFERENT principal whose token
438
+ // also differs, so a bare "different token" check would retry as the wrong agent.
439
+ // Require source "minted" AND a well-formed, changed session token.
440
+ if (fresh?.__source !== "minted") return null;
441
+ const token = fresh["x-agent-api-key"];
442
+ if (typeof token !== "string" || !AGENT_KEY_RE.test(token) || token === auth["x-agent-api-key"]) return null;
443
+ return fresh;
444
+ }
445
+
446
+ /**
447
+ * BOT-1750 (Codex R10 P2): a stack RPC caller that transparently recovers from a
448
+ * server-rejected cached session ONCE — the owner-scoped management commands
449
+ * (`status`/`touch`/`done`) need the same recovery as the lease-creating paths, or a
450
+ * revoked-but-locally-fresh cache leaves an active lease unreleasable. `init()`
451
+ * resolves auth; `call()` runs the RPC and, on the FIRST auth rejection, clears +
452
+ * self-heals + retries with the fresh credential (every later `call` reuses it).
453
+ */
454
+ export function retryingStackCall({
455
+ callTool = callToolJson, authProvider = stackAuthHeader,
456
+ refresh = refreshRejectedCacheAuth, cwd = () => process.cwd(),
457
+ auth: seedAuth,
458
+ } = {}) {
459
+ // A pre-resolved `auth` seed lets a caller that already resolved the credential
460
+ // (runStackLifecycle short-circuits its own await) skip init() — avoiding an extra
461
+ // await point before the first RPC is dispatched.
462
+ let auth = seedAuth;
463
+ let refreshed = false;
464
+ return {
465
+ async init() { if (auth === undefined) auth = await authProvider(); return auth; },
466
+ async call(name, args, callOptions = {}) {
467
+ let r = await callTool(name, args, { ...callOptions, auth });
468
+ if (!refreshed && !r?.ok && r?.auth) {
469
+ refreshed = true;
470
+ const fresh = await refresh(auth, { cwd: cwd() });
471
+ if (fresh) { auth = fresh; r = await callTool(name, args, { ...callOptions, auth }); }
472
+ }
473
+ return r;
474
+ },
475
+ get auth() { return auth; },
476
+ };
477
+ }
478
+
479
+ // BOT-1750 (Codex R7 P2): map a thrown auth/self-heal failure to the documented exit
480
+ // taxonomy so automation sees AUTH/BACKEND, not a CLI-defect INTERNAL. Credential
481
+ // problems are AUTH (3); a transport/server mint failure is BACKEND (5); anything
482
+ // unrecognized stays INTERNAL (7).
483
+ const AUTH_ERROR_CODES = new Set(["client_key_required", "client_key_unavailable", "tenant_binding_required", "session_token_malformed", "session_mint_unauthorized"]);
484
+ // BOT-1750 (Codex R11 P2): `session_mint_invalid` is an incompatible/malformed SERVER
485
+ // response, not a credential problem — classify it with the transport/server failures.
486
+ const BACKEND_ERROR_CODES = new Set(["session_mint_failed", "session_mint_invalid"]);
487
+ export function classifyStackAuthError(error) {
488
+ const code = error?.code;
489
+ if (AUTH_ERROR_CODES.has(code)) return EXIT.AUTH;
490
+ if (BACKEND_ERROR_CODES.has(code)) return EXIT.BACKEND;
491
+ return EXIT.INTERNAL;
492
+ }
493
+
494
+ // BOT-1750 (AC-2): name the credential kind a refused lease was authenticated with,
495
+ // so the AGENT_HOST_REQUIRED / WORKTREE_NOT_REGISTERED hint tells the operator what
496
+ // they actually sent (a hostless MCP key vs. a real session token).
497
+ export function describeStackCredentialKind(auth) {
498
+ if (!auth) return "no credential";
499
+ const key = auth["x-agent-api-key"];
500
+ if (typeof key === "string" && AGENT_KEY_RE.test(key)) return "agent session token";
501
+ const bearer = typeof auth.Authorization === "string" ? auth.Authorization.replace(/^Bearer\s+/, "") : null;
502
+ // The MCP-key-only fallback sends the SAME key as bearer AND x-agent-api-key; a
503
+ // real OAuth client credential sends a distinct owner token as the bearer.
504
+ if (bearer && key && bearer === key) return "tier-2 MCP key";
505
+ if (bearer) return "OAuth client credential";
506
+ if (key) return "tier-2 MCP key";
507
+ return "unknown credential";
508
+ }
509
+
510
+ // BOT-1750 (AC-2): the server refuses a lease from a worktree it can't attest with
511
+ // one of these codes. They are actionable in the same way — register this worktree's
512
+ // host + machine so the lease resolves to a host-bearing agent.
513
+ const WORKTREE_UNREGISTERED_CODES = new Set(["AGENT_HOST_REQUIRED", "WORKTREE_NOT_REGISTERED"]);
514
+
515
+ /** One-line, operator-facing fix for an unregistered-worktree lease refusal (or null). */
516
+ export function worktreeRegistrationHint(code, auth) {
517
+ if (!WORKTREE_UNREGISTERED_CODES.has(code)) return null;
518
+ return `${yellow("⚠")} stack: this worktree is not registered for stack leases (sent ${describeStackCredentialKind(auth)}). `
519
+ + `Run \`bb doctor --fix\` here to register its host + machine id, then retry.`;
520
+ }
521
+
374
522
  // The MCP lease endpoint accepts a session token in x-agent-api-key, whereas
375
523
  // the event-stream relay requires that same token in its bearer form too. Keep
376
524
  // the lease RPC shape unchanged and derive the relay-compatible form only at
@@ -954,6 +1102,7 @@ export async function cmdUp(opts, {
954
1102
  emitResult = emit,
955
1103
  machineUuidFn = machineUuid,
956
1104
  assertIsolated = validateLocalExecTarget,
1105
+ refreshAuth = refreshRejectedCacheAuth,
957
1106
  } = {}) {
958
1107
  let slot;
959
1108
  try { slot = deriveSlot(opts); } catch (e) {
@@ -994,9 +1143,9 @@ export async function cmdUp(opts, {
994
1143
  process.stderr.write(`${yellow("⚠")} stack: ${engine} preflight warns of network pressure; ${remedy}.\n`);
995
1144
  }
996
1145
  }
997
- const auth = await authProvider();
1146
+ let auth = await authProvider();
998
1147
  if (!auth) return emitResult(buildReceipt({ command: "up", outcome: "error", error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
999
- const call = (name, args, callOptions = {}) => callTool(name, args, { ...callOptions, auth });
1148
+ let call = (name, args, callOptions = {}) => callTool(name, args, { ...callOptions, auth });
1000
1149
 
1001
1150
  // BOT-1585: co-location dispatches the lease to the Helper enrolled for THIS
1002
1151
  // physical machine (hardware id), since a hostname is not machine-unique. The
@@ -1005,7 +1154,7 @@ export async function cmdUp(opts, {
1005
1154
  if (!hardwareUuid) {
1006
1155
  return emitResult(buildReceipt({ command: "up", outcome: "error", code: "MACHINE_UUID_REQUIRED", error: "could not determine this machine's hardware id (needed to dispatch the stack lease to the right machine)" }), opts, EXIT.BACKEND);
1007
1156
  }
1008
- const req = await call("request_stack_lease", {
1157
+ const leaseArgs = {
1009
1158
  slot, host_key: opts.host || undefined, repo: opts.repo || undefined,
1010
1159
  ticket_id: opts.ticket || undefined, ticket_url: opts.ticketUrl || undefined,
1011
1160
  pr_id: opts.prId || undefined, pr_url: opts.prUrl || undefined,
@@ -1013,7 +1162,20 @@ export async function cmdUp(opts, {
1013
1162
  stack_path: execution.stackPath,
1014
1163
  worktree_root: execution.worktreeRoot,
1015
1164
  machine_uuid: hardwareUuid,
1016
- });
1165
+ };
1166
+ let req = await call("request_stack_lease", leaseArgs);
1167
+ if (!req.ok && req.auth) {
1168
+ // BOT-1750 (Codex R7 P2): a locally-fresh cached session whose token was revoked
1169
+ // server-side (e.g. a concurrent re-registration) is rejected here. Clear the
1170
+ // cache and self-heal once, then retry — parity with run/test/pw/wait, so `bb
1171
+ // stack` recovers on its own instead of bricking until a manual cache repair.
1172
+ const fresh = await refreshAuth(auth, { cwd: process.cwd() });
1173
+ if (fresh) {
1174
+ auth = fresh;
1175
+ call = (name, args, callOptions = {}) => callTool(name, args, { ...callOptions, auth });
1176
+ req = await call("request_stack_lease", leaseArgs);
1177
+ }
1178
+ }
1017
1179
  if (!req.ok) {
1018
1180
  return req.auth
1019
1181
  ? emitResult(buildReceipt({ command: "up", outcome: "error", error: req.error || "unauthorized" }), opts, EXIT.AUTH)
@@ -1021,6 +1183,8 @@ export async function cmdUp(opts, {
1021
1183
  }
1022
1184
  const d = req.data;
1023
1185
  if (!d.success) {
1186
+ const hint = worktreeRegistrationHint(d.code, auth);
1187
+ if (hint) process.stderr.write(`${hint}\n`);
1024
1188
  return emitResult(buildReceipt({ command: "up", outcome: "error", code: d.code, error: d.message || d.code || "request refused", slot }), opts, EXIT.BACKEND);
1025
1189
  }
1026
1190
  let leaseId = d.lease_id;
@@ -1208,10 +1372,10 @@ export async function cmdUp(opts, {
1208
1372
  }), opts, EXIT.OK);
1209
1373
  }
1210
1374
 
1211
- async function cmdStatus(leaseId, opts) {
1212
- const auth = await stackAuthHeader();
1375
+ async function cmdStatus(leaseId, opts, { rpc = retryingStackCall() } = {}) {
1376
+ const auth = await rpc.init();
1213
1377
  if (!auth) return emit(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
1214
- const got = await callToolJson("get_stack_lease", { lease_id: leaseId }, { auth });
1378
+ const got = await rpc.call("get_stack_lease", { lease_id: leaseId });
1215
1379
  if (!got.ok) return got.auth
1216
1380
  ? emit(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: got.error }), opts, EXIT.AUTH)
1217
1381
  : emit(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: got.error }), opts, EXIT.BACKEND);
@@ -1224,10 +1388,10 @@ async function cmdStatus(leaseId, opts) {
1224
1388
  }), opts, EXIT.OK);
1225
1389
  }
1226
1390
 
1227
- async function cmdTouch(leaseId, opts) {
1228
- const auth = await stackAuthHeader();
1391
+ async function cmdTouch(leaseId, opts, { rpc = retryingStackCall() } = {}) {
1392
+ const auth = await rpc.init();
1229
1393
  if (!auth) return emit(buildReceipt({ command: "touch", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
1230
- const r = await callToolJson("touch_stack_lease", { lease_id: leaseId }, { auth });
1394
+ const r = await rpc.call("touch_stack_lease", { lease_id: leaseId });
1231
1395
  if (!r.ok) return emit(buildReceipt({ command: "touch", outcome: "error", lease_id: leaseId, error: r.error }), opts, r.auth ? EXIT.AUTH : EXIT.BACKEND);
1232
1396
  if (!r.data.success) return emit(buildReceipt({ command: "touch", outcome: "error", lease_id: leaseId, code: r.data.code, error: r.data.code }), opts, EXIT.BACKEND);
1233
1397
  return emit(buildReceipt({ command: "touch", outcome: "touched", lease_id: leaseId, last_used_at: r.data.last_used_at }), opts, EXIT.OK);
@@ -1240,10 +1404,15 @@ export async function cmdDone(leaseId, opts, {
1240
1404
  proveLegacyTarget = proveLegacyLocalExecTarget,
1241
1405
  localTeardownFn = localTeardown,
1242
1406
  emitResult = emit,
1407
+ refreshAuth = refreshRejectedCacheAuth,
1243
1408
  } = {}) {
1244
- const auth = await authProvider();
1409
+ // BOT-1750 (Codex R10 P2): `done` must recover a server-rejected cached session on
1410
+ // its own — otherwise an active lease can't be released until the cache is manually
1411
+ // repaired. The retrying caller clears+self-heals+retries the FIRST auth rejection.
1412
+ const rpc = retryingStackCall({ callTool, authProvider, refresh: refreshAuth });
1413
+ const auth = await rpc.init();
1245
1414
  if (!auth) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
1246
- const call = (name, args, callOptions = {}) => callTool(name, args, { ...callOptions, auth });
1415
+ const call = rpc.call;
1247
1416
  let dockerTarget = null;
1248
1417
  let legacyTargetProof = null;
1249
1418
  let teardownDir = process.cwd();
@@ -1475,12 +1644,29 @@ export async function materializeLeasedTestConfig(worktreeRoot, leaseId, connect
1475
1644
  * without Docker or a live BotBuddy service.
1476
1645
  */
1477
1646
  export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1478
- const auth = adapters.auth ?? await stackAuthHeader();
1647
+ // BOT-1750 (Codex R9/R11 P2): `stack run` is a lease-creating entry point AND a
1648
+ // long-lived one (queue → provision → child → heartbeat → cleanup). A cached token
1649
+ // can be rotated server-side at ANY of those points, so EVERY RPC — not just the
1650
+ // initial request — must be able to clear + self-heal + retry once. Route them all
1651
+ // through one retryingStackCall so a single self-heal re-authenticates the rest
1652
+ // (heartbeat, get, cleanup release), instead of fencing a healthy child or leaking
1653
+ // an unreleasable lease. Retry only when we own the credential or a test provides an
1654
+ // explicit refreshAuth — never for an injected api double without one.
1655
+ const refreshAuth = adapters.refreshAuth ?? (adapters.auth ? async () => null : refreshRejectedCacheAuth);
1656
+ // Resolve the initial credential with the same short-circuit as before (an injected
1657
+ // auth skips the network resolve), then seed the retrying caller so no extra await
1658
+ // point is introduced before the first RPC.
1659
+ const initialAuth = adapters.auth ?? await stackAuthHeader();
1660
+ const rpc = retryingStackCall({
1661
+ callTool: adapters.callTool ?? callToolJson,
1662
+ refresh: refreshAuth,
1663
+ auth: initialAuth,
1664
+ });
1479
1665
  const api = adapters.api ?? {
1480
- request: (args) => callToolJson("request_stack_lease", args, { auth }),
1481
- get: (leaseId) => callToolJson("get_stack_lease", { lease_id: leaseId }, { auth }),
1482
- touch: (leaseId) => callToolJson("touch_stack_lease", { lease_id: leaseId }, { auth }),
1483
- release: (leaseId, signal) => callToolJson("release_stack_lease", { lease_id: leaseId, disposition: "destroy" }, { auth, signal }),
1666
+ request: (args) => rpc.call("request_stack_lease", args),
1667
+ get: (leaseId) => rpc.call("get_stack_lease", { lease_id: leaseId }),
1668
+ touch: (leaseId) => rpc.call("touch_stack_lease", { lease_id: leaseId }),
1669
+ release: (leaseId, signal) => rpc.call("release_stack_lease", { lease_id: leaseId, disposition: "destroy" }, { signal }),
1484
1670
  };
1485
1671
  const machineUuidFn = adapters.machineUuidFn ?? machineUuid;
1486
1672
  const wait = adapters.wait ?? ((leaseId, done, failed, options) => waitForLease(leaseId, done, failed, options));
@@ -1526,7 +1712,7 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1526
1712
  return cleanupResult = { ok: false, error: release?.error || release?.data?.code || "release failed" };
1527
1713
  }
1528
1714
  if (release.data.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
1529
- const reaped = await wait(leaseId, (state) => state === "reaped", () => false, { timeoutSec: opts.reapTimeout, auth });
1715
+ const reaped = await wait(leaseId, (state) => state === "reaped", () => false, { timeoutSec: opts.reapTimeout, auth: rpc.auth });
1530
1716
  if (reaped?.woke || reaped?.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
1531
1717
  return cleanupResult = { ok: false, error: reaped?.timeout ? `signed reap did not arrive within ${opts.reapTimeout}s` : (reaped?.error || "signed reap was not proven") };
1532
1718
  })();
@@ -1558,7 +1744,7 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1558
1744
  }
1559
1745
 
1560
1746
  try {
1561
- if (!auth) return { exitCode: EXIT.AUTH, outcome: "error", error: "not authenticated — run botbuddy login (agents: botbuddy mcp setup)" };
1747
+ if (!rpc.auth) return { exitCode: EXIT.AUTH, outcome: "error", error: "not authenticated — run botbuddy login (agents: botbuddy mcp setup)" };
1562
1748
  // BOT-1585: request_stack_lease requires this machine's hardware id so the lease
1563
1749
  // dispatches to the machine the worktree was registered on. `stack run` builds its
1564
1750
  // own payload (separate from `cmdUp`), so it must send it too.
@@ -1568,15 +1754,22 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1568
1754
  }
1569
1755
  const slot = deriveSlot(opts);
1570
1756
  const execution = resolveStackPath(process.cwd(), opts.stackPath);
1571
- const request = await api.request({
1757
+ const leaseArgs = {
1572
1758
  slot, host_key: opts.host || undefined, repo: opts.repo, ticket_id: opts.ticket,
1573
1759
  ticket_url: opts.ticketUrl || undefined, pr_id: opts.prId || undefined, pr_url: opts.prUrl || undefined,
1574
1760
  purpose: opts.purpose || "stack run", idle_ttl_secs: opts.idleTtl ?? undefined,
1575
1761
  stack_path: execution.stackPath, worktree_root: execution.worktreeRoot,
1576
1762
  machine_uuid: hardwareUuid,
1577
- });
1763
+ };
1764
+ // The retrying caller (api → rpc.call) transparently clears+self-heals+retries the
1765
+ // FIRST auth rejection among any of request/get/touch/release.
1766
+ const request = await api.request(leaseArgs);
1578
1767
  if (!request?.ok) return { exitCode: request?.auth ? EXIT.AUTH : EXIT.BACKEND, outcome: "error", error: request?.error || "lease request failed" };
1579
- if (!request.data?.success) return { exitCode: EXIT.BACKEND, outcome: "error", error: request.data?.message || request.data?.code || "lease request refused" };
1768
+ if (!request.data?.success) {
1769
+ const hint = worktreeRegistrationHint(request.data?.code, rpc.auth);
1770
+ if (hint) process.stderr.write(`${hint}\n`);
1771
+ return { exitCode: EXIT.BACKEND, outcome: "error", error: request.data?.message || request.data?.code || "lease request refused" };
1772
+ }
1580
1773
  leaseId = request.data.lease_id;
1581
1774
  if (request.data.reused) {
1582
1775
  return { exitCode: EXIT.BACKEND, outcome: "error", leaseId, error: "a live lease already exists for this agent and stack slot; wait for that batch to finish instead of sharing its stack" };
@@ -1589,7 +1782,7 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1589
1782
  }
1590
1783
  if (state === "queued") {
1591
1784
  activeWaitAbort = new AbortController();
1592
- const parked = await wait(leaseId, (s) => s !== "queued" && s != null, (s) => s === "reaped", { timeoutSec: opts.timeout, auth, signal: activeWaitAbort.signal });
1785
+ const parked = await wait(leaseId, (s) => s !== "queued" && s != null, (s) => s === "reaped", { timeoutSec: opts.timeout, auth: rpc.auth, signal: activeWaitAbort.signal });
1593
1786
  activeWaitAbort = null;
1594
1787
  if (parked?.interrupted || receivedSignal) { const cleanup = await requestCleanup(); return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup }; }
1595
1788
  if (parked?.timeout) { const cleanup = await requestCleanup(); return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `parked ${opts.timeout}s without capacity`, cleanup }; }
@@ -1598,7 +1791,7 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1598
1791
  }
1599
1792
  if (state !== "active") {
1600
1793
  activeWaitAbort = new AbortController();
1601
- const active = await wait(leaseId, (s) => s === "active", (s) => s === "reaped", { timeoutSec: provisionTimeout, auth, signal: activeWaitAbort.signal });
1794
+ const active = await wait(leaseId, (s) => s === "active", (s) => s === "reaped", { timeoutSec: provisionTimeout, auth: rpc.auth, signal: activeWaitAbort.signal });
1602
1795
  activeWaitAbort = null;
1603
1796
  if (active?.interrupted || receivedSignal) { const cleanup = await requestCleanup(); return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup }; }
1604
1797
  if (active?.timeout) { const cleanup = await requestCleanup(); return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `physical provision did not reach active in ${provisionTimeout}s`, cleanup }; }
@@ -1657,7 +1850,12 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1657
1850
  return { exitCode, outcome: exitCode === 0 ? "completed" : "failed", leaseId, childExitCode: childResult.code, cleanup, fenced: fencing };
1658
1851
  } catch (error) {
1659
1852
  const cleanup = await requestCleanup();
1660
- return { exitCode: cleanup?.ok === false ? EXIT.CLEANUP_FAILED : EXIT.INTERNAL, outcome: "error", leaseId, error: String(error?.message ?? error), cleanup };
1853
+ // BOT-1750 (Codex R10 P2): a thrown auth/self-heal failure (e.g. a refresh that
1854
+ // re-minted and was rejected) must keep the documented exit taxonomy — AUTH for a
1855
+ // credential problem, BACKEND for a transport/server mint failure — instead of a
1856
+ // blanket INTERNAL. A genuine cleanup failure still wins (it is the louder signal).
1857
+ const exitCode = cleanup?.ok === false ? EXIT.CLEANUP_FAILED : classifyStackAuthError(error);
1858
+ return { exitCode, outcome: "error", leaseId, error: String(error?.message ?? error), cleanup };
1661
1859
  } finally {
1662
1860
  if (heartbeat) clock.clearInterval(heartbeat);
1663
1861
  if (hardTimer) clock.clearTimeout(hardTimer);
@@ -1689,9 +1887,9 @@ export async function cmdStack(argv) {
1689
1887
  process.exitCode = code;
1690
1888
  return code;
1691
1889
  }
1692
- const sessionToken = readAgentKeyEnv();
1890
+ const sessionToken = readAgentSessionTokenEnv();
1693
1891
  if (sessionToken && !AGENT_KEY_RE.test(sessionToken)) {
1694
- process.stderr.write(`${yellow("⚠")} stack: $BOTBUDDY_AGENT_KEY must match bb_agent_<64 hex>; re-register and export a fresh session token.\n`);
1892
+ process.stderr.write(`${yellow("⚠")} stack: $BOTBUDDY_AGENT_SESSION_TOKEN must match bb_sess_<64 hex>; re-register and export a fresh session token.\n`);
1695
1893
  const code = emit(buildReceipt({ command, outcome: "error", error: "invalid_session_token" }), opts, EXIT.INVALID);
1696
1894
  process.exitCode = code;
1697
1895
  return code;
@@ -1709,7 +1907,13 @@ export async function cmdStack(argv) {
1709
1907
  code = emit(buildReceipt({ command: command || "?", outcome: "error", error: `unknown subcommand: ${command}` }), opts, EXIT.INVALID);
1710
1908
  }
1711
1909
  } catch (err) {
1712
- code = emit(buildReceipt({ command: command || "?", outcome: "error", error: `internal: ${err?.message ?? err}` }), opts, EXIT.INTERNAL);
1910
+ // BOT-1750 (Codex R7 P2): a thrown auth/self-heal failure carries a typed code
1911
+ // map it to the documented exit taxonomy (AUTH for credential problems, BACKEND
1912
+ // for a transport/server mint failure) so automation never reads a routine
1913
+ // outage as a CLI defect. Only a genuinely unrecognized error stays INTERNAL.
1914
+ const exit = classifyStackAuthError(err);
1915
+ const message = exit === EXIT.INTERNAL ? `internal: ${err?.message ?? err}` : (err?.message ?? String(err));
1916
+ code = emit(buildReceipt({ command: command || "?", outcome: "error", code: err?.code, error: message }), opts, exit);
1713
1917
  }
1714
1918
  process.exitCode = code;
1715
1919
  return code;