Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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) {
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);
Comment thread
shrey150 marked this conversation as resolved.
Outdated
await writeResponse(socket, {
data: await manager.execute(request.command, request.params),
id: request.id,
Expand Down
15 changes: 14 additions & 1 deletion 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, RemoteConnectionTarget } from "./types.js";

Expand Down Expand Up @@ -42,9 +43,21 @@ 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. */
/**
* 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. The target carries
* the optional verified/proxies flags. 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(
target?: RemoteConnectionTarget,
credentials?: ForwardedCredentials,
): Promise<StagehandConstructorOptions>;
/** Map a failed remote `stagehand.init()` to an actionable message + code. */
classifyRemoteInitError(error: unknown): RemoteInitErrorClassification;
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
20 changes: 19 additions & 1 deletion 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 { DriverModeFlags } from "./mode.js";
import type {
DriverInitHints,
Expand Down Expand Up @@ -34,10 +35,27 @@ export function autoSelectRemoteTarget(): ConnectionTarget | null {
return process.env.BROWSERBASE_API_KEY ? { kind: "remote" } : null;
}

/**
* 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(
target?: RemoteConnectionTarget,
credentials?: ForwardedCredentials,
): Promise<StagehandConstructorOptions> {
const apiKey = process.env.BROWSERBASE_API_KEY;
// 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 @@ -67,6 +71,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 @@ -91,6 +97,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 @@ -366,7 +402,9 @@ export class DriverSessionManager {
target: ConnectionTarget,
): Promise<ConstructorParameters<typeof Stagehand>[0]> {
if (target.kind === "remote") {
return await (await getRemote()).remoteStagehandOptions(target);
return await (
await getRemote()
).remoteStagehandOptions(target, 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 @@ -933,6 +937,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",
Comment thread
pirate marked this conversation as resolved.
}),
).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