Skip to content

Implement Browserbase sessions in the Python browser factories - #2552

Merged
miguelg719 merged 16 commits into
feat/stagehand-py-create-lifecyclefrom
feat/stagehand-py-browserbase
Aug 3, 2026
Merged

Implement Browserbase sessions in the Python browser factories#2552
miguelg719 merged 16 commits into
feat/stagehand-py-create-lifecyclefrom
feat/stagehand-py-browserbase

Conversation

@miguelg719

@miguelg719 miguelg719 commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

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. Add Python browser handle and factories #2544 — 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
  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)

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.

Written for commit 8234a67. Summary will update on new commits.

Review in cubic

Review fixes on the Browserbase session integration:

- Map fingerprint keys to the Browserbase API's camelCase wire names
  (httpVersion, operatingSystems, screen.maxHeight/maxWidth/minHeight/
  minWidth). browserbase-py 1.15 has no fingerprint TypedDict key, so
  the Stainless transform passed the dumped snake_case keys through
  unchanged and the API silently dropped them. The kwargs construction
  now lives in _session_create_kwargs with direct test coverage.
- Delete the owned extension when session creation is cancelled:
  the cleanup handler now catches BaseException, re-raising
  non-Exception errors (CancelledError) unchanged.
- Suppress the Content-Type header on extensions.delete via the omit
  sentinel, porting the TS SDK's explicit workaround (TS sends
  headers {"Content-Type": null}).
@changeset-bot

changeset-bot Bot commented Aug 1, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 8234a67

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

@socket-security

socket-security Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedpypi/​browserbase@​1.15.0100100100100100

View full report

@miguelg719
miguelg719 marked this pull request as ready for review August 1, 2026 06:11

@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.

7 issues found across 12 files

Confidence score: 3/5

  • In packages/sdk-python/src/stagehand/browser.py, a failed Browserbase release/delete can make handle.close() reuse the failed task and skip session.close() on later attempts, which can orphan sessions/extensions and leak resources—reset close-task state after failure so cleanup remains retryable.
  • In packages/sdk-python/src/stagehand/cdp_client.py (_wait_for_preloaded_service_worker), timeout/error paths can include raw CDP target URLs and broad except Exception: pass hides attach/evaluate failures, creating both data-exposure risk and poor diagnosability—sanitize timeout content and narrow/log caught exceptions.
  • In packages/sdk-python/src/stagehand/browserbase_session.py, session create/retrieve paths chain upstream SDK exceptions into BrowserbaseSessionError, so serialized tracebacks can leak details the public error is meant to hide; packages/sdk-python/tests/test_browserbase_session.py currently reinforces that behavior—raise sanitized errors with from None and update tests to assert typed, redacted failures.
  • In packages/sdk-python/src/stagehand/extension_assets.py, non-deterministic ZIP metadata (mtime via ZipFile.write()) can make identical extension content hash differently and cause unnecessary reprovisioning/churn—write entries with fixed ZipInfo timestamps/attributes.
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/extension_assets.py">

<violation number="1" location="packages/sdk-python/src/stagehand/extension_assets.py:29">
P3: Identical extension contents do not produce a deterministic archive because `ZipFile.write()` embeds filesystem mtimes. Write entries with a fixed `ZipInfo.date_time` (and fixed attributes) so repeated provisioning produces stable bytes.</violation>
</file>

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

<violation number="1" location="packages/sdk-python/src/stagehand/cdp_client.py:490">
P3: In the preloaded-extension discovery loop, `except Exception: pass` swallows every attach/evaluate failure, and the eventual TimeoutError message only lists observed target types/URLs. If the Stagehand worker is present but the readiness evaluation keeps failing for a real reason (e.g. a session error, permission issue, or the worker dying between getTargets and attach), the operator sees only a generic 'Timed out discovering the preloaded Stagehand service worker' with no hint of the underlying failure. Consider capturing the last exception/detail (like _wait_for_runtime_receiver already does with its last_error) and including it in the timeout message, so a failed discovery is debuggable instead of opaque.</violation>

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

The new `_wait_for_preloaded_service_worker` raises a generic `TimeoutError` that embeds raw CDP target URLs in the message. This exposes potentially sensitive page URLs, query parameters, and internal endpoints to the user. Consider using a typed error class (e.g., `BrowserbaseSessionError`, which this PR already sanitizes elsewhere) and redacting or limiting the target URLs included in the message.</violation>
</file>

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

<violation number="1" location="packages/sdk-python/src/stagehand/browserbase_session.py:219">
P2: Session-create failures retain the upstream SDK exception as a chained cause, so traceback/error serialization can expose details the sanitized `BrowserbaseSessionError` is meant to hide. Raise the public error with `from None` instead.

