Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/honor-forwarded-daemon-api-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"browse": patch
---

Honor `BROWSERBASE_API_KEY` passed to an already-running driver daemon. Previously, if the first remote command started the daemon without a key, a later `BROWSERBASE_API_KEY=… browse open <url> --remote` (or an exported key in a new shell) kept failing with "Missing BROWSERBASE_API_KEY" because the detached daemon captured `process.env` once at spawn time and never saw the new key. The client now forwards the caller's key over the (localhost, owner-only) driver socket with every command, and the daemon threads it straight into the Stagehand constructor when it creates the session — so an inline or exported key works without a manual `browse stop` and restart. The forwarded key is never written back into the daemon's `process.env`; its only home is the live session. Already-initialized warm sessions are untouched; the forwarded key only takes effect at session init. The local-only (CDP-only) build forwards nothing and remains free of any API-key code path.
3 changes: 3 additions & 0 deletions packages/cli/src/lib/driver/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
getSocketPath,
PRIVATE_FILE_MODE,
} from "./paths.js";
import { collectClientCredentials } from "./credentials.js";
import { isProcessAlive } from "./process.js";
import { ResponseSchema, type DriverRequest } from "./protocol.js";

Expand Down Expand Up @@ -74,6 +75,7 @@ export async function openViaDaemon(
): Promise<OpenResult> {
return sendDriverRequest<OpenResult>(session, {
...options,
credentials: await collectClientCredentials(),
id: requestId(),
type: "open",
url,
Expand All @@ -87,6 +89,7 @@ export async function runDriverCommandViaDaemon(
): Promise<unknown> {
return sendDriverRequest(session, {
command,
credentials: await collectClientCredentials(),
id: requestId(),
params,
type: "command",
Expand Down
65 changes: 65 additions & 0 deletions packages/cli/src/lib/driver/daemon/credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { createHash } from "node:crypto";

import { getRemote } from "../remote-binding.js";

/**
* Credentials the client forwards to a running daemon so that an inline or
* exported API key set *after* the daemon started is still honored.
*
* The daemon is a detached background process whose `process.env` is captured
* once at spawn time. A key set on a *later* client invocation never reaches
* that process on its own, so the client ships the relevant credentials over
* the (localhost, owner-only) driver socket with every command. The daemon
* threads them straight into the Stagehand constructor at init — it never
* writes them back into its own `process.env`, so the key's only home is the
* live session, not the daemon's global environment.
*
* The *list* of forwardable keys is Browserbase-specific and therefore lives
* behind the remote capability (`remote.ts`), which the local-only build
* excludes. That keeps the literal key names out of the CDP-only artifact, the
* same security contract that confines `BROWSERBASE_API_KEY` reads to
* `remote.ts`. The signature side iterates the received object's own keys, so
* it needs no key list and stays key-name-free.
*/
export type ForwardedCredentials = Record<string, string>;

/** Collect the forwardable credentials that are set in the caller's env. */
export async function collectClientCredentials(
env: NodeJS.ProcessEnv = process.env,
): Promise<ForwardedCredentials | undefined> {
const keys = (await getRemote()).forwardedCredentialKeys();
const credentials: ForwardedCredentials = {};
for (const key of keys) {
const value = env[key];
if (typeof value === "string" && value.length > 0) {
Comment thread
shrey150 marked this conversation as resolved.
credentials[key] = value;
}
}
return Object.keys(credentials).length > 0 ? credentials : undefined;
}

/**
* Stable, secret-free fingerprint of a forwarded credential set, used only to
* detect whether the caller's credentials changed between requests (so a cold
* session can bust its cached init-failure backoff and retry with the new key).
* Hashing keeps the raw key out of any retained field. Iterates the received
* object's own keys — the client already filtered to the forwardable set — so
* this carries no literal key names. Returns "" for an empty/absent set.
*/
export function credentialSignature(
credentials: ForwardedCredentials | undefined,
): string {
if (!credentials) return "";
const entries = Object.entries(credentials)
.filter(([, value]) => typeof value === "string" && value.length > 0)
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
if (entries.length === 0) return "";
const hash = createHash("sha256");
for (const [key, value] of entries) {
hash.update(key);
hash.update("\0");
hash.update(value);
hash.update("\0");
}
return hash.digest("hex");
}
3 changes: 3 additions & 0 deletions packages/cli/src/lib/driver/daemon/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { DriverCommandNameSchema } from "../commands/types.js";

const RequestBaseSchema = z.object({
id: z.string().min(1),
// Credentials forwarded from the caller's env so a key set after the daemon
// started is still honored. See ./credentials.ts.
credentials: z.record(z.string(), z.string()).optional(),
});

export const OpenRequestSchema = RequestBaseSchema.extend({
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/lib/driver/daemon/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ async function handleLine(

try {
if (request.type === "open") {
manager.applyForwardedCredentials(request.credentials);
await writeResponse(socket, {
data: await manager.execute("open", {
timeoutMs: request.timeoutMs,
Expand All @@ -145,6 +146,7 @@ async function handleLine(
}

if (request.type === "command") {
manager.applyForwardedCredentials(request.credentials);
await writeResponse(socket, {
data: await manager.execute(request.command, request.params),
id: request.id,
Expand Down
17 changes: 15 additions & 2 deletions packages/cli/src/lib/driver/remote-types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Stagehand } from "@browserbasehq/stagehand";

import type { ForwardedCredentials } from "./daemon/credentials.js";
import type { DriverModeFlags } from "./mode.js";
import type { ConnectionTarget } from "./types.js";

Expand Down Expand Up @@ -42,8 +43,20 @@ export interface RemoteCapability {
resolveExplicitRemoteTarget(flags: DriverModeFlags): ConnectionTarget;
/** Auto-select remote when an API key is present; null otherwise. */
autoSelectRemoteTarget(): ConnectionTarget | null;
/** Stagehand options for a remote (BROWSERBASE) session. */
remoteStagehandOptions(): Promise<StagehandConstructorOptions>;
/**
* Env var names the client forwards to a running daemon (e.g. the API key)
* so a key set after the daemon started is honored. Empty in the local-only
* build, which never reaches the cloud.
*/
forwardedCredentialKeys(): readonly string[];
/**
* Stagehand options for a remote (BROWSERBASE) session. Forwarded
* credentials (if any) are threaded into the constructor here so a key set
* after the daemon started is honored without touching `process.env`.
*/
remoteStagehandOptions(
credentials?: ForwardedCredentials,
): Promise<StagehandConstructorOptions>;
/** Map a failed remote `stagehand.init()` to an actionable message + code. */
classifyRemoteInitError(error: unknown): RemoteInitErrorClassification;
/** Remediation strings for driver init failures. */
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/lib/driver/remote.disabled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,15 @@ export function autoSelectRemoteTarget(): ConnectionTarget | null {
return null;
}

export function forwardedCredentialKeys(): readonly string[] {
// The local-only build never reaches the cloud, so there is nothing to
// forward — and no API-key names may appear in this artifact.
return [];
}

export async function remoteStagehandOptions(): Promise<StagehandConstructorOptions> {
// Accepts the forwarded-credentials arg structurally (fewer params is
// assignable) without naming it, keeping this stub key-name-free.
throw new Error(DISABLED_MESSAGE);
}

Expand Down
23 changes: 21 additions & 2 deletions packages/cli/src/lib/driver/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
resolveInstallId,
toMetadataValue,
} from "../identity.js";
import type { ForwardedCredentials } from "./daemon/credentials.js";
import type {
DriverInitHints,
RemoteDoctorResult,
Expand All @@ -27,8 +28,26 @@ export function autoSelectRemoteTarget(): ConnectionTarget | null {
return process.env.BROWSERBASE_API_KEY ? { kind: "remote" } : null;
}

export async function remoteStagehandOptions(): Promise<StagehandConstructorOptions> {
const apiKey = process.env.BROWSERBASE_API_KEY;
/**
* Credentials the client forwards to a running daemon. Only the API key needs
* forwarding: the Browserbase backend infers the project from the key, so a
* project id is not required for session creation. (A multi-project key that
* wants to pin a non-default project via BROWSERBASE_PROJECT_ID is a rare edge
* case; that still resolves from the daemon's own env, not the forwarded set.)
*/
export function forwardedCredentialKeys(): readonly string[] {
return ["BROWSERBASE_API_KEY"];
}

export async function remoteStagehandOptions(
credentials?: ForwardedCredentials,
): Promise<StagehandConstructorOptions> {
// Prefer the caller's forwarded key; fall back to the daemon's own spawn-time
// env (e.g. a daemon that was started with a key). Threading the value here
// avoids writing the key back into the daemon's `process.env`. The project id
// is left to Stagehand to resolve (constructor opt → env → inferred from key).
const apiKey =
credentials?.BROWSERBASE_API_KEY ?? process.env.BROWSERBASE_API_KEY;
if (!apiKey) {
throw new Error(
"Missing BROWSERBASE_API_KEY for remote mode. Pass --local to run a managed local browser (no key needed), or set BROWSERBASE_API_KEY for cloud sessions.",
Expand Down
40 changes: 39 additions & 1 deletion packages/cli/src/lib/driver/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import {
} from "./commands/selectors.js";
import { executeDriverCommand } from "./commands/registry.js";
import type { DriverCommandName } from "./commands/types.js";
import {
credentialSignature,
type ForwardedCredentials,
} from "./daemon/credentials.js";
import { DriverError } from "./errors.js";
import { discoverLocalCdp } from "./local-cdp-discovery.js";
import { NetworkCapture } from "./network-capture.js";
Expand Down Expand Up @@ -66,6 +70,8 @@ export class DriverSessionManager {

private consecutiveInitFailures = 0;
private context: DriverContext | null = null;
private lastCredentialSignature: string | null = null;
private pendingCredentials: ForwardedCredentials | undefined;
private initFailure: InitFailure | null = null;
private initPromise: Promise<void> | null = null;
private refMaps: RefMaps = emptyRefMaps();
Expand All @@ -90,6 +96,36 @@ export class DriverSessionManager {
return executeDriverCommand(this, command, params);
}

/**
* Apply credentials forwarded by the client (e.g. an inline or exported API
* key set after the daemon started). Honoring a late key without a manual
* restart is the whole point of forwarding.
*
* The credentials are stashed for the next `init()`, which threads them
* straight into the Stagehand constructor — never into `process.env` — so the
* key's only home is the live session. A live, already-initialized session
* keeps its existing browser (credentials only matter at init), so the
* warm-daemon fast path is untouched. When the credentials change *before* a
* successful init (the common case: a first key-less `open` failed, then a
* key is supplied), clear the cached init failure and backoff so the retry
* runs immediately with the new key instead of replaying the stale
* missing-key error.
*/
applyForwardedCredentials(
credentials: ForwardedCredentials | undefined,
): void {
// Keep the caller's latest credentials available to the next init.
this.pendingCredentials = credentials;

const signature = credentialSignature(credentials);
if (signature === this.lastCredentialSignature) return;
this.lastCredentialSignature = signature;

if (this.stagehand && this.context) return;
this.initFailure = null;
this.consecutiveInitFailures = 0;
}

async activePage(): Promise<DriverPage> {
return this.ensurePage();
}
Expand Down Expand Up @@ -339,7 +375,9 @@ export class DriverSessionManager {
target: ConnectionTarget,
): Promise<ConstructorParameters<typeof Stagehand>[0]> {
if (target.kind === "remote") {
return await (await getRemote()).remoteStagehandOptions();
return await (
await getRemote()
).remoteStagehandOptions(this.pendingCredentials);
}

if (target.kind === "managed-local") {
Expand Down
100 changes: 100 additions & 0 deletions packages/cli/tests/driver-foundation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ import {
getDriverStatus,
openViaDaemon,
} from "../src/lib/driver/daemon/client.js";
import {
collectClientCredentials,
credentialSignature,
} from "../src/lib/driver/daemon/credentials.js";
import { runDriverDaemon } from "../src/lib/driver/daemon/server.js";
import { resolveWsTarget } from "../src/lib/driver/resolve-ws.js";
import { DriverSessionManager } from "../src/lib/driver/session-manager.js";
Expand Down Expand Up @@ -862,6 +866,102 @@ describe("driver foundation", () => {
"Target missing-target was not found in the attached browser.",
);
});

it("collects forwardable credentials from the caller's env", async () => {
// Only the API key is forwarded; the project is inferred from the key.
await expect(
collectClientCredentials({
BROWSERBASE_API_KEY: "client-key",
BROWSERBASE_PROJECT_ID: "client-project",
UNRELATED: "ignored",
}),
).resolves.toEqual({
BROWSERBASE_API_KEY: "client-key",
});
await expect(collectClientCredentials({})).resolves.toBeUndefined();
await expect(
collectClientCredentials({ BROWSERBASE_API_KEY: "" }),
).resolves.toBeUndefined();
});

it("produces a stable, secret-free credential signature", () => {
const sig = credentialSignature({ BROWSERBASE_API_KEY: "forwarded-key" });
// A non-empty hash that does not leak the raw key value.
expect(sig).toMatch(/^[a-f0-9]{64}$/);
expect(sig).not.toContain("forwarded-key");
// Stable for the same input, distinct for a different key.
expect(credentialSignature({ BROWSERBASE_API_KEY: "forwarded-key" })).toBe(
sig,
);
expect(credentialSignature({ BROWSERBASE_API_KEY: "other-key" })).not.toBe(
sig,
);
// Empty / absent sets collapse to "".
expect(credentialSignature(undefined)).toBe("");
expect(credentialSignature({})).toBe("");
expect(credentialSignature({ BROWSERBASE_API_KEY: "" })).toBe("");
});

it("retries init with a forwarded key after a key-less failure", async () => {
const manager = new DriverSessionManager("forwarded-key-retry", {
kind: "remote",
});

// Simulate the repro: the first key-less init failed and cached a backoff.
Object.assign(manager, {
consecutiveInitFailures: 1,
initFailure: {
error: new Error("Missing key"),
retryAt: Date.now() + 60_000,
},
});

// A new key forwarded before init clears the cached failure so the retry
// runs immediately instead of replaying the stale error.
manager.applyForwardedCredentials({ BROWSERBASE_API_KEY: "late-key" });
expect(
(manager as unknown as { initFailure: unknown }).initFailure,
).toBeNull();
expect(
(manager as unknown as { consecutiveInitFailures: number })
.consecutiveInitFailures,
).toBe(0);
// The key is stashed for the next init (threaded into the constructor),
// never written back into process.env.
expect(
(manager as unknown as { pendingCredentials: unknown })
.pendingCredentials,
).toEqual({ BROWSERBASE_API_KEY: "late-key" });
expect(process.env.BROWSERBASE_API_KEY).not.toBe("late-key");
expect(
(manager as unknown as { lastCredentialSignature: string })
.lastCredentialSignature,
).toBe(credentialSignature({ BROWSERBASE_API_KEY: "late-key" }));

// Re-applying the same credentials is a no-op (idempotent).
Object.assign(manager, {
initFailure: { error: new Error("x"), retryAt: Date.now() + 60_000 },
});
manager.applyForwardedCredentials({ BROWSERBASE_API_KEY: "late-key" });
expect(
(manager as unknown as { initFailure: unknown }).initFailure,
).not.toBeNull();
});

it("does not disturb an already-initialized session when credentials change", () => {
const manager = new DriverSessionManager("warm-session", {
kind: "remote",
});
Object.assign(manager, { stagehand: {}, context: {} });

manager.applyForwardedCredentials({ BROWSERBASE_API_KEY: "new-key" });

// The warm session is preserved: forwarded credentials only matter at init.
expect(
(manager as unknown as { stagehand: unknown }).stagehand,
).not.toBeNull();
expect((manager as unknown as { context: unknown }).context).not.toBeNull();
});
});

function restoreEnv(key: string, value: string | undefined): void {
Expand Down
Loading
Loading