Skip to content

feat(cli): add --connect flag for daemon mode with existing Chrome#1830

Open
peytoncasper wants to merge 4 commits intobrowserbase:mainfrom
peytoncasper:feat/connect-to-existing-chrome
Open

feat(cli): add --connect flag for daemon mode with existing Chrome#1830
peytoncasper wants to merge 4 commits intobrowserbase:mainfrom
peytoncasper:feat/connect-to-existing-chrome

Conversation

@peytoncasper
Copy link

Summary

  • Adds --connect <url> CLI option that starts the daemon attached to an existing Chrome instance via CDP WebSocket URL
  • The daemon persists between commands, caching accessibility tree refs from snapshots
  • Unlike --ws (stateless, no ref caching), --connect gives you persistent daemon mode without launching Chrome

Use case

Remote node management where Chrome is launched externally with custom flags (anti-bot detection, persistent profiles, specific ports) and the browse CLI needs to interact with it while preserving refs between commands.

Example:

# External Chrome is running on port 9300 with custom flags
browse --connect ws://127.0.0.1:9300/devtools/browser/abc123 open https://example.com
browse --connect ws://127.0.0.1:9300/devtools/browser/abc123 snapshot  # refs cached
browse --connect ws://127.0.0.1:9300/devtools/browser/abc123 click @0-5  # refs work!

Changes

  • packages/cli/src/index.ts: Added --connect option to GlobalOpts, program options, runDaemon, ensureDaemon, sendCommand, and runCommand
  • When --connect is set, ensureBrowserInitialized uses localBrowserLaunchOptions: { cdpUrl: connectUrl } instead of launching Chrome
  • Retry logic in sendCommand skips killChromeProcesses when using --connect (Chrome is externally managed)

Test plan

  • browse --connect <ws_url> open <url> navigates successfully
  • browse --connect <ws_url> snapshot -c returns accessibility tree with refs
  • browse --connect <ws_url> click @0-1 uses cached refs from previous snapshot
  • Daemon persists between invocations (refs survive across CLI calls)
  • Existing --ws behavior unchanged
  • Local daemon mode (no flags) unchanged

Made with Cursor

Adds a --connect <url> option that tells the browse daemon to attach to
an existing Chrome instance via CDP WebSocket URL instead of launching
its own Chrome. The daemon persists between commands (refs from snapshot
are cached), but Chrome lifecycle is managed externally.

Use case: remote node management where Chrome is launched with custom
flags (anti-detection, profiles, specific ports) and the browse CLI
needs to interact with it while preserving accessibility tree refs.

--ws (stateless, no ref cache) remains unchanged.
--connect (daemon mode, persistent refs, external Chrome) is new.

Made-with: Cursor
@changeset-bot
Copy link

changeset-bot bot commented Mar 15, 2026

🦋 Changeset detected

Latest commit: dbdd604

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@browserbasehq/browse-cli Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Contributor

This PR is from an external contributor and must be approved by a stagehand team member with write access before CI can run.
Approving the latest commit mirrors it into an internal PR owned by the approver.
If new commits are pushed later, the internal PR stays open but is marked stale until someone approves the latest external commit and refreshes it.

@github-actions github-actions bot added external-contributor Tracks PRs mirrored from external contributor forks. external-contributor:awaiting-approval Waiting for a stagehand team member to approve the latest external commit. labels Mar 15, 2026
@miguelg719
Copy link
Collaborator

@peytoncasper changeset for release

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 15, 2026

Greptile Summary

Adds a --connect <url> CLI flag that runs the daemon attached to an externally managed Chrome instance via CDP WebSocket URL, preserving ref caching between commands (unlike the existing stateless --ws flag). The CDP integration is correct — localBrowserLaunchOptions: { cdpUrl } is valid, and stagehand.close() safely disconnects without killing the external browser.

  • The --connect URL is correctly threaded through runDaemon, ensureDaemon, and sendCommand, with killChromeProcesses properly skipped in retry logic
  • ensureDaemon only checks mode (local vs browserbase) when deciding whether to reuse a running daemon — it does not track which connectUrl the daemon was started with, so switching Chrome instances on the same session silently reuses the old connection
  • stop --force does not guard killChromeProcesses against --connect mode, unlike the retry path in sendCommand

Confidence Score: 3/5

  • Generally safe for the primary use case but has a daemon identity bug when switching connect URLs on the same session
  • The core CDP plumbing is correct and the happy path works. Score reduced because ensureDaemon silently reuses a daemon connected to a different Chrome URL (a logical bug), and the stop --force path is inconsistent with the retry guard. These issues mainly affect edge cases (switching URLs on same session, force-stopping a connect session).
  • Pay close attention to packages/cli/src/index.ts — specifically the ensureDaemon function's daemon reuse logic and the stop --force command path

Important Files Changed

Filename Overview
packages/cli/src/index.ts Adds --connect flag plumbed through daemon lifecycle functions. Core CDP connection logic is correct, but ensureDaemon doesn't track connect URL identity (silent reuse of wrong Chrome instance when URL changes).

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["browse --connect &lt;url&gt; &lt;command&gt;"] --> B{"opts.ws set?"}
    B -->|Yes| C["Direct stateless connection\n(bypasses daemon)"]
    B -->|No| D["ensureDaemon(session, headless, connectUrl)"]
    D --> E{"Daemon running\nfor session?"}
    E -->|Yes| F{"Mode matches?"}
    F -->|Yes| G["Reuse existing daemon\n⚠️ connectUrl not checked"]
    F -->|No| H["stopDaemonAndCleanup → restart"]
    E -->|No| I["Spawn daemon with\n--connect &lt;url&gt; flag"]
    I --> J["runDaemon(session, headless, connectUrl)"]
    J --> K["ensureBrowserInitialized"]
    K --> L{"connectUrl set?"}
    L -->|Yes| M["Stagehand with\ncdpUrl: connectUrl\n(no Chrome launch)"]
    L -->|No| N["Stagehand with\nheadless + viewport\n(launches Chrome)"]
    G --> O["sendCommand via Unix socket"]
    H --> O
    M --> O
    N --> O
