@botbuddy/cli 1.13.1 → 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.1",
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
  }