← reports

#7349 · Browser CLI loses session recovery guidance

Bug Priority: Low Effort: Low plugins cli · 2026-11-10 · GitHub issue

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

ClaimFindingEvidence
Worker-loss and short expiry errors lack the session error code and reopen hint.VerifiedTwo regression cases receive command_failed, without hint.
Cancelled/timed-out work lacks reopen and timeout guidance.VerifiedThe 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.VerifiedThe 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 liveThe 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

4. Minimal reproduction

  1. Clone the trusted repository and check out the recorded main commit in a new directory.
  2. Install the frozen workspace dependencies, then apply the investigator-authored regression patch. This changes only the existing CLI boundary test, not production code.
  3. 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" });

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