@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 +1 -1
- package/src/commands.mjs +34 -2
- package/src/update-check.mjs +50 -10
- package/src/wait.mjs +70 -7
package/package.json
CHANGED
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
|
-
|
|
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
|
package/src/update-check.mjs
CHANGED
|
@@ -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
|
|
120
|
-
// spawn
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
|