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
5 changes: 5 additions & 0 deletions .changeset/cli-connect-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@browserbasehq/browse-cli": minor
---

Add --connect flag to attach to an existing Browserbase session by ID
94 changes: 82 additions & 12 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ function getContextPath(session: string): string {
return path.join(SOCKET_DIR, `browse-${session}.context`);
}

function getConnectPath(session: string): string {
return path.join(SOCKET_DIR, `browse-${session}.connect`);
}

type BrowseMode = "browserbase" | "local";

function hasBrowserbaseCredentials(): boolean {
Expand Down Expand Up @@ -226,8 +230,9 @@ const DAEMON_STATE_FILES = (session: string) => [
async function cleanupStaleFiles(session: string): Promise<void> {
const files = [
...DAEMON_STATE_FILES(session),
// Context is client-written config, only cleaned on full shutdown
// Client-written config, only cleaned on full shutdown
getContextPath(session),
getConnectPath(session),
];

for (const file of files) {
Expand Down Expand Up @@ -339,23 +344,41 @@ async function runDaemon(session: string, headless: boolean): Promise<void> {
contextConfig = JSON.parse(raw);
} catch {}

// Read connect config if present (written by `browse --connect <id>`)
let connectSessionId: string | null = null;
try {
connectSessionId = (
await fs.readFile(getConnectPath(session), "utf-8")
).trim();
} catch {}

stagehand = new Stagehand({
env: useBrowserbase ? "BROWSERBASE" : "LOCAL",
verbose: 0,
disablePino: true,
...(useBrowserbase
? {
disableAPI: true,
browserbaseSessionCreateParams: {
userMetadata: { "browse-cli": "true" },
...(contextConfig
? {
browserSettings: {
context: contextConfig,
},
}
: {}),
},
...(connectSessionId
? {
browserbaseSessionID: connectSessionId,
keepAlive: true,
}
: {}),
...(!connectSessionId
? {
browserbaseSessionCreateParams: {
userMetadata: { "browse-cli": "true" },
...(contextConfig
? {
browserSettings: {
context: contextConfig,
},
}
: {}),
},
}
: {}),
}
: {
localBrowserLaunchOptions: {
Expand Down Expand Up @@ -1437,6 +1460,7 @@ interface GlobalOpts {
headed?: boolean;
json?: boolean;
session?: string;
connect?: string;
}

function getSession(opts: GlobalOpts): string {
Expand Down Expand Up @@ -1479,6 +1503,34 @@ async function runCommand(command: string, args: unknown[]): Promise<unknown> {
}
}

// Handle --connect flag: write session ID for daemon to read
if (opts.connect) {
const desiredMode = await getDesiredMode(session);
if (desiredMode === "local") {
throw new Error(
"--connect is only supported in remote mode. Run `browse env remote` first.",
);
}

if (await isDaemonRunning(session)) {
let currentConnect: string | null = null;
try {
currentConnect = (
await fs.readFile(getConnectPath(session), "utf-8")
).trim();
} catch {}
if (currentConnect !== opts.connect) {
await stopDaemonAndCleanup(session);
}
}

await fs.writeFile(getConnectPath(session), opts.connect);
} else {
try {
await fs.unlink(getConnectPath(session));
} catch {}
}

await ensureDaemon(session, headless);
return sendCommand(session, command, args, headless);
}
Expand All @@ -1497,6 +1549,10 @@ program
.option(
"--session <name>",
"Session name for multiple browsers (or use BROWSE_SESSION env var)",
)
.option(
"--connect <session-id>",
"Connect to an existing Browserbase session by ID",
);

// ==================== DAEMON COMMANDS ====================
Expand Down Expand Up @@ -1549,13 +1605,21 @@ program
const running = await isDaemonRunning(session);
let wsUrl = null;
let mode: BrowseMode | null = null;
let browserbaseSessionId: string | null = null;
if (running) {
try {
wsUrl = await fs.readFile(getWsPath(session), "utf-8");
} catch {}
mode = await readCurrentMode(session);
try {
browserbaseSessionId = (
await fs.readFile(getConnectPath(session), "utf-8")
).trim();
} catch {}
}
console.log(JSON.stringify({ running, session, wsUrl, mode }));
console.log(
JSON.stringify({ running, session, wsUrl, mode, browserbaseSessionId }),
);
});

program
Expand Down Expand Up @@ -1681,6 +1745,12 @@ program
const session = getSession(opts);

if (cmdOpts.contextId) {
if (opts.connect) {
console.error(
"Error: --context-id cannot be used with --connect (the session already exists)",
);
process.exit(1);
}
// Contexts only work with Browserbase remote sessions
const desiredMode = await getDesiredMode(session);
if (desiredMode === "local") {
Expand Down
158 changes: 158 additions & 0 deletions packages/cli/tests/connect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { describe, it, expect, afterEach } from "vitest";
import { exec } from "child_process";
import { promises as fs } from "fs";
import * as path from "path";
import * as os from "os";

const CLI_PATH = path.join(__dirname, "../dist/index.js");
const TEST_SESSION = `connect-test-${Date.now()}`;

async function browse(
args: string,
options: { timeout?: number; env?: NodeJS.ProcessEnv } = {},
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const timeout = options.timeout ?? 30000;
const env = { ...process.env, ...options.env };

return new Promise((resolve) => {
const fullArgs = `node ${CLI_PATH} --headless --session ${TEST_SESSION} ${args}`;
exec(fullArgs, { timeout, env }, (error, stdout, stderr) => {
resolve({
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: error?.code ?? 0,
});
});
});
}

function parseJson<T = Record<string, unknown>>(output: string): T {
try {
return JSON.parse(output) as T;
} catch {
throw new Error(`Failed to parse JSON: ${output}`);
}
}

async function cleanupSession(session: string): Promise<void> {
const tmpDir = os.tmpdir();
const patterns = [
`browse-${session}.sock`,
`browse-${session}.pid`,
`browse-${session}.ws`,
`browse-${session}.chrome.pid`,
`browse-${session}.mode`,
`browse-${session}.mode-override`,
`browse-${session}.context`,
`browse-${session}.connect`,
];

for (const pattern of patterns) {
try {
await fs.unlink(path.join(tmpDir, pattern));
} catch {
// Ignore missing files.
}
}

try {
await fs.rm(path.join(tmpDir, `browse-${session}-network`), {
recursive: true,
});
} catch {
// Ignore missing directory.
}
}

describe("Browse CLI --connect flag", () => {
afterEach(async () => {
await browse("stop --force");
await cleanupSession(TEST_SESSION);
});

it("rejects --connect in local mode", async () => {
// `open` routes through runCommand() where --connect validation happens
const result = await browse(
"--connect fake-session-id open https://example.com",
{
env: {
...process.env,
BROWSERBASE_API_KEY: "",
},
},
);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain(
"--connect is only supported in remote mode",
);
});

it("rejects --connect with --context-id on open", async () => {
const result = await browse(
"--connect fake-session-id open --context-id fake-ctx-id https://example.com",
{
env: {
...process.env,
BROWSERBASE_API_KEY: "test-key",
},
},
);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain(
"--context-id cannot be used with --connect",
);
});

it("writes connect file when --connect is provided", async () => {
const tmpDir = os.tmpdir();
const connectPath = path.join(tmpDir, `browse-${TEST_SESSION}.connect`);

// open routes through runCommand() which writes the connect file before
// ensureDaemon(). The daemon will fail (fake API key) but the file should
// still be written.
await browse("--connect test-bb-session-123 open https://example.com", {
env: {
...process.env,
BROWSERBASE_API_KEY: "test-key",
},
});

let content: string | null = null;
try {
content = (await fs.readFile(connectPath, "utf-8")).trim();
} catch {
// File may not exist if cleanup ran
}
expect(content).toBe("test-bb-session-123");
});

it("clears connect file when --connect is not provided", async () => {
const tmpDir = os.tmpdir();
const connectPath = path.join(tmpDir, `browse-${TEST_SESSION}.connect`);

// Pre-create a connect file
await fs.writeFile(connectPath, "old-session-id");

// `open` routes through runCommand() which clears the connect file when
// --connect is absent. The command itself may fail (no daemon) but the
// file cleanup happens first.
await browse("open https://example.com");

let exists = true;
try {
await fs.access(connectPath);
} catch {
exists = false;
}
expect(exists).toBe(false);
});

it("status includes browserbaseSessionId field", async () => {
const result = await browse("status");
expect(result.exitCode).toBe(0);

const data = parseJson(result.stdout);
expect("browserbaseSessionId" in data).toBe(true);
expect(data.browserbaseSessionId).toBeNull();
});
});
Loading