@botbuddy/cli 1.30.0 → 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 +1 -1
- package/src/agent-doctor.mjs +93 -0
- package/src/agent-key.mjs +14 -9
- package/src/agent-session.mjs +0 -0
- package/src/agent-state.mjs +166 -0
- package/src/commands.mjs +151 -12
- package/src/credential-kinds.mjs +4 -11
- package/src/pw/coordinator.mjs +7 -1
- package/src/pw/run.mjs +37 -11
- package/src/run.mjs +59 -12
- package/src/setup-block.mjs +16 -27
- package/src/test-lane.mjs +45 -14
- package/src/wait.mjs +245 -168
package/package.json
CHANGED
|
@@ -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-
|
|
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: `
|
|
9
|
-
// (supabase/functions/_shared/sessionToken.ts). The
|
|
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-
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
|
24
|
-
const
|
|
25
|
-
if (
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
300
|
-
|
|
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(
|
|
304
|
-
callerHarness: pick(
|
|
305
|
-
callerTicket: pick(
|
|
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
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
when
|
|
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
|
|
package/src/credential-kinds.mjs
CHANGED
|
@@ -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-
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
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" },
|
package/src/pw/coordinator.mjs
CHANGED
|
@@ -5,7 +5,13 @@ function mcpCaller(authHeader, fetchImpl) {
|
|
|
5
5
|
let id = 0;
|
|
6
6
|
return async function call(name, args) {
|
|
7
7
|
const response = await fetchImpl(SERVER_URL, { method: "POST", headers: { "content-type": "application/json", ...authHeader }, body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method: "tools/call", params: { name, arguments: args } }) });
|
|
8
|
-
if (!response.ok)
|
|
8
|
+
if (!response.ok) {
|
|
9
|
+
let payload = null;
|
|
10
|
+
try { payload = await response.json(); } catch { /* keep the HTTP diagnostic */ }
|
|
11
|
+
const error = new Error(payload?.error?.message ?? payload?.error ?? `BotBuddy lock verification returned HTTP ${response.status}`);
|
|
12
|
+
error.code = payload?.error?.code ?? payload?.error?.error ?? payload?.code ?? payload?.error ?? null;
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
9
15
|
const json = await response.json(); if (json.error) throw new Error(json.error.message || "BotBuddy lock verification failed");
|
|
10
16
|
const text = json.result?.content?.find((item) => item.type === "text")?.text; try { return text ? JSON.parse(text) : {}; } catch { throw new Error("BotBuddy lock verification returned invalid JSON"); }
|
|
11
17
|
};
|