diff --git a/.changeset/cli-connect-session.md b/.changeset/cli-connect-session.md new file mode 100644 index 000000000..dd53771d8 --- /dev/null +++ b/.changeset/cli-connect-session.md @@ -0,0 +1,5 @@ +--- +"@browserbasehq/browse-cli": minor +--- + +Add --connect flag to attach to an existing Browserbase session by ID diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index dc2205a85..d8eb072db 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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 { @@ -226,8 +230,9 @@ const DAEMON_STATE_FILES = (session: string) => [ async function cleanupStaleFiles(session: string): Promise { 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) { @@ -339,6 +344,14 @@ async function runDaemon(session: string, headless: boolean): Promise { contextConfig = JSON.parse(raw); } catch {} + // Read connect config if present (written by `browse --connect `) + 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, @@ -346,16 +359,26 @@ async function runDaemon(session: string, headless: boolean): Promise { ...(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: { @@ -1437,6 +1460,7 @@ interface GlobalOpts { headed?: boolean; json?: boolean; session?: string; + connect?: string; } function getSession(opts: GlobalOpts): string { @@ -1479,6 +1503,34 @@ async function runCommand(command: string, args: unknown[]): Promise { } } + // 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); } @@ -1497,6 +1549,10 @@ program .option( "--session ", "Session name for multiple browsers (or use BROWSE_SESSION env var)", + ) + .option( + "--connect ", + "Connect to an existing Browserbase session by ID", ); // ==================== DAEMON COMMANDS ==================== @@ -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 @@ -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") { diff --git a/packages/cli/tests/connect.test.ts b/packages/cli/tests/connect.test.ts new file mode 100644 index 000000000..95d815150 --- /dev/null +++ b/packages/cli/tests/connect.test.ts @@ -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>(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 { + 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(); + }); +});