#7349 · Browser CLI loses session recovery guidance
Verdict: REPRODUCED · Root-cause confidence: high · Reproduction label: confirmed-repro
1. TL;DR
When a browser session disappears or browser work is cancelled, the CLI should tell the caller to open a new session. The CLI instead emits the generic command_failed code without recovery guidance for three errors produced by its own host/process implementation. A focused regression exercises the real error mapper and CLI renderer with those trusted producer messages. All three cases fail on main in two separate clean checkouts; the existing stopped-session case and ownership-error control pass. This is a narrow error-reporting bug, not evidence of a new browser crash or data loss.
2. Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| Worker-loss and short expiry errors lack the session error code and reopen hint. | Verified | Two regression cases receive command_failed, without hint. |
| Cancelled/timed-out work lacks reopen and timeout guidance. | Verified | The third regression case receives the same generic error. Trusted host code closes the session when runtime work throws. |
| The CLI supports increasing the timeout to 120 seconds. | Verified | The existing run options validate a maximum of 120,000 ms; no flag change is needed. |
| A live browser restart or actual 30-second timeout causes this symptom. | Not exercised live | The reproduction injects producer errors at the CLI execution boundary. Producer text and lifecycle behavior are verified by source inspection, not a launched browser. |
3. Environment
- Trusted public
get-bb/bbmain:7c1353d80729983b86ea1555d18b42407f320406. - Linux 4.19.0-gvisor x86_64; Node v22.19.0; pnpm 9.15.0; Vitest 4.1.12.
- Separate temporary git worktrees, each installed with
pnpm install --frozen-lockfile --prefer-offline. - No server, browser, provider, port, application database, or user runtime data was used.
4. Minimal reproduction
- Clone the trusted repository and check out the recorded main commit in a new directory.
- Install the frozen workspace dependencies, then apply the investigator-authored regression patch. This changes only the existing CLI boundary test, not production code.
- Run these commands from the checkout root:
git checkout --detach 7c1353d80729983b86ea1555d18b42407f320406 pnpm install --frozen-lockfile --prefer-offline git apply /path/to/regression.patch pnpm exec turbo run test --filter=bb-plugin-browser-automation -- cli.test.ts
Expected: each session-loss case exits 1, preserves the original message, renders session_unavailable and an open-session hint in JSON and text. Cancellation/timeout also mentions --timeout and the 120-second limit.
Actual, excerpted verbatim from the first run:
bb-plugin-browser-automation:test: - Expected
bb-plugin-browser-automation:test: + Received
bb-plugin-browser-automation:test:
bb-plugin-browser-automation:test: {
bb-plugin-browser-automation:test: "error": {
bb-plugin-browser-automation:test: - "code": "session_unavailable",
bb-plugin-browser-automation:test: - "hint": StringContaining "bb browser-automation open",
bb-plugin-browser-automation:test: + "code": "command_failed",
bb-plugin-browser-automation:test: "message": "Session stopped, expired, or worker restarted; open a new session",
bb-plugin-browser-automation:test: },
bb-plugin-browser-automation:test: "ok": false,
bb-plugin-browser-automation:test: }
bb-plugin-browser-automation:test:
All three failing cases receive the generic error code before their text/timeout assertions can run. Two existing-compatible session cases pass. Full test file, including its unchanged existing tests: cli.test.ts.
Complete reproduction test file
import { describe, expect, it, vi } from "vitest";
import type { PluginCliContext, PluginCliResult } from "@get-bb/plugin-sdk";
import {
browserCliFailure,
createBrowserAutomationCli,
type BrowserCliRequest,
} from "./cli.js";
import { rpcContract } from "./contracts.js";
const id = "1a12a3f1-12de-4fbb-a011-df0905678757";
function cli() {
const execute = vi.fn(
async (
_request: BrowserCliRequest,
_ctx: PluginCliContext,
): Promise<PluginCliResult> => ({
exitCode: 0,
stdout: "{}",
}),
);
const registration = createBrowserAutomationCli({ execute });
const invoke = (
argv: string[],
ctx: PluginCliContext = { threadId: "thread" },
) => Promise.resolve(registration.run(argv, ctx));
const requestFor = async (argv: string[], ctx?: PluginCliContext) => {
const result = await invoke(argv, ctx);
expect(result.exitCode, result.stderr).toBe(0);
const call = execute.mock.calls.at(-1);
if (call === undefined) throw new Error("execute was not called");
return call[0];
};
return { registration, execute, invoke, requestFor };
}
describe("CLI boundaries", () => {
it("requires explicit backend, host, and headless selection", async () => {
const { invoke, requestFor, execute } = cli();
const missingHeadless = await invoke([
"open",
"--backend",
"local",
"--machine",
"host",
]);
expect(missingHeadless.exitCode).toBe(1);
expect(missingHeadless.stderr).toContain("--headless");
const request = await requestFor([
"open",
"--backend",
"local",
"--machine",
"host",
"--headless",
]);
expect(request.input.selection).toEqual({
backend: "local",
hostId: "host",
});
const badBackend = await invoke(["open", "--backend", "cloud"]);
expect(badBackend.exitCode).toBe(1);
expect(badBackend.stderr).toContain(
"invalid value 'cloud' for --backend. Expected one of: desktop, local",
);
const missingMachine = await invoke(["open", "--backend", "desktop"]);
expect(missingMachine.exitCode).toBe(1);
expect(missingMachine.stderr).toContain(
"missing required options: --machine",
);
expect(execute).toHaveBeenCalledTimes(1);
});
it("rejects ignored flags and both script sources", async () => {
const { invoke, execute } = cli();
const unknownFlag = await invoke(["list", "--headless"]);
expect(unknownFlag.stderr).toContain("unknown option '--headless'");
const bothScripts = await invoke([
"run",
id,
"--script",
"1",
"--script-file",
"a.js",
"--script-host",
"host",
]);
expect(bothScripts.stderr).toContain("exactly one");
expect(execute).not.toHaveBeenCalled();
});
it("requires a script host for a script file", async () => {
const { invoke, requestFor } = cli();
const missingHost = await invoke(["run", id, "--script-file", "a.js"]);
expect(missingHost.stderr).toContain(
"--script-file requires --script-host",
);
const request = await requestFor([
"run",
id,
"--script-file",
"a.js",
"--script-host",
"host-1",
]);
expect(request).toMatchObject({
scriptFile: "a.js",
scriptHost: "host-1",
});
});
it("preserves script strings and rejects a timeout outside the run boundary", async () => {
const { invoke, requestFor } = cli();
const request = await requestFor([
"run",
id,
"--script",
"await browser.getPage('main')",
]);
expect(request.input.script).toBe("await browser.getPage('main')");
expect(request.input.timeoutMs).toBe(30_000);
expect(() => rpcContract.run.input.parse(request.input)).not.toThrow();
const tooSmall = await invoke([
"run",
id,
"--script",
"1",
"--timeout-ms",
"1",
]);
expect(tooSmall.exitCode).toBe(1);
expect(tooSmall.stderr).toContain(
"invalid value '1' for --timeout-ms. Expected an integer between 1000 and 120000",
);
});
it("accepts --timeout as a duration and resolves bare numbers by range", async () => {
const { invoke, requestFor } = cli();
for (const [value, expected] of [
["90s", 90_000],
["2m", 120_000],
["1500ms", 1500],
["90", 90_000],
["1500", 1500],
] as const) {
const request = await requestFor([
"run",
id,
"--script",
"1",
"--timeout",
value,
]);
expect(request.input.timeoutMs, value).toBe(expected);
}
const ambiguous = await invoke([
"run",
id,
"--script",
"1",
"--timeout",
"500",
]);
expect(ambiguous.exitCode).toBe(1);
expect(ambiguous.stderr).toContain("invalid value '500' for --timeout");
expect(ambiguous.stderr).toContain("a bare number of seconds (1-120)");
expect(ambiguous.stderr).toContain(
"a bare number of milliseconds (1000-120000)",
);
const both = await invoke([
"run",
id,
"--script",
"1",
"--timeout",
"90s",
"--timeout-ms",
"5000",
]);
expect(both.exitCode).toBe(1);
expect(both.stderr).toContain("cannot be combined");
});
it("parses preview sequence cursors and rejects invalid ones before dispatch", async () => {
const { invoke, requestFor } = cli();
expect((await requestFor(["preview", id])).input).toEqual({
threadId: "thread",
sessionId: id,
afterSequence: 0,
});
const request = await requestFor(["preview", id, "--after", "12"]);
expect(rpcContract.preview.input.parse(request.input).afterSequence).toBe(
12,
);
const notNumeric = await invoke(["preview", id, "--after", "soon"]);
expect(notNumeric.exitCode).toBe(1);
expect(notNumeric.stderr).toContain("invalid value 'soon' for --after");
const wrongFlag = await invoke(["preview", id, "--page", "main"]);
expect(wrongFlag.stderr).toContain("unknown option '--page'");
});
it("requires a session id and rejects a stray one", async () => {
const { invoke } = cli();
const missing = await invoke(["pages"]);
expect(missing.exitCode).toBe(1);
expect(missing.stderr).toContain(
"missing required arguments: <session-id>",
);
const unexpected = await invoke(["list", id]);
expect(unexpected.exitCode).toBe(1);
expect(unexpected.stderr).toContain(`unexpected argument '${id}'`);
});
it("documents the capture limits and every command", async () => {
const { registration, invoke, execute } = cli();
const top = await invoke(["--help"]);
expect(top.exitCode).toBe(0);
expect(top.stdout).toContain("bb browser-automation open");
expect(top.stdout).toContain("bb browser-automation screenshot");
expect(top.stdout).toContain(
"run, pages, and screenshot renew expiry to 30 minutes",
);
expect(top.stdout).toContain("capped at 8 hours from creation");
expect(top.stdout).toContain(
"non-renewable control lease/connection deadline",
);
expect(top.stdout).toContain("list and live preview do not renew expiry");
const runHelp = await invoke(["run", "--help"]);
expect(runHelp.exitCode).toBe(0);
expect(runHelp.stdout).toContain("at most 4 screenshots per run");
expect(runHelp.stdout).toContain("JPEG only, 500 KB combined");
expect(runHelp.stdout).toContain("--timeout <duration>");
expect(execute).not.toHaveBeenCalled();
expect(registration.commands?.map((command) => command.name)).toEqual([
"open",
"list",
"run",
"pages",
"screenshot",
"preview",
"stop",
"close",
]);
});
it.each([
"Session stopped or expired; open a new session",
"Session stopped, expired, or worker restarted; open a new session",
"Session expired; open a new session",
"Session does not belong to this thread",
"Browser work cancelled or timed out",
])("reports recovery guidance for %s in JSON and text", async (message) => {
const execute = vi.fn(async (): Promise<PluginCliResult> => {
throw browserCliFailure(new Error(message));
});
const registration = createBrowserAutomationCli({ execute });
const json = await registration.run(["pages", id, "--json"], {
threadId: "thread",
});
expect(json.exitCode).toBe(1);
expect(JSON.parse(json.stdout ?? "")).toEqual({
ok: false,
error: {
code: "session_unavailable",
message,
hint: expect.stringContaining("bb browser-automation open"),
},
});
expect(json.stderr).toContain(message);
expect(json.stderr).toContain("bb browser-automation open");
const text = await registration.run(["pages", id], { threadId: "thread" });
expect(text.exitCode).toBe(1);
expect(text.stdout).toBeUndefined();
expect(text.stderr).toContain(message);
expect(text.stderr).toContain("bb browser-automation open");
if (message === "Browser work cancelled or timed out") {
expect(JSON.parse(json.stdout ?? "").error.hint).toContain("--timeout");
expect(JSON.parse(json.stdout ?? "").error.hint).toContain("120 seconds");
expect(text.stderr).toContain("--timeout");
expect(text.stderr).toContain("120 seconds");
}
});
it("classifies capture-limit failures", () => {
expect(
browserCliFailure(
new Error("Combined screenshots exceed the 500 KB output budget"),
),
).toMatchObject({ code: "screenshot_limit" });
expect(browserCliFailure(new Error("host unreachable"))).toMatchObject({
code: "command_failed",
});
});
});
5. Root cause
The server CLI catches dispatch failures and routes them through browserCliFailure. Its session matcher recognizes only one particular stopped/expired phrasing plus ownership errors. It falls through to the generic error for the other producer messages:
if (/stopped or expired|does not belong to this thread/u.test(message)) {
return new PluginCliError(message, {
code: "session_unavailable",
hint: REOPEN_HINT,
});
}
…
return new PluginCliError(message, { code: "command_failed" });
- Host availability throws
Session stopped, expired, or worker restarted; open a new session. - Host open throws
Session expired; open a new session. - Process abort produces
Browser work cancelled or timed out. - Host run closes the session on an exception, so a cancelled process does require reopening.
- Run option validation establishes the timeout ceiling.
The underlying fragility is string-based classification across the host/server boundary. This report does not propose changing that wire contract for a narrow recovery-hint fix.
6. Proposed fix
Recognize the host's stopped/expired session messages and the process cancellation message at the existing CLI error mapper. Preserve the message, error exit status, ownership behavior, screenshot-limit classification, and unknown-error fallback. Add the existing reopen hint, plus an optional larger --timeout suggestion capped at 120 seconds for cancellation/timeout. Extend the existing owner-boundary test table rather than adding a test-only export or duplicating a lower-level predicate test. Update the owning plugin skill to document the recovery hint.
7. Related issues
No open pull request was connected to this issue or returned by the issue-number PR search at investigation time. A related-issue search did not identify a duplicate of this narrow classification defect.
8. Verification
The same investigator repeated the reproduction in a second clean, detached worktree at the exact trusted base commit. Only the new test patch was applied; production files remained unchanged. A separate frozen install was run, followed by pnpm exec turbo run test --filter=bb-plugin-browser-automation -- cli.test.ts.
First run: 3 failed, 11 passed (14). Second run: 3 failed, 11 passed (14), failing on the same missing session code/hint in all three cases. No correction to the root-cause finding was necessary. This is a repeated check by one investigator, not independent verification.
9. Appendix
First full test log · Second full test log · Test-only patch
Setup caveat: the first workspace-wide build stopped in the unrelated GitHub plugin because SDK process/host build artifacts were not yet present in a fresh install. The focused Turbo test dependency graph successfully built its prerequisites and executed the tests. The expected red regression is not a build/import/setup failure.
Trust boundary: the issue's suggested changes and snippets were treated only as untrusted claims, not executed or copied. The test was authored after inspecting trusted main. No linked code, external issue-provided URL, real session, or credential was accessed.
Classification rationale: Bug is unexpected recovery reporting; Low priority reflects a narrow defect with manual reopen/timeout workarounds; Low effort reflects a contained existing-subsystem change. No existing classification values were overwritten.
> AGENT GENERATED