Add Python browser handle and factories - #2544
Conversation
…, and RPC transport detach
…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.
|
There was a problem hiding this comment.
2 issues found across 6 files
Confidence score: 4/5
- In
packages/sdk-python/src/stagehand/browser.py, explicit viewport requests can be dropped whenignore_default_args=True(or when--window-sizeis selectively ignored), which risks launching browsers at unintended dimensions and causing flaky layout-dependent behavior—preserve user-specified viewport flags and only strip the implicit1280x800default. - 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
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
There was a problem hiding this comment.
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-sandboxis 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 whilereturncodeis stillNone, raisingProcessLookupErrorand 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 andstrict=Truecurrently rejects natural inputs likedevice_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 singleawait 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
There was a problem hiding this comment.
5 issues found across 6 files
Confidence score: 3/5
- In
packages/sdk-python/src/stagehand/client_models.py, factory methods forLocalBrowserConnectOptions/LocalBrowserLaunchOptionsmay expose sensitive values likecdp_urland 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, normalizingargs="--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.pyandpackages/sdk-python/src/stagehand/browser.py, duplicated local-launch field/normalization definitions can drift and makeStagehand(browser="local")behave differently fromlocal_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
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
There was a problem hiding this comment.
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 readsstagehand.browserglobals, which risks breaking existing integrator customizations at runtime — restore the previous override/lookup behavior for backward compatibility. - In
_connect_browserwithinpackages/sdk-python/src/stagehand/browser.py, broadexcept BaseException as errorhandling may mishandleasyncio.CancelledErrorduring cleanup, potentially swallowing cancellation signals or leaking resources under cancellation pressure — handleCancelledErrorexplicitly 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
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
There was a problem hiding this comment.
4 issues found across 6 files
Confidence score: 3/5
- In
packages/sdk-python/src/stagehand/browser.py(via_raise_for_statusin 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.pynow 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.pyandStagehand.initboth 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
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…-browser-factories
…-browser-factories
## 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. -->
# 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. -->
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
stagehand/browser.py: nominalStagehandBrowserhandle (token-guarded construction, one-time Stagehand claim, idempotent memoizedclose())local_browser.launch()/local_browser.connect()factories that resolve only after the Stagehand extension service worker is readyowns_source = launched and not keep_alive) withExceptionGroupcleanup semantics mirroring the TSconnectBrowserBrowser.setDownloadBehavior(previouslyNotImplementedError)browserbase.launch()/browserbase.connect()API shape asNotImplementedErrorstubs — the Python SDK has never had Browserbase support; worker metadata is shaped{api_key, browser: BrowserSessionMetadata}so a future implementation needs noStagehand.createchangesRPCClient.close(close_transport=False)so Stagehand can later detach from a browser-owned transportbrowser_source.pyto a legacy adapter re-binding the moved Chrome launcher; all existing monkeypatch seams keep workingintentionally not included
__init__.pyexports untouchedStagehandconstructor,init(), or async-with_generated/(already synced by the merged TS stack)stack
init()lifecycle withStagehand.create+ browser factoriestest plan
tests/test_browser.py(374 lines): claim/release, ownership matrix, launch flags, downloads, connect extension routingRPCClientclose_transport=Falsecoverage; existing legacy suites pass unchangeduv sync --locked,generate.py --check,ruff format --check,ruff check,ty check,pytest