Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 85 additions & 2 deletions apps/cli/src/__tests__/plugin-cli-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -175,6 +179,85 @@ 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,
}),
});
}

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));
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();
Expand Down
8 changes: 4 additions & 4 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
type CliRuntimeContext,
} from "./context-env.js";
import {
describeUnreachableServer,
fetchPluginCliContributions,
findDisabledPluginForCommand,
findPluginCliCommand,
Expand Down Expand Up @@ -119,10 +120,9 @@ async function tryPluginCommandProxy(): Promise<void> {
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;
Expand Down
100 changes: 93 additions & 7 deletions apps/cli/src/plugin-cli-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,102 @@ 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 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 blockedCode: "EPERM" | "EACCES" | undefined;
let timedOut = false;
const messages: string[] = [];
const terminalCodes: Array<string | undefined> = [];
const seen = new Set<object>();
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;
};
const code = typeof record.code === "string" ? record.code : undefined;
if (code === "EPERM" || code === "EACCES") {
blockedCode ??= code;
}
if (record.name === "TimeoutError") {
timedOut = true;
}
if (typeof record.message === "string" && record.message.length > 0) {
messages.push(record.message);
}

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 (blockedCode !== undefined) {
return (
`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)
}`;
}

/** Fetch plugin CLI contributions with a short timeout. */
export async function fetchPluginCliContributions(
baseUrl: string,
Expand All @@ -40,8 +126,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" };
Expand Down
Loading