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
10 changes: 10 additions & 0 deletions .changeset/driver-error-remediation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"browse": patch
---

Make driver (browser session) failures actionable, classified, and self-correcting.

- An invalid `BROWSERBASE_API_KEY` no longer surfaces a bare `401 Unauthorized`: remote init failures are classified (401 invalid key, 403 permissions/plan, other) into actionable messages that point at the key settings page, `--local`, and `browse doctor`.
- A missing local Chrome now explains how to install Chrome, attach with `--cdp`, or switch to remote, instead of leaking chrome-launcher internals.
- Cached init failures back off exponentially (5s doubling, capped at 5 minutes) and append a "failing repeatedly" hint after 3 consecutive failures, so retry-looping agents get a clear self-correction signal instead of instant identical errors forever.
- The daemon protocol now carries optional `code`/`httpStatus` on error responses (backward compatible), and the client records them as telemetry result codes — `open` failures stop being 94% `unexpected`. New codes include `remote_auth_401`, `remote_auth_403`, `remote_session_create_failed`, `no_chrome_found`, `stale_ref`, `no_active_page`, `daemon_lock_timeout`, `daemon_unresponsive`, `daemon_socket_timeout`, and `daemon_spawn_failed`.
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
"deepmerge": "^4.3.1",
"dotenv": "^16.5.0",
"fastest-levenshtein": "^1.0.16",
"http-status-codes": "^2.3.0",
"ignore": "^7.0.5",
"node-html-markdown": "^1.3.0",
"semver": "^7.7.4",
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/lib/driver/commands/selectors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { DriverError } from "../errors.js";

export interface RefMaps {
urlMap: Record<string, string>;
xpathMap: Record<string, string>;
Expand Down Expand Up @@ -36,8 +38,9 @@ export function resolveSelector(selector: string, refMaps: RefMaps): string {

const xpath = refMaps.xpathMap[ref];
if (!xpath) {
throw new Error(
throw new DriverError(
`Unknown ref "${ref}" - run browse snapshot first to populate refs (have ${Object.keys(refMaps.xpathMap).length} refs).`,
{ code: "stale_ref" },
);
}

Expand Down
29 changes: 24 additions & 5 deletions packages/cli/src/lib/driver/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
import { promises as fs } from "node:fs";
import net from "node:net";

import { fail } from "../../errors.js";
import { CommandFailure, fail } from "../../errors.js";
import type { DriverCommandName } from "../commands/types.js";
import { targetsCompatible } from "../mode.js";
import type { ConnectionTarget, DriverStatus, OpenResult } from "../types.js";
Expand Down Expand Up @@ -40,7 +40,11 @@ export async function ensureDriverDaemon({

const locked = await acquireLock(session);
if (!locked) {
fail(`Timed out waiting for driver daemon lock for session "${session}".`);
fail(
`Timed out waiting for driver daemon lock for session "${session}".`,
1,
{ resultCode: "daemon_lock_timeout" },
);
}

try {
Expand All @@ -52,6 +56,8 @@ export async function ensureDriverDaemon({
if (await isDaemonPidAlive(session)) {
fail(
`Driver daemon session "${session}" is running but not responding. Run browse stop --session ${session} --force to clean it up.`,
1,
{ resultCode: "daemon_unresponsive" },
);
}
spawnDaemon(session, target);
Expand Down Expand Up @@ -180,7 +186,11 @@ async function sendDriverRequest<T>(

const timeout = setTimeout(() => {
failRequest(
new Error(`Timed out waiting for driver daemon session "${session}".`),
new CommandFailure(
`Timed out waiting for driver daemon session "${session}".`,
1,
{ resultCode: "daemon_socket_timeout" },
),
);
}, 35_000);

Expand All @@ -196,7 +206,14 @@ async function sendDriverRequest<T>(
JSON.parse(buffer.slice(0, newline)),
);
if (response.type === "error") {
failRequest(new Error(response.error));
failRequest(
new CommandFailure(response.error, 1, {
...(response.code ? { resultCode: response.code } : {}),
...(response.httpStatus !== undefined
? { httpStatus: response.httpStatus }
: {}),
}),
);
return;
}
completeRequest(response.data as T);
Expand All @@ -219,7 +236,9 @@ async function sendDriverRequest<T>(
function spawnDaemon(session: string, target: ConnectionTarget): void {
const entrypoint = process.argv[1];
if (!entrypoint) {
fail("Unable to locate browse CLI entrypoint for daemon startup.");
fail("Unable to locate browse CLI entrypoint for daemon startup.", 1, {
resultCode: "daemon_spawn_failed",
});
}

const child = spawn(
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/lib/driver/daemon/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ export const SuccessResponseSchema = z.object({
});

export const ErrorResponseSchema = z.object({
code: z.string().optional(),
error: z.string(),
httpStatus: z.number().optional(),
id: z.string().optional(),
type: z.literal("error"),
});
Expand Down
23 changes: 19 additions & 4 deletions packages/cli/src/lib/driver/daemon/server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import net from "node:net";
import readline from "node:readline";

import { DriverError } from "../errors.js";
import { DriverSessionManager } from "../session-manager.js";
import type { ConnectionTarget } from "../types.js";
import {
Expand Down Expand Up @@ -125,7 +126,7 @@ async function handleLine(
try {
request = parseRequest(line);
} catch (error) {
await writeResponse(socket, { error: formatError(error), type: "error" });
await writeResponse(socket, { ...formatError(error), type: "error" });
return false;
}

Expand Down Expand Up @@ -169,7 +170,7 @@ async function handleLine(
return true;
} catch (error) {
await writeResponse(socket, {
error: formatError(error),
...formatError(error),
id: request.id,
type: "error",
});
Expand Down Expand Up @@ -212,6 +213,20 @@ function endSocket(socket: net.Socket): Promise<void> {
});
}

function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
function formatError(error: unknown): {
code?: string;
error: string;
httpStatus?: number;
} {
const message = error instanceof Error ? error.message : String(error);
if (error instanceof DriverError) {
return {
code: error.code,
error: message,
...(error.httpStatus !== undefined
? { httpStatus: error.httpStatus }
: {}),
};
}
return { error: message };
}
19 changes: 19 additions & 0 deletions packages/cli/src/lib/driver/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Typed driver error. The daemon serializes `code`/`httpStatus` into error
* responses so the client can record a telemetry result code and agents get
* a stable, machine-readable failure reason alongside the human message.
*/
export class DriverError extends Error {
readonly code: string;
readonly httpStatus?: number;

constructor(
message: string,
options: { cause?: unknown; code: string; httpStatus?: number },
) {
super(message, options.cause === undefined ? {} : { cause: options.cause });
this.name = "DriverError";
this.code = options.code;
if (options.httpStatus !== undefined) this.httpStatus = options.httpStatus;
}
}
22 changes: 22 additions & 0 deletions packages/cli/src/lib/driver/remote-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,24 @@ export interface RemoteDoctorResult {
fix?: string;
}

export interface RemoteInitErrorClassification {
code: string;
httpStatus?: number;
message: string;
}

/**
* Driver init remediation strings that may reference `BROWSERBASE_API_KEY`.
* They live behind the remote capability so the local-only artifact contains
* key-free variants (its build excludes `remote.ts` entirely).
*/
export interface DriverInitHints {
/** Actionable message when no local Chrome can be found. */
chromeNotFound: string;
/** Suffix appended after repeated consecutive init failures. */
repeatedInitFailure: string;
}

/**
* The Browserbase (cloud) capability surface. The real implementation lives in
* `remote.ts` and is the only place that reads `BROWSERBASE_API_KEY`. The
Expand All @@ -26,6 +44,10 @@ export interface RemoteCapability {
autoSelectRemoteTarget(): ConnectionTarget | null;
/** Stagehand options for a remote (BROWSERBASE) session. */
remoteStagehandOptions(): StagehandConstructorOptions;
/** Map a failed remote `stagehand.init()` to an actionable message + code. */
classifyRemoteInitError(error: unknown): RemoteInitErrorClassification;
/** Remediation strings for driver init failures. */
driverInitHints(): DriverInitHints;
/** Doctor readiness check for remote/Browserbase. */
remoteDoctorCheck(env: NodeJS.ProcessEnv): RemoteDoctorResult;
}
23 changes: 23 additions & 0 deletions packages/cli/src/lib/driver/remote.disabled.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type {
DriverInitHints,
RemoteDoctorResult,
RemoteInitErrorClassification,
StagehandConstructorOptions,
} from "./remote-types.js";
import type { ConnectionTarget } from "./types.js";
Expand All @@ -25,6 +27,27 @@ export function remoteStagehandOptions(): StagehandConstructorOptions {
throw new Error(DISABLED_MESSAGE);
}

export function classifyRemoteInitError(
error: unknown,
): RemoteInitErrorClassification {
// Remote targets cannot be selected in a local-only build, so this is only
// reachable if a remote error somehow surfaces anyway; preserve it as-is.
return {
code: "remote_session_create_failed",
message: error instanceof Error ? error.message : String(error),
};
}

export function driverInitHints(): DriverInitHints {
// Key-free variants: a local-only artifact must not mention the API key.
return {
chromeNotFound:
"No Chrome or Chromium found on this machine. Install one (Linux: apt install chromium \u00b7 macOS: brew install --cask google-chrome, or Chromium with CHROME_PATH set) or attach to a running browser with --cdp <port>.",
repeatedInitFailure:
" (failing repeatedly — check your browser setup or run browse doctor)",
};
}

export function remoteDoctorCheck(): RemoteDoctorResult {
return { ok: true, message: "remote disabled (local-only build)" };
}
49 changes: 49 additions & 0 deletions packages/cli/src/lib/driver/remote.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { StatusCodes } from "http-status-codes";

import type {
DriverInitHints,
RemoteDoctorResult,
RemoteInitErrorClassification,
StagehandConstructorOptions,
} from "./remote-types.js";
import type { ConnectionTarget } from "./types.js";
Expand Down Expand Up @@ -38,6 +42,51 @@ export function remoteStagehandOptions(): StagehandConstructorOptions {
};
}

/**
* Map a failed remote `stagehand.init()` to an actionable message and a
* stable result code. Browserbase SDK errors carry an HTTP `status`.
*/
export function classifyRemoteInitError(
error: unknown,
): RemoteInitErrorClassification {
const status = (error as { status?: unknown } | null | undefined)?.status;
const httpStatus = typeof status === "number" ? status : undefined;
const original = error instanceof Error ? error.message : String(error);

if (httpStatus === StatusCodes.UNAUTHORIZED) {
return {
code: "remote_auth_401",
httpStatus,
message:
"Browserbase rejected your BROWSERBASE_API_KEY (401 Unauthorized). A set key makes browse default to remote mode. Check the key at https://browserbase.com/settings, run without one using --local (browse open <url> --local), or diagnose with browse doctor.",
};
}

if (httpStatus === StatusCodes.FORBIDDEN) {
return {
code: "remote_auth_403",
httpStatus,
message:
"Browserbase refused this request (403 Forbidden). Your BROWSERBASE_API_KEY may lack access to this project, or your plan may not allow this session type. Check the key at https://browserbase.com/settings, run without one using --local (browse open <url> --local), or diagnose with browse doctor.",
};
}

return {
code: "remote_session_create_failed",
...(httpStatus !== undefined ? { httpStatus } : {}),
message: `Failed to start a remote (Browserbase) session: ${original}\nRun browse doctor to diagnose remote connectivity.`,
};
}

export function driverInitHints(): DriverInitHints {
return {
chromeNotFound:
"No Chrome or Chromium found on this machine. Install one (Linux: apt install chromium \u00b7 macOS: brew install --cask google-chrome, or Chromium with CHROME_PATH set), attach to a running browser with --cdp <port>, or set BROWSERBASE_API_KEY to use a remote browser.",
repeatedInitFailure:
" (failing repeatedly — fix BROWSERBASE_API_KEY, use --local, or run browse doctor)",
};
}

export function remoteDoctorCheck(env: NodeJS.ProcessEnv): RemoteDoctorResult {
if (env.BROWSERBASE_API_KEY) {
return { ok: true, message: "BROWSERBASE_API_KEY is set" };
Expand Down
Loading
Loading