(Based on your team's feedback about sanitizing Browserbase session errors.)</violation>

<violation number="2" location="packages/sdk-python/src/stagehand/browserbase_session.py:250">
P2: Session-retrieve failures retain the upstream SDK exception as a chained cause, so traceback/error serialization can expose details the sanitized `BrowserbaseSessionError` is meant to hide. Raise the public error with `from None` instead.

(Based on your team's feedback about sanitizing Browserbase session errors.)</violation>
</file>

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

<violation number="1" location="packages/sdk-python/tests/test_browserbase_session.py:332">
P2: Custom agent: **Exception and error message sanitization**

The tests here lock in error behavior that conflicts with the sanitized, typed-error requirement. In packages/sdk-python/src/stagehand/browserbase_session.py, `close()` re-raises the raw upstream release/delete exceptions verbatim (`raise release_error` / `raise extension_error`), so a caller closing a session can receive an un-sanitized, untyped upstream SDK exception (the test even asserts it is the exact same object: `raised.value is release_error`). Separately, the extension-upload failure path raises a generic `RuntimeError("Failed to upload the Stagehand extension to Browserbase")` and chains the raw SDK error via `from error`, rather than using the dedicated `BrowserbaseSessionError` wrapper used everywhere else (create/retrieve) in this file. Re-raising raw SDK exceptions can surface upstream request/endpoint details to the user, and the generic `RuntimeError` for uploads is not a `BrowserbaseSessionError`. Recommend wrapping the upload failure (and the close-time release/delete errors) in `BrowserbaseSessionError` with the sanitized message, and ensure `close()` raises a typed, sanitized error rather than forwarding the raw upstream exception (or at minimum strip the raw cause), so the SDK never leaks untrusted upstream details to callers.</violation>
</file>

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

<violation number="1" location="packages/sdk-python/src/stagehand/browser.py:461">
P1: A transient Browserbase release/delete failure leaves the session or uploaded extension orphaned: subsequent `handle.close()` calls reuse the failed task and never reach `session.close()` again. Preserve retryability at the public handle/source boundary so the owned session cleanup logic can run on a later close.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Caller as Caller Code
    participant BrowserFile as browser.py
    participant ClientModels as client_models.py
    participant API as BrowserbaseSession API
    participant OfficialSDK as browserbase PyPI SDK
    participant CDPClient as CDP Client
    participant ExtAssets as extension_assets.py
    participant Browserbase as Browserbase Cloud

    Note over Caller,Browserbase: Browserbase Launch Flow

    Caller->>BrowserFile: browserbase.launch(api_key, region, ...)
    BrowserFile->>ClientModels: Validate BrowserbaseSessionCreateParams
    ClientModels-->>BrowserFile: Validated options
    BrowserFile->>API: _create_browserbase_session_client(api_key)
    API->>OfficialSDK: AsyncBrowserbase(api_key)

    alt Caller didn't supply extension_id
        API->>ExtAssets: asyncio.to_thread(build_extension_archive)
        ExtAssets->>ExtAssets: Zip extension directory
        ExtAssets-->>API: archive bytes
        API->>OfficialSDK: extensions.create(file=archive)
        OfficialSDK->>Browserbase: Upload extension
        Browserbase-->>OfficialSDK: extension.id
        OfficialSDK-->>API: Uploaded extension ID
    else Caller supplied extension_id (top-level or in browser_settings)
        API->>API: Skip upload, use caller's extension_id
    end

    API->>API: Merge user_metadata with stagehand keys
    API->>OfficialSDK: sessions.create(**kwargs) with camelCase fingerprint mapping
    OfficialSDK->>Browserbase: Create session
    Browserbase-->>OfficialSDK: session.id, session.connect_url
    OfficialSDK-->>API: Session created

    alt Create succeeded
        API-->>BrowserFile: _OwnedBrowserbaseSession (owns extension + session)
        BrowserFile->>BrowserFile: Build _WorkerInitMetadata (api_key, session_id, region)
        BrowserFile->>CDPClient: connect(preloaded_extension=True, ...)
        CDPClient->>CDPClient: _wait_for_preloaded_service_worker()
        CDPClient->>CDPClient: Poll Target.getTargets for service_worker

        loop Until ready or timeout
            CDPClient->>Browserbase: Target.attachToTarget
            Browserbase-->>CDPClient: Session ID
            CDPClient->>Browserbase: Runtime.evaluate (readiness check)
            Browserbase-->>CDPClient: Worker ready?
            alt Worker not ready
                CDPClient->>Browserbase: Target.detachFromTarget
            end
        end

        alt Worker ready
            CDPClient-->>BrowserFile: CDPClient with service worker
            BrowserFile-->>Caller: StagehandBrowser
        else Timeout
            CDPClient-->>BrowserFile: TimeoutError
            BrowserFile->>API: session.close() (release + delete extension)
            API-->>BrowserFile: Cleanup complete
            BrowserFile-->>Caller: Error propagated
        end
    else Create failed or cancelled
        API->>API: _delete_extension_best_effort(owned_extension_id)
        alt CancelledError
            API-->>Caller: CancelledError (re-raised)
        else Exception
            API-->>Caller: BrowserbaseSessionError wrapped
        end
    end

    Note over Caller,Browserbase: Session Close/Release (when keep_alive=False)

    Caller->>BrowserFile: StagehandBrowser.close()
    BrowserFile->>API: session.close()
    API->>API: Acquire _close_lock
    API->>OfficialSDK: sessions.update(status="REQUEST_RELEASE")
    OfficialSDK->>Browserbase: Release session
    alt Owned extension exists
        API->>OfficialSDK: extensions.delete(extension_id, Content-Type: omit)
        OfficialSDK->>Browserbase: Delete extension
    end
    API-->>BrowserFile: Close complete (release error takes precedence)

    Note over Caller,Browserbase: Browserbase Connect Flow

    Caller->>BrowserFile: browserbase.connect(api_key, session_id, ...)
    BrowserFile->>ClientModels: Validate BrowserbaseConnectOptions
    ClientModels-->>BrowserFile: Validated options
    BrowserFile->>API: _create_browserbase_session_client(api_key)
    API->>OfficialSDK: sessions.retrieve(session_id)
    OfficialSDK->>Browserbase: Retrieve session
    Browserbase-->>OfficialSDK: session.id, connect_url, region
    OfficialSDK-->>API: Retrieved session

    alt connect_url is empty/missing
        API-->>Caller: BrowserbaseSessionError "not available for connection"
    else Valid connect_url
        API-->>BrowserFile: _BrowserbaseSessionConnection (no ownership)
        alt Caller supplied extension_id
            CDPClient->>CDPClient: connect(extension_id=caller_extension)
        else No extension_id
            CDPClient->>CDPClient: connect(preloaded_extension=True)
        end
        CDPClient-->>BrowserFile: CDPClient connected
        BrowserFile-->>Caller: StagehandBrowser (no session release on close)
    end
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/cdp_client.py
Comment thread packages/sdk-python/src/stagehand/browser.py
Comment thread packages/sdk-python/src/stagehand/browserbase_session.py Outdated
Comment thread packages/sdk-python/src/stagehand/browserbase_session.py Outdated
Comment thread packages/sdk-python/src/stagehand/browser.py
Comment thread packages/sdk-python/tests/test_browserbase_session.py
Comment thread packages/sdk-python/src/stagehand/extension_assets.py Outdated
Comment thread packages/sdk-python/src/stagehand/cdp_client.py
…-py-browserbase

# Conflicts:
#	packages/sdk-python/tests/test_browser.py
…ion IDs, and build deterministic extension archives

@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.

7 issues found and verified against the latest diff

Confidence score: 2/5

  • In packages/sdk-python/src/stagehand/browserbase_session.py, extension upload and close() currently surface/chains raw SDK exceptions, which can leak sensitive request/response details and expose unstable upstream error types to users; wrap and sanitize these into BrowserbaseSessionError with scrubbed messages.
  • In packages/sdk-python/src/stagehand/browserbase_session.py, a transient cleanup failure can get cached so later browser.close() calls just rethrow the same failed task, leaving sessions/extensions effectively unretryable and potentially orphaned — reset failed close state or allow a fresh close attempt after failure.
  • In packages/sdk-python/src/stagehand/browser.py, launching with a non-Stagehand Browserbase extension ID can skip provisioning while discovery still expects a Stagehand worker, so valid caller configurations may fail to attach Stagehand functionality — keep caller extensions but still provision/select the Stagehand extension path needed for discovery.
  • In packages/sdk-python/src/stagehand/cdp_client.py, _wait_for_preloaded_service_worker can mark keep_attached before ServiceWorkerInfo(...) succeeds and also suppress broad exceptions in the polling loop, which risks stale attachments and masked discovery failures that are hard to diagnose — narrow exception handling and only set attachment state after successful worker parsing.
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/browserbase_session.py">

<violation number="1" location="packages/sdk-python/src/stagehand/browserbase_session.py:154">
P1: Transient Browserbase cleanup failures leave launched sessions/extensions unretryable through the public browser handle: later `browser.close()` calls only rethrow the cached failed task. Reset failed close state or otherwise route a later close to this retry-capable cleanup path.</violation>

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

The `close()` method surfaces raw upstream Browserbase SDK exceptions directly to users instead of wrapping them in `BrowserbaseSessionError`. Raw SDK exceptions from `release_session` and `delete_extension` may include sensitive request/response details or connection URLs. The rest of this file consistently wraps user-facing errors in sanitized `BrowserbaseSessionError` instances (e.g., in `create_session` and `connect_session`). Consider wrapping the close-time errors the same way to avoid leaking internal SDK details.</violation>

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

The extension upload failure raises a generic `RuntimeError` and chains the upstream SDK exception via `from error`, which can leak sensitive request/response details to users. The file already defines and consistently uses `BrowserbaseSessionError` with `from None` for all other Browserbase failures (session creation, retrieve, empty results). Align this path with that pattern to keep messages sanitized and avoid exposing upstream SDK internals.

**Suggested fix:**
```python
            except Exception:
                raise BrowserbaseSessionError(
                    "Failed to upload the Stagehand extension to Browserbase"
                ) from None
```</violation>
</file>

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

<violation number="1" location="packages/sdk-python/src/stagehand/browser.py:475">
P2: Launching with a non-Stagehand Browserbase extension ID cannot attach Stagehand: provisioning is suppressed but discovery still requires a Stagehand worker. Preserve caller extensions while provisioning/selecting the packaged Stagehand extension separately.

(Based on your team's feedback about support for non-Stagehand Browserbase extensions.) .</violation>
</file>

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

<violation number="1" location="packages/sdk-python/tests/test_browserbase_session.py:178">
P2: This assertion reads the internal snake_case key back out of `_session_create_kwargs`, which cements the wrong wire format. The helper only camelCases the nested `fingerprint`/`screen` blocks; every other `browser_settings` sub-field (`advanced_stealth`, `block_ads`, `log_session`, `record_session`, `solve_captchas`, `captcha_image_selector`) is left snake_case and forwarded verbatim to the Browserbase SDK, whose browser_settings dictionary uses camelCase keys (e.g. captchaImageSelector/captchaInputSelector per the SDK docs). So `advanced_stealth=True` would silently not reach the API. Consider asserting the camelCase key here and camelCasing the remaining browser_settings fields in the helper.</violation>
</file>

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

<violation number="1" location="packages/sdk-python/src/stagehand/cdp_client.py:424">
P3: This new discovery loop has non-obvious semantics that aren't explained in the code: it attaches to candidate service workers and detaches those that aren't ready yet, keeps polling with a broad `except Exception: pass`, and distinguishes 'stale' workers (ready marker but `hasReceiver` false) from ready ones. A short docstring/comment explaining why each candidate is attached then conditionally detached, and why readiness is evaluated via `Runtime.evaluate` with the readiness marker, would help future maintainers avoid regressing the retry/detach behavior.</violation>

<violation number="2" location="packages/sdk-python/src/stagehand/cdp_client.py:476">
P2: In `_wait_for_preloaded_service_worker`, `keep_attached = True` is set before the `ServiceWorkerInfo(...)` constructor runs inside the same try block. If that constructor raises — e.g. `_required_string(target_info, "title", ...)` when a service-worker target reports an empty/missing `title` — the exception is swallowed by `except Exception: pass`, but because `keep_attached` is already True the `finally` block skips the `Target.detachFromTarget`, leaking an attached flat session that is never cleaned up. Note the non-preloaded `_wait_for_service_worker` path does not have this issue: a similar `_required_string` failure there propagates so `connect`'s `except BaseException: await client.close()` runs. Build the `ServiceWorkerInfo` first and only flip `keep_attached` immediately before returning so a construction failure still triggers the detach.</violation>
</file>

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

Re-trigger cubic

Comment thread packages/sdk-python/src/stagehand/browserbase_session.py
Comment thread packages/sdk-python/src/stagehand/browserbase_session.py
Comment thread packages/sdk-python/src/stagehand/browserbase_session.py
Comment thread packages/sdk-python/src/stagehand/browser.py
Comment thread packages/sdk-python/tests/test_browserbase_session.py
Comment thread packages/sdk-python/src/stagehand/cdp_client.py Outdated
Comment thread packages/sdk-python/README.md Outdated
Comment thread packages/sdk-python/src/stagehand/cdp_client.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 12 files

Confidence score: 3/5

  • In packages/sdk-python/src/stagehand/browserbase_session.py, extension-upload and teardown paths can re-raise raw Browserbase SDK exceptions (from error / raise ...) instead of sanitized errors, which risks exposing sensitive upstream request details to users and logs—wrap and redact these exceptions before re-raising.
  • In packages/sdk-python/src/stagehand/browserbase_session.py (close()/handle-close flow), a failed release or extension-deletion attempt is cached as the close task, so later handle.close() calls cannot retry and sessions may remain unreleased—allow retries by resetting failed close state or re-invoking the source close path.
  • In packages/sdk-python/README.md, keep_alive=True behavior leaves users without a clear way to release the launched session via StagehandBrowser.close(), increasing the chance of leaked remote sessions and cost surprises—document the release mechanism (or expose a release API/session identifier).
  • packages/sdk-python/src/stagehand/extension_assets.py and packages/sdk-python/tests/test_cdp_client.py leave some de-risking gaps: archive bytes can vary by platform and two reachable extension-negotiation branches are untested, which can cause cross-platform drift and missed runtime edge regressions—pin ZIP metadata and add the missing branch tests.
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/README.md">

<violation number="1" location="packages/sdk-python/README.md:36">
P2: `keep_alive=True` users cannot release the launched session through the returned public `StagehandBrowser`; `close()` only disconnects. Document the available release mechanism (or expose the session ID/release API) so this guidance does not direct callers to an unavailable operation.</violation>
</file>

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

<violation number="1" location="packages/sdk-python/src/stagehand/extension_assets.py:33">
P3: Windows builds produce different archive bytes for identical extension files because `ZipInfo.create_system` inherits the host platform. Pin it to Unix like the server artifact so this upload remains deterministic across developer and CI platforms.</violation>
</file>

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

<violation number="1" location="packages/sdk-python/src/stagehand/browserbase_session.py:154">
P2: A failed Browserbase release or extension deletion cannot be retried through the returned browser handle: later `handle.close()` calls reuse the failed close task instead of invoking this method. Keep the source/handle closable after a failed cleanup, or retry cleanup internally, so transient failures do not leave owned sessions/extensions orphaned.</violation>

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

Extension-upload failures raise a generic `RuntimeError` and chain the raw upstream Browserbase SDK exception (`from error`), which can leak sensitive request details (API keys, URLs) in user-facing tracebacks. The file already defines `BrowserbaseSessionError` and uses it—along with `from None` to suppress upstream causes—for the analogous session create/retrieve failures just below. The empty-extension-ID check a few lines later also raises a generic `RuntimeError` instead of the typed error. Switch both to `BrowserbaseSessionError(...)` and suppress the SDK cause with `from None` on the upload path so messages stay sanitized and typed.</violation>
</file>

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

<violation number="1" location="packages/sdk-python/tests/test_cdp_client.py:430">
P3: The new preloaded-extension discovery has good test coverage, but two reachable branches are untested: a candidate whose runtime marker makes `_negotiate_runtime` report incompatible (e.g. a non-Stagehand extension whose service worker URL happens to contain `service-worker.js`, or a protocol-version mismatch), and the `_extension_id_from_url` case that returns `None`. The incompatible-candidate path is the primary reason the discovery loop detaches-and-retries, so a focused test that returns an incompatible marker (then a compatible one) would lock in that behavior and prevent drift from the TS/Go path. Consider adding it alongside the existing stale/missing-title tests.</violation>
</file>

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

<violation number="1" location="packages/sdk-python/tests/test_browserbase_session.py:339">
P2: Custom agent: **Exception and error message sanitization**

The close() path re-raises the raw Browserbase SDK exception from session release and owned-extension deletion unchanged (`raise release_error` / `raise extension_error` in browserbase_session.py), so a failure surfaces the SDK's original, unsanitized message — which can carry the session/extension ID or upstream response body — to the caller. This is inconsistent with the create/connect paths in the same PR, which wrap failures in the typed BrowserbaseSessionError with a sanitized message and `__cause__ = None`. The test locks in this behavior with `assert raised.value is release_error`. Consider wrapping the release/delete failures in a sanitized BrowserbaseSessionError (without retaining the upstream cause) to match the sanitization contract the rest of this PR establishes, while still tracking whether release/delete succeeded so the retry-on-next-close behavior is preserved.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client as SDK Client
    participant BBrowser as BrowserbaseBrowser
    participant BSession as _BrowserbaseSessionClient
    participant BAPI as _OfficialBrowserbaseAPI
    participant BSrv as Browserbase Cloud API
    participant CDP as CDPClient
    
    Note over Client,CDP: Browserbase Session Launch Flow
    
    Client->>BBrowser: launch(api_key, region, keep_alive, user_metadata)
    BBrowser->>BBrowser: Validate inputs (api_key, extension_id)
    BBrowser->>BBrowser: Build BrowserbaseSessionCreateParams
    BBrowser->>BSession: create_session(options)
    
    alt Caller provided extension_id
        BSession->>BSession: Skip extension upload
    else No caller extension
        BSession->>BAPI: upload_extension(archive)
        BAPI->>BSrv: POST /extensions (stagehand-extension.zip)
        BSrv-->>BAPI: extension.id
        BAPI-->>BSession: uploaded_extension_id
        BSession->>BSession: Store owned_extension_id
    end
    
    BSession->>BSession: Merge user_metadata with {"stagehand": "true", "stagehand_sdk_language": "python"}
    BSession->>BAPI: create_session(options, user_metadata, extension_id)
    BAPI->>BSrv: POST /sessions (with api_timeout, camelCase fingerprint)
    BSrv-->>BAPI: {id, connect_url}
    BAPI-->>BSession: (session_id, cdp_url)
    
    alt Empty session_id or cdp_url
        BSession->>BAPI: delete_extension(owned_extension_id)
        BSession-->>BBrowser: BrowserbaseSessionError
    end
    
    BSession-->>BBrowser: _OwnedBrowserbaseSession(session_id, cdp_url, close_callback)
    BBrowser->>CDP: connect(cdp_url, preloaded_extension=True)
    
    Note over CDP: Discover preloaded Stagehand service worker
    
    loop Poll every 100ms until timeout
        CDP->>BSrv: Target.getTargets
        BSrv-->>CDP: [targetInfos]
        alt Found service_worker with chrome-extension:// URL
            CDP->>BSrv: Target.attachToTarget(targetId)
            BSrv-->>CDP: sessionId
            CDP->>BSrv: Runtime.evaluate (readiness check)
            BSrv-->>CDP: {result: {value: {hasReceiver: true, marker: ...}}}
            Note over CDP: Worker ready - keep attached
            CDP-->>BBrowser: ServiceWorkerInfo + session_id
        else Incompatible worker (missing title, no receiver)
            CDP->>BSrv: Target.detachFromTarget(sessionId)
            CDP->>CDP: Continue polling
        end
    end
    
    CDP-->>BBrowser: Connected with preloaded extension
    BBrowser-->>Client: StagehandBrowser
    
    Note over Client,CDP: Session Close / Cleanup (keep_alive=False)
    
    Client->>BBrowser: close()
    BBrowser->>BSession: close()
    BSession->>BSession: Acquire _close_lock
    
    alt Session not yet released
        BSession->>BAPI: release_session(session_id)
        BAPI->>BSrv: PATCH /sessions/{id} (status=REQUEST_RELEASE)
        BSrv-->>BAPI: Success
        BSession->>BSession: Mark released
    else Already released
        BSession->>BSession: Skip (idempotent)
    end
    
    alt Has owned extension and not yet deleted
        BSession->>BAPI: delete_extension(extension_id)
        BAPI->>BSrv: DELETE /extensions/{id} (Content-Type omitted)
        BSrv-->>BAPI: Success
        BSession->>BSession: Mark deleted
    else No owned extension or already deleted
        BSession->>BSession: Skip (idempotent)
    end
    
    alt Release error occurred
        BSession-->>BBrowser: raise release_error (takes precedence)
    else Extension error occurred
        BSession-->>BBrowser: raise extension_error
    end
    
    Note over Client,CDP: Keep-alive path (keep_alive=True)
    
    Client->>BBrowser: close()
    BBrowser->>BSession: close()
    BSession->>BSession: _session_released remains False
    BSession->>BSession: No release call made
    BSession-->>BBrowser: Success (session persists in Browserbase)

    Note over Client,CDP: Connect to Existing Session
    
    Client->>BBrowser: connect(api_key, session_id, extension_id)
    BBrowser->>BSession: connect_session(session_id)
    BSession->>BAPI: retrieve_session(session_id)
    BAPI->>BSrv: GET /sessions/{id}
    BSrv-->>BAPI: {id, connect_url, region}
    BAPI-->>BSession: (id, connect_url, region)
    
    alt connect_url is empty
        BSession-->>BBrowser: BrowserbaseSessionError: not available for connection
    end
    
    BSession-->>BBrowser: _BrowserbaseSessionConnection(cdp_url, region)
    
    alt Caller provided extension_id
        BBrowser->>CDP: connect(cdp_url, extension_id="caller-extension", preloaded_extension=False)
        CDP->>CDP: Skip preloaded discovery, use caller extension
    else No extension_id
        BBrowser->>CDP: connect(cdp_url, preloaded_extension=True)
        CDP->>CDP: Discover preloaded extension (same as launch)
    end
    
    CDP-->>BBrowser: Connected
    BBrowser-->>Client: StagehandBrowser (no ownership, no close side effects)
Loading

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

Re-trigger cubic

Comment thread packages/sdk-python/src/stagehand/browserbase_session.py
Comment thread packages/sdk-python/README.md Outdated
Comment thread packages/sdk-python/src/stagehand/browserbase_session.py
Comment thread packages/sdk-python/tests/test_browserbase_session.py
Comment thread packages/sdk-python/src/stagehand/extension_assets.py
Comment thread packages/sdk-python/tests/test_cdp_client.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.

7 issues found across 12 files

Confidence score: 2/5

  • In packages/sdk-python/src/stagehand/browserbase_session.py, the extension-upload failure path raises a generic RuntimeError and chains upstream SDK details, which can leak raw internals and break expected typed error handling for callers — raise BrowserbaseSessionError with sanitized messages and avoid exposing raw upstream exception text.
  • In packages/sdk-python/src/stagehand/cdp_client.py (_wait_for_preloaded_service_worker), the new TimeoutError includes raw target URLs from Target.getTargets, creating a user-visible data exposure risk in logs and error surfaces — redact or omit target URLs in timeout messages.
  • In packages/sdk-python/src/stagehand/browserbase_session.py and packages/sdk-python/src/stagehand/browser.py, failure paths around release/delete/launch plus keep_alive=True can orphan sessions or owned extensions, so retries and later close() calls may not actually clean up cloud resources — preserve cleanup ownership until launch succeeds and keep a retryable close path for transient teardown failures.
  • In packages/sdk-python/src/stagehand/browser.py, constructing BrowserbaseRegion(None) when connect URL has no region causes premature failure before CDP attach, and current tests in packages/sdk-python/tests/test_browserbase_session.py reinforce conflicting exception behavior — treat missing region as None and update tests to assert sanitized, typed exceptions instead of raw error details.
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/cdp_client.py">

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

New `TimeoutError` in `_wait_for_preloaded_service_worker` includes raw browser target URLs from the CDP `Target.getTargets` response in the exception message. Browser target URLs can contain sensitive query-string tokens, session identifiers, or signed URL parameters. The PR's own `browserbase_session.py` follows a sanitization pattern using `BrowserbaseSessionError` with generic messages and `from None` to suppress upstream details — the new CDP client method should align with the same principle. Removing the raw URL dump from the error message prevents leaking potentially sensitive browsing state to end users.</violation>
</file>

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

<violation number="1" location="packages/sdk-python/src/stagehand/browserbase_session.py:159">
P1: A transient release or extension-delete failure leaves a launched Browserbase session or owned extension orphaned because subsequent public `browser.close()` calls only replay the cached failure. Preserve a retry path in the browser/source close wrapper (or reset its closed task/state after callback failure) so this method's failed-step retry semantics are reachable.</violation>

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

The extension upload failure path raises generic `RuntimeError` instead of the file's typed `BrowserbaseSessionError`, and it chains the upstream SDK exception via `from error`, which can leak sensitive request/response details in tracebacks. The empty-extension-ID check right after also uses `RuntimeError`. For consistency with the error sanitization rule and the rest of this file, use `BrowserbaseSessionError` with `from None` in both places to prevent leaking upstream exception details.</violation>
</file>

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

<violation number="1" location="packages/sdk-python/tests/test_browserbase_session.py:339">
P2: Custom agent: **Exception and error message sanitization**

The new tests lock in exception behavior that conflicts with our exception-sanitization rule. During `browserbase_session.create_session`, the extension-upload failure is raised as a generic `RuntimeError` with the upstream SDK error retained as its cause (`raise RuntimeError(...) from error`), and `_OwnedBrowserbaseSession.close()` re-raises the raw exception from `release_session`/`delete_extension` untouched. Both are user-reachable paths (session creation and cleanup), and rule 31c8a33a requires surfaced errors to be typed, sanitized classes that never reflect upstream causes which could expose sensitive details. Consider wrapping the close() release/delete failures and the upload failure in a sanitized `BrowserbaseSessionError` (as already done for create/retrieve via `from None`), suppressing the upstream cause so the raw SDK error never reaches callers. The tests currently assert the unsanitized propagation (`assert raised.value is release_error`, `RuntimeError` match), so they should be updated to assert the sanitized behavior instead.</violation>
</file>

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

<violation number="1" location="packages/sdk-python/src/stagehand/browser.py:472">
P2: When `browserbase.launch(api_key=..., keep_alive=True)` is used without a caller-supplied extension, the SDK auto-uploads and owns a Stagehand extension, but `keep_alive=True` makes the handle's `close()` only disconnect (owns_source is False), so `_OwnedBrowserbaseSession.close()` never runs and the uploaded extension is never deleted. The session itself is intentionally released out of band per the README, but nothing releases the registered extension at that point, so each keep-alive session leaves an orphaned extension on the account. Consider tracking the owned extension and deleting it when the session is released out of band, or documenting this extension-retention tradeoff explicitly next to the keep_alive handling so it is a conscious decision.</violation>

<violation number="2" location="packages/sdk-python/src/stagehand/browser.py:472">
P2: `keep_alive=True` leaks a newly created session when setup fails before `launch()` returns. Keep cleanup ownership through connection setup, then suppress release only for a successfully returned handle.</violation>

<violation number="3" location="packages/sdk-python/src/stagehand/browser.py:507">
P2: Sessions with a connect URL but no returned region fail before CDP attachment because `BrowserbaseRegion(None)` raises. Preserve a missing region as `None` before constructing `BrowserbaseRegion`.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant User as User Code
    participant BB as BrowserbaseBrowser
    participant Ext as extension_assets.py
    participant Client as _BrowserbaseSessionClient
    participant API as _OfficialBrowserbaseAPI
    participant SDK as browserbase SDK (AsyncBrowserbase)
    participant CDP as CDPClient
    participant WS as WebSocket / Browser
    
    Note over User,WS: NEW: Browserbase Session Launch Flow
    
    User->>BB: browserbase.launch(api_key, region, ...)
    BB->>BB: Validate params (api_key, extension_id not empty)
    
    alt Caller provides extension_id (top-level or browser_settings)
        BB->>Client: create_session(options) [no upload]
    else No caller extension_id
        BB->>Ext: build_extension_archive()
        Ext-->>BB: deterministic zip bytes
        BB->>Client: create_session(options)
        Client->>API: upload_extension(archive)
        API->>SDK: extensions.create(file=...)
        SDK-->>API: extension.id
        API-->>Client: uploaded_extension_id
    end
    
    Client->>Client: Merge user_metadata with {"stagehand":"true","stagehand_sdk_language":"python"}
    Client->>API: create_session(options, user_metadata, extension_id)
    API->>SDK: sessions.create(**kwargs)
    Note over API,SDK: kwargs include api_timeout, camelCase fingerprint
    
    alt Create fails
        SDK-->>API: Exception
        API-->>Client: Exception
        Client->>Client: _delete_extension_best_effort(owned_extension_id)
        Client-->>BB: BrowserbaseSessionError
        BB-->>User: Error
    else Create succeeds
        SDK-->>API: session (id, connect_url)
        API-->>Client: (session_id, cdp_url)
        Client-->>BB: _OwnedBrowserbaseSession
    end
    
    BB->>CDP: connect(preloaded_extension=True, worker_init_metadata)
    
    Note over CDP,WS: NEW: Preloaded Extension Discovery
    
    loop Poll every 100ms until timeout
        CDP->>WS: Target.getTargets
        WS-->>CDP: targetInfos list
        alt Found service_worker with chrome-extension URL
            CDP->>WS: Target.attachToTarget(targetId)
            WS-->>CDP: sessionId
            CDP->>WS: Runtime.evaluate (readiness check)
            WS-->>CDP: result value
            
            alt Worker ready and compatible
                Note over CDP: Keep attached, return worker
            else Worker stale, incompatible, or error
                CDP->>WS: Target.detachFromTarget(sessionId)
                Note over CDP: Continue polling
            end
        end
    end
    
    alt Discovery timeout
        CDP-->>BB: TimeoutError
        BB->>Client: session.close() (release session + delete owned extension)
        BB-->>User: Error
    else Discovery succeeds
        CDP-->>BB: ServiceWorkerInfo (extension_id from URL)
        BB-->>User: StagehandBrowser
    end
    
    Note over User,WS: Browser Handle Close Flow
    
    User->>BB: close()
    BB->>BB: Check keep_alive flag
    
    alt keep_alive=True
        BB->>CDP: close() (just disconnect WebSocket)
    else keep_alive=False (default)
        BB->>Client: session.close()
        Client->>Client: Lock acquired
        
        alt Session not yet released
            Client->>API: release_session(session_id)
            API->>SDK: sessions.update(status="REQUEST_RELEASE")
        end
        
        alt Owned extension not yet deleted
            Client->>API: delete_extension(extension_id)
            API->>SDK: extensions.delete(id, Content-Type=omit)
        end
        
        alt Any release/delete error
            Client->>Client: Collect error, continue cleanup
            Client-->>BB: raise release_error (takes precedence)
        end
        
        Client->>CDP: close() (disconnect)
    end
    
    Note over User,WS: Browserbase Connect Flow (no ownership)
    
    User->>BB: browserbase.connect(api_key, session_id, extension_id?)
    BB->>Client: connect_session(session_id)
    Client->>API: retrieve_session(session_id)
    API->>SDK: sessions.retrieve(session_id)
    SDK-->>API: session (id, connect_url, region)
    API-->>Client: (id, connect_url, region)
    
    alt connect_url empty or invalid
        Client-->>BB: BrowserbaseSessionError
        BB-->>User: Error
    else Valid connection
        Client-->>BB: _BrowserbaseSessionConnection
        BB->>CDP: connect(preloaded_extension=(extension_id is None), extension_id=...)
        CDP-->>BB: StagehandBrowser (no close_callback, never releases session)
        BB-->>User: StagehandBrowser
    end
    
    User->>BB: close() (connect case)
    BB->>CDP: close() (disconnect only, no session release)
Loading

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

Re-trigger cubic

Comment thread packages/sdk-python/src/stagehand/cdp_client.py
Comment thread packages/sdk-python/src/stagehand/browserbase_session.py
Comment thread packages/sdk-python/src/stagehand/browserbase_session.py
Comment thread packages/sdk-python/tests/test_browserbase_session.py
Comment thread packages/sdk-python/src/stagehand/browser.py
Comment thread packages/sdk-python/src/stagehand/browser.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.

8 issues found across 12 files

Confidence score: 2/5

  • In packages/sdk-python/src/stagehand/browserbase_session.py and packages/sdk-python/src/stagehand/cdp_client.py, multiple error paths surface raw upstream exceptions or full observed target URLs, which can leak Browserbase/CDP request details into logs and user-visible traces. Wrap and sanitize these failures as BrowserbaseSessionError/safe TimeoutError messages and raise from None to suppress sensitive causes.
  • close() handling across packages/sdk-python/src/stagehand/browserbase_session.py and packages/sdk-python/src/stagehand/browser.py is not reliably retryable after transient release/delete failures, so owned sessions or extensions can remain allocated. Keep close callbacks and resolved browser state retry-capable so callers can re-attempt cleanup after temporary SDK errors.
  • Cancellation during launch in packages/sdk-python/src/stagehand/browserbase_session.py can race with in-flight session creation and extension upload, allowing resources to be created but never released. Preserve and await shielded create/upload tasks on cancellation, then release returned IDs before propagating cancellation.
  • packages/sdk-python/src/stagehand/browser.py still chains upstream upload exceptions on launch failure, creating another path for internal request/response leakage and inconsistent error semantics. Normalize this path to sanitized BrowserbaseSessionError behavior used elsewhere for safer, predictable failures.
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/cdp_client.py">

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

The `TimeoutError` in the new `_wait_for_preloaded_service_worker` method includes an `observed` string that dumps full CDP target URLs for every browser target (page, iframe, worker, etc.) without sanitization. Page URLs can contain sensitive query-string parameters such as auth tokens, session IDs, or signed URLs. Per Rule 2, error messages must be sanitized and must not reflect secrets or sensitive URLs. Consider filtering the `observed` list to only service_worker targets (the relevant type for this timeout) or stripping query parameters from any URLs included in the message.</violation>
</file>

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

<violation number="1" location="packages/sdk-python/src/stagehand/browserbase_session.py:158">
P2: Transient Browserbase cleanup failures cannot be retried through the public browser handle, leaving sessions/extensions allocated until external cleanup. Keep the source/browser close path retryable when this callback raises so `_OwnedBrowserbaseSession.close()` can fulfill its retry logic.</violation>

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

The extension upload failure path raises a generic `RuntimeError` and chains the upstream SDK exception with `from error`, which can leak sensitive Browserbase details (API keys, project IDs, etc.). The file's established convention - followed by every other user-facing error path - is to raise `BrowserbaseSessionError` with `from None` to sanitize the cause. Likewise, the empty-extension-ID guard should use the typed error class for consistency.</violation>

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

`close()` re-raises raw upstream SDK exceptions from session release and extension deletion instead of wrapping them in `BrowserbaseSessionError`, which leaks unsanitized error messages that may contain sensitive details like API keys or CDP URLs. The `create_session` and `connect_session` methods in the same file already wrap failures in a sanitized `BrowserbaseSessionError(...)` with `from None`; `close()` should follow the same pattern so all user-facing Browserbase error paths are consistently sanitized.</violation>

<violation number="4" location="packages/sdk-python/src/stagehand/browserbase_session.py:200">
P2: Cancelling launch during extension upload can orphan the uploaded extension, which has no automatic expiry. Retain and await a shielded upload task during cancellation so its returned ID can be deleted before propagating cancellation.</violation>

<violation number="5" location="packages/sdk-python/src/stagehand/browserbase_session.py:215">
P2: Cancelling launch while Browserbase session creation is in flight can leave a newly created session running until its timeout. Preserve the create task's eventual result during cancellation, release its returned ID, then re-raise cancellation.</violation>
</file>

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

<violation number="1" location="packages/sdk-python/src/stagehand/browser.py:474">
P2: Browserbase launch upload failures retain the upstream SDK exception as a traceback cause, which can expose request/response details. Sanitize this failure as `BrowserbaseSessionError` and raise it from `None` like session create/retrieve failures.

(Based on your team's feedback about sanitizing Browserbase session errors.) [FEEDBACK_USED]</violation>

<violation number="2" location="packages/sdk-python/src/stagehand/browser.py:478">
P2: Transient Browserbase release or extension-delete failures cannot be retried through the returned handle, leaving owned resources orphaned. Allow a failed close to invoke `session.close()` again and mark `ResolvedBrowserSource` closed only after its callback succeeds.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Caller as Caller Code
    participant Factory as BrowserbaseBrowser
    participant Session as _BrowserbaseSessionClient
    participant SDK as Browserbase Async SDK
    participant API as Browserbase Cloud API
    participant CDP as CDPClient
    participant SW as Stagehand Extension SW

    Note over Caller,SW: launch(): create and own a Browserbase session
    Caller->>Factory: launch(api_key, keep_alive, region, ...)
    Factory->>Session: create_session(options)
    alt no caller extension_id
        Session->>SDK: upload_extension(deterministic zip)
        SDK->>API: POST /extensions
        API-->>SDK: extension id
    else caller extension_id present (top-level or browser_settings)
        Note over Session: reuse caller extension, no upload/cleanup
    end
    Session->>SDK: create_session(Stagehand metadata wins, fingerprint camelCase, api_timeout)
    SDK->>API: POST /sessions
    API-->>SDK: session id + connect_url
    alt creation failed or empty session id/connect_url
        Session->>SDK: delete owned extension (best effort)
        Session-->>Factory: BrowserbaseSessionError
    end
    Session-->>Factory: owned session with cdp_url
    Factory->>CDP: connect(cdp_url, preloaded_extension=True, metadata session_id/region)
    CDP->>SW: discover/attach service worker, evaluate readiness
    alt ready + compatible Stagehand marker
        SW-->>CDP: protocol marker
    else stale/incompatible candidate
        CDP->>SW: detach, poll next candidate
    end
    CDP-->>Factory: CDPClient attached
    Factory-->>Caller: StagehandBrowser

    Caller->>Factory: close()
    alt keep_alive=False
        Factory->>Session: close() releases session + deletes owned extension
        Session->>SDK: REQUEST_RELEASE + delete_extension
        Note over Session: idempotent per resource, retried on next close
    else keep_alive=True
        Note over Caller,Factory: close() disconnects only, session/extension retained
    end

    Note over Caller,SW: connect(): attach to existing session, never release on close
    Caller->>Factory: connect(api_key, session_id, connect_timeout_ms, extension_id?)
    Factory->>Session: connect_session(session_id)
    Session->>SDK: retrieve_session(session_id) via SDK
    SDK-->>Session: connect_url + region
    alt connect_url missing
        Session-->>Factory: BrowserbaseSessionError (not available for connection)
    else
        Session-->>Factory: unowned connection
        Factory->>CDP: connect(cdp_url, extension_id or preloaded_extension=True)
        CDP-->>Factory: CDPClient
        Factory-->>Caller: StagehandBrowser
    end
Loading

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

Re-trigger cubic

Comment thread packages/sdk-python/src/stagehand/cdp_client.py
Comment thread packages/sdk-python/src/stagehand/browserbase_session.py
Comment thread packages/sdk-python/src/stagehand/browserbase_session.py
Comment thread packages/sdk-python/src/stagehand/browser.py
Comment thread packages/sdk-python/src/stagehand/browser.py
Comment thread packages/sdk-python/src/stagehand/browserbase_session.py
Comment thread packages/sdk-python/src/stagehand/browserbase_session.py
Comment thread packages/sdk-python/src/stagehand/browserbase_session.py
…re payload

Live validation against the real Browserbase API surfaced the worker
rejecting "region": null — the wire schema wants the key omitted.
Construct BrowserSessionMetadata with region unset so exclude_unset
drops it, matching the TypeScript payload.
@miguelg719

Copy link
Copy Markdown
Collaborator Author

Live validation run against the production Browserbase API:

1. launching browserbase session (uploads extension)... ok
2. attaching Stagehand.create ... initialized=True
3. closing stagehand then browser ... handle.closed=True
4. session status after close: COMPLETED
5. extension retrieve after delete -> NotFoundError 404
LIVE CHECK PASSED: upload -> create -> attach -> close -> release -> extension deleted

This also verified the Content-Type-omitted extension DELETE against the real API. The run surfaced one wire bug — the worker rejects "region": null in stagehand.init browser metadata (schema wants the key omitted, as TS does) — fixed in 87369b4 with a regression test asserting the encoded payload omits unset region.

Comment thread .changeset/python-browserbase-sessions.md Outdated
miguelg719 added a commit that referenced this pull request Aug 3, 2026
# 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. #2545 — replace the constructor/`init()` lifecycle with
`Stagehand.create` + browser factories
3. #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`
@miguelg719
miguelg719 merged commit 81c13f0 into v4-spike Aug 3, 2026
27 of 40 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. -->
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