@botbuddy/cli 1.12.2 → 1.13.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.12.2",
3
+ "version": "1.13.1",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,17 +43,25 @@ function keychainService(profile) {
43
43
  // succeeded. It carries its own code so the CLI never mislabels a storage
44
44
  // failure as an auth failure (which would tell the user to re-`login`).
45
45
  export class ProfileCredentialStoreError extends Error {
46
- constructor(message, { cause } = {}) {
46
+ constructor(message, { cause, code = "profile_credential_store_failed" } = {}) {
47
47
  super(message);
48
48
  this.name = "ProfileCredentialStoreError";
49
- this.code = "profile_credential_store_failed";
49
+ this.code = code;
50
50
  if (cause !== undefined) this.cause = cause;
51
51
  }
52
52
  }
53
53
 
54
54
  function writePasswordPrompt(command, args, password, spawnProcess) {
55
55
  return new Promise((resolve, reject) => {
56
- const child = spawnProcess(command, args, { stdio: ["pipe", "ignore", "pipe"] });
56
+ // BOT-1566: `security -w` (prompt mode) reads the secret with
57
+ // readpassphrase(3), which opens the CONTROLLING TERMINAL whenever the
58
+ // process has one — ignoring the piped stdin and silently storing whatever
59
+ // the human types at "password data for new item:". Spawning the child
60
+ // detached puts it in a new session with no controlling TTY, so
61
+ // readpassphrase falls back to stdin and the piped value is what lands.
62
+ // Headless (CI / agent harness) behaviour is unchanged: there was never a
63
+ // TTY to grab.
64
+ const child = spawnProcess(command, args, { stdio: ["pipe", "ignore", "pipe"], detached: true });
57
65
  let stderr = "";
58
66
  child.stderr.on("data", (chunk) => { stderr += chunk; });
59
67
  child.once("error", reject);
@@ -74,7 +82,19 @@ function writePasswordPrompt(command, args, password, spawnProcess) {
74
82
  // name, so both the profile agent-key store and the owner OAuth-token store
75
83
  // share the single implementation of the `security -w` double-read prompt and
76
84
  // the actionable error mapping — no second copy to drift.
77
- export async function writeKeychainSecret(service, token, { spawnProcess = spawn } = {}) {
85
+ //
86
+ // BOT-1566: every write is verified by reading the item back and comparing it
87
+ // byte-for-byte with what was written. `readSecret` is the seam (defaults to
88
+ // the real `security find-generic-password`); a mismatch — the incident shape
89
+ // where the Keychain held a typed password instead of the OAuth token — throws
90
+ // `keychain_readback_mismatch` so callers fail loudly instead of announcing a
91
+ // successful login. Only equality is asserted, never token shape: profile keys
92
+ // (`bb_agent_…`) are not JWTs.
93
+ export async function writeKeychainSecret(service, token, {
94
+ spawnProcess = spawn,
95
+ execFileImpl = execFileAsync,
96
+ readSecret = (svc) => readKeychainSecret(svc, { execFileImpl }),
97
+ } = {}) {
78
98
  if (!service) throw new ProfileCredentialStoreError("keychain write requires a service name");
79
99
  try {
80
100
  await writePasswordPrompt(
@@ -95,6 +115,17 @@ export async function writeKeychainSecret(service, token, { spawnProcess = spawn
95
115
  }
96
116
  throw new ProfileCredentialStoreError(`could not write the credential to the macOS Keychain: ${detail}`, { cause: error });
97
117
  }
118
+ const stored = await readSecret(service);
119
+ if (stored !== token) {
120
+ // Never echo either value: the written token is a credential and the stored
121
+ // one may be a human's typed password.
122
+ throw new ProfileCredentialStoreError(
123
+ `macOS Keychain read-back verification failed for ${service}: the stored value differs from the credential written. `
124
+ + "If `security` prompted you for a password, delete the item (`security delete-generic-password -s "
125
+ + `${service}\`) and retry.`,
126
+ { code: "keychain_readback_mismatch" },
127
+ );
128
+ }
98
129
  }
99
130
 
100
131
  export async function readKeychainSecret(service, { execFileImpl = execFileAsync } = {}) {
@@ -117,10 +148,10 @@ export async function deleteKeychainSecret(service, { execFileImpl = execFileAsy
117
148
  }
118
149
  }
119
150
 
120
- export async function writeKeychain(profile, token, { spawnProcess = spawn } = {}) {
151
+ export async function writeKeychain(profile, token, options = {}) {
121
152
  const service = keychainService(profile);
122
153
  if (!service) throw new ProfileCredentialStoreError(`unknown profile keychain service for "${profile}"`);
123
- return writeKeychainSecret(service, token, { spawnProcess });
154
+ return writeKeychainSecret(service, token, options);
124
155
  }
125
156
 
126
157
  async function readKeychain(profile) {
package/src/auth.mjs CHANGED
@@ -50,7 +50,7 @@ function callbackErrorMessage(err) {
50
50
 
51
51
  // AC-1..AC-13: orchestrate the loopback authorization-code flow.
52
52
  //
53
- // options: { noBrowser }
53
+ // options: { noBrowser, tenant }
54
54
  // deps (all injectable for tests): serverUrl, fetch, openBrowser, saveConfig,
55
55
  // getConfig, log, errorLog, timeoutMs, now.
56
56
  export async function doLogin(options = {}, deps = {}) {
@@ -67,6 +67,8 @@ export async function doLogin(options = {}, deps = {}) {
67
67
  persist = persistOwnerToken,
68
68
  } = deps;
69
69
  const noBrowser = Boolean(options.noBrowser);
70
+ // BOT-1566: optional tenant preselection (validated by the argument parser).
71
+ const tenant = typeof options.tenant === "string" && options.tenant ? options.tenant : null;
70
72
 
71
73
  log(`${bold("BotBuddy OAuth Login")}\n`);
72
74
 
@@ -106,7 +108,7 @@ export async function doLogin(options = {}, deps = {}) {
106
108
 
107
109
  // AC-6/AC-7: build the authorization URL and always print it (headless users
108
110
  // open it themselves). The CLI never fetches /authorize.
109
- const authUrl = buildAuthorizeUrl({ serverUrl, clientId, redirectUri, state, codeChallenge });
111
+ const authUrl = buildAuthorizeUrl({ serverUrl, clientId, redirectUri, state, codeChallenge, tenant });
110
112
  log("");
111
113
  log(dim("→ Authorize BotBuddy in your browser:"));
112
114
  log(` ${cyan(authUrl)}`);
@@ -159,6 +161,9 @@ export async function doLogin(options = {}, deps = {}) {
159
161
  // BOT-1520: the durable secret goes to the Keychain (darwin); config.json
160
162
  // keeps only non-secret metadata. On a non-darwin host without a Keychain,
161
163
  // persistOwnerToken falls back to the 0600 config.json and warns.
164
+ // BOT-1566: a Keychain read-back mismatch (`keychain_readback_mismatch`)
165
+ // propagates from here, so login exits non-zero and the success line below
166
+ // is never printed for a credential that did not verifiably land.
162
167
  const { storedInKeychain } = await persist(
163
168
  { token: tokenData.access_token, expiresAt, clientId },
164
169
  { getConfig, saveConfig, warn: errorLog },
package/src/commands.mjs CHANGED
@@ -14,16 +14,33 @@ import { green, red, cyan, dim, bold, die } from "./utils.mjs";
14
14
  import { VERSION } from "./version.mjs";
15
15
  import { bootstrapProfile, ProfileBootstrapError, profileBootstrapRecovery, profileShellRefresh } from "./profile-bootstrap.mjs";
16
16
  import { runPw } from "./pw/run.mjs";
17
+ import { maybeWarnStale, cmdUpdate } from "./update-check.mjs";
18
+
19
+ // BOT-1566 D2: the stale-CLI check runs for every command EXCEPT `wait` and any
20
+ // invocation carrying `--json` — those are hot paths whose receipts (BOT-1229)
21
+ // must not pay for a registry round-trip.
22
+ export function shouldCheckForUpdates(argv) {
23
+ const [command] = argv;
24
+ if (command === "wait") return false;
25
+ if (argv.includes("--json")) return false;
26
+ return true;
27
+ }
17
28
 
18
- export async function run(argv) {
19
- loadConfig();
29
+ export async function run(argv, {
30
+ loadConfig: load = loadConfig,
31
+ warnStale = maybeWarnStale,
32
+ errorLog = (line) => console.error(line),
33
+ } = {}) {
34
+ load();
20
35
  const [command, ...args] = argv;
36
+ if (shouldCheckForUpdates(argv)) await warnStale({ version: VERSION });
21
37
 
22
38
  switch (command) {
23
39
  case "start": return cmdStart(args);
24
- case "login": return cmdLogin(args);
40
+ case "login": return cmdLogin(args, { errorLog });
25
41
  case "logout": return cmdLogout();
26
42
  case "status": return cmdStatus();
43
+ case "update": return cmdUpdate();
27
44
  // Agent-only commands (used by MCP agents, not humans)
28
45
  case "heartbeat": return cmdHeartbeat(args);
29
46
  case "lock": return cmdLock(args);
@@ -74,9 +91,10 @@ ${bold("OPTIONS")}
74
91
  --no-server Don't auto-start codex app-server
75
92
 
76
93
  ${bold("AUTH")}
77
- login [--no-browser] Authenticate via OAuth (opens browser + localhost callback)
94
+ login [--no-browser] [--tenant <slug>]
95
+ Authenticate via OAuth (opens browser + localhost callback)
78
96
  logout Remove saved credentials
79
- status Show current auth status
97
+ status Show current auth status (local metadata + server check)
80
98
  profile setup <profile> Mint/reconnect and securely store a tenant-bound agent key
81
99
 
82
100
  ${bold("TOOLS")}
@@ -112,6 +130,7 @@ ${bold("BROWSER LANES")}
112
130
 
113
131
  ${bold("OTHER")}
114
132
  locks -m [--host name] Reserve typed local resources, including Playwright MCP lanes
133
+ update Install the latest @botbuddy/cli in the background via npm
115
134
  help Show this help
116
135
  version Show version`);
117
136
  }
@@ -141,15 +160,57 @@ async function cmdToolHelp(args) {
141
160
  console.log(formatDiscovery(await res.json(), { tool }));
142
161
  }
143
162
 
163
+ // BOT-1566 C1: a tenant slug as the server accepts it on `?tenant=` — lowercase
164
+ // alphanumerics and hyphens, 2–63 chars, not starting with a hyphen.
165
+ export const TENANT_SLUG_RE = /^[a-z0-9][a-z0-9-]{1,62}$/;
166
+
167
+ export class LoginUsageError extends Error {
168
+ constructor(message, { exitCode = 1 } = {}) {
169
+ super(message);
170
+ this.name = "LoginUsageError";
171
+ this.exitCode = exitCode;
172
+ }
173
+ }
174
+
175
+ // Parse `botbuddy login` options. `--tenant <slug>` / `--tenant=<slug>`
176
+ // preselects the tenant on the OAuth picker; a malformed slug is a usage
177
+ // error with exit code 2 (never sent to the server).
178
+ export function parseLoginArgs(args) {
179
+ const out = { noBrowser: false, tenant: null, help: false };
180
+ for (let i = 0; i < args.length; i++) {
181
+ const arg = args[i];
182
+ if (arg === "--help" || arg === "-h") { out.help = true; continue; }
183
+ if (arg === "--no-browser") { out.noBrowser = true; continue; }
184
+ if (arg === "--tenant" || arg.startsWith("--tenant=")) {
185
+ const value = arg.startsWith("--tenant=") ? arg.slice("--tenant=".length) : args[++i];
186
+ if (typeof value !== "string" || !TENANT_SLUG_RE.test(value)) {
187
+ throw new LoginUsageError("error: --tenant must be a lowercase slug", { exitCode: 2 });
188
+ }
189
+ out.tenant = value;
190
+ continue;
191
+ }
192
+ if (arg.startsWith("-")) {
193
+ throw new LoginUsageError(`Unknown login option: ${arg}. Run ${cyan("botbuddy login --help")}.`);
194
+ }
195
+ }
196
+ return out;
197
+ }
198
+
144
199
  // BOT-1383: login now runs the RFC 8252 loopback flow (opens the browser,
145
- // waits for the localhost callback). Parse --no-browser and print focused help.
146
- async function cmdLogin(args) {
147
- if (args.includes("--help") || args.includes("-h")) return loginHelp();
148
- const noBrowser = args.includes("--no-browser");
149
- const unknown = args.find((a) => a.startsWith("-") && !["--no-browser", "--help", "-h"].includes(a));
150
- if (unknown) die(`Unknown login option: ${unknown}. Run ${cyan("botbuddy login --help")}.`);
200
+ // waits for the localhost callback). Parse --no-browser / --tenant and print
201
+ // focused help.
202
+ async function cmdLogin(args, { errorLog = (line) => console.error(line) } = {}) {
203
+ let options;
151
204
  try {
152
- await doLogin({ noBrowser });
205
+ options = parseLoginArgs(args);
206
+ } catch (err) {
207
+ if (!(err instanceof LoginUsageError)) throw err;
208
+ if (err.exitCode === 2) { errorLog(err.message); return 2; }
209
+ die(err.message);
210
+ }
211
+ if (options.help) return loginHelp();
212
+ try {
213
+ await doLogin({ noBrowser: options.noBrowser, tenant: options.tenant });
153
214
  } catch (err) {
154
215
  die(err.message);
155
216
  }
@@ -159,7 +220,7 @@ function loginHelp() {
159
220
  console.log(`${bold("botbuddy login")} — authenticate via OAuth (browser + loopback callback)
160
221
 
161
222
  ${bold("USAGE")}
162
- botbuddy login [--no-browser]
223
+ botbuddy login [--no-browser] [--tenant <slug>]
163
224
 
164
225
  ${bold("HOW IT WORKS")}
165
226
  Starts a localhost callback listener on an ephemeral 127.0.0.1 port, opens
@@ -173,6 +234,10 @@ ${bold("OPTIONS")}
173
234
  --no-browser Don't launch a browser; print the authorization URL to open
174
235
  in a browser on THIS machine yourself. The callback listener
175
236
  still runs on this machine's localhost.
237
+ --tenant <slug>
238
+ Preselect a tenant (e.g. ${cyan("supply-guard")}) on the sign-in page when
239
+ your account belongs to more than one. Must be a lowercase slug;
240
+ membership is still enforced by the server.
176
241
 
177
242
  ${bold("NOTES")}
178
243
  • The authorization URL is always printed so you can open it manually.
@@ -216,34 +281,92 @@ async function cmdStart(args) {
216
281
  return runBridge(args);
217
282
  }
218
283
 
219
- async function cmdStatus() {
220
- const cfg = getConfig();
221
- const owner = await resolveOwnerToken({ getConfig });
284
+ // BOT-1566 A4: after the local-metadata lines, ask the SERVER whether the stored
285
+ // credential is actually accepted. `status` used to decode only local metadata,
286
+ // so a Keychain item holding a typed password (the BOT-1566 incident) was
287
+ // reported as "Authenticated". One extra line, never throws, exit code
288
+ // unchanged:
289
+ // server: authenticated (<tenant slug>) whoami resolved
290
+ // server: rejected (<error message>) the server refused the credential
291
+ // server: unreachable network / transport failure
292
+ //
293
+ // The probe is bounded by STATUS_PROBE_TIMEOUT_MS: a server that accepts the
294
+ // connection but stalls before headers must not hang this formerly local
295
+ // diagnostic — an aborted fetch returns a `transport:` error → `unreachable`
296
+ // (BOT-1566, Codex P2).
297
+ const STATUS_PROBE_TIMEOUT_MS = 4000;
298
+
299
+ async function logServerStatus(call, auth, log) {
300
+ let res;
301
+ try {
302
+ res = await call("whoami", {}, { auth, signal: AbortSignal.timeout(STATUS_PROBE_TIMEOUT_MS) });
303
+ } catch {
304
+ log(` server: ${red("unreachable")}`);
305
+ return;
306
+ }
307
+ if (res?.ok && !res.isError) {
308
+ const slug = typeof res.data?.tenant_id === "string" && res.data.tenant_id ? res.data.tenant_id : "tenant unresolved";
309
+ log(` server: ${green("authenticated")} (${slug})`);
310
+ return;
311
+ }
312
+ if (res?.ok && res.isError) {
313
+ log(` server: ${red("rejected")} (${res.data?.error ?? res.data?.code ?? "tool error"})`);
314
+ return;
315
+ }
316
+ if (res?.status === null && /^transport:/.test(String(res?.error ?? ""))) {
317
+ log(` server: ${red("unreachable")}`);
318
+ return;
319
+ }
320
+ log(` server: ${red("rejected")} (${res?.error ?? "unknown error"})`);
321
+ }
322
+
323
+ export async function cmdStatus({
324
+ getConfig: getCfg = getConfig,
325
+ resolveOwnerToken: resolveOwner = resolveOwnerToken,
326
+ resolveAgentKey: resolveAgent = resolveAgentKey,
327
+ callToolJson: call = callToolJson,
328
+ log = (line) => console.log(line),
329
+ now = () => Date.now(),
330
+ } = {}) {
331
+ const cfg = getCfg();
332
+ const owner = await resolveOwner({ getConfig: getCfg });
222
333
  if (owner) {
223
- console.log(`${green("✓")} Authenticated via OAuth ${dim("(Keychain)")}`);
224
- if (cfg.agent_name) console.log(` Agent: ${cyan(cfg.agent_name)}`);
225
- if (cfg.client_id) console.log(` Client: ${dim(cfg.client_id)}`);
334
+ log(`${green("✓")} Authenticated via OAuth ${dim("(Keychain)")}`);
335
+ if (cfg.agent_name) log(` Agent: ${cyan(cfg.agent_name)}`);
336
+ if (cfg.client_id) log(` Client: ${dim(cfg.client_id)}`);
226
337
  if (owner.expiresAt) {
227
- const remaining = owner.expiresAt - Date.now();
338
+ const remaining = owner.expiresAt - now();
228
339
  if (remaining <= 0) {
229
- console.log(` Token: ${red("EXPIRED")} — run ${cyan("botbuddy start")}`);
340
+ log(` Token: ${red("EXPIRED")} — run ${cyan("botbuddy start")}`);
230
341
  } else {
231
342
  const mins = Math.round(remaining / 60000);
232
343
  const label = mins > 60 ? `${Math.round(mins / 60)}h ${mins % 60}m` : `${mins}m`;
233
- console.log(` Token expires in: ${dim(label)}`);
344
+ log(` Token expires in: ${dim(label)}`);
234
345
  }
235
346
  }
236
- console.log(` Config: ${dim(getConfigPath())}`);
347
+ log(` Config: ${dim(getConfigPath())}`);
348
+ // BOT-1566 (Codex P2): match resolveCallAuth — an expired owner token defers
349
+ // to a valid profile agent key, so probe the credential real commands would
350
+ // actually use instead of reporting a false `server: rejected` on the dead
351
+ // bearer while authenticated commands still succeed via the key.
352
+ const ownerExpired = owner.expiresAt && owner.expiresAt <= now();
353
+ const fallbackKey = ownerExpired ? await resolveAgent() : null;
354
+ await logServerStatus(
355
+ call,
356
+ fallbackKey ? { "x-agent-api-key": fallbackKey } : { Authorization: `Bearer ${owner.token}` },
357
+ log,
358
+ );
237
359
  return;
238
360
  }
239
- const agentKey = await resolveAgentKey();
361
+ const agentKey = await resolveAgent();
240
362
  if (agentKey) {
241
- console.log(`${green("✓")} Authenticated via agent key ${dim("(Keychain profile store)")}`);
242
- if (cfg.agent_name) console.log(` Agent: ${cyan(cfg.agent_name)}`);
363
+ log(`${green("✓")} Authenticated via agent key ${dim("(Keychain profile store)")}`);
364
+ if (cfg.agent_name) log(` Agent: ${cyan(cfg.agent_name)}`);
365
+ await logServerStatus(call, { "x-agent-api-key": agentKey }, log);
243
366
  return;
244
367
  }
245
- console.log(`${red("✗")} Not authenticated`);
246
- console.log(` Run: ${cyan("botbuddy start")}`);
368
+ log(`${red("✗")} Not authenticated`);
369
+ log(` Run: ${cyan("botbuddy start")}`);
247
370
  }
248
371
 
249
372
  async function cmdLogout() {
@@ -46,7 +46,7 @@ export function generateState() {
46
46
 
47
47
  // AC-6/AC-7: build the complete /authorize URL. The browser follows it; the CLI
48
48
  // never fetches it. `redirectUri` must be the exact loopback callback we bound.
49
- export function buildAuthorizeUrl({ serverUrl, clientId, redirectUri, state, codeChallenge, scope = "read write lock" }) {
49
+ export function buildAuthorizeUrl({ serverUrl, clientId, redirectUri, state, codeChallenge, scope = "read write lock", tenant = null }) {
50
50
  const url = new URL(`${serverUrl}/authorize`);
51
51
  url.searchParams.set("client_id", clientId);
52
52
  url.searchParams.set("redirect_uri", redirectUri);
@@ -55,6 +55,10 @@ export function buildAuthorizeUrl({ serverUrl, clientId, redirectUri, state, cod
55
55
  url.searchParams.set("state", state);
56
56
  url.searchParams.set("code_challenge", codeChallenge);
57
57
  url.searchParams.set("code_challenge_method", "S256");
58
+ // BOT-1566: `login --tenant <slug>` preselects the tenant. /authorize forwards
59
+ // it to the /mcp-auth picker as `requestedTenant`; membership is still
60
+ // enforced server-side at authorize_complete — this only preselects.
61
+ if (tenant) url.searchParams.set("tenant", tenant);
58
62
  return url.toString();
59
63
  }
60
64
 
@@ -0,0 +1,127 @@
1
+ // BOT-1566 D: stale-CLI warning + `botbuddy update`.
2
+ //
3
+ // The CLI is published to npm; a human running an old build gets no signal
4
+ // that a fix already shipped (the BOT-1566 keychain bug was found on a CLI two
5
+ // releases behind). `maybeWarnStale` prints ONE line to stderr when npm's
6
+ // `latest` is newer than the running version. It is throttled through a 24h
7
+ // JSON cache at ~/.botbuddy/update-check.json, bounded by a 1500 ms registry
8
+ // timeout, and fail-open: any error (offline, registry down, unreadable cache)
9
+ // is silent. It never writes to stdout — JSON receipts own stdout.
10
+ //
11
+ // `cmdUpdate` deliberately does NOT update in-process (BOT-1499: a self-update
12
+ // that replaces its own running files can kill itself mid-install). It hands
13
+ // off to a detached, unref'd `npm install -g` and exits 0.
14
+
15
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
16
+ import { spawn } from "node:child_process";
17
+ import { homedir } from "node:os";
18
+ import { dirname, join } from "node:path";
19
+
20
+ import { VERSION } from "./version.mjs";
21
+
22
+ export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
23
+ export const NPM_LATEST_URL = "https://registry.npmjs.org/@botbuddy/cli/latest";
24
+ export const NPM_LATEST_TIMEOUT_MS = 1500;
25
+ export const UPDATE_COMMAND = "npm i -g @botbuddy/cli@latest";
26
+
27
+ export function defaultUpdateCheckCachePath(home = homedir()) {
28
+ return join(home, ".botbuddy", "update-check.json");
29
+ }
30
+
31
+ function parseSemver(value) {
32
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value ?? "").trim());
33
+ if (!match) return null;
34
+ return { parts: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease: match[4] ?? null };
35
+ }
36
+
37
+ // True when `latest` is strictly greater than `current`. Numeric per segment;
38
+ // a prerelease of the same core version ranks below the release. Unparseable
39
+ // input is never "newer" — a garbage registry answer must not nag.
40
+ export function isNewerVersion(latest, current) {
41
+ const a = parseSemver(latest);
42
+ const b = parseSemver(current);
43
+ if (!a || !b) return false;
44
+ for (let i = 0; i < 3; i++) {
45
+ if (a.parts[i] !== b.parts[i]) return a.parts[i] > b.parts[i];
46
+ }
47
+ if (a.prerelease && !b.prerelease) return false;
48
+ if (!a.prerelease && b.prerelease) return true;
49
+ return false;
50
+ }
51
+
52
+ export async function fetchLatestFromNpm({ fetchImpl = fetch, timeoutMs = NPM_LATEST_TIMEOUT_MS } = {}) {
53
+ const res = await fetchImpl(NPM_LATEST_URL, {
54
+ headers: { accept: "application/json" },
55
+ signal: AbortSignal.timeout(timeoutMs),
56
+ });
57
+ if (!res.ok) throw new Error(`npm registry responded ${res.status}`);
58
+ const body = await res.json();
59
+ if (typeof body?.version !== "string") throw new Error("npm registry response carried no version");
60
+ return body.version;
61
+ }
62
+
63
+ async function readCache(cachePath) {
64
+ try {
65
+ const parsed = JSON.parse(await readFile(cachePath, "utf8"));
66
+ if (typeof parsed?.checkedAt !== "number") return null;
67
+ // `latest` is a version string, or null for a recorded-but-failed attempt
68
+ // (offline/registry-down) that still counts toward the 24h throttle.
69
+ if (parsed.latest !== null && typeof parsed.latest !== "string") return null;
70
+ return parsed;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ async function writeCache(cachePath, entry) {
77
+ await mkdir(dirname(cachePath), { recursive: true, mode: 0o700 });
78
+ await writeFile(cachePath, JSON.stringify(entry), { mode: 0o600 });
79
+ }
80
+
81
+ export async function maybeWarnStale({
82
+ version = VERSION,
83
+ fetchLatest = fetchLatestFromNpm,
84
+ now = () => Date.now(),
85
+ cachePath = defaultUpdateCheckCachePath(),
86
+ stderr = process.stderr,
87
+ } = {}) {
88
+ try {
89
+ const cached = await readCache(cachePath);
90
+ let latest;
91
+ if (cached && now() - cached.checkedAt < UPDATE_CHECK_INTERVAL_MS) {
92
+ latest = cached.latest;
93
+ } else {
94
+ // BOT-1566 (Codex P2): run() awaits this for nearly every command, so a
95
+ // failed lookup (offline/registry-down/garbage) MUST still stamp the
96
+ // attempt — otherwise the throttle never engages and every invocation
97
+ // retries and can eat the full timeout. Retain any previously-known
98
+ // latest so we can still warn from it.
99
+ let fetched = null;
100
+ try {
101
+ fetched = await fetchLatest();
102
+ } catch {
103
+ fetched = null;
104
+ }
105
+ latest = typeof fetched === "string" ? fetched : (cached?.latest ?? null);
106
+ await writeCache(cachePath, { checkedAt: now(), latest });
107
+ }
108
+ if (typeof latest !== "string" || !isNewerVersion(latest, version)) return { warned: false, latest: latest ?? null };
109
+ stderr.write(`botbuddy: update available ${version} → ${latest}: ${UPDATE_COMMAND}\n`);
110
+ return { warned: true, latest };
111
+ } catch {
112
+ return { warned: false, latest: null };
113
+ }
114
+ }
115
+
116
+ // `botbuddy update` — hand off to npm and get out of the way.
117
+ export function cmdUpdate({ version = VERSION, spawnImpl = spawn, log = (line) => console.log(line), platform = process.platform } = {}) {
118
+ log(`current: ${version}`);
119
+ // BOT-1566 (Codex P2): on Windows npm is the `npm.cmd` shim, and a shell-free
120
+ // spawn cannot execute .cmd scripts (Node docs) — it would ENOENT after we
121
+ // already claimed the update started. Select the platform-correct binary.
122
+ const npmBin = platform === "win32" ? "npm.cmd" : "npm";
123
+ const child = spawnImpl(npmBin, ["install", "-g", "@botbuddy/cli@latest"], { stdio: "inherit", detached: true });
124
+ child.unref();
125
+ log("updating in background — re-run botbuddy --version to confirm");
126
+ return 0;
127
+ }
package/src/wait.mjs CHANGED
@@ -399,6 +399,14 @@ function makeConnect(opts) {
399
399
  // the agent (and its locks) aren't reaped during a long wait.
400
400
  if (opts.heartbeat) url.searchParams.set("heartbeat", "1");
401
401
 
402
+ // BOT-1565: abort the fetch itself on idle. A silently half-open SSE body
403
+ // already has an `iterator.next()` pending, and `iterator.return()` queues
404
+ // BEHIND that read — it cannot cancel it until bytes/EOF eventually arrive,
405
+ // so it would leave the response locked and the socket open while we
406
+ // reconnect, leaking a connection per stall (Codex P2). Aborting the fetch's
407
+ // signal tears the stalled socket down immediately, then the read rejects and
408
+ // the stream ends so runWaitLoop reconnects.
409
+ const ac = new AbortController();
402
410
  const res = await fetch(url, {
403
411
  headers: {
404
412
  Authorization: `Bearer ${opts.token}`,
@@ -407,12 +415,13 @@ function makeConnect(opts) {
407
415
  // BOT-741: never let a proxy gzip-buffer an SSE stream.
408
416
  "Accept-Encoding": "identity",
409
417
  },
418
+ signal: ac.signal,
410
419
  });
411
420
  if (res.status === 401) return errorStream("unauthorized");
412
421
  if (res.status === 403) return errorStream("forbidden");
413
422
  if (!res.ok || !res.body) throw new Error(`relay responded ${res.status}`);
414
423
 
415
- return sseFrameStream(res.body);
424
+ return sseFrameStream(res.body, { onIdle: () => ac.abort() });
416
425
  };
417
426
  }
418
427
 
@@ -468,14 +477,68 @@ function makeFeedLagProbe(opts) {
468
477
  };
469
478
  }
470
479
 
471
- async function* sseFrameStream(body) {
480
+ // BOT-1565: a silently half-open SSE socket (a network blip, or a gateway/edge
481
+ // connection-lifetime ceiling ~45–51 min) delivers no bytes AND no end-of-stream,
482
+ // so a bare `for await` here would block forever and never reconnect. Meanwhile
483
+ // the relay's last_seen_at keepalive stops bumping and the wait reaper abandons
484
+ // the still-parked wait after its grace → /waits goes blank (BOT-1565). Guard
485
+ // every read with an IDLE watchdog: the relay sends a keepalive comment every
486
+ // ~30s (EVENT_STREAM_KEEPALIVE_MS), and every chunk (comment or frame) resets
487
+ // the timer, so a healthy-but-quiet connection never trips it. If NOTHING
488
+ // arrives for idleMs the connection is presumed dead: end the stream so
489
+ // runWaitLoop reconnects from the cursor, which re-bumps wait_sessions.last_seen_at
490
+ // (and, with --heartbeat, agents.last_heartbeat) before the reaper's grace.
491
+ // 70s ≈ 2.3 missed keepalives — long enough to never false-trip on jitter, short
492
+ // enough that the reconnect lands inside the 90s last_seen reap window.
493
+ export const DEFAULT_SSE_IDLE_TIMEOUT_MS = 70_000;
494
+
495
+ function sseIdleTimeoutMs() {
496
+ const raw = Number(process.env.BOTBUDDY_WAIT_IDLE_TIMEOUT_MS);
497
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_SSE_IDLE_TIMEOUT_MS;
498
+ }
499
+
500
+ const SSE_IDLE = Symbol("sse_idle");
501
+
502
+ export async function* sseFrameStream(body, { idleMs = sseIdleTimeoutMs(), onIdle = null } = {}) {
472
503
  const decoder = new TextDecoder();
473
504
  let buf = "";
474
- for await (const chunk of body) {
475
- buf += decoder.decode(chunk, { stream: true });
476
- const { frames, rest } = parseSseFrames(buf);
477
- buf = rest;
478
- for (const f of frames) yield f;
505
+ const iterator = body[Symbol.asyncIterator]();
506
+ try {
507
+ while (true) {
508
+ const nextP = iterator.next();
509
+ let timer;
510
+ const idle = new Promise((resolve) => { timer = setTimeout(() => resolve(SSE_IDLE), idleMs); });
511
+ let result;
512
+ try {
513
+ result = await Promise.race([nextP, idle]);
514
+ } finally {
515
+ clearTimeout(timer);
516
+ }
517
+ if (result === SSE_IDLE) {
518
+ // Presumed dead. onIdle() aborts the underlying fetch (makeConnect wires
519
+ // it to the request's AbortController) — the ONLY thing that actually
520
+ // tears a stalled socket down, since iterator.return() would queue behind
521
+ // the pending read and never fire until bytes/EOF arrive (Codex P2). The
522
+ // abort settles the pending read (rejects with AbortError); swallow it so
523
+ // it isn't an unhandled rejection, then end the stream to force a reconnect.
524
+ nextP.then(() => {}, () => {});
525
+ if (onIdle) onIdle();
526
+ return;
527
+ }
528
+ const { value: chunk, done } = result;
529
+ if (done) return;
530
+ buf += decoder.decode(chunk, { stream: true });
531
+ const { frames, rest } = parseSseFrames(buf);
532
+ buf = rest;
533
+ for (const f of frames) yield f;
534
+ }
535
+ } finally {
536
+ // Belt-and-braces release for the non-idle early-stop path (the consumer
537
+ // stopped iterating, e.g. runWaitLoop matched). On the idle path the fetch
538
+ // has already been aborted above, so the body is torn down regardless; this
539
+ // return() then resolves promptly instead of queuing behind a live read.
540
+ // Fire-and-forget + swallow: never let teardown block the generator's exit.
541
+ Promise.resolve(iterator.return?.()).then(() => {}, () => {});
479
542
  }
480
543
  }
481
544