Skip to content

Add Python browser handle and factories - #2544

Merged
miguelg719 merged 9 commits into
v4-spikefrom
feat/stagehand-py-browser-factories
Aug 3, 2026
Merged

Add Python browser handle and factories#2544
miguelg719 merged 9 commits into
v4-spikefrom
feat/stagehand-py-browser-factories

Conversation

@miguelg719

@miguelg719 miguelg719 commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

why

Ports the merged TypeScript browser-lifecycle stack (#2517#2523) to the Python SDK. This bottom PR lands the entire new-lifecycle machinery as internal, unexported code so it can be reviewed in isolation while the legacy constructor/init() lifecycle stays fully green.

what changed

  • adds stagehand/browser.py: nominal StagehandBrowser handle (token-guarded construction, one-time Stagehand claim, idempotent memoized close())
  • implements internal local_browser.launch() / local_browser.connect() factories that resolve only after the Stagehand extension service worker is ready
  • enforces launched-versus-connected ownership (owns_source = launched and not keep_alive) with ExceptionGroup cleanup semantics mirroring the TS connectBrowser
  • implements local downloads via root-session Browser.setDownloadBehavior (previously NotImplementedError)
  • reserves browserbase.launch() / browserbase.connect() API shape as NotImplementedError stubs — the Python SDK has never had Browserbase support; worker metadata is shaped {api_key, browser: BrowserSessionMetadata} so a future implementation needs no Stagehand.create changes
  • adds RPCClient.close(close_transport=False) so Stagehand can later detach from a browser-owned transport
  • slims browser_source.py to a legacy adapter re-binding the moved Chrome launcher; all existing monkeypatch seams keep working

intentionally not included

  • no public API change; __init__.py exports untouched
  • no changes to the Stagehand constructor, init(), or async-with
  • no changes under _generated/ (already synced by the merged TS stack)

stack

  1. this PR — browser handle, factories, and transport detach (internal)
  2. Replace the Python Stagehand lifecycle with Stagehand.create #2545 — replace the constructor/init() lifecycle with Stagehand.create + browser factories
  3. Implement Browserbase sessions in the Python browser factories #2552 — implement Browserbase sessions

test plan

  • new tests/test_browser.py (374 lines): claim/release, ownership matrix, launch flags, downloads, connect extension routing
  • RPCClient close_transport=False coverage; existing legacy suites pass unchanged
  • full package gates green: uv sync --locked, generate.py --check, ruff format --check, ruff check, ty check, pytest

…review

- Mark the handle closed synchronously when close() is requested so a
  pending close cannot race a claim (close() now memoizes the task on
  call and returns a shielded awaitable).
- Run CDP-client and owned-source cleanup independently on connect
  failure, aggregating failures (BaseExceptionGroup specializes to
  ExceptionGroup for ordinary errors), and guarantee owned-source close
  in the handle close path via try/finally.
- Catch BaseException in _connect_browser so cancellation during CDP
  connect/download configuration no longer leaks a launched Chrome.
- Raise TypeError (matching the legacy constructor) for unpaired
  viewport_width/viewport_height.
- Validate nested proxy/viewport values strictly so bool ints and
  bytes strings are rejected instead of coerced.
@changeset-bot

changeset-bot Bot commented Aug 1, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 9ff4d4d

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

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

@miguelg719
miguelg719 marked this pull request as ready for review August 1, 2026 04:12

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

2 issues found across 6 files

Confidence score: 4/5

  • In packages/sdk-python/src/stagehand/browser.py, explicit viewport requests can be dropped when ignore_default_args=True (or when --window-size is selectively ignored), which risks launching browsers at unintended dimensions and causing flaky layout-dependent behavior—preserve user-specified viewport flags and only strip the implicit 1280x800 default.
  • In packages/sdk-python/src/stagehand/browser_source.py, the module-level aliases (_DEFAULT_CHROME_FLAGS, _available_port, _find_chrome_path) appear unused after delegation to _launch_local_browser_impl, which adds maintenance noise and can mislead future edits—remove them or wire them back intentionally to keep launcher ownership clear.
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="packages/sdk-python/src/stagehand/browser.py">

<violation number="1" location="packages/sdk-python/src/stagehand/browser.py:507">
P2: An explicit viewport is silently removed by `ignore_default_args=True` or when its matching window-size flag is selectively ignored. Keep an explicitly requested viewport flag; only suppress the implicit 1280x800 default.

(Based on your team's feedback about honoring selective window-size ignores.) [FEEDBACK_USED].</violation>
</file>

<file name="packages/sdk-python/src/stagehand/browser_source.py">

<violation number="1" location="packages/sdk-python/src/stagehand/browser_source.py:9">
P3: These three module-level aliases (`_DEFAULT_CHROME_FLAGS`, `_available_port`, `_find_chrome_path`) in browser_source.py are now unused: the legacy launcher delegates entirely to `_launch_local_browser_impl`, and no other module or test references them. Keeping them is harmless but adds drift with the source of truth in browser.py (they could silently diverge from the real constants/helpers). Consider dropping the unused aliases and letting legacy callers import from `stagehand.browser` directly, or leave only the ones actually consumed (`_launch_local_browser_impl`, plus `_WEBMCP_CHROME_FLAG`/`_local_browser_flags` used by tests).</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Ext as Stagehand Extension
    participant CDP as CDPClient
    participant LocalB as local_browser
    participant BB as browserbase
    participant SGB as StagehandBrowser
    participant Claim as _claim_browser
    participant RPCC as RPCClient
    participant App as Client Code

    Note over Ext,App: NEW: Browser lifecycle flow

    LocalB->>LocalB: launch(options)
    alt download options provided
        LocalB->>CDP: Browser.setDownloadBehavior
    end
    LocalB->>LocalB: _launch_local_browser(options)
    LocalB-->>LocalB: ResolvedBrowserSource

    LocalB->>LocalB: _connect_browser(provider="local", origin="launched")
    LocalB->>CDP: connect(cdp_url, extension_dir, extension_id, service_worker_url_includes="service-worker.js")
    CDP->>Ext: Wait for service worker ready
    Ext-->>CDP: Service worker active
    CDP-->>LocalB: CDPClient instance
    alt after_connect callback
        LocalB->>LocalB: execute after_connect(CDPClient)
    end
    LocalB-->>LocalB: StagehandBrowser(_token=BROWSER_TOKEN)

    alt launched and not keep_alive
        Note over LocalB: owns_source = True
    else keep_alive or connected
        Note over LocalB: owns_source = False
    end

    BB->>BB: launch/connect
    BB-->>App: NotImplementedError

    App->>SGB: StagehandBrowser(provider, origin, attachment, close)
    Note over SGB: Token-guarded constructor

    App->>Claim: _claim_browser(handle)
    alt handle closed
        Claim-->>App: RuntimeError
    else already claimed
        Claim-->>App: RuntimeError
    else valid
        Claim->>SGB: _claimed = True
        Claim-->>App: _ClaimedBrowser(cdp_client, worker_init_metadata)
    end

    App->>SGB: close()
    SGB->>SGB: idempotent memoized close
    alt owns_source
        SGB->>CDP: cdp_client.close()
        SGB->>LocalB: source.close()
    else not owns_source
        SGB->>CDP: cdp_client.close()
    end

    Note over RPCC,SGB: Stagehand detach from browser-owned transport
    App->>RPCC: close(reason, close_transport=False)
    RPCC->>RPCC: resolve pending requests with error
    RPCC->>RPCC: clear handlers/notifications
    Note over RPCC: transport stays open
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/sdk-python/src/stagehand/browser.py
Comment thread packages/sdk-python/src/stagehand/browser_source.py

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

6 issues found across 6 files

Confidence score: 2/5

  • In packages/sdk-python/src/stagehand/browser.py, Linux root local launches outside CI can fail to ever reach CDP readiness because --no-sandbox is not added in that path, so browser startup can consistently break for containerized root users — include the Linux-root condition in the sandbox-flag logic.
  • In packages/sdk-python/src/stagehand/browser.py, close() can hit a race where the process group is already gone while returncode is still None, raising ProcessLookupError and skipping normal cleanup — treat this race as an exited process so shutdown/wait paths complete safely.
  • In packages/sdk-python/src/stagehand/client_models.py, launch options are split across two schemas and strict=True currently rejects natural inputs like device_scale_factor=2, which raises avoidable runtime validation errors and increases drift risk — centralize shared launch fields and relax/coerce this numeric input path.
  • In packages/sdk-python/tests/test_rpc_client.py, the _pending_notifications == [] check depends on a single await asyncio.sleep(0) scheduling outcome, so notification-clearing behavior may look validated when the buffered message was never consumed — make the test wait deterministically for message consumption before asserting.
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="packages/sdk-python/src/stagehand/client_models.py">

<violation number="1" location="packages/sdk-python/src/stagehand/client_models.py:116">
P3: Launch-option fields now have two independent schemas. A shared internal launch-fields model would prevent factory behavior and legacy `LocalBrowserSource` validation from drifting when an option changes.</violation>

<violation number="2" location="packages/sdk-python/src/stagehand/client_models.py:131">
P3: Because LocalBrowserLaunchOptions uses strict=True, device_scale_factor only accepts float literals — a very natural call such as device_scale_factor=2 (an int) raises a ValidationError at runtime even though it is a perfectly valid scale factor. Consider accepting int for this numeric field (e.g. float | int) so common integer values do not surprise users.</violation>
</file>

<file name="packages/sdk-python/tests/test_rpc_client.py">

<violation number="1" location="packages/sdk-python/tests/test_rpc_client.py:458">
P3: The `_pending_notifications == []` assertion only verifies close() clears buffered notifications if the reader has actually consumed the 'test.buffered' message during the single `await asyncio.sleep(0)` yield. If the reader hasn't run by then, the assertion passes trivially and the clear-on-close path isn't really exercised. Consider waiting deterministically (e.g. polling for the notification to appear in `_pending_notifications` before closing) so the detach test actually validates the behavior it claims.</violation>
</file>

<file name="packages/sdk-python/src/stagehand/browser.py">

<violation number="1" location="packages/sdk-python/src/stagehand/browser.py:71">
P3: The command_timeout_ms field on _ClaimedBrowser is never read — the command timeout is actually applied directly in _connect_browser when connecting the CDP client. This stray field/documentation carries the misleading implication that the timeout is carried on the attachment while providing no behavior. Remove the field (and its default) since it is dead.</violation>

<violation number="2" location="packages/sdk-python/src/stagehand/browser.py:451">
P2: Closing a browser that has just exited can raise `ProcessLookupError` instead of completing cleanup, because `returncode` may still be `None` while its process group is gone. Handle that race as exited so waiting and temporary-profile cleanup continue.</violation>

<violation number="3" location="packages/sdk-python/src/stagehand/browser.py:523">
P1: Local launches from non-CI Linux containers running as root never become CDP-ready: Chromium exits unless `--no-sandbox` is present, but this condition only adds it for CI or explicit disable. Include the Linux-root condition, matching the Go launcher behavior.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/sdk-python/src/stagehand/browser.py
Comment thread packages/sdk-python/src/stagehand/browser.py Outdated
Comment thread packages/sdk-python/src/stagehand/client_models.py
Comment thread packages/sdk-python/tests/test_rpc_client.py Outdated
Comment thread packages/sdk-python/src/stagehand/client_models.py
Comment thread packages/sdk-python/src/stagehand/browser.py

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

5 issues found across 6 files

Confidence score: 3/5

  • In packages/sdk-python/src/stagehand/client_models.py, factory methods for LocalBrowserConnectOptions/LocalBrowserLaunchOptions may expose sensitive values like cdp_url and proxy credentials in exceptions, creating a concrete secrets-leak risk in logs and error surfaces — add explicit sanitization/redaction in error and exception paths.
  • In packages/sdk-python/src/stagehand/browser.py, normalizing args="--headless" into per-character Chrome flags can produce invalid launch behavior instead of a clear validation error, which risks confusing runtime failures for users — keep scalar strings intact so Pydantic rejects them deterministically.
  • In packages/sdk-python/src/stagehand/browser_source.py, delegated local launch logic bypasses legacy patch points (_find_chrome_path, _available_port, _local_browser_flags), so downstream monkeypatch/customization may silently stop applying — either route through the same globals or document and migrate the extension points.
  • Across packages/sdk-python/src/stagehand/client_models.py and packages/sdk-python/src/stagehand/browser.py, duplicated local-launch field/normalization definitions can drift and make Stagehand(browser="local") behave differently from local_browser.launch() over time — extract a shared base/mixin and shared normalization helper to keep behavior aligned.
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="packages/sdk-python/src/stagehand/client_models.py">

<violation number="1" location="packages/sdk-python/src/stagehand/client_models.py:119">
P3: Launch-option fields now have two independent definitions, so adding or changing a local launch setting can silently leave the legacy source and factory schema out of sync. A shared internal base/mixin for the common fields would retain their distinct config/discriminator behavior without duplicating the contract.</violation>

<violation number="2" location="packages/sdk-python/src/stagehand/client_models.py:140">
P1: Custom agent: **Exception and error message sanitization**

The new `LocalBrowserConnectOptions` and `LocalBrowserLaunchOptions` schemas carry sensitive fields (`cdp_url`, proxy credentials), but the factory methods in `browser.py` that validate user input against these schemas do not wrap `pydantic.ValidationError`. Pydantic validation errors embed the raw input dict, so any validation failure leaks the CDP URL or proxy credentials to the user. Please catch `ValidationError` in the `launch()` and `connect()` call sites and re-raise a sanitized, typed Stagehand error that strips raw input values.</violation>
</file>

<file name="packages/sdk-python/src/stagehand/browser_source.py">

<violation number="1" location="packages/sdk-python/src/stagehand/browser_source.py:13">
P3: Legacy patches to `stagehand.browser_source._find_chrome_path`, `_available_port`, or `_local_browser_flags` no longer affect local launches: the delegated function resolves those globals in `stagehand.browser`. Preserve the adapter seam or redirect the launcher through these aliases; otherwise existing isolated launcher tests/custom integrations can invoke real Chrome discovery and subprocess creation.</violation>
</file>

<file name="packages/sdk-python/src/stagehand/browser.py">

<violation number="1" location="packages/sdk-python/src/stagehand/browser.py:280">
P3: Local launch option normalization now has two near-identical implementations, so future local-option changes can make `Stagehand(browser="local")` and `local_browser.launch()` diverge. Extract the shared normalization into an internal helper or model factory.</violation>

<violation number="2" location="packages/sdk-python/src/stagehand/browser.py:283">
P3: Passing `args="--headless"` silently becomes one-character Chrome arguments instead of failing validation. Preserve scalar strings for Pydantic to reject, rather than expanding them.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant ExternalCode as External Caller
    participant Stagehand as Stagehand (constructor/init)
    participant BrowserFactory as local_browser
    participant LegacyAdapter as browser_source adapter
    participant ChromeProc as Chrome Process
    participant CDPClient as CDPClient
    participant StagehandBrowser as StagehandBrowser Handle
    participant RPCClient as RPCClient

    Note over ExternalCode,RPCClient: NEW: Browser Lifecycle (internal, unexported)

    alt Launch Flow
        ExternalCode->>Stagehand: Stagehand.create() (future PR)
        Stagehand->>BrowserFactory: local_browser.launch(options)
        BrowserFactory->>BrowserFactory: Validate options (viewport, proxy)
        BrowserFactory->>LegacyAdapter: _launch_local_browser_impl(options)
        LegacyAdapter->>ChromeProc: asyncio.create_subprocess_exec(chrome, flags...)
        ChromeProc-->>LegacyAdapter: process handle
        LegacyAdapter-->>BrowserFactory: ResolvedBrowserSource (cdp_url, keep_alive)
        BrowserFactory->>CDPClient: CDPClient.connect(cdp_url, extension_dir, service-worker.js)
        CDPClient->>CDPClient: Discover & connect to extension service worker
        CDPClient-->>BrowserFactory: connected client
        alt accept_downloads is True
            BrowserFactory->>CDPClient: Browser.setDownloadBehavior(behavior="allow", downloadPath)
            CDPClient-->>BrowserFactory: success
        end
        BrowserFactory->>StagehandBrowser: NEW: StagehandBrowser(provider="local", origin="launched")
        StagehandBrowser-->>BrowserFactory: handle
        BrowserFactory-->>Stagehand: StagehandBrowser handle
        Stagehand-->>ExternalCode: Stagehand instance
    else Connect Flow
        ExternalCode->>Stagehand: Stagehand.create() (future PR)
        Stagehand->>BrowserFactory: local_browser.connect(cdp_url)
        BrowserFactory->>CDPClient: CDPClient.connect(cdp_url, extension_dir...)
        CDPClient-->>BrowserFactory: connected client
        BrowserFactory->>StagehandBrowser: NEW: StagehandBrowser(provider="local", origin="connected")
        StagehandBrowser-->>BrowserFactory: handle
        BrowserFactory-->>Stagehand: StagehandBrowser handle
        Stagehand-->>ExternalCode: Stagehand instance
    end

    Note over StagehandBrowser,CDPClient: Claim/Release (single-use token guard)

    Stagehand->>StagehandBrowser: _claim_browser(handle)
    alt Already claimed
        StagehandBrowser-->>Stagehand: RuntimeError("already attached")
    else Success
        StagehandBrowser-->>Stagehand: _ClaimedBrowser (cdp_client, metadata)
    end

    Stagehand->>StagehandBrowser: close()
    StagehandBrowser->>StagehandBrowser: asyncio.create_task(_run_close())
    StagehandBrowser->>CDPClient: cdp_client.close()
    alt Owns source (launched & not keep_alive)
        StagehandBrowser->>ChromeProc: source.close() → kill Chrome process + cleanup profile
    end
    StagehandBrowser-->>Stagehand: done

    Note over RPCClient: Detach transport support

    Stagehand->>RPCClient: close(reason, close_transport=False)
    RPCClient->>RPCClient: Clear pending requests, handlers, notifications
    RPCClient-->>Stagehand: closed (transport stays open)

    Note over StagehandBrowser: Failure cleanup (ExceptionGroup)

    alt Connect fails
        CDPClient->>CDPClient: connect() raises error
        alt Owns source
            CDPClient->>ChromeProc: source.close()
        end
        CDPClient-->>BrowserFactory: ExceptionGroup("connection failed" + cleanup errors)
    end

    alt After-connect callback fails
        BrowserFactory->>CDPClient: cdp_client.close()
        alt Owns source
            BrowserFactory->>ChromeProc: source.close()
        end
        BrowserFactory-->>BrowserFactory: ExceptionGroup("configuration failed" + cleanup errors)
    end

    Note over LegacyAdapter: Slimmed adapter (re-exports moved logic)

    LegacyAdapter->>ChromeProc: _launch_local_browser_impl (imported from browser module)
    LegacyAdapter-->>BrowserFactory: ResolvedBrowserSource
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/sdk-python/src/stagehand/client_models.py
Comment thread packages/sdk-python/src/stagehand/client_models.py
Comment thread packages/sdk-python/src/stagehand/browser_source.py
Comment thread packages/sdk-python/src/stagehand/browser.py
Comment thread packages/sdk-python/src/stagehand/browser.py Outdated

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

3 issues found across 6 files

Confidence score: 3/5

  • In packages/sdk-python/src/stagehand/browser.py, local concurrent launches can race on debug-port selection, so Chrome may fail to start intermittently when the reserved port is released too early; this can cause flaky startup in parallel workloads — switch to Chrome-assigned ephemeral debugging ports (and discover them) or add bounded launch/retry logic.
  • In packages/sdk-python/src/stagehand/browser_source.py, legacy monkeypatches (_find_chrome_path, _available_port, _local_browser_flags) no longer influence startup because the captured implementation reads stagehand.browser globals, which risks breaking existing integrator customizations at runtime — restore the previous override/lookup behavior for backward compatibility.
  • In _connect_browser within packages/sdk-python/src/stagehand/browser.py, broad except BaseException as error handling may mishandle asyncio.CancelledError during cleanup, potentially swallowing cancellation signals or leaking resources under cancellation pressure — handle CancelledError explicitly and ensure deterministic cleanup/re-raise semantics.
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="packages/sdk-python/src/stagehand/browser.py">

<violation number="1" location="packages/sdk-python/src/stagehand/browser.py:211">
P2: Cancellation handling in `_connect_browser` cleanup can swallow or leak under `asyncio.CancelledError`. Because the `except BaseException as error:` branch catches `CancelledError`, when the connection is cancelled *and* a cleanup step also fails, the code raises an `ExceptionGroup` wrapping the `CancelledError` (`raise BaseExceptionGroup(...) from error`). asyncio only treats a task as cancelled if `CancelledError` actually propagates, so this downgrades a cancellation into a normal aggregate failure. Relatedly, the cleanup guards use `except Exception` around `await cdp_client.close()` / `await source.close()`; if the task gets cancelled *during* those awaits, asyncio re-raises `CancelledError` at the await point, which is a `BaseException` and so is NOT caught — `source.close()` is skipped and a launched Chrome process/profile can be orphaned. The existing `test_connect_cancellation_closes_owned_source` covers cancellation before connect but not cancellation during cleanup or cancellation-plus-cleanup-failure. Consider re-raising `CancelledError` unchanged (and still running the owned-source cleanup best-effort) instead of wrapping it in an `ExceptionGroup`.</violation>

<violation number="2" location="packages/sdk-python/src/stagehand/browser.py:431">
P2: Concurrent local launches can intermittently fail because the automatically selected debug port is released before Chrome starts. Have Chrome allocate an ephemeral debug port and discover it, or add launch/retry logic that reserves a port through successful startup.</violation>
</file>

<file name="packages/sdk-python/src/stagehand/browser_source.py">

<violation number="1" location="packages/sdk-python/src/stagehand/browser_source.py:13">
P2: Legacy launcher monkeypatches of `_find_chrome_path`, `_available_port`, or `_local_browser_flags` no longer affect browser startup: the captured implementation reads `stagehand.browser` globals instead. Preserve the legacy injection seams or update callers/tests to patch the implementation module; this contradicts the stated compatibility guarantee.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Ext as Stagehand Extension
    participant LB as local_browser
    participant BB as browserbase
    participant SHB as StagehandBrowser
    participant FC as _connect_browser()
    participant CDP as CDPClient
    participant LP as _launch_local_browser()
    participant SRC as Browser Source
    participant RPC as RPCClient
    participant BW as Browser WebSocket

    Note over LB,BB: Browser factories

    LB->>LP: launch(options)
    LP->>LP: spawn chrome process, get CDP URL
    LP-->>LB: BrowserSource with cdp_url
    
    BB->>BB: stub, raises NotImplementedError

    LB->>FC: _connect_browser(local, launched, source)
    FC->>CDP: connect(cdp_url, extension_dir, extension_id)
    CDP->>BW: WebSocket connection
    CDP->>Ext: wait for service-worker.js ready
    Ext-->>CDP: service worker detected
    CDP-->>FC: CDPClient instance
    FC->>FC: call after_connect() if provided
    alt Downloads enabled
        FC->>CDP: Browser.setDownloadBehavior
        CDP-->>FC: download behavior configured
    end
    FC->>SHB: StagehandBrowser(provider, origin, ClaimedBrowser, close_callback)
    FC-->>LB: return StagehandBrowser handle

    Note over SHB,RPC: Browser handle lifecycle

    SHB->>SHB: _run_close() when close() called
    SHB->>CDP: close()
    alt owns_source (launched & not keep_alive)
        SHB->>SRC: close() to kill chrome process
    end
    SRC-->>SHB: source closed
    CDP-->>SHB: CDP disconnected
    SHB-->>SHB: mark closed

    Note over SHB,RPC: Token-guarded construction

    Note over SHB: _token check prevents external instantiation

    Note over SHB: One-time claim system

    SHB->>SHB: _claim_browser() sets _claimed=True
    alt browser already claimed or closed
        SHB-->>SHB: raise RuntimeError
    end
    SHB->>SHB: _release_browser() resets _claimed

    Note over SHB,RPC: Error cleanup paths

    alt CDP connect fails
        FC->>CDP: close() if connected
        alt owns_source
            FC->>SRC: close()
        end
        FC-->>FC: ExceptionGroup with all errors
    end

    alt after_connect fails
        FC->>CDP: close()
        alt owns_source
            FC->>SRC: close()
        end
        FC-->>FC: ExceptionGroup
    end

    Note over RPC: Transport detach support

    RPC->>RPC: close(close_transport=False)
    RPC->>RPC: reject pending requests
    RPC->>RPC: clear handlers & notifications
    RPC-->>RPC: transport stays open
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/sdk-python/src/stagehand/browser.py
Comment thread packages/sdk-python/src/stagehand/browser_source.py
Comment thread packages/sdk-python/src/stagehand/browser.py
Comment thread packages/sdk-python/src/stagehand/client_models.py

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

4 issues found across 6 files

Confidence score: 3/5

  • In packages/sdk-python/src/stagehand/browser.py (via _raise_for_status in the CDP client path), raw connection errors can expose caller-supplied CDP URLs, which creates a concrete information-leak risk in user-visible exceptions and logs — sanitize/redact URL content before rethrowing errors.
  • packages/sdk-python/src/stagehand/client_models.py now duplicates local-browser launch fields across schemas, so future field additions or validator changes can drift between legacy and factory launch paths and cause inconsistent behavior — extract shared launch options into a single private base model.
  • In packages/sdk-python/src/stagehand/browser_source.py, delegation bypasses legacy monkeypatch seams (_find_chrome_path, _available_port, _local_browser_flags), which can silently break existing test/custom launch integrations — preserve or reintroduce an override point in the delegated path.
  • packages/sdk-python/src/stagehand/browser.py and Stagehand.init both resolve extension directories independently, so packaging/fallback updates could make different lifecycle paths load different extensions — centralize resolution into one helper used by both call sites.
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="packages/sdk-python/src/stagehand/client_models.py">

<violation number="1" location="packages/sdk-python/src/stagehand/client_models.py:119">
P3: Launch-option schema now has a second copy of every local-browser setting, so future additions or validation changes can make legacy and factory launches behave differently. Factor shared fields into a private base model, then add `type` only on `LocalBrowserSource` and strict config on the factory model.</violation>
</file>

<file name="packages/sdk-python/src/stagehand/browser_source.py">

<violation number="1" location="packages/sdk-python/src/stagehand/browser_source.py:13">
P3: Legacy launcher monkeypatches for `_find_chrome_path`, `_available_port`, or `_local_browser_flags` no longer affect launches: delegation executes `stagehand.browser` globals instead. Keep the launcher dependency seam in `browser_source` or explicitly route these adapter hooks into the delegated implementation.</violation>
</file>

<file name="packages/sdk-python/src/stagehand/browser.py">

<violation number="1" location="packages/sdk-python/src/stagehand/browser.py:211">
P1: Custom agent: **Exception and error message sanitization**

Raw CDP connection errors are propagated to the user without sanitizing the error message, which can leak the caller-supplied CDP URL. In `cdp_client.py`, `_resolve_browser_web_socket_url` embeds `base_url` in a `TimeoutError`. `_connect_browser` then either re-raises that exception (`raise`) or attaches it as the cause of a `BaseExceptionGroup(... ) from error`. Both paths expose the raw exception to the user, violating the requirement that exceptions never reflect sensitive values such as CDP connect URLs.

Replace the bare re-raise and the `from error` cause chain with a sanitized, typed exception that omits the URL. For example, catch the failure and raise a dedicated `StagehandBrowserError("Browser connection failed") from None` (or a similar typed class) so the original message and any upstream error containing the URL are not surfaced.</violation>

<violation number="2" location="packages/sdk-python/src/stagehand/browser.py:243">
P3: Extension directory resolution now has two copies: this factory path and `Stagehand.init`. Centralizing it prevents one lifecycle from loading a different extension when packaging/fallback rules change.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client as Stagehand Client Code
    participant SH as Stagehand Instance
    participant LB as local_browser Factory
    participant BS as BrowserSource (legacy)
    participant B as _BrowserConnectionSource
    participant CDP as CDPClient
    participant Process as Chrome Process
    participant D as Downloads Manager

    Note over Client,SH: NEW: StagehandBrowser handle lifecycle

    alt Launch Path
        Client->>SH: request browser
        SH->>LB: local_browser.launch(options)
        LB->>B: create _BrowserConnectionSource
        B->>Process: asyncio.create_subprocess_exec(chrome, flags...)
        Process-->>B: process handle
        B-->>LB: source with cdp_url + close callback
        LB->>CDP: CDPClient.connect(cdp_url, extension_dir...)
        CDP->>CDP: connect to Chrome DevTools<br/>wait for service-worker.js
        alt After connect
            CDP-->>LB: CDPClient instance
            LB->>CDP: setDownloadBehavior(downloads_path)
            CDP-->>LB: configured
        end
        LB-->>SH: StagehandBrowser(provider="local", origin="launched")
    else Connect Path
        SH->>LB: local_browser.connect(options)
        LB->>CDP: CDPClient.connect(cdp_url, extension_id...)
        CDP-->>LB: CDPClient instance
        LB-->>SH: StagehandBrowser(provider="local", origin="connected")
    end

    Note over SH,CDP: Ownership & Claim Semantics

    SH->>SH: _claim_browser(handle)
    alt Already claimed
        SH->>SH: raise RuntimeError("already attached")
    else Closed handle
        SH->>SH: raise RuntimeError("closed browser")
    end
    SH->>SH: _ClaimedBrowser(cdp_client, metadata)

    alt Release
        SH->>SH: _release_browser(handle)
        SH->>SH: allow re-claim
    end

    Note over SH,D: Close Path

    Client->>SH: Stagehand.close()
    SH->>SH: memoized asyncio.create_task(_run_close())
    SH->>CDP: CDPClient.close()
    alt Owns source (launched & !keep_alive)
        SH->>B: source.close()
        B->>Process: kill process
        alt Temporary profile
            B->>B: shutil.rmtree(user_data_dir)
        end
    end
    CDP-->>SH: closed
    SH-->>Client: done

    Note over SH,D: Error Handling (ExceptionGroup)

    alt CDP connect failure
        CDP-->>SH: RuntimeError
        SH->>SH: cleanup_errors collection
        alt Owns source
            SH->>B: source.close()
            B-->>SH: success or error
        end
        alt CDP close also fails
            SH->>SH: BaseExceptionGroup(connect error, cleanup error)
        end
    end

    Note over B,CDP: Transport Detach (NEW: close_transport=False)

    alt Stagehand detaches from browser-owned transport
        SH->>CDP: RPCClient.close(reason, close_transport=False)
        CDP->>CDP: clear handlers, notifications,<br/>but leave transport open
        CDP-->>SH: detached
    end

    Note over BS: Legacy Adapter

    Client->>BS: existing _launch_local_browser(options)
    BS->>BS: delegates to _browser._launch_local_browser_impl
    BS-->>Client: ResolvedBrowserSource (unchanged API)

    Note over LB,BS: Browserbase stubs reserved

    LB->>LB: browserbase.launch() returns NotImplementedError
    LB->>LB: browserbase.connect() returns NotImplementedError
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/sdk-python/src/stagehand/browser.py
Comment thread packages/sdk-python/src/stagehand/client_models.py
Comment thread packages/sdk-python/src/stagehand/browser_source.py
Comment thread packages/sdk-python/src/stagehand/browser.py
@miguelg719
miguelg719 merged commit d6d5483 into v4-spike Aug 3, 2026
22 checks passed
miguelg719 added a commit that referenced this pull request Aug 3, 2026
## Summary

Completes the Python port of the browser-lifecycle stack (#2517#2523)
by making `await Stagehand.create(browser=..., ...)` the sole
construction path, mirroring the TypeScript end state.

- makes `Stagehand()` unconstructible directly (module-private token;
`TypeError` points at `Stagehand.create`)
- removes `init()`, async-with, the flattened browser constructor
kwargs, the `BrowserSource` union models, and `browser_source.py`
- exports `local_browser`, `browserbase`, and `StagehandBrowser` from
the package root
- `stagehand.browser` returns the exact handle passed to `create`; a
failed `create` releases the claim so the same handle can be retried
- `stagehand.close()` never touches the browser or CDP socket —
browser/session lifetime is exclusively `browser.close()`
- worker init keeps the exact `StagehandInitParams` wire shape; handle
metadata overrides caller `api_key`, and local handles omit `browser`
entirely
- migrates all 7 examples, README, and the CI wheel-smoke script to
launch → create → `stagehand.close()` → `browser.close()` (browser close
in the outermost `finally`)
- updates the Python ast-grep example-parity patterns to the
`Stagehand.create` shape

## Reviewer focus

1. `stagehand.close()` stops the runtime; `browser.close()` owns browser
cleanup — under no configuration does Stagehand close the transport or
process.
2. Browser acquisition options stay client-side and never cross the RPC
boundary.
3. One-shot asyncio semantics: memoized `close()`, claim release under
failed `create`, concurrent-close safety.

## Stack

- #2544 — browser handle, factories, and transport detach (internal)
- **this PR** — replace the constructor/`init()` lifecycle with
`Stagehand.create`
- #2552 — implement Browserbase sessions

## Verification

- full Python package gates green: `generate.py --check`, `ruff format
--check`, `ruff check`, `ty check`, `pytest` (rewritten lifecycle suite
incl. wire-shape, claim-retry, and concurrent-close tests)
- repo `pnpm run test:unit` green (ast-grep example-parity + sdk-parity
against the migrated sources)
- changeset check passed; wheel smoke exercised by CI
`python-wheel-smoke`

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Make `await Stagehand.create(browser=...)` the only way to start the
Python client. Adds `local_browser` and `browserbase` factories, moves
browser lifetime to the browser handle, and hardens close/cancel and
launch validation (incl. Python 3.12’s eager task factory).

- **Refactors**
  - `Stagehand()` is no longer constructible; use `Stagehand.create`.
- Removed `init()`, async-with usage, and `BrowserSource` models;
deleted `browser_source.py`. `ResolvedBrowserSource` now lives in
`browser.py` with dead fields removed.
- Exported `local_browser`, `browserbase`, and `StagehandBrowser` from
`stagehand`.
- `stagehand.browser` returns the exact handle passed to `create`.
Failed or canceled `create` releases the claim and detaches the RPC
client with `close_transport=False`.
- `stagehand.close()` stops the runtime only, is safe under Python
3.12’s eager task factory, and never closes the browser or CDP socket.
- Removed `connect_rpc_client`; updated README, examples (incl.
`model_gateway.py`), tests, smoke script, and ast-grep rules (Python now
requires `Stagehand.create`).
- Hardened browser handle: stricter launch validation (viewport pairing,
list arg types), preserves explicit viewport when default flags are
ignored, and treats a vanished Chrome as exited on close.

- **Migration**
- Launch a browser: `browser = await local_browser.launch(...)` or
`browser = await browserbase.launch(api_key=...)`.
- Create the client: `stagehand = await
Stagehand.create(browser=browser, ...)`.
- Close in order: `await stagehand.close()` then `await browser.close()`
(put browser close in the outermost finally).
  - Remove any `async with Stagehand(...)` and calls to `init()`.

<sup>Written for commit 9fdb16d.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/browserbase/stagehand/pull/2545?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
miguelg719 added a commit that referenced this pull request Aug 3, 2026
# why

#2544 reserved `browserbase.launch` / `browserbase.connect` as
`NotImplementedError` stubs. This PR makes them real, using the official
`browserbase` PyPI SDK — the Python counterpart of the TS factories'
`@browserbasehq/sdk` usage.

# what changed

- adds `browserbase>=1.15,<2` as a runtime dependency
(`AsyncBrowserbase`), the SDK's first beyond pydantic + websockets
- `browserbase.launch(api_key=..., ...)`: creates a session,
provisioning the packaged Stagehand extension via an in-memory
deterministic zip upload; caller-supplied extension IDs (top-level or
`browser_settings`) suppress provisioning **and** cleanup
- merges `user_metadata` with `{"stagehand": "true",
"stagehand_sdk_language": "python"}` (Stagehand keys win)
- ownership mirrors TS: `keep_alive ?? False` decides release-on-close;
session release and owned-extension delete are idempotent per resource
with retry-on-next-close; release errors take precedence
- `browserbase.connect(api_key=..., session_id=...)`:
`sessions.retrieve`-based, errors `Browserbase session is not available
for connection` when no connect URL, never takes release ownership
- adds a preloaded-extension discovery mode to `CDPClient.connect`
(Browserbase sessions have the extension preinstalled; mirrors the TS/Go
service-worker readiness polling)
- worker init metadata carries `{api_key, browser: {session_id,
region?}}`, overriding the caller's `api_key` per the TS spread order
- sanitized `BrowserbaseSessionError` messages; official-SDK calls
verified against the installed package (camelCase `fingerprint` wire
mapping, `api_timeout` kwarg, `Content-Type` omitted on the body-less
extension DELETE)
- `examples/caching.py`'s existing `browserbase.launch` call now works
as written — no example inventory changes (ast-grep parity)

# stack

1. #2544 — browser handle, factories, and transport detach (internal)
2. #2545 — replace the constructor/`init()` lifecycle with
`Stagehand.create`
3. **this PR — implement Browserbase sessions**

# test plan

- hand-rolled fake `_BrowserbaseAPI` seam tests:
provisioning/suppression matrix, create-failure and cancellation
cleanup, empty-id/URL handling, close idempotence + retry, connect
paths, kwargs wire-shape tests (fingerprint camelCase, `api_timeout`),
preloaded-extension CDP discovery tests
- full package gates green: `uv lock --check`, `generate.py --check`,
`ruff format --check`, `ruff check`, `ty check`, `pytest` (239 passed),
sdist+wheel build, changeset + python-version sync checks, and every
`just check`/`just test` recipe command run manually
- live validation against the real Browserbase API (2026-08-02):
extension upload -> session create -> preloaded-worker discovery ->
`Stagehand.create` -> `stagehand.close` -> `browser.close` -> session
`COMPLETED`, uploaded extension deleted (404 on retrieve). Caught and
fixed one wire bug: `region: null` was rejected by the worker; the key
is now omitted when unset (87369b4)

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Adds Browserbase session support to the Python browser factories via
`browserbase.launch(...)` and `browserbase.connect(...)`. Improves
preloaded extension discovery, deterministic extension packaging, and
omits an unset Browserbase region from the `stagehand.init` payload.

- **New Features**
- `browserbase.launch(api_key, ...)` creates a session, uploads the
Stagehand extension unless an `extension_id` is given, respects
`keep_alive`, and sets worker metadata `{api_key, session_id, region}`.
- `browserbase.connect(api_key, session_id, ...)` attaches to an
existing session; uses a caller `extension_id` or the preloaded
extension; never releases the session.
- CDP discovers preloaded extensions with service worker readiness
polling, detaches stale/incompatible or malformed workers, and exposes
the discovered extension ID.
  - Adds `browserbase>=1.15,<2` as a runtime dependency.

- **Bug Fixes**
- Correct Browserbase wire mapping: camelCase fingerprint keys,
`api_timeout`; reject empty `extension_id` (top-level or nested); omit
`Content-Type` on extension delete; clearer errors.
- Omit unset `region` from the `stagehand.init` payload (worker schema
rejects `region: null`).
- More robust cleanup and packaging: deterministic extension archives;
idempotent release/delete with retry; delete owned extensions on create
failure or cancellation. Docs clarify `keep_alive` behavior and
extension retention.

<sup>Written for commit 8234a67.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/browserbase/stagehand/pull/2552?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants