@botbuddy/cli 1.29.4 → 1.30.1

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.29.4",
3
+ "version": "1.30.1",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,93 @@
1
+ // BOT-1649 — diagnose and repair a worktree's short-lived agent credential
2
+ // without ever printing the token unless the operator explicitly requests an
3
+ // export snippet for the current shell.
4
+ import { readAgentBinding } from "./wait-profile.mjs";
5
+ import { resolveOwnerToken } from "./cli-credentials.mjs";
6
+ import { getConfig } from "./config.mjs";
7
+ import { isAgentStateFresh, readAgentState } from "./agent-state.mjs";
8
+ import { selfHealAgentSession } from "./agent-session.mjs";
9
+
10
+ export function parseDoctorArgs(argv = []) {
11
+ const parsed = { fix: false, printExports: false, json: false, errors: [] };
12
+ for (const arg of argv) {
13
+ if (arg === "--fix") parsed.fix = true;
14
+ else if (arg === "--print-exports") parsed.printExports = true;
15
+ else if (arg === "--json") parsed.json = true;
16
+ else if (arg === "--help" || arg === "-h") parsed.help = true;
17
+ else parsed.errors.push(`unknown option: ${arg}`);
18
+ }
19
+ return parsed;
20
+ }
21
+
22
+ function shellQuote(value) {
23
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
24
+ }
25
+
26
+ /**
27
+ * BOT-1649 (Codex P2): a durable login token past its `expiresAt` can no longer
28
+ * mint a session — the server rejects it as `session_mint_failed`. Report it as
29
+ * `expired` (not `available`) so `--fix` does not try to repair the cache and the
30
+ * verdict points at `bb login` instead of a bogus binding repair.
31
+ */
32
+ function clientKeyStatus(owner, nowMs) {
33
+ if (!owner?.token) return { status: "missing" };
34
+ if (typeof owner.expiresAt === "number" && owner.expiresAt <= nowMs) return { status: "expired" };
35
+ return { status: "available" };
36
+ }
37
+
38
+ /** Inspect a worktree credential. Only `exports` ever contains the secret. */
39
+ export async function doctorAgentAuth({
40
+ cwd = process.cwd(), env = process.env, fix = false, printExports = false,
41
+ readState = readAgentState, isFresh = isAgentStateFresh, now = () => Date.now(),
42
+ binding = readAgentBinding, ownerToken = () => resolveOwnerToken({ getConfig }),
43
+ selfHeal = selfHealAgentSession,
44
+ } = {}) {
45
+ const [state, bound, owner] = await Promise.all([
46
+ readState(cwd), binding(cwd).catch(() => null), ownerToken().catch(() => null),
47
+ ]);
48
+ const fresh = Boolean(state && isFresh(state, new Date()));
49
+ const report = {
50
+ schema_version: 1,
51
+ worktree: cwd,
52
+ binding: bound?.tenant ? { status: "available", tenant: bound.tenant } : { status: "missing" },
53
+ client_key: clientKeyStatus(owner, now()),
54
+ session: fresh
55
+ ? { status: "fresh", agent_id: state.agent_id, session_id: state.session_id, expires_at: state.expires_at }
56
+ : { status: state ? "stale" : "missing" },
57
+ };
58
+ let credential = fresh
59
+ ? { token: state.agent_session_token, sessionId: state.session_id, agentId: state.agent_id, source: "cache" }
60
+ : null;
61
+ if (!fresh && fix && report.binding.status === "available" && report.client_key.status === "available") {
62
+ try {
63
+ credential = await selfHeal({ cwd, env });
64
+ report.session = { status: credential.source === "minted" ? "minted" : credential.source, agent_id: credential.agentId ?? null, session_id: credential.sessionId ?? null };
65
+ } catch (error) {
66
+ 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`";
70
+ return { exitCode: 3, report, exports: null };
71
+ }
72
+ }
73
+ 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";
80
+ return { exitCode: 3, report, exports: null };
81
+ }
82
+ const exports = printExports
83
+ ? [
84
+ `export BOTBUDDY_AGENT_SESSION_TOKEN=${shellQuote(credential.token)}`,
85
+ ...(credential.sessionId ? [`export BOTBUDDY_SESSION_ID=${shellQuote(credential.sessionId)}`] : []),
86
+ ].join("\n")
87
+ : null;
88
+ return { exitCode: 0, report, exports };
89
+ }
90
+
91
+ 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.";
93
+ }
package/src/agent-key.mjs CHANGED
@@ -1,29 +1,34 @@
1
- // BOT-1582 — the per-session agent key the CLI presents to the relay for
1
+ // BOT-1649 — the per-session agent token the CLI presents to the relay for
2
2
  // `botbuddy wait`/`run`/`test`/`pw`.
3
3
  //
4
4
  // register_agent mints this token (bound to the work-graph session it returns)
5
5
  // and the relay derives agent, tenant, and the arming session from it, so a
6
6
  // session that exports it needs no --profile/--session-id/--token.
7
7
  //
8
- // Prefix: `bb_agent_` + 64 lowercase hex, mirroring the server's SESSION_TOKEN_RE
9
- // (supabase/functions/_shared/sessionToken.ts). The BOT-1572 `bb_sess_` shape is
8
+ // Prefix: `bb_sess_` + 64 lowercase hex, mirroring the server's SESSION_TOKEN_RE
9
+ // (supabase/functions/_shared/sessionToken.ts). The prior `bb_agent_` shape is
10
10
  // accepted as a legacy alias for one release (its hash is already on `sessions`).
11
11
  // This is a SHAPE check only — authorization is the server's hashed lookup.
12
12
  export const AGENT_KEY_RE = /^bb_(agent|sess)_[0-9a-f]{64}$/;
13
13
 
14
- // BOT-1582: the env var was renamed BOTBUDDY_SESSION_TOKEN → BOTBUDDY_AGENT_KEY.
15
- // The new name wins; the old one is honoured for one release so a session that
16
- // still exports it keeps working. Returns null when neither is set.
14
+ // BOT-1649: BOTBUDDY_AGENT_SESSION_TOKEN names the short-lived credential
15
+ // honestly. BOTBUDDY_AGENT_KEY and BOTBUDDY_SESSION_TOKEN remain aliases for one
16
+ // release. Returns null when none is set.
17
17
  //
18
18
  // An EMPTY/blank value counts as unset (not as a present-but-empty token): an env
19
19
  // template that declares BOTBUDDY_AGENT_KEY="" while a valid BOTBUDDY_SESSION_TOKEN
20
20
  // is still exported must fall through to the legacy name, or the one-release
21
21
  // compatibility guarantee breaks (Codex P2). So trim and skip blanks rather than
22
22
  // `??`, which would stop at the empty string.
23
- export function readAgentKeyEnv(env = process.env) {
24
- const first = typeof env.BOTBUDDY_AGENT_KEY === "string" ? env.BOTBUDDY_AGENT_KEY.trim() : "";
25
- if (first) return env.BOTBUDDY_AGENT_KEY;
23
+ export function readAgentSessionTokenEnv(env = process.env) {
24
+ const canonical = typeof env.BOTBUDDY_AGENT_SESSION_TOKEN === "string" ? env.BOTBUDDY_AGENT_SESSION_TOKEN.trim() : "";
25
+ if (canonical) return env.BOTBUDDY_AGENT_SESSION_TOKEN;
26
+ const alias = typeof env.BOTBUDDY_AGENT_KEY === "string" ? env.BOTBUDDY_AGENT_KEY.trim() : "";
27
+ if (alias) return env.BOTBUDDY_AGENT_KEY;
26
28
  const legacy = typeof env.BOTBUDDY_SESSION_TOKEN === "string" ? env.BOTBUDDY_SESSION_TOKEN.trim() : "";
27
29
  if (legacy) return env.BOTBUDDY_SESSION_TOKEN;
28
30
  return null;
29
31
  }
32
+
33
+ // Deprecated exported alias for downstream CLI integrations.
34
+ export const readAgentKeyEnv = readAgentSessionTokenEnv;
Binary file
@@ -0,0 +1,166 @@
1
+ // BOT-1649 — a per-worktree, gitignored cache for the short-lived agent session.
2
+ // It deliberately holds a session token (mode 0600), never a durable client key.
3
+ import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join, parse } from "node:path";
6
+ import { randomUUID } from "node:crypto";
7
+ import { AGENT_KEY_RE } from "./agent-key.mjs";
8
+ import { acquireStackLock } from "./stack-file-lock.mjs";
9
+
10
+ // BOT-1649 (Codex round-4 P2): every mutation of the cache (self-heal mint, an
11
+ // SSE keepalive expiry touch, logout's clear) must be serialized on ONE lock —
12
+ // the same `<agent-state>.lock` selfHealAgentSession takes — so a delayed touch
13
+ // can never rename a stale credential back over a newer mint or a logout delete.
14
+ async function withStateLock(cwd, { timeoutMs } = {}, fn) {
15
+ const lockPath = `${await agentStatePath(cwd)}.lock`;
16
+ await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
17
+ const lease = await acquireStackLock(lockPath, timeoutMs ? { timeoutMs } : {});
18
+ try { return await fn(); } finally { lease.release(); }
19
+ }
20
+
21
+ export const AGENT_STATE_DIR = ".botbuddy";
22
+ export const AGENT_STATE_FILE = "agent-state.json";
23
+ export const AGENT_STATE_PATH = join(AGENT_STATE_DIR, AGENT_STATE_FILE);
24
+ // A stable, per-machine random id kept under the user's home (not a worktree),
25
+ // so every worktree on one machine shares it and two machines never do.
26
+ export const MACHINE_ID_PATH = join(AGENT_STATE_DIR, "machine-id");
27
+
28
+ async function exists(path) {
29
+ try { await readFile(path); return true; } catch { return false; }
30
+ }
31
+
32
+ /** Locate the nearest worktree metadata directory without ever escaping upward
33
+ * past a repository binding. A standalone caller falls back to its cwd. */
34
+ export async function findAgentStateRoot(cwd = process.cwd()) {
35
+ let dir = cwd;
36
+ const root = parse(dir).root;
37
+ while (true) {
38
+ if (await exists(join(dir, ".botbuddy-agent.json")) || await exists(join(dir, AGENT_STATE_DIR))) return dir;
39
+ if (dir === root) return cwd;
40
+ dir = dirname(dir);
41
+ }
42
+ }
43
+
44
+ export async function agentStatePath(cwd = process.cwd()) {
45
+ return join(await findAgentStateRoot(cwd), AGENT_STATE_PATH);
46
+ }
47
+
48
+ function validState(value) {
49
+ return Boolean(value) && typeof value === "object" && value.schema_version === 1
50
+ && typeof value.agent_id === "string" && value.agent_id.length > 0
51
+ && typeof value.agent_session_token === "string" && AGENT_KEY_RE.test(value.agent_session_token)
52
+ && (typeof value.session_id === "string" || value.session_id === null)
53
+ && typeof value.tenant === "string" && value.tenant.length > 0
54
+ && typeof value.host === "string" && value.host.length > 0
55
+ && typeof value.worktree === "string" && value.worktree.length > 0
56
+ && typeof value.minted_at === "string" && !Number.isNaN(Date.parse(value.minted_at))
57
+ && typeof value.expires_at === "string" && !Number.isNaN(Date.parse(value.expires_at));
58
+ }
59
+
60
+ /** Never throw malformed cache contents into credential resolution. */
61
+ export async function readAgentState(cwd = process.cwd()) {
62
+ try {
63
+ const value = JSON.parse(await readFile(await agentStatePath(cwd), "utf8"));
64
+ return validState(value) ? value : null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /** Atomically replace cache content and ensure the destination remains owner-only. */
71
+ export async function writeAgentState(cwd, state) {
72
+ if (!validState(state)) throw new Error("agent state is missing required fields");
73
+ const path = await agentStatePath(cwd);
74
+ const directory = dirname(path);
75
+ await mkdir(directory, { recursive: true, mode: 0o700 });
76
+ const temp = join(directory, `.${AGENT_STATE_FILE}.${process.pid}.${randomUUID()}.tmp`);
77
+ try {
78
+ await writeFile(temp, `${JSON.stringify(state)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
79
+ await chmod(temp, 0o600);
80
+ await rename(temp, path);
81
+ await chmod(path, 0o600);
82
+ } finally {
83
+ try { await unlink(temp); } catch { /* rename or failed write already removed it */ }
84
+ }
85
+ return path;
86
+ }
87
+
88
+ /** The expiry must be more than skewSeconds away; exactly at the boundary is stale. */
89
+ export function isAgentStateFresh(state, now = new Date(), skewSeconds = 300) {
90
+ if (!validState(state)) return false;
91
+ const expiresAt = Date.parse(state.expires_at);
92
+ const nowMs = now instanceof Date ? now.getTime() : Number(now);
93
+ return Number.isFinite(nowMs) && expiresAt - (skewSeconds * 1000) > nowMs;
94
+ }
95
+
96
+ export async function clearAgentState(cwd = process.cwd()) {
97
+ return withStateLock(cwd, {}, async () => {
98
+ try {
99
+ await unlink(await agentStatePath(cwd));
100
+ return true;
101
+ } catch (error) {
102
+ if (error?.code === "ENOENT") return false;
103
+ throw error;
104
+ }
105
+ });
106
+ }
107
+
108
+ // The session-token lifetime the server mints (SESSION_TOKEN_TTL_MS, 8 h). The
109
+ // server rolls session_token_expires_at to now + this on every relay use.
110
+ export const SESSION_TOKEN_TTL_MS = 8 * 60 * 60 * 1000;
111
+
112
+ /**
113
+ * BOT-1649 (Codex P1): the server rolls `session_token_expires_at` forward on
114
+ * every relay use, but the local cache keeps its original deadline — so an
115
+ * actively-used session can be wrongly judged stale and re-minted, which revokes
116
+ * the token a concurrent wait still holds. After a successful relay use, mirror
117
+ * the server's roll into the cache (only when it still holds the SAME token we
118
+ * used, so we never extend a credential that isn't the one just accepted).
119
+ */
120
+ export async function touchAgentStateExpiry(cwd, token, now = new Date(), ttlMs = SESSION_TOKEN_TTL_MS) {
121
+ if (!token) return false;
122
+ const nowMs = now instanceof Date ? now.getTime() : Number(now);
123
+ if (!Number.isFinite(nowMs)) return false;
124
+ // Best-effort and fire-and-forget from the SSE loop: fail fast on contention
125
+ // (self-heal holds the lock across a network mint) rather than piling up.
126
+ try {
127
+ return await withStateLock(cwd, { timeoutMs: 3000 }, async () => {
128
+ // Re-read INSIDE the lock so a concurrent mint/clear that changed the token
129
+ // (or removed the cache) is seen — never resurrect a replaced/deleted cache.
130
+ const state = await readAgentState(cwd);
131
+ if (!state || state.agent_session_token !== token) return false;
132
+ await writeAgentState(cwd, { ...state, expires_at: new Date(nowMs + ttlMs).toISOString() });
133
+ return true;
134
+ });
135
+ } catch {
136
+ return false;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * BOT-1649 (Codex P1): the server's technical agent name must be globally
142
+ * unique, but `hostname()` + worktree path collide across two operators running
143
+ * standardized containers with the same hostname and checkout path — the second
144
+ * operator's first self-heal then fails with `agent_name_taken`. Fold in a
145
+ * persisted, per-machine random id (created once, mode 0600) so the name carries
146
+ * entropy no host-provided identifier can supply. Two machines never share it
147
+ * (separate filesystems); a read-only home degrades to a per-process id, which
148
+ * still avoids cross-machine collision.
149
+ */
150
+ export async function resolveMachineId({
151
+ home = homedir(), read = readFile, write = writeFile, makeDir = mkdir,
152
+ setMode = chmod, newId = () => randomUUID(),
153
+ } = {}) {
154
+ const path = join(home, MACHINE_ID_PATH);
155
+ try {
156
+ const existing = (await read(path, "utf8")).trim();
157
+ if (/^[0-9a-f-]{8,}$/i.test(existing)) return existing;
158
+ } catch { /* missing or unreadable — mint one below */ }
159
+ const id = newId();
160
+ try {
161
+ await makeDir(dirname(path), { recursive: true, mode: 0o700 });
162
+ await write(path, `${id}\n`, { encoding: "utf8", mode: 0o600 });
163
+ await setMode(path, 0o600);
164
+ } catch { /* a read-only home still yields a usable (if unpersisted) id */ }
165
+ return id;
166
+ }
package/src/commands.mjs CHANGED
@@ -1,4 +1,7 @@
1
1
  import os from "node:os";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { execFileSync } from "node:child_process";
4
+ import { join } from "node:path";
2
5
  import { callTool, callToolJson, readResource } from "./api.mjs";
3
6
  import { doLogin } from "./auth.mjs";
4
7
  import { runBridge } from "./codex-bridge.mjs";
@@ -22,6 +25,8 @@ import { maybeWarnStale, cmdUpdate } from "./update-check.mjs";
22
25
  import { createTelemetryOutbox } from "./telemetry-outbox.mjs";
23
26
  import { attestTelemetryCredential, loadTelemetryIdentity, loadTelemetryLocation } from "./telemetry-config.mjs";
24
27
  import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
28
+ import { doctorAgentAuth, doctorHelp, parseDoctorArgs } from "./agent-doctor.mjs";
29
+ import { clearAgentState, selfHealAgentSession } from "./agent-session.mjs";
25
30
 
26
31
  // BOT-1566 D2: the stale-CLI check runs for every command EXCEPT `wait` and any
27
32
  // invocation carrying `--json` — those are hot paths whose receipts (BOT-1229)
@@ -45,8 +50,10 @@ export async function run(argv, {
45
50
  switch (command) {
46
51
  case "start": return cmdStart(args);
47
52
  case "login": return cmdLogin(args, { errorLog });
53
+ case "setup": return cmdSetup(args, { errorLog });
48
54
  case "logout": return cmdLogout();
49
55
  case "status": return cmdStatus();
56
+ case "doctor": return cmdDoctor(args);
50
57
  case "update": return cmdUpdate();
51
58
  // Agent-only commands (used by MCP agents, not humans)
52
59
  case "heartbeat": return cmdHeartbeat(args);
@@ -133,8 +140,12 @@ ${bold("OPTIONS")}
133
140
  ${bold("AUTH")}
134
141
  login [--no-browser] [--tenant <slug>] [--caller <name>] [--caller-harness <h>] [--caller-ticket <ref>]
135
142
  Authenticate via OAuth (opens browser + localhost callback)
143
+ setup [--no-browser] [--tenant <slug>]
144
+ Bootstrap this bound worktree for variable-free waits
136
145
  logout Remove saved credentials
137
146
  status Show current auth status (local metadata + server check)
147
+ doctor [--fix] [--print-exports] [--json]
148
+ Diagnose or repair the cached agent session
138
149
 
139
150
  ${bold("MCP CONFIG KEY")}
140
151
  mcp setup [--env <NAME>] [--tenant <slug>] [--label <l>] [--expiry-days <n>]
@@ -190,6 +201,23 @@ ${bold("OTHER")}
190
201
  ${SETUP_BLOCK}`);
191
202
  }
192
203
 
204
+ export async function cmdDoctor(args, { log = (line) => console.log(line), errorLog = (line) => console.error(line) } = {}) {
205
+ const parsed = parseDoctorArgs(args);
206
+ if (parsed.help) { log(doctorHelp()); return 0; }
207
+ if (parsed.errors.length) { errorLog(`bb doctor: ${parsed.errors.join("; ")}`); return 4; }
208
+ const result = await doctorAgentAuth({ fix: parsed.fix, printExports: parsed.printExports });
209
+ if (parsed.json) {
210
+ log(JSON.stringify(result.report));
211
+ } else {
212
+ log(`binding: ${result.report.binding.status}${result.report.binding.tenant ? ` (${result.report.binding.tenant})` : ""}`);
213
+ log(`client key: ${result.report.client_key.status}`);
214
+ log(`agent session: ${result.report.session.status}`);
215
+ if (result.report.recovery) log(`recovery: ${result.report.recovery}`);
216
+ }
217
+ if (result.exports) log(result.exports);
218
+ return result.exitCode;
219
+ }
220
+
193
221
  // ─── BOT-876: generic tool access ───────────────────────────────
194
222
 
195
223
  async function cmdCall(args) {
@@ -290,19 +318,53 @@ export function parseLoginArgs(args) {
290
318
  // BOTBUDDY_CALLER* environment variables so an agent harness can advertise who it
291
319
  // is without every wrapper having to pass flags. A flag always wins over the env.
292
320
  // Returns only the three caller fields; empty values collapse to null.
293
- export function resolveCaller(parsed = {}, env = {}) {
294
- const pick = (flagVal, envKey) => {
321
+ function gitHint(cwd, args) {
322
+ try { return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 1_000 }).trim() || null; }
323
+ catch { return null; }
324
+ }
325
+
326
+ /** Best-effort display-only context. It is never an authorization input. */
327
+ export function deriveCallerHint({ env = process.env, cwd = process.cwd() } = {}) {
328
+ let cachedAgentId = null;
329
+ try {
330
+ const path = join(cwd, ".botbuddy", "agent-state.json");
331
+ if (existsSync(path)) {
332
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
333
+ cachedAgentId = typeof parsed?.agent_id === "string" && parsed.agent_id ? parsed.agent_id : null;
334
+ }
335
+ } catch { /* a malformed cache is not a login failure */ }
336
+ const branch = gitHint(cwd, ["branch", "--show-current"]);
337
+ const ticket = branch?.match(/(?:^|\/)((?:bot|ent)-\d+)(?:-|$)/i)?.[1]?.toUpperCase() ?? null;
338
+ const remote = gitHint(cwd, ["remote", "get-url", "origin"]);
339
+ const repo = remote?.match(/(?:github\.com[:/])([^/]+\/[^/.]+)(?:\.git)?$/i)?.[1] ?? null;
340
+ const harness = env.BOTBUDDY_CALLER_HARNESS
341
+ || (env.CLAUDE_CODE || env.CLAUDECODE ? "claude-code" : Object.keys(env).some((key) => key.startsWith("CODEX_")) ? "codex" : null);
342
+ return {
343
+ caller: env.BOTBUDDY_AGENT_NAME || (cachedAgentId ? `agent ${cachedAgentId}` : repo),
344
+ callerHarness: harness,
345
+ callerTicket: ticket ? `https://linear.app/botbuddy/issue/${ticket}` : null,
346
+ };
347
+ }
348
+
349
+ export function resolveCaller(parsed = {}, env = {}, { cwd = process.cwd(), derive = deriveCallerHint } = {}) {
350
+ const inferred = derive({ env, cwd });
351
+ const pick = (field, envKey, inferredValue) => {
352
+ const flagVal = parsed[field];
295
353
  // A flag that was PROVIDED wins over the environment, even when it is an
296
354
  // explicit empty `--caller=` — that is how a user clears a stale inherited
297
355
  // identity, so it must NOT fall back to the env. `null` means the flag was
298
356
  // omitted → fall back to the env var. Either way an empty result is null.
299
- const raw = flagVal != null ? flagVal : env[envKey];
300
- return typeof raw === "string" && raw !== "" ? raw : null;
357
+ if (flagVal != null) return typeof flagVal === "string" && flagVal !== "" ? flagVal : null;
358
+ if (Object.prototype.hasOwnProperty.call(env, envKey)) {
359
+ const raw = env[envKey];
360
+ return typeof raw === "string" && raw !== "" ? raw : null;
361
+ }
362
+ return inferredValue ?? null;
301
363
  };
302
364
  return {
303
- caller: pick(parsed.caller, "BOTBUDDY_CALLER"),
304
- callerHarness: pick(parsed.callerHarness, "BOTBUDDY_CALLER_HARNESS"),
305
- callerTicket: pick(parsed.callerTicket, "BOTBUDDY_CALLER_TICKET"),
365
+ caller: pick("caller", "BOTBUDDY_CALLER", inferred.caller),
366
+ callerHarness: pick("callerHarness", "BOTBUDDY_CALLER_HARNESS", inferred.callerHarness),
367
+ callerTicket: pick("callerTicket", "BOTBUDDY_CALLER_TICKET", inferred.callerTicket),
306
368
  };
307
369
  }
308
370
 
@@ -328,6 +390,80 @@ async function cmdLogin(args, { errorLog = (line) => console.error(line) } = {})
328
390
  }
329
391
  }
330
392
 
393
+ /** Bootstrap a bound worktree without exposing credential or registration steps. */
394
+ export async function cmdSetup(args, {
395
+ login = doLogin,
396
+ binding = readAgentBinding,
397
+ clearState = clearAgentState,
398
+ selfHeal = selfHealAgentSession,
399
+ callerResolver = resolveCaller,
400
+ cwd = process.cwd(),
401
+ env = process.env,
402
+ log = (line) => console.log(line),
403
+ errorLog = (line) => console.error(line),
404
+ } = {}) {
405
+ let options;
406
+ try {
407
+ options = parseLoginArgs(args);
408
+ } catch (error) {
409
+ if (!(error instanceof LoginUsageError)) throw error;
410
+ errorLog(error.message);
411
+ return error.exitCode === 2 ? 2 : 4;
412
+ }
413
+ if (options.help) {
414
+ log("bb setup [--no-browser] [--tenant <slug>]\n\nSigns in, verifies the committed .botbuddy-agent.json binding, and caches this worktree's session. After it succeeds, use `bb wait <condition>` with no credential flags or environment variables.");
415
+ return 0;
416
+ }
417
+ let bound;
418
+ try {
419
+ bound = await binding(cwd);
420
+ } catch (error) {
421
+ errorLog(`bb setup: ${error?.code ?? "invalid_binding"}; fix the committed .botbuddy-agent.json binding and retry.`);
422
+ return 4;
423
+ }
424
+ if (!bound?.tenant) {
425
+ errorLog("bb setup: no .botbuddy-agent.json binding found; run this from a configured project worktree.");
426
+ return 4;
427
+ }
428
+ if (options.tenant && options.tenant !== bound.tenant) {
429
+ errorLog(`bb setup: requested tenant ${options.tenant} does not match this worktree's ${bound.tenant} binding.`);
430
+ return 4;
431
+ }
432
+ const tenant = bound.tenant;
433
+ try {
434
+ // Login owns a machine/user credential, not a worktree tenant credential.
435
+ // The committed binding below is the only place setup scopes the minted
436
+ // session. Forwarding --tenant here would overwrite a multi-tenant user's
437
+ // reusable client key with a tenant-sealed one.
438
+ await login({ noBrowser: options.noBrowser, ...callerResolver(options, env) });
439
+ // Setup is an explicit session bootstrap/repair command. Rotate before
440
+ // resolving so it cannot merely report an old cache as ready.
441
+ await clearState(cwd);
442
+ const session = await selfHeal({ cwd, env });
443
+ // BOT-1649 (Codex round-6 P1): register_agent reports any OTHER live session
444
+ // already holding this ticket (heartbeat < 15m) in `ticket_signals`. One
445
+ // ticket = one live session is a hard stop (AGENTS.md), so refuse to report
446
+ // the worktree ready — otherwise setup silently green-lights a second coding
447
+ // session on the ticket. The mint above re-adopts this worktree's own agent
448
+ // and is not itself a second holder.
449
+ const holders = session.ticketSignals?.concurrency?.holders;
450
+ if (Array.isArray(holders) && holders.length > 0) {
451
+ const warning = session.ticketSignals.concurrency.warning
452
+ || `another live session already holds ${tenant}'s ticket`;
453
+ const who = holders
454
+ .map((h) => `${h.agent ?? h.agent_id}${h.branch ? ` (${h.branch})` : ""}`)
455
+ .join(", ");
456
+ errorLog(`bb setup: ${warning} — held by ${who}. Do not start a second coding session on this ticket; hand off to the live holder or wait for it to finish.`);
457
+ return 5;
458
+ }
459
+ log(`bb setup: ${tenant} is ready (${session.source} worktree session). Run bb wait '<condition>'.`);
460
+ return 0;
461
+ } catch (error) {
462
+ errorLog(`bb setup: could not prepare this worktree (${error?.code ?? "setup_failed"}). Retry bb setup after completing sign-in.`);
463
+ return 3;
464
+ }
465
+ }
466
+
331
467
  export function loginHelp(log = console.log) {
332
468
  log(`${bold("botbuddy login")} — authenticate via OAuth (browser + loopback callback)
333
469
 
@@ -338,10 +474,10 @@ ${bold("USAGE")}
338
474
  ${bold("TOKEN SCOPE")}
339
475
  By default login installs this machine's ${bold("client key (bb_cli_)")}: a user
340
476
  token that is not bound to a tenant and reaches every tenant you belong to —
341
- each request pins one. ONE login serves ALL tenants; ${cyan("register_agent")} then
342
- exchanges it for a per-session agent token. No per-tenant credential is needed
343
- for normal use mint a ${bold("bb_mcp_")} key per tenant (${cyan("botbuddy mcp setup")}) only
344
- when a repo binds a distinct ${dim("mcp_env")}.
477
+ each request pins one. ONE login serves ALL tenants. For a project worktree,
478
+ use ${cyan("bb setup")} to finish setup and cache its session; normal waits need
479
+ no further credential commands. Mint a ${bold("bb_mcp_")} key per tenant (${cyan("botbuddy mcp setup")}) only
480
+ when configuring an MCP client.
345
481
  ${cyan("--tenant <slug>")} instead mints a ${bold("tenant token")} sealed to that one
346
482
  tenant (the MCP-session model); use it only when you want that guarantee.
347
483
 
@@ -592,9 +728,12 @@ export async function cmdStatus({
592
728
  log(` Run: ${cyan("botbuddy start")}`);
593
729
  }
594
730
 
595
- async function cmdLogout() {
731
+ export async function cmdLogout({ cwd = process.cwd(), clearState = clearAgentState } = {}) {
596
732
  clearConfig();
597
733
  await clearOwnerToken();
734
+ // The cached session is a bearer credential. Removing its durable precursor
735
+ // must also remove the per-worktree cache so logout cannot leave a live wait.
736
+ await clearState(cwd);
598
737
  console.log(`${green("✓")} Logged out. Credentials removed.`);
599
738
  }
600
739
 
@@ -20,17 +20,10 @@ export const CREDENTIAL_PREFIXES = [
20
20
  // BOT-1607: the tier-2 MCP config key a .mcp.json presents (independently
21
21
  // revocable; minted by `botbuddy mcp`). Unambiguous by prefix.
22
22
  { prefix: "bb_mcp_", kind: "mcp", label: "MCP key" },
23
- // BOT-1573/1582 transition: bb_agent_ is now BOTH the per-session agent token
24
- // (BOT-1582 renamed it from bb_sess_) AND the older shared service carrier, so
25
- // the prefix ALONE can no longer tell them apart. Classification is display
26
- // only (never auth), and mislabelling a live carrier as an agent is worse than
27
- // the reverse, so bb_agent_ stays classified as the carrier — matching the UI
28
- // adapter. bb_sess_ (in-flight BOT-1572 tokens, accepted one release) is the
29
- // one unambiguously-a-session shape, so it keeps the agent kind. A
30
- // shape-independent way to name a live bb_agent_ session token is deferred to
31
- // the taxonomy/status work (BOT-1573).
32
- { prefix: "bb_agent_", kind: "svc", label: "carrier (legacy)" },
33
- { prefix: "bb_sess_", kind: "agent", label: "agent (session)" },
23
+ // BOT-1649: the durable agent identity and the short-lived agent session are
24
+ // distinct by prefix. This table is display-only; authorization stays server-side.
25
+ { prefix: "bb_agent_", kind: "agent", label: "agent key" },
26
+ { prefix: "bb_sess_", kind: "agent_session", label: "agent session token" },
34
27
  { prefix: "bb_ci_", kind: "ci", label: "CI key" },
35
28
  { prefix: "bb_svc_", kind: "svc", label: "carrier (legacy)" },
36
29
  { prefix: "bb_ios_", kind: "ios", label: "device pairing token" },