@botbuddy/cli 1.13.0 → 1.13.2

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.13.0",
3
+ "version": "1.13.2",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/commands.mjs CHANGED
@@ -296,6 +296,15 @@ async function cmdStart(args) {
296
296
  // (BOT-1566, Codex P2).
297
297
  const STATUS_PROBE_TIMEOUT_MS = 4000;
298
298
 
299
+ // The OAuth server issues OPAQUE access tokens, not JWTs: `mcp_at_` + hex
300
+ // (oauth_tokens.access_token default = 'mcp_at_' + two dash-stripped uuids = 64
301
+ // hex chars; handleToken returns that verbatim). We use this only to refuse
302
+ // shipping a legacy typed-password "owner token" (the pre-1.13 Keychain bug) to
303
+ // the server in an Authorization header — which would disclose the secret just
304
+ // to learn it is invalid (BOT-1566, Codex P1). Prefix + hex body, length-lenient
305
+ // so a future opaque-token size change can't reintroduce the false rejection.
306
+ const looksLikeOwnerToken = (t) => typeof t === "string" && /^mcp_at_[0-9a-f]{32,}$/.test(t);
307
+
299
308
  async function logServerStatus(call, auth, log) {
300
309
  let res;
301
310
  try {
@@ -331,10 +340,20 @@ export async function cmdStatus({
331
340
  const cfg = getCfg();
332
341
  const owner = await resolveOwner({ getConfig: getCfg });
333
342
  if (owner) {
334
- log(`${green("✓")} Authenticated via OAuth ${dim("(Keychain)")}`);
343
+ // BOT-1566 (Codex P1): a pre-1.13 login could have stored a typed password
344
+ // where the OAuth token belongs. Such a value is NOT healthy — resolveCallAuth
345
+ // / authHeader still prefer this entry, so every `botbuddy call` keeps sending
346
+ // the bad secret until `botbuddy login` overwrites it. Never present it as
347
+ // authenticated, and never put it on the wire.
348
+ const ownerValid = looksLikeOwnerToken(owner.token);
349
+ if (ownerValid) {
350
+ log(`${green("✓")} Authenticated via OAuth ${dim("(Keychain)")}`);
351
+ } else {
352
+ log(`${red("✗")} Stored OAuth token is invalid — not a sign-in token (pre-1.13 login bug). Run ${cyan("botbuddy login")} to replace it.`);
353
+ }
335
354
  if (cfg.agent_name) log(` Agent: ${cyan(cfg.agent_name)}`);
336
355
  if (cfg.client_id) log(` Client: ${dim(cfg.client_id)}`);
337
- if (owner.expiresAt) {
356
+ if (ownerValid && owner.expiresAt) {
338
357
  const remaining = owner.expiresAt - now();
339
358
  if (remaining <= 0) {
340
359
  log(` Token: ${red("EXPIRED")} — run ${cyan("botbuddy start")}`);
@@ -345,6 +364,19 @@ export async function cmdStatus({
345
364
  }
346
365
  }
347
366
  log(` Config: ${dim(getConfigPath())}`);
367
+ if (!ownerValid) {
368
+ // Never send the non-token secret to the server. An agent key can still be
369
+ // health-checked, but the broken owner credential is what `botbuddy call`
370
+ // will use, so report it either way rather than looking healthy.
371
+ const key = await resolveAgent();
372
+ if (key) {
373
+ await logServerStatus(call, { "x-agent-api-key": key }, log);
374
+ log(` ${red("!")} the stored OAuth token is invalid — botbuddy commands will keep using it until you run ${cyan("botbuddy login")}`);
375
+ } else {
376
+ log(` server: ${red("not checked")} — stored OAuth token is invalid; run ${cyan("botbuddy login")}`);
377
+ }
378
+ return;
379
+ }
348
380
  // BOT-1566 (Codex P2): match resolveCallAuth — an expired owner token defers
349
381
  // to a valid profile agent key, so probe the credential real commands would
350
382
  // actually use instead of reporting a false `server: rejected` on the dead
@@ -13,7 +13,7 @@
13
13
  // off to a detached, unref'd `npm install -g` and exits 0.
14
14
 
15
15
  import { mkdir, readFile, writeFile } from "node:fs/promises";
16
- import { spawn } from "node:child_process";
16
+ import { spawn, spawnSync } from "node:child_process";
17
17
  import { homedir } from "node:os";
18
18
  import { dirname, join } from "node:path";
19
19
 
@@ -114,14 +114,54 @@ export async function maybeWarnStale({
114
114
  }
115
115
 
116
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 } = {}) {
117
+ export function cmdUpdate({ version = VERSION, spawnImpl = spawn, probeImpl = spawnSync, log = (line) => console.log(line), platform = process.platform, stderr = process.stderr } = {}) {
118
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;
119
+ // BOT-1566 (Codex P1/P2): on Windows npm is the `npm.cmd` shim, and Node
120
+ // cannot spawn a .cmd file shell-free (docs) — run it through a shell on
121
+ // win32 (args are all static constants, so no injection surface) and select
122
+ // the shim name; elsewhere a plain shell-free `npm` is correct.
123
+ const isWin = platform === "win32";
124
+ const npmBin = isWin ? "npm.cmd" : "npm";
125
+ const shellOpt = isWin ? { shell: true } : {};
126
+ // Resolve npm BEFORE the detached handoff. With `shell: true` the async
127
+ // `spawn` event only confirms the shell (cmd.exe) launched, not that npm
128
+ // resolved — a missing `npm.cmd` would otherwise be reported as a successful
129
+ // background update (Codex P2). A synchronous `npm --version` probe confirms
130
+ // the executable exists; `botbuddy update` is an explicit command, not a hot
131
+ // path, so the extra call is acceptable.
132
+ const probe = probeImpl(npmBin, ["--version"], { stdio: "ignore", ...shellOpt });
133
+ if (probe?.error || (typeof probe?.status === "number" && probe.status !== 0)) {
134
+ try { stderr.write(`botbuddy update: npm was not found — install Node.js/npm, then retry (looked for ${npmBin})\n`); } catch { /* ignore */ }
135
+ return Promise.resolve(1);
136
+ }
137
+ const child = spawnImpl(npmBin, ["install", "-g", "@botbuddy/cli@latest"], {
138
+ stdio: "inherit",
139
+ detached: true,
140
+ ...shellOpt,
141
+ });
142
+ // Node reports a launch failure asynchronously via the `error` event, so we
143
+ // must NOT announce success until the child has actually spawned (Codex P2).
144
+ // Race spawn vs error: print + unref only on `spawn`, exit nonzero on `error`.
145
+ return new Promise((resolve) => {
146
+ if (!child || typeof child.once !== "function") {
147
+ log("updating in background — re-run botbuddy --version to confirm");
148
+ if (child && typeof child.unref === "function") child.unref();
149
+ resolve(0);
150
+ return;
151
+ }
152
+ let settled = false;
153
+ child.once("spawn", () => {
154
+ if (settled) return;
155
+ settled = true;
156
+ if (typeof child.unref === "function") child.unref();
157
+ log("updating in background — re-run botbuddy --version to confirm");
158
+ resolve(0);
159
+ });
160
+ child.once("error", (err) => {
161
+ if (settled) return;
162
+ settled = true;
163
+ try { stderr.write(`botbuddy update: failed to launch npm (${err?.message ?? err})\n`); } catch { /* ignore */ }
164
+ resolve(1);
165
+ });
166
+ });
127
167
  }
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