Loading

Comments Outside Diff (2)

  1. packages/cli/src/index.ts, line 1358-1363 (link)

    Daemon silently reuses wrong Chrome instance

    When --connect is used, ensureDaemon only checks that the running daemon's mode matches (both will be "local"). If a daemon is already running for this session connected to Chrome at URL A, and the user runs browse --connect ws://...B snapshot, the daemon check at line 1361 passes — the daemon stays connected to URL A, silently ignoring the new connectUrl.

    Consider persisting the connectUrl to a file (like mode is persisted) and comparing it here so that a URL mismatch triggers stopDaemonAndCleanup and a restart with the new URL.

  2. packages/cli/src/index.ts, line 1547-1549 (link)

    stop --force may kill externally managed Chrome

    The retry logic in sendCommand (line 1333) correctly guards killChromeProcesses with if (!connectUrl), but the stop --force path here has no such guard. The killChromeProcesses function uses pgrep -f "browse-${session}" which could match processes if the externally launched Chrome's command-line happens to include the session string. While unlikely in most cases, this is inconsistent with the protection added in sendCommand and could surprise users who expect --connect to never touch their external Chrome process.

Last reviewed commit: 09f2161

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 1 file

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.
Architecture diagram
sequenceDiagram
    participant User
    participant CLI as CLI Process
    participant FS as Session Files (PID/Socket)
    participant Daemon as Daemon Process (Background)
    participant Core as Stagehand Core
    participant Chrome as Chrome (CDP Host)

    User->>CLI: browse --connect <url> <command>
    CLI->>FS: Check for existing daemon socket
    
    alt Daemon NOT running
        CLI->>Daemon: NEW: Spawn daemon with --connect <url>
        Daemon->>Core: NEW: Initialize with cdpUrl (No local launch)
        Core->>Chrome: Connect to existing CDP WebSocket
        Daemon-->>FS: Write socket/PID
    end

    CLI->>Daemon: sendCommand(session, command, args)
    
    alt Standard execution
        Daemon->>Core: Execute command
        Core->>Chrome: CDP Interactions
        Chrome-->>Core: Result + Accessibility Tree
        Core->>Core: Cache Accessibility Refs
        Daemon-->>CLI: Command Response
    else CHANGED: Command failure / Retry
        CLI->>Daemon: Attempt restart
        opt NEW: if --connect is active
            CLI->>CLI: Skip killChromeProcesses (External)
        end
        CLI->>FS: cleanupStaleFiles()
        CLI->>Daemon: ensureDaemon(session, connectUrl)
    end

    CLI-->>User: Display Result
    Note over Daemon,Chrome: Daemon & CDP Connection persist after CLI exits

    User->>CLI: browse --connect <url> click @0-5
    CLI->>Daemon: sendCommand("click", ["@0-5"])
    Note over Daemon,Core: Uses cached refs from previous snapshot
    Daemon->>Core: Resolve @0-5 to CDP node
    Core->>Chrome: Dispatch Click
    Daemon-->>CLI: Success
    CLI-->>User: Clicked element
Loading

Since this is your first cubic review, here's how it works:

  • cubic automatically reviews your code and comments on bugs and improvements
  • Teach cubic by replying to its comments. cubic learns from your replies and gets better over time
  • Add one-off context when rerunning by tagging @cubic-dev-ai with guidance or docs links (including llms.txt)
  • Ask questions if you need clarification on any suggestion

@github-actions
Copy link
Contributor

The latest approval by @miguelg719 could not refresh the mirrored PR automatically (missing-previous-source). The external PR stays open, and the mirrored PR should be updated manually before work continues.

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name=".changeset/clean-dogs-chew.md">

<violation number="1" location=".changeset/clean-dogs-chew.md:2">
P2: This changeset uses `patch` but the change introduces a new CLI feature (`--connect`). Per semver, new backward-compatible features should be a `minor` bump so users tracking versions can discover new capabilities.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

When connecting to an existing Chrome via CDP, the default chrome://newtab
tab is not injectable by Stagehand (filtered by hasInjectableDOM). The
ensureFirstTopLevelPage fallback creates a new about:blank tab, but the
chrome://newtab tab remains visible in front of it.

Fix: close any chrome:// tabs before creating the about:blank tab so the
new tab becomes the only visible one. Combined with the sidecar's
pre-navigation of chrome://newtab to about:blank, this ensures the
user always sees the automated page.

Made-with: Cursor
try {
const targets = await this.conn.getTargets();
for (const t of targets) {
if (t.type === "page" && t.url?.startsWith("chrome://")) {
Copy link
Member

@pirate pirate Mar 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (t.type === "page" && t.url?.startsWith("chrome://")) {
if (t.type === "page" && t.url?.startsWith("chrome://")) {
// TODO: consider expanding this list if needed, see chrome://chrome-urls
// many things can cause weird unusable targets to appear at launch.
// e.g. chrome extensions, new feature info pages, chrome error pages

@github-actions
Copy link
Contributor

The latest approval by @pirate could not refresh the mirrored PR automatically (missing-previous-source). The external PR stays open, and the mirrored PR should be updated manually before work continues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

external-contributor:awaiting-approval Waiting for a stagehand team member to approve the latest external commit. external-contributor Tracks PRs mirrored from external contributor forks.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants