From 1ac6af60d09330508133829250d237ce65a8b018 Mon Sep 17 00:00:00 2001 From: sholub-dev Date: Sat, 8 Aug 2026 20:57:01 -0700 Subject: [PATCH 1/2] Classify CLI server probe failures instead of asserting bb is down --- .../src/__tests__/plugin-cli-proxy.test.ts | 53 +++++++++++++- apps/cli/src/index.ts | 8 +-- apps/cli/src/plugin-cli-proxy.ts | 70 +++++++++++++++++-- 3 files changed, 118 insertions(+), 13 deletions(-) diff --git a/apps/cli/src/__tests__/plugin-cli-proxy.test.ts b/apps/cli/src/__tests__/plugin-cli-proxy.test.ts index 3803ce150f..325b2396ce 100644 --- a/apps/cli/src/__tests__/plugin-cli-proxy.test.ts +++ b/apps/cli/src/__tests__/plugin-cli-proxy.test.ts @@ -12,6 +12,7 @@ import { registerStatusCommand } from "../commands/status.js"; import { registerThemeCommands } from "../commands/theme.js"; import { registerThreadCommands } from "../commands/thread/index.js"; import { + describeUnreachableServer, fetchPluginCliContributions, findDisabledPluginForCommand, findPluginCliCommand, @@ -119,17 +120,20 @@ describe("fetchPluginCliContributions", () => { }); it("distinguishes an unreachable server from an old/invalid one", async () => { - // Unreachable (server down): fetch rejects → tell the user to start bb. + // Unreachable (server down): fetch rejects → keep the thrown error so + // the caller can diagnose refused vs blocked vs timed out. + const thrown = new Error("ECONNREFUSED"); vi.stubGlobal( "fetch", vi.fn(async () => { - throw new Error("ECONNREFUSED"); + throw thrown; }), ); await expect( fetchPluginCliContributions("http://localhost"), ).resolves.toEqual({ outcome: "unreachable", + cause: thrown, }); // Old server without the route: silent fallback to commander's error. @@ -175,6 +179,51 @@ describe("fetchPluginCliContributions", () => { }); }); +describe("describeUnreachableServer", () => { + const url = "http://127.0.0.1:38886"; + + function fetchFailed(code: string): Error { + return new TypeError("fetch failed", { + cause: Object.assign(new Error(`connect ${code} 127.0.0.1:38886`), { + code, + }), + }); + } + + it("says bb is not running only on ECONNREFUSED", () => { + expect(describeUnreachableServer(url, fetchFailed("ECONNREFUSED"))).toBe( + `bb is not running at ${url} — open the bb app, then re-run this command.`, + ); + }); + + it("reports a blocked connection without declaring bb down", () => { + for (const code of ["EPERM", "EACCES"]) { + const message = describeUnreachableServer(url, fetchFailed(code)); + expect(message).toContain(`Cannot reach bb at ${url}: ${code}`); + expect(message).toContain("bb may still be running"); + expect(message).not.toContain("not running at"); + } + }); + + it("reports a timeout with the probe window", () => { + const timeout = Object.assign(new Error("The operation timed out"), { + name: "TimeoutError", + }); + expect(describeUnreachableServer(url, timeout, 2000)).toBe( + `bb did not respond at ${url} within 2000ms — it may be busy or unreachable.`, + ); + }); + + it("falls back to the unwrapped cause chain", () => { + const err = new TypeError("fetch failed", { + cause: new Error("getaddrinfo ENOTFOUND example.invalid"), + }); + expect(describeUnreachableServer(url, err)).toBe( + `Cannot reach bb at ${url}: fetch failed: getaddrinfo ENOTFOUND example.invalid`, + ); + }); +}); + describe("findDisabledPluginForCommand", () => { afterEach(() => { vi.unstubAllGlobals(); diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index fef7ec0a5b..b08fe0d168 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -24,6 +24,7 @@ import { type CliRuntimeContext, } from "./context-env.js"; import { + describeUnreachableServer, fetchPluginCliContributions, findDisabledPluginForCommand, findPluginCliCommand, @@ -119,10 +120,9 @@ async function tryPluginCommandProxy(): Promise { if (result.outcome === "unreachable") { // The candidate may be a plugin command (`bb connect` on a fresh // machine is the canonical case) — only the running server can say, so - // a dead server must not degrade into commander's "unknown command". - console.error( - "bb isn't running — open the bb app, then re-run this command.", - ); + // an unreachable server must not degrade into commander's "unknown + // command". + console.error(describeUnreachableServer(getUrl(), result.cause)); process.exit(1); } if (result.outcome === "invalid") return; diff --git a/apps/cli/src/plugin-cli-proxy.ts b/apps/cli/src/plugin-cli-proxy.ts index e97d33fa9e..2c103d8fec 100644 --- a/apps/cli/src/plugin-cli-proxy.ts +++ b/apps/cli/src/plugin-cli-proxy.ts @@ -20,16 +20,72 @@ const CONTRIBUTIONS_TIMEOUT_MS = 2000; /** * Result of asking the server for plugin CLI contributions. "unreachable" - * (fetch threw: server down, timeout) is distinguished from "invalid" (an - * old server without the route, or a malformed payload) so unknown-command - * handling can tell the user to start bb instead of printing a misleading - * "unknown command" for a plugin command that would exist if bb were up. + * (fetch threw: server down, blocked, timeout) is distinguished from + * "invalid" (an old server without the route, or a malformed payload) so + * unknown-command handling can tell the user to start bb instead of printing + * a misleading "unknown command" for a plugin command that would exist if bb + * were up. The thrown error is kept: EPERM (blocked shell) and a timeout mean + * something very different from ECONNREFUSED (nothing listening). */ export type PluginCliContributionsResult = | { outcome: "ok"; contributions: PluginCliContributionEntry[] } - | { outcome: "unreachable" } + | { outcome: "unreachable"; cause: unknown } | { outcome: "invalid" }; +/** + * Diagnose a failed probe of the server without overclaiming: only + * ECONNREFUSED is evidence that bb is not running. Blocked connections + * (sandboxed agent shells) and timeouts name the address and errno so the + * reader — often an agent — does not declare a running bb dead. + */ +export function describeUnreachableServer( + baseUrl: string, + cause: unknown, + timeoutMs: number = CONTRIBUTIONS_TIMEOUT_MS, +): string { + let code: string | undefined; + let timedOut = false; + const messages: string[] = []; + const seen = new Set(); + for ( + let current = cause; + typeof current === "object" && current !== null && !seen.has(current); + current = (current as { cause?: unknown }).cause + ) { + seen.add(current); + const record = current as { + code?: unknown; + name?: unknown; + message?: unknown; + }; + if (code === undefined && typeof record.code === "string") { + code = record.code; + } + if (record.name === "TimeoutError") { + timedOut = true; + } + if (typeof record.message === "string" && record.message.length > 0) { + messages.push(record.message); + } + } + + if (code === "ECONNREFUSED") { + return `bb is not running at ${baseUrl} — open the bb app, then re-run this command.`; + } + if (code === "EPERM" || code === "EACCES") { + return ( + `Cannot reach bb at ${baseUrl}: ${code} — the connection was blocked. ` + + `bb may still be running; check sandbox or firewall rules for this shell.` + ); + } + if (timedOut) { + return `bb did not respond at ${baseUrl} within ${timeoutMs}ms — it may be busy or unreachable.`; + } + return `Cannot reach bb at ${baseUrl}: ${ + messages.length > 0 ? messages.join(": ") : String(cause) + }`; +} + /** Fetch plugin CLI contributions with a short timeout. */ export async function fetchPluginCliContributions( baseUrl: string, @@ -40,8 +96,8 @@ export async function fetchPluginCliContributions( response = await cliFetch(`${baseUrl}/api/v1/plugins/contributions`, { signal: AbortSignal.timeout(timeoutMs), }); - } catch { - return { outcome: "unreachable" }; + } catch (error) { + return { outcome: "unreachable", cause: error }; } try { if (!response.ok) return { outcome: "invalid" }; From 1412c5f3dfe9a6ec38850ea47b5c13d4da78e45f Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Mon, 10 Aug 2026 15:40:53 -0700 Subject: [PATCH 2/2] Handle aggregate CLI probe failures conservatively --- .../src/__tests__/plugin-cli-proxy.test.ts | 34 ++++++++++ apps/cli/src/plugin-cli-proxy.ts | 64 ++++++++++++++----- 2 files changed, 81 insertions(+), 17 deletions(-) diff --git a/apps/cli/src/__tests__/plugin-cli-proxy.test.ts b/apps/cli/src/__tests__/plugin-cli-proxy.test.ts index 325b2396ce..947502f05a 100644 --- a/apps/cli/src/__tests__/plugin-cli-proxy.test.ts +++ b/apps/cli/src/__tests__/plugin-cli-proxy.test.ts @@ -190,12 +190,46 @@ describe("describeUnreachableServer", () => { }); } + function aggregateFetchFailed(codes: string[]): Error { + const errors = codes.map((code, index) => + Object.assign(new Error(`connect ${code} address-${index + 1}:38886`), { + code, + }), + ); + return new TypeError("fetch failed", { + // NodeAggregateError exposes the first attempt's code on the aggregate, + // even when later attempts failed for a different reason. + cause: Object.assign(new AggregateError(errors), { + code: errors[0]?.code, + }), + }); + } + it("says bb is not running only on ECONNREFUSED", () => { expect(describeUnreachableServer(url, fetchFailed("ECONNREFUSED"))).toBe( `bb is not running at ${url} — open the bb app, then re-run this command.`, ); }); + it("requires every aggregate connection attempt to be refused", () => { + expect( + describeUnreachableServer( + url, + aggregateFetchFailed(["ECONNREFUSED", "ECONNREFUSED"]), + ), + ).toBe( + `bb is not running at ${url} — open the bb app, then re-run this command.`, + ); + + const mixedMessage = describeUnreachableServer( + url, + aggregateFetchFailed(["ECONNREFUSED", "EPERM"]), + ); + expect(mixedMessage).toContain(`Cannot reach bb at ${url}: EPERM`); + expect(mixedMessage).toContain("bb may still be running"); + expect(mixedMessage).not.toContain("not running at"); + }); + it("reports a blocked connection without declaring bb down", () => { for (const code of ["EPERM", "EACCES"]) { const message = describeUnreachableServer(url, fetchFailed(code)); diff --git a/apps/cli/src/plugin-cli-proxy.ts b/apps/cli/src/plugin-cli-proxy.ts index 2c103d8fec..5c33584ec2 100644 --- a/apps/cli/src/plugin-cli-proxy.ts +++ b/apps/cli/src/plugin-cli-proxy.ts @@ -33,33 +33,45 @@ export type PluginCliContributionsResult = | { outcome: "invalid" }; /** - * Diagnose a failed probe of the server without overclaiming: only - * ECONNREFUSED is evidence that bb is not running. Blocked connections - * (sandboxed agent shells) and timeouts name the address and errno so the - * reader — often an agent — does not declare a running bb dead. + * Diagnose a failed probe of the server without overclaiming: only when every + * connection attempt reports ECONNREFUSED is there evidence that bb is not + * running. Blocked connections (sandboxed agent shells) and timeouts name the + * address and errno so the reader — often an agent — does not declare a + * running bb dead. */ export function describeUnreachableServer( baseUrl: string, cause: unknown, timeoutMs: number = CONTRIBUTIONS_TIMEOUT_MS, ): string { - let code: string | undefined; + let blockedCode: "EPERM" | "EACCES" | undefined; let timedOut = false; const messages: string[] = []; + const terminalCodes: Array = []; const seen = new Set(); - for ( - let current = cause; - typeof current === "object" && current !== null && !seen.has(current); - current = (current as { cause?: unknown }).cause - ) { + const pending: unknown[] = [cause]; + + while (pending.length > 0) { + const current = pending.pop(); + if (typeof current !== "object" || current === null) { + terminalCodes.push(undefined); + continue; + } + if (seen.has(current)) { + terminalCodes.push(undefined); + continue; + } seen.add(current); const record = current as { + cause?: unknown; code?: unknown; + errors?: unknown; name?: unknown; message?: unknown; }; - if (code === undefined && typeof record.code === "string") { - code = record.code; + const code = typeof record.code === "string" ? record.code : undefined; + if (code === "EPERM" || code === "EACCES") { + blockedCode ??= code; } if (record.name === "TimeoutError") { timedOut = true; @@ -67,20 +79,38 @@ export function describeUnreachableServer( if (typeof record.message === "string" && record.message.length > 0) { messages.push(record.message); } - } - if (code === "ECONNREFUSED") { - return `bb is not running at ${baseUrl} — open the bb app, then re-run this command.`; + const children: unknown[] = []; + if (record.cause !== undefined && record.cause !== null) { + children.push(record.cause); + } + if (Array.isArray(record.errors)) { + children.push(...record.errors); + } + if (children.length === 0) { + terminalCodes.push(code); + continue; + } + for (let index = children.length - 1; index >= 0; index -= 1) { + pending.push(children[index]); + } } - if (code === "EPERM" || code === "EACCES") { + + if (blockedCode !== undefined) { return ( - `Cannot reach bb at ${baseUrl}: ${code} — the connection was blocked. ` + + `Cannot reach bb at ${baseUrl}: ${blockedCode} — the connection was blocked. ` + `bb may still be running; check sandbox or firewall rules for this shell.` ); } if (timedOut) { return `bb did not respond at ${baseUrl} within ${timeoutMs}ms — it may be busy or unreachable.`; } + if ( + terminalCodes.length > 0 && + terminalCodes.every((code) => code === "ECONNREFUSED") + ) { + return `bb is not running at ${baseUrl} — open the bb app, then re-run this command.`; + } return `Cannot reach bb at ${baseUrl}: ${ messages.length > 0 ? messages.join(": ") : String(cause) }`;