Skip to content

Commit e81dde7

Browse files
shrey150claude
andauthored
STG-1672: Add --connect flag to browse CLI for existing Browserbase sessions (browserbase#1889)
## Summary - Adds a global `--connect <session-id>` flag to the `browse` CLI that connects to an existing Browserbase session instead of creating a new one - Uses Stagehand core's existing `browserbaseSessionID` support with `keepAlive: true` so the session stays alive after the CLI disconnects - Follows the same state-file pattern as `--context-id` (writes session ID to `/tmp/browse-{session}.connect`, daemon reads it during initialization) ## Test procedure ``` # 1. Sync cookies node cookie-sync.mjs --domains github.com # → Session ID: 3ee737d4-237f-4557-a1ca-09a3d5dcbaf2 # 2. Connect and browse (dev build) browse --connect 3ee737d4-... open https://github.com/notifications ✅ browse eval "..." ✅ ``` ## Tests completed - [x] `browse --connect <id> open ...` in local mode → errors with "only supported in remote mode" - [x] `browse --connect <id> open --context-id <ctx> ...` → errors with mutual exclusion message - [x] `--connect` writes session ID to connect file before daemon start - [x] Commands without `--connect` clear stale connect file - [x] `browse status` output includes `browserbaseSessionId` field - [x] Manual: `browse --connect <valid-bb-session> open https://example.com` connects and navigates - [x] Manual: `browse stop` disconnects without killing the BB session 🤖 Generated with [Claude Code](https://claude.com/claude-code) Fixes STG-1672 <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds a `--connect <session-id>` flag to the `browse` CLI to attach to an existing Browserbase session instead of creating a new one, leaving the session running after the CLI exits. Implements STG-1672. - **New Features** - New global `--connect <session-id>` for `browse` to attach to an existing Browserbase session - Remote mode only; errors in local mode - Mutually exclusive with `--context-id` - Persists the session ID to `/tmp/browse-{session}.connect`; clears when unused and restarts the daemon if the ID changes - Uses `browserbaseSessionID` with `keepAlive: true` so the session persists after disconnect - `browse status` now includes a `browserbaseSessionId` field - Minor release for `@browserbasehq/browse-cli` <sup>Written for commit aff1bad. Summary will update on new commits. <a href="https://cubic.dev/pr/browserbase/stagehand/pull/1889">Review in cubic</a></sup> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6e3c14b commit e81dde7

3 files changed

Lines changed: 245 additions & 12 deletions

File tree

.changeset/cli-connect-session.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@browserbasehq/browse-cli": minor
3+
---
4+
5+
Add --connect flag to attach to an existing Browserbase session by ID

packages/cli/src/index.ts

Lines changed: 82 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,10 @@ function getContextPath(session: string): string {
155155
return path.join(SOCKET_DIR, `browse-${session}.context`);
156156
}
157157

158+
function getConnectPath(session: string): string {
159+
return path.join(SOCKET_DIR, `browse-${session}.connect`);
160+
}
161+
158162
type BrowseMode = "browserbase" | "local";
159163

160164
function hasBrowserbaseCredentials(): boolean {
@@ -226,8 +230,9 @@ const DAEMON_STATE_FILES = (session: string) => [
226230
async function cleanupStaleFiles(session: string): Promise<void> {
227231
const files = [
228232
...DAEMON_STATE_FILES(session),
229-
// Context is client-written config, only cleaned on full shutdown
233+
// Client-written config, only cleaned on full shutdown
230234
getContextPath(session),
235+
getConnectPath(session),
231236
];
232237

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

347+
// Read connect config if present (written by `browse --connect <id>`)
348+
let connectSessionId: string | null = null;
349+
try {
350+
connectSessionId = (
351+
await fs.readFile(getConnectPath(session), "utf-8")
352+
).trim();
353+
} catch {}
354+
342355
stagehand = new Stagehand({
343356
env: useBrowserbase ? "BROWSERBASE" : "LOCAL",
344357
verbose: 0,
345358
disablePino: true,
346359
...(useBrowserbase
347360
? {
348361
disableAPI: true,
349-
browserbaseSessionCreateParams: {
350-
userMetadata: { "browse-cli": "true" },
351-
...(contextConfig
352-
? {
353-
browserSettings: {
354-
context: contextConfig,
355-
},
356-
}
357-
: {}),
358-
},
362+
...(connectSessionId
363+
? {
364+
browserbaseSessionID: connectSessionId,
365+
keepAlive: true,
366+
}
367+
: {}),
368+
...(!connectSessionId
369+
? {
370+
browserbaseSessionCreateParams: {
371+
userMetadata: { "browse-cli": "true" },
372+
...(contextConfig
373+
? {
374+
browserSettings: {
375+
context: contextConfig,
376+
},
377+
}
378+
: {}),
379+
},
380+
}
381+
: {}),
359382
}
360383
: {
361384
localBrowserLaunchOptions: {
@@ -1446,6 +1469,7 @@ interface GlobalOpts {
14461469
headed?: boolean;
14471470
json?: boolean;
14481471
session?: string;
1472+
connect?: string;
14491473
}
14501474

14511475
function getSession(opts: GlobalOpts): string {
@@ -1488,6 +1512,34 @@ async function runCommand(command: string, args: unknown[]): Promise<unknown> {
14881512
}
14891513
}
14901514

1515+
// Handle --connect flag: write session ID for daemon to read
1516+
if (opts.connect) {
1517+
const desiredMode = await getDesiredMode(session);
1518+
if (desiredMode === "local") {
1519+
throw new Error(
1520+
"--connect is only supported in remote mode. Run `browse env remote` first.",
1521+
);
1522+
}
1523+
1524+
if (await isDaemonRunning(session)) {
1525+
let currentConnect: string | null = null;
1526+
try {
1527+
currentConnect = (
1528+
await fs.readFile(getConnectPath(session), "utf-8")
1529+
).trim();
1530+
} catch {}
1531+
if (currentConnect !== opts.connect) {
1532+
await stopDaemonAndCleanup(session);
1533+
}
1534+
}
1535+
1536+
await fs.writeFile(getConnectPath(session), opts.connect);
1537+
} else {
1538+
try {
1539+
await fs.unlink(getConnectPath(session));
1540+
} catch {}
1541+
}
1542+
14911543
await ensureDaemon(session, headless);
14921544
return sendCommand(session, command, args, headless);
14931545
}
@@ -1506,6 +1558,10 @@ program
15061558
.option(
15071559
"--session <name>",
15081560
"Session name for multiple browsers (or use BROWSE_SESSION env var)",
1561+
)
1562+
.option(
1563+
"--connect <session-id>",
1564+
"Connect to an existing Browserbase session by ID",
15091565
);
15101566

15111567
// ==================== DAEMON COMMANDS ====================
@@ -1558,13 +1614,21 @@ program
15581614
const running = await isDaemonRunning(session);
15591615
let wsUrl = null;
15601616
let mode: BrowseMode | null = null;
1617+
let browserbaseSessionId: string | null = null;
15611618
if (running) {
15621619
try {
15631620
wsUrl = await fs.readFile(getWsPath(session), "utf-8");
15641621
} catch {}
15651622
mode = await readCurrentMode(session);
1623+
try {
1624+
browserbaseSessionId = (
1625+
await fs.readFile(getConnectPath(session), "utf-8")
1626+
).trim();
1627+
} catch {}
15661628
}
1567-
console.log(JSON.stringify({ running, session, wsUrl, mode }));
1629+
console.log(
1630+
JSON.stringify({ running, session, wsUrl, mode, browserbaseSessionId }),
1631+
);
15681632
});
15691633

15701634
program
@@ -1690,6 +1754,12 @@ program
16901754
const session = getSession(opts);
16911755

16921756
if (cmdOpts.contextId) {
1757+
if (opts.connect) {
1758+
console.error(
1759+
"Error: --context-id cannot be used with --connect (the session already exists)",
1760+
);
1761+
process.exit(1);
1762+
}
16931763
// Contexts only work with Browserbase remote sessions
16941764
const desiredMode = await getDesiredMode(session);
16951765
if (desiredMode === "local") {

packages/cli/tests/connect.test.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { describe, it, expect, afterEach } from "vitest";
2+
import { exec } from "child_process";
3+
import { promises as fs } from "fs";
4+
import * as path from "path";
5+
import * as os from "os";
6+
7+
const CLI_PATH = path.join(__dirname, "../dist/index.js");
8+
const TEST_SESSION = `connect-test-${Date.now()}`;
9+
10+
async function browse(
11+
args: string,
12+
options: { timeout?: number; env?: NodeJS.ProcessEnv } = {},
13+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
14+
const timeout = options.timeout ?? 30000;
15+
const env = { ...process.env, ...options.env };
16+
17+
return new Promise((resolve) => {
18+
const fullArgs = `node ${CLI_PATH} --headless --session ${TEST_SESSION} ${args}`;
19+
exec(fullArgs, { timeout, env }, (error, stdout, stderr) => {
20+
resolve({
21+
stdout: stdout.trim(),
22+
stderr: stderr.trim(),
23+
exitCode: error?.code ?? 0,
24+
});
25+
});
26+
});
27+
}
28+
29+
function parseJson<T = Record<string, unknown>>(output: string): T {
30+
try {
31+
return JSON.parse(output) as T;
32+
} catch {
33+
throw new Error(`Failed to parse JSON: ${output}`);
34+
}
35+
}
36+
37+
async function cleanupSession(session: string): Promise<void> {
38+
const tmpDir = os.tmpdir();
39+
const patterns = [
40+
`browse-${session}.sock`,
41+
`browse-${session}.pid`,
42+
`browse-${session}.ws`,
43+
`browse-${session}.chrome.pid`,
44+
`browse-${session}.mode`,
45+
`browse-${session}.mode-override`,
46+
`browse-${session}.context`,
47+
`browse-${session}.connect`,
48+
];
49+
50+
for (const pattern of patterns) {
51+
try {
52+
await fs.unlink(path.join(tmpDir, pattern));
53+
} catch {
54+
// Ignore missing files.
55+
}
56+
}
57+
58+
try {
59+
await fs.rm(path.join(tmpDir, `browse-${session}-network`), {
60+
recursive: true,
61+
});
62+
} catch {
63+
// Ignore missing directory.
64+
}
65+
}
66+
67+
describe("Browse CLI --connect flag", () => {
68+
afterEach(async () => {
69+
await browse("stop --force");
70+
await cleanupSession(TEST_SESSION);
71+
});
72+
73+
it("rejects --connect in local mode", async () => {
74+
// `open` routes through runCommand() where --connect validation happens
75+
const result = await browse(
76+
"--connect fake-session-id open https://example.com",
77+
{
78+
env: {
79+
...process.env,
80+
BROWSERBASE_API_KEY: "",
81+
},
82+
},
83+
);
84+
expect(result.exitCode).not.toBe(0);
85+
expect(result.stderr).toContain(
86+
"--connect is only supported in remote mode",
87+
);
88+
});
89+
90+
it("rejects --connect with --context-id on open", async () => {
91+
const result = await browse(
92+
"--connect fake-session-id open --context-id fake-ctx-id https://example.com",
93+
{
94+
env: {
95+
...process.env,
96+
BROWSERBASE_API_KEY: "test-key",
97+
},
98+
},
99+
);
100+
expect(result.exitCode).not.toBe(0);
101+
expect(result.stderr).toContain(
102+
"--context-id cannot be used with --connect",
103+
);
104+
});
105+
106+
it("writes connect file when --connect is provided", async () => {
107+
const tmpDir = os.tmpdir();
108+
const connectPath = path.join(tmpDir, `browse-${TEST_SESSION}.connect`);
109+
110+
// open routes through runCommand() which writes the connect file before
111+
// ensureDaemon(). The daemon will fail (fake API key) but the file should
112+
// still be written.
113+
await browse("--connect test-bb-session-123 open https://example.com", {
114+
env: {
115+
...process.env,
116+
BROWSERBASE_API_KEY: "test-key",
117+
},
118+
});
119+
120+
let content: string | null = null;
121+
try {
122+
content = (await fs.readFile(connectPath, "utf-8")).trim();
123+
} catch {
124+
// File may not exist if cleanup ran
125+
}
126+
expect(content).toBe("test-bb-session-123");
127+
});
128+
129+
it("clears connect file when --connect is not provided", async () => {
130+
const tmpDir = os.tmpdir();
131+
const connectPath = path.join(tmpDir, `browse-${TEST_SESSION}.connect`);
132+
133+
// Pre-create a connect file
134+
await fs.writeFile(connectPath, "old-session-id");
135+
136+
// `open` routes through runCommand() which clears the connect file when
137+
// --connect is absent. The command itself may fail (no daemon) but the
138+
// file cleanup happens first.
139+
await browse("open https://example.com");
140+
141+
let exists = true;
142+
try {
143+
await fs.access(connectPath);
144+
} catch {
145+
exists = false;
146+
}
147+
expect(exists).toBe(false);
148+
});
149+
150+
it("status includes browserbaseSessionId field", async () => {
151+
const result = await browse("status");
152+
expect(result.exitCode).toBe(0);
153+
154+
const data = parseJson(result.stdout);
155+
expect("browserbaseSessionId" in data).toBe(true);
156+
expect(data.browserbaseSessionId).toBeNull();
157+
});
158+
});

0 commit comments

Comments
 (0)