From 8e0791329d9ea6a77d3c6bf588eb17dc17c0e4d1 Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 27 Jul 2026 15:35:05 -0700 Subject: [PATCH 1/7] Opt into the built-in Browserbase extension --- packages/protocol/schemas.ts | 6 +- packages/protocol/stagehand.v4.json | 11 +- packages/protocol/types.ts | 2 + .../src/stagehand/_generated/models.py | 8 +- .../sdk-python/tests/test_browser_source.py | 22 +++ packages/sdk-ts/src/browserSource.ts | 2 +- packages/sdk-ts/src/browserbaseExtension.ts | 73 ---------- packages/sdk-ts/src/browserbaseSession.ts | 62 ++++----- packages/sdk-ts/src/clientSchemas.ts | 7 +- .../stagehand-browserbase-smoke.test.ts | 5 +- packages/sdk-ts/tests/browserSource.test.ts | 2 +- .../sdk-ts/tests/browserbaseExtension.test.ts | 78 ----------- .../sdk-ts/tests/browserbaseSession.test.ts | 125 ++++++------------ .../stagehand-client-init-params.test.ts | 46 +++++-- 14 files changed, 156 insertions(+), 293 deletions(-) delete mode 100644 packages/sdk-ts/src/browserbaseExtension.ts delete mode 100644 packages/sdk-ts/tests/browserbaseExtension.test.ts diff --git a/packages/protocol/schemas.ts b/packages/protocol/schemas.ts index cb7d48f40..85bc7bbca 100644 --- a/packages/protocol/schemas.ts +++ b/packages/protocol/schemas.ts @@ -952,6 +952,10 @@ export const BrowserbaseContextSchema = z .meta({ id: "BrowserbaseContext" }); /** Browserbase browser settings for session creation. */ +export const BrowserbaseExtensionSchema = z + .enum(["onepassword", "browser-events", "stagehand"]) + .meta({ id: "BrowserbaseExtension" }); + export const BrowserbaseBrowserSettingsSchema = z .object({ advancedStealth: z.boolean().optional(), @@ -959,7 +963,7 @@ export const BrowserbaseBrowserSettingsSchema = z captchaImageSelector: z.string().optional(), captchaInputSelector: z.string().optional(), context: BrowserbaseContextSchema.optional(), - extensionId: z.string().optional(), + extensions: z.array(BrowserbaseExtensionSchema).optional(), fingerprint: BrowserbaseFingerprintSchema.optional(), logSession: z.boolean().optional(), os: z.enum(["windows", "mac", "linux", "mobile", "tablet"]).optional(), diff --git a/packages/protocol/stagehand.v4.json b/packages/protocol/stagehand.v4.json index e44432f3b..843d5dc74 100644 --- a/packages/protocol/stagehand.v4.json +++ b/packages/protocol/stagehand.v4.json @@ -1150,8 +1150,11 @@ "context": { "$ref": "#/$defs/BrowserbaseContext" }, - "extension_id": { - "type": "string" + "extensions": { + "type": "array", + "items": { + "$ref": "#/$defs/BrowserbaseExtension" + } }, "fingerprint": { "$ref": "#/$defs/BrowserbaseFingerprint" @@ -1189,6 +1192,10 @@ }, "required": ["id"] }, + "BrowserbaseExtension": { + "type": "string", + "enum": ["onepassword", "browser-events", "stagehand"] + }, "BrowserbaseFingerprint": { "type": "object", "properties": { diff --git a/packages/protocol/types.ts b/packages/protocol/types.ts index cc0062e9b..4886f13da 100644 --- a/packages/protocol/types.ts +++ b/packages/protocol/types.ts @@ -20,6 +20,7 @@ import type { BrowserbaseBrowserSettingsSchema, BrowserbaseBrowserSourceSchema, BrowserbaseContextSchema, + BrowserbaseExtensionSchema, BrowserbaseFingerprintSchema, BrowserbaseFingerprintScreenSchema, BrowserbaseProxyConfigSchema, @@ -381,6 +382,7 @@ export type ThinkingEffort = z.infer; export type BrowserbaseBrowserSettings = z.infer; export type BrowserbaseContext = z.infer; +export type BrowserbaseExtension = z.infer; export type BrowserbaseFingerprint = z.infer; export type BrowserbaseFingerprintScreen = z.infer; export type BrowserbaseProxyConfig = z.infer; diff --git a/packages/sdk-python/src/stagehand/_generated/models.py b/packages/sdk-python/src/stagehand/_generated/models.py index fbe3b99a2..d9dc42273 100644 --- a/packages/sdk-python/src/stagehand/_generated/models.py +++ b/packages/sdk-python/src/stagehand/_generated/models.py @@ -169,7 +169,7 @@ class BrowserbaseBrowserSettings(WireModel): captcha_image_selector: Optional[StrictStr] = None captcha_input_selector: Optional[StrictStr] = None context: Optional[BrowserbaseContext] = None - extension_id: Optional[StrictStr] = None + extensions: Optional[list[BrowserbaseExtension]] = None fingerprint: Optional[BrowserbaseFingerprint] = None log_session: Optional[StrictBool] = None os: Optional[Os] = None @@ -203,6 +203,12 @@ class BrowserbaseContext(WireModel): persist: Optional[StrictBool] = None +class BrowserbaseExtension(StrEnum): + onepassword = "onepassword" + browser_events = "browser-events" + stagehand = "stagehand" + + class BrowserbaseFingerprint(WireModel): model_config = ConfigDict( validate_by_name=True, diff --git a/packages/sdk-python/tests/test_browser_source.py b/packages/sdk-python/tests/test_browser_source.py index cc16b195c..0fb435fde 100644 --- a/packages/sdk-python/tests/test_browser_source.py +++ b/packages/sdk-python/tests/test_browser_source.py @@ -1,11 +1,33 @@ from __future__ import annotations import pytest +from pydantic import ValidationError from stagehand.browser_source import ResolvedBrowserSource, resolve_browser_source from stagehand.client_models import StagehandClientInitParams +def test_browserbase_uploaded_extension_id_is_top_level_only() -> None: + params = StagehandClientInitParams.model_validate({ + "api_key": "test", + "browser": { + "type": "browserbase", + "extension_id": "uploaded-extension", + "browser_settings": {"extensions": ["browser-events"]}, + }, + }) + + assert params.browser.extension_id == "uploaded-extension" + with pytest.raises(ValidationError): + StagehandClientInitParams.model_validate({ + "api_key": "test", + "browser": { + "type": "browserbase", + "browser_settings": {"extension_id": "uploaded-extension"}, + }, + }) + + @pytest.mark.asyncio async def test_cdp_browser_sources_connect_to_an_existing_browser() -> None: source = await resolve_browser_source( diff --git a/packages/sdk-ts/src/browserSource.ts b/packages/sdk-ts/src/browserSource.ts index e21691b6a..07516ca9f 100644 --- a/packages/sdk-ts/src/browserSource.ts +++ b/packages/sdk-ts/src/browserSource.ts @@ -55,7 +55,7 @@ export async function resolveBrowserSource( cdpUrl: session.cdpUrl, browserbaseSessionId: session.sessionId, preloadedExtension: true, - residentBrowserConnection: false, + residentBrowserConnection: true, keepAlive: browser.keepAlive ?? false, close: session.close, }; diff --git a/packages/sdk-ts/src/browserbaseExtension.ts b/packages/sdk-ts/src/browserbaseExtension.ts deleted file mode 100644 index 7a6184690..000000000 --- a/packages/sdk-ts/src/browserbaseExtension.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { createReadStream } from "node:fs"; -import Browserbase from "@browserbasehq/sdk"; -import { STAGEHAND_EXTENSION_ARCHIVE_PATH } from "./extensionAssets.js"; - -export type BrowserbaseExtensionClient = { - uploadExtension(archivePath: string): Promise<{ id: string }>; - deleteExtension(extensionId: string): Promise; -}; - -export type ProvisionedBrowserbaseExtension = { - extensionId: string; - cleanup(): Promise; -}; - -export type BrowserbaseExtensionSdk = { - extensions: { - create(params: { file: ReturnType }): Promise<{ id: string }>; - delete( - extensionId: string, - options?: { headers?: Record }, - ): Promise; - }; -}; - -type BrowserbaseSdkFactory = (apiKey: string) => BrowserbaseExtensionSdk; - -export function createBrowserbaseExtensionClient( - apiKey: string, - createSdk: BrowserbaseSdkFactory = (key) => new Browserbase({ apiKey: key }), -): BrowserbaseExtensionClient { - const browserbase = createSdk(apiKey); - return { - async uploadExtension(archivePath) { - const extension = await browserbase.extensions.create({ - file: createReadStream(archivePath), - }); - return { id: extension.id }; - }, - async deleteExtension(extensionId) { - await browserbase.extensions.delete(extensionId, { - headers: { "Content-Type": null }, - }); - }, - }; -} - -export async function provisionBrowserbaseExtension( - client: BrowserbaseExtensionClient, - archivePath = STAGEHAND_EXTENSION_ARCHIVE_PATH, -): Promise { - let uploaded: { id: string }; - - try { - uploaded = await client.uploadExtension(archivePath); - } catch (error) { - throw new Error("Failed to upload the Stagehand extension to Browserbase", { cause: error }); - } - - const extensionId = uploaded.id.trim(); - if (extensionId.length === 0) { - throw new Error("Browserbase extension upload returned an empty extension ID"); - } - - let cleaned = false; - return { - extensionId, - async cleanup() { - if (cleaned) return; - await client.deleteExtension(extensionId); - cleaned = true; - }, - }; -} diff --git a/packages/sdk-ts/src/browserbaseSession.ts b/packages/sdk-ts/src/browserbaseSession.ts index 9e889982b..3fda11d87 100644 --- a/packages/sdk-ts/src/browserbaseSession.ts +++ b/packages/sdk-ts/src/browserbaseSession.ts @@ -1,12 +1,7 @@ import Browserbase from "@browserbasehq/sdk"; -import type { BrowserbaseSessionCreateParams } from "../../protocol/types.js"; -import { - createBrowserbaseExtensionClient, - provisionBrowserbaseExtension, - type BrowserbaseExtensionClient, - type BrowserbaseExtensionSdk, - type ProvisionedBrowserbaseExtension, -} from "./browserbaseExtension.js"; +import type { BrowserbaseExtension, BrowserbaseSessionCreateParams } from "../../protocol/types.js"; + +const STAGEHAND_BROWSER_EXTENSION = "stagehand"; export type BrowserbaseSessionClient = { createSession( @@ -16,7 +11,7 @@ export type BrowserbaseSessionClient = { export type BrowserbaseSessionClientFactory = (apiKey: string) => BrowserbaseSessionClient; -export type BrowserbaseApiClient = BrowserbaseExtensionClient & { +export type BrowserbaseApiClient = { createSession( params: BrowserbaseSessionCreateParams, ): Promise<{ id: string; connectUrl: string }>; @@ -25,12 +20,9 @@ export type BrowserbaseApiClient = BrowserbaseExtensionClient & { type BrowserbaseSessionClientDependencies = { browserbase?: BrowserbaseApiClient; - provisionExtension?: ( - client: BrowserbaseExtensionClient, - ) => Promise; }; -type BrowserbaseSdk = BrowserbaseExtensionSdk & { +type BrowserbaseSdk = { sessions: { create(params: Browserbase.SessionCreateParams): Promise<{ id: string; connectUrl: string }>; update(sessionId: string, params: { status: "REQUEST_RELEASE" }): Promise; @@ -44,27 +36,21 @@ export function createBrowserbaseSessionClient( dependencies: BrowserbaseSessionClientDependencies = {}, ): BrowserbaseSessionClient { const browserbase = dependencies.browserbase ?? createBrowserbaseApiClient(apiKey); - const provisionExtension = dependencies.provisionExtension ?? provisionBrowserbaseExtension; return { async createSession(params) { - const extension = await provisionExtension(browserbase); let session: { id: string; connectUrl: string }; try { - session = await browserbase.createSession({ - ...params, - extensionId: extension.extensionId, - }); + session = await browserbase.createSession(withStagehandExtension(params)); } catch (error) { - await extension.cleanup().catch(() => undefined); throw new Error("Failed to create a Browserbase session", { cause: error }); } const sessionId = session.id.trim(); const cdpUrl = session.connectUrl.trim(); if (sessionId.length === 0 || cdpUrl.length === 0) { - await cleanupInvalidSession(browserbase, sessionId, extension); + await cleanupInvalidSession(browserbase, sessionId); throw new Error( sessionId.length === 0 ? "Browserbase session creation returned an empty session ID" @@ -73,7 +59,6 @@ export function createBrowserbaseSessionClient( } let sessionReleased = false; - let extensionCleaned = false; return { sessionId, cdpUrl, @@ -88,18 +73,7 @@ export function createBrowserbaseSessionClient( } } - let extensionCleanupError: unknown; - if (!extensionCleaned) { - try { - await extension.cleanup(); - extensionCleaned = true; - } catch (error) { - extensionCleanupError = error; - } - } - if (releaseError) throw releaseError; - if (extensionCleanupError) throw extensionCleanupError; }, }; }, @@ -111,10 +85,8 @@ export function createBrowserbaseApiClient( createSdk: BrowserbaseSdkFactory = (key) => new Browserbase({ apiKey: key }), ): BrowserbaseApiClient { const sdk = createSdk(apiKey); - const extensionClient = createBrowserbaseExtensionClient(apiKey, () => sdk); return { - ...extensionClient, async createSession(params) { const session = await sdk.sessions.create(params as Browserbase.SessionCreateParams); return { id: session.id, connectUrl: session.connectUrl }; @@ -128,10 +100,26 @@ export function createBrowserbaseApiClient( async function cleanupInvalidSession( browserbase: BrowserbaseApiClient, sessionId: string, - extension: ProvisionedBrowserbaseExtension, ): Promise { if (sessionId.length > 0) { await browserbase.releaseSession(sessionId).catch(() => undefined); } - await extension.cleanup().catch(() => undefined); +} + +function withStagehandExtension( + params: BrowserbaseSessionCreateParams, +): BrowserbaseSessionCreateParams { + const extensions = [ + ...new Set([ + ...(params.browserSettings?.extensions ?? []), + STAGEHAND_BROWSER_EXTENSION, + ]), + ]; + return { + ...params, + browserSettings: { + ...params.browserSettings, + extensions, + }, + }; } diff --git a/packages/sdk-ts/src/clientSchemas.ts b/packages/sdk-ts/src/clientSchemas.ts index 51ed07b14..44556c0f4 100644 --- a/packages/sdk-ts/src/clientSchemas.ts +++ b/packages/sdk-ts/src/clientSchemas.ts @@ -22,14 +22,11 @@ import { } from "../../protocol/schemas.js"; import { Page } from "./page.js"; -const BrowserbaseClientBrowserSettingsSchema = BrowserbaseBrowserSettingsSchema.omit({ - extensionId: true, -}).strict(); +const BrowserbaseClientBrowserSettingsSchema = BrowserbaseBrowserSettingsSchema.strict(); -/** Browserbase source fields exposed by the TS SDK. Stagehand provisions its own extension. */ +/** Browserbase source fields exposed by the TS SDK. */ export const BrowserbaseBrowserSourceSchema = BrowserbaseSessionCreateParamsSchema.omit({ browserSettings: true, - extensionId: true, }) .extend({ type: z.literal("browserbase"), diff --git a/packages/sdk-ts/tests/browser-runtime/stagehand-browserbase-smoke.test.ts b/packages/sdk-ts/tests/browser-runtime/stagehand-browserbase-smoke.test.ts index da4c87874..2529e5a43 100644 --- a/packages/sdk-ts/tests/browser-runtime/stagehand-browserbase-smoke.test.ts +++ b/packages/sdk-ts/tests/browser-runtime/stagehand-browserbase-smoke.test.ts @@ -2,7 +2,10 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Stagehand } from "../../src/index.js"; const browserbaseApiKey = process.env.BROWSERBASE_API_KEY; -const shouldRun = process.env.BROWSERBASE_SMOKE === "1" || Boolean(browserbaseApiKey); +// Production Browserbase does not accept the built-in Stagehand extension until +// the corresponding Core rollout lands. Keep this smoke explicitly opt-in so +// the client stack can land first, then enable it as part of that rollout. +const shouldRun = process.env.BROWSERBASE_RESIDENT_SMOKE === "1"; describe.runIf(shouldRun)("Stagehand TS SDK Browserbase smoke", () => { let stagehand: Stagehand | undefined; diff --git a/packages/sdk-ts/tests/browserSource.test.ts b/packages/sdk-ts/tests/browserSource.test.ts index e8b35134d..be4ba4b44 100644 --- a/packages/sdk-ts/tests/browserSource.test.ts +++ b/packages/sdk-ts/tests/browserSource.test.ts @@ -24,7 +24,7 @@ describe("resolveBrowserSource", () => { cdpUrl: "wss://connect.browserbase.com/devtools/browser/new-session", browserbaseSessionId: "new-session", preloadedExtension: true, - residentBrowserConnection: false, + residentBrowserConnection: true, keepAlive: false, close, }); diff --git a/packages/sdk-ts/tests/browserbaseExtension.test.ts b/packages/sdk-ts/tests/browserbaseExtension.test.ts deleted file mode 100644 index da550bdac..000000000 --- a/packages/sdk-ts/tests/browserbaseExtension.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - createBrowserbaseExtensionClient, - provisionBrowserbaseExtension, - type BrowserbaseExtensionClient, -} from "../src/browserbaseExtension.js"; - -describe("Browserbase extension client", () => { - it("maps extension upload and deletion to the official SDK surface", async () => { - const create = vi.fn(async () => ({ id: "ext_uploaded" })); - const remove = vi.fn(async () => {}); - const createSdk = vi.fn(() => ({ - extensions: { create, delete: remove }, - })); - const client = createBrowserbaseExtensionClient("bb_key", createSdk); - - await expect(client.uploadExtension(import.meta.filename)).resolves.toStrictEqual({ - id: "ext_uploaded", - }); - await client.deleteExtension("ext_uploaded"); - - expect(createSdk).toHaveBeenCalledWith("bb_key"); - expect(create).toHaveBeenCalledWith({ file: expect.anything() }); - expect(remove).toHaveBeenCalledWith("ext_uploaded", { - headers: { "Content-Type": null }, - }); - }); -}); - -describe("Browserbase extension provisioning", () => { - it("uploads the prebuilt archive and owns remote cleanup", async () => { - const archivePath = import.meta.filename; - const uploadExtension = vi.fn(async () => ({ id: " ext_uploaded " })); - const deleteExtension = vi.fn(async () => {}); - const client: BrowserbaseExtensionClient = { uploadExtension, deleteExtension }; - - const provisioned = await provisionBrowserbaseExtension(client, archivePath); - expect(provisioned.extensionId).toBe("ext_uploaded"); - expect(uploadExtension).toHaveBeenCalledWith(archivePath); - - await provisioned.cleanup(); - await provisioned.cleanup(); - expect(deleteExtension).toHaveBeenCalledOnce(); - expect(deleteExtension).toHaveBeenCalledWith("ext_uploaded"); - }); - - it("preserves an upload failure as the cause", async () => { - const uploadError = new Error("Browserbase unavailable"); - const client: BrowserbaseExtensionClient = { - async uploadExtension() { - throw uploadError; - }, - async deleteExtension() {}, - }; - - const error = await provisionBrowserbaseExtension(client, import.meta.filename).catch( - (caught: unknown) => caught, - ); - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toBe( - "Failed to upload the Stagehand extension to Browserbase", - ); - expect((error as Error).cause).toBe(uploadError); - }); - - it("rejects an empty extension ID", async () => { - const client: BrowserbaseExtensionClient = { - async uploadExtension() { - return { id: " " }; - }, - async deleteExtension() {}, - }; - - await expect(provisionBrowserbaseExtension(client, import.meta.filename)).rejects.toThrow( - "empty extension ID", - ); - }); -}); diff --git a/packages/sdk-ts/tests/browserbaseSession.test.ts b/packages/sdk-ts/tests/browserbaseSession.test.ts index e112fd930..2bbe9b87d 100644 --- a/packages/sdk-ts/tests/browserbaseSession.test.ts +++ b/packages/sdk-ts/tests/browserbaseSession.test.ts @@ -12,13 +12,7 @@ describe("Browserbase session creation", () => { connectUrl: "wss://connect.browserbase.com/devtools/browser/session_123", })); const update = vi.fn(async () => ({})); - const createSdk = vi.fn(() => ({ - extensions: { - create: vi.fn(async () => ({ id: "ext_stagehand" })), - delete: vi.fn(async () => {}), - }, - sessions: { create, update }, - })); + const createSdk = vi.fn(() => ({ sessions: { create, update } })); const client = createBrowserbaseApiClient("bb_key", createSdk); await expect(client.createSession({ region: "us-west-2" })).resolves.toStrictEqual({ @@ -32,32 +26,32 @@ describe("Browserbase session creation", () => { expect(update).toHaveBeenCalledWith("session_123", { status: "REQUEST_RELEASE" }); }); - it("creates a session with the provisioned extension and maps its connection URL", async () => { - const cleanupExtension = vi.fn(async () => {}); + it("opts into the built-in Stagehand extension without provisioning an upload", async () => { const createSession = vi.fn(async () => ({ id: "session_123", connectUrl: "wss://connect.browserbase.com/devtools/browser/session_123", })); const releaseSession = vi.fn(async () => {}); const browserbase = fakeBrowserbaseApiClient({ createSession, releaseSession }); - const provisionExtension = vi.fn(async () => ({ - extensionId: "ext_stagehand", - cleanup: cleanupExtension, - })); - const client = createBrowserbaseSessionClient("bb_key", { - browserbase, - provisionExtension, - }); + const client = createBrowserbaseSessionClient("bb_key", { browserbase }); const session = await client.createSession({ + browserSettings: { + advancedStealth: true, + extensions: ["browser-events", "stagehand", "browser-events"], + }, + extensionId: "uploaded_extension_123", keepAlive: false, region: "eu-central-1", userMetadata: { suite: "unit" }, }); - expect(provisionExtension).toHaveBeenCalledWith(browserbase); expect(createSession).toHaveBeenCalledWith({ - extensionId: "ext_stagehand", + browserSettings: { + advancedStealth: true, + extensions: ["browser-events", "stagehand"], + }, + extensionId: "uploaded_extension_123", keepAlive: false, region: "eu-central-1", userMetadata: { suite: "unit" }, @@ -69,22 +63,33 @@ describe("Browserbase session creation", () => { await session.close?.(); expect(releaseSession).toHaveBeenCalledOnce(); expect(releaseSession).toHaveBeenCalledWith("session_123"); - expect(cleanupExtension).toHaveBeenCalledOnce(); }); - it("deletes the uploaded extension when session creation fails", async () => { - const createError = new Error("concurrency limit reached"); - const cleanupExtension = vi.fn(async () => {}); - const browserbase = fakeBrowserbaseApiClient({ - createSession: vi.fn(async () => { - throw createError; - }), + it("appends Stagehand while preserving existing extension order", async () => { + const createSession = vi.fn(async () => ({ + id: "session_123", + connectUrl: "wss://connect.browserbase.com/devtools/browser/session_123", + })); + const client = createBrowserbaseSessionClient("bb_key", { + browserbase: fakeBrowserbaseApiClient({ createSession }), + }); + + await client.createSession({ + browserSettings: { extensions: ["onepassword", "browser-events"] }, + }); + + expect(createSession).toHaveBeenCalledWith({ + browserSettings: { extensions: ["onepassword", "browser-events", "stagehand"] }, }); + }); + + it("wraps session creation failures without extension cleanup", async () => { + const createError = new Error("concurrency limit reached"); const client = createBrowserbaseSessionClient("bb_key", { - browserbase, - provisionExtension: async () => ({ - extensionId: "ext_stagehand", - cleanup: cleanupExtension, + browserbase: fakeBrowserbaseApiClient({ + createSession: vi.fn(async () => { + throw createError; + }), }), }); @@ -92,7 +97,6 @@ describe("Browserbase session creation", () => { expect(error).toBeInstanceOf(Error); expect((error as Error).message).toBe("Failed to create a Browserbase session"); expect((error as Error).cause).toBe(createError); - expect(cleanupExtension).toHaveBeenCalledOnce(); }); it.each([ @@ -107,22 +111,15 @@ describe("Browserbase session creation", () => { expectedRelease: "session_123", }, ])("cleans up an invalid Browserbase response with $message", async (testCase) => { - const cleanupExtension = vi.fn(async () => {}); const releaseSession = vi.fn(async () => {}); - const browserbase = fakeBrowserbaseApiClient({ - createSession: vi.fn(async () => testCase.response), - releaseSession, - }); const client = createBrowserbaseSessionClient("bb_key", { - browserbase, - provisionExtension: async () => ({ - extensionId: "ext_stagehand", - cleanup: cleanupExtension, + browserbase: fakeBrowserbaseApiClient({ + createSession: vi.fn(async () => testCase.response), + releaseSession, }), }); await expect(client.createSession({})).rejects.toThrow(testCase.message); - expect(cleanupExtension).toHaveBeenCalledOnce(); if (testCase.expectedRelease) { expect(releaseSession).toHaveBeenCalledWith(testCase.expectedRelease); } else { @@ -130,48 +127,16 @@ describe("Browserbase session creation", () => { } }); - it("deletes the uploaded extension even when session release fails", async () => { - const cleanupExtension = vi.fn(async () => {}); - const releaseError = new Error("release failed"); - const browserbase = fakeBrowserbaseApiClient({ - releaseSession: vi.fn(async () => { - throw releaseError; - }), - }); - const client = createBrowserbaseSessionClient("bb_key", { - browserbase, - provisionExtension: async () => ({ - extensionId: "ext_stagehand", - cleanup: cleanupExtension, - }), - }); - const session = await client.createSession({}); - - await expect(session.close?.()).rejects.toBe(releaseError); - expect(cleanupExtension).toHaveBeenCalledOnce(); - }); - - it("does not repeat a successful release when extension cleanup is retried", async () => { - const cleanupError = new Error("extension cleanup failed"); - const cleanupExtension = vi - .fn<() => Promise>() - .mockRejectedValueOnce(cleanupError) - .mockResolvedValueOnce(); + it("does not repeat a successful release", async () => { const releaseSession = vi.fn(async () => {}); - const browserbase = fakeBrowserbaseApiClient({ releaseSession }); const client = createBrowserbaseSessionClient("bb_key", { - browserbase, - provisionExtension: async () => ({ - extensionId: "ext_stagehand", - cleanup: cleanupExtension, - }), + browserbase: fakeBrowserbaseApiClient({ releaseSession }), }); const session = await client.createSession({}); - await expect(session.close?.()).rejects.toBe(cleanupError); - await expect(session.close?.()).resolves.toBeUndefined(); + await session.close?.(); + await session.close?.(); expect(releaseSession).toHaveBeenCalledOnce(); - expect(cleanupExtension).toHaveBeenCalledTimes(2); }); }); @@ -179,10 +144,6 @@ function fakeBrowserbaseApiClient( overrides: Partial = {}, ): BrowserbaseApiClient { return { - async uploadExtension() { - return { id: "ext_stagehand" }; - }, - async deleteExtension() {}, async createSession() { return { id: "session_123", diff --git a/packages/sdk-ts/tests/stagehand-client-init-params.test.ts b/packages/sdk-ts/tests/stagehand-client-init-params.test.ts index 75f68a2d1..08b2be397 100644 --- a/packages/sdk-ts/tests/stagehand-client-init-params.test.ts +++ b/packages/sdk-ts/tests/stagehand-client-init-params.test.ts @@ -85,21 +85,45 @@ describe("Stagehand client browser sources", () => { ).toThrow(); }); - it("rejects Browserbase extension IDs because the SDK provisions its own extension", () => { - const browserSources = [ - { + it("accepts an uploaded extension alongside the built-in Stagehand extension", () => { + expect( + StagehandClientInitParamsSchema.parse({ + apiKey: "bb_key", + browser: { + type: "browserbase", + extensionId: "ext_caller", + }, + }), + ).toMatchObject({ + browser: { type: "browserbase", extensionId: "ext_caller", }, - { - type: "browserbase", - browserSettings: { extensionId: "ext_caller" }, - }, - ]; + }); + }); - for (const browser of browserSources) { - expect(() => StagehandClientInitParamsSchema.parse({ apiKey: "bb_key", browser })).toThrow(); - } + it("rejects an uploaded extension ID nested in browser settings", () => { + expect(() => + StagehandClientInitParamsSchema.parse({ + apiKey: "bb_key", + browser: { + type: "browserbase", + browserSettings: { extensionId: "ext_caller" }, + }, + }), + ).toThrow(); + }); + + it("rejects unknown built-in extension names", () => { + expect(() => + StagehandClientInitParamsSchema.parse({ + apiKey: "bb_key", + browser: { + type: "browserbase", + browserSettings: { extensions: ["customer-extension"] }, + }, + }), + ).toThrow(); }); it("rejects a caller-provided Browserbase session ID", () => { From eac669d2a2357ba94f1dd2d88882f69ab4fe1a05 Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 27 Jul 2026 16:17:18 -0700 Subject: [PATCH 2/7] Align Browserbase settings validation across SDKs --- packages/protocol/schemas.ts | 1 + packages/protocol/stagehand.v4.json | 3 ++- .../tests/browser-runtime/resident-browser-proxy-smoke.test.ts | 3 ++- packages/sdk-python/src/stagehand/_generated/models.py | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/protocol/schemas.ts b/packages/protocol/schemas.ts index 85bc7bbca..cb0b72cc3 100644 --- a/packages/protocol/schemas.ts +++ b/packages/protocol/schemas.ts @@ -972,6 +972,7 @@ export const BrowserbaseBrowserSettingsSchema = z verified: z.boolean().optional(), viewport: BrowserbaseViewportSchema.optional(), }) + .strict() .meta({ id: "BrowserbaseBrowserSettings" }); /** Browserbase managed proxy geolocation configuration. */ diff --git a/packages/protocol/stagehand.v4.json b/packages/protocol/stagehand.v4.json index 843d5dc74..f02461aec 100644 --- a/packages/protocol/stagehand.v4.json +++ b/packages/protocol/stagehand.v4.json @@ -1178,7 +1178,8 @@ "viewport": { "$ref": "#/$defs/BrowserbaseViewport" } - } + }, + "additionalProperties": false }, "BrowserbaseContext": { "type": "object", diff --git a/packages/protocol/tests/browser-runtime/resident-browser-proxy-smoke.test.ts b/packages/protocol/tests/browser-runtime/resident-browser-proxy-smoke.test.ts index 133e220aa..aa08e3fd5 100644 --- a/packages/protocol/tests/browser-runtime/resident-browser-proxy-smoke.test.ts +++ b/packages/protocol/tests/browser-runtime/resident-browser-proxy-smoke.test.ts @@ -11,6 +11,7 @@ import { instrumentedDecoratorBuild } from "../../../server/instrumentedDecorato import { CDPClient } from "../../../sdk-ts/src/cdpClient.js"; import { connectRPCClient, type RPCClient } from "../../../sdk-ts/src/rpcClient.js"; import { StagehandMethods } from "../../schema-registry.js"; +import { STAGEHAND_PROTOCOL_VERSION } from "../../schemas.js"; const COMMAND_TIMEOUT_MS = 15_000; const serverDistDir = path.resolve(fileURLToPath(new URL("../../../server/dist", import.meta.url))); @@ -54,7 +55,7 @@ describe("resident browser proxy", () => { await expect( rpcClient.send(StagehandMethods.stagehandInit, { - protocolVersion: 4, + protocolVersion: STAGEHAND_PROTOCOL_VERSION, clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, logLevel: "off", telemetry: { traces: { endpoint: "http://127.0.0.1:4318/v1/traces", headers: {} } }, diff --git a/packages/sdk-python/src/stagehand/_generated/models.py b/packages/sdk-python/src/stagehand/_generated/models.py index d9dc42273..72bfb86ab 100644 --- a/packages/sdk-python/src/stagehand/_generated/models.py +++ b/packages/sdk-python/src/stagehand/_generated/models.py @@ -162,6 +162,7 @@ class BrowserGetVersionResult(WireModel): class BrowserbaseBrowserSettings(WireModel): model_config = ConfigDict( + extra="forbid", validate_by_name=True, ) advanced_stealth: Optional[StrictBool] = None From 14e29813430debe46fe3d1d7cd571d081d3abe51 Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 27 Jul 2026 16:19:20 -0700 Subject: [PATCH 3/7] Remove obsolete SDK extension archive --- packages/sdk-ts/src/extensionAssets.ts | 4 ---- packages/sdk-ts/tsdown.config.ts | 4 ---- 2 files changed, 8 deletions(-) diff --git a/packages/sdk-ts/src/extensionAssets.ts b/packages/sdk-ts/src/extensionAssets.ts index 3c709e07d..e1eb89a76 100644 --- a/packages/sdk-ts/src/extensionAssets.ts +++ b/packages/sdk-ts/src/extensionAssets.ts @@ -2,10 +2,6 @@ import { fileURLToPath } from "node:url"; const packageRoot = new URL("../", import.meta.url); -export const STAGEHAND_EXTENSION_ARCHIVE_PATH = fileURLToPath( - new URL("dist/assets/stagehand-extension.zip", packageRoot), -); - export const STAGEHAND_EXTENSION_DIRECTORY_PATH = fileURLToPath( new URL("dist/extension/", packageRoot), ); diff --git a/packages/sdk-ts/tsdown.config.ts b/packages/sdk-ts/tsdown.config.ts index 44e551cc5..b17737f71 100644 --- a/packages/sdk-ts/tsdown.config.ts +++ b/packages/sdk-ts/tsdown.config.ts @@ -31,10 +31,6 @@ export default defineConfig({ ], }, copy: [ - { - from: "../server/artifacts/stagehand-extension.zip", - to: "dist/assets", - }, { from: "../server/dist", to: "dist", From e44c2a7750431bd63bbbde2c6b590f585df9d167 Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 27 Jul 2026 16:22:37 -0700 Subject: [PATCH 4/7] Narrow Browserbase source in parity test --- packages/sdk-python/tests/test_browser_source.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/sdk-python/tests/test_browser_source.py b/packages/sdk-python/tests/test_browser_source.py index 0fb435fde..a773e5ff7 100644 --- a/packages/sdk-python/tests/test_browser_source.py +++ b/packages/sdk-python/tests/test_browser_source.py @@ -4,7 +4,7 @@ from pydantic import ValidationError from stagehand.browser_source import ResolvedBrowserSource, resolve_browser_source -from stagehand.client_models import StagehandClientInitParams +from stagehand.client_models import BrowserbaseBrowserSource, StagehandClientInitParams def test_browserbase_uploaded_extension_id_is_top_level_only() -> None: @@ -17,6 +17,7 @@ def test_browserbase_uploaded_extension_id_is_top_level_only() -> None: }, }) + assert isinstance(params.browser, BrowserbaseBrowserSource) assert params.browser.extension_id == "uploaded-extension" with pytest.raises(ValidationError): StagehandClientInitParams.model_validate({ From 0c2fb5f026413399a32a4cfef6fd6a71c86eccd0 Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 27 Jul 2026 16:32:45 -0700 Subject: [PATCH 5/7] Drop archive assertion with legacy uploader --- packages/sdk-ts/tests/package-contract.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/sdk-ts/tests/package-contract.test.ts b/packages/sdk-ts/tests/package-contract.test.ts index 42562f48a..4a2b43883 100644 --- a/packages/sdk-ts/tests/package-contract.test.ts +++ b/packages/sdk-ts/tests/package-contract.test.ts @@ -9,7 +9,7 @@ const execFileAsync = promisify(execFile); const sdkRoot = new URL("..", import.meta.url); describe("published TypeScript SDK", () => { - it("installs the tarball and resolves both packaged extension forms", async () => { + it("installs the tarball and resolves the unpacked extension", async () => { const temporaryRoot = await mkdtemp( path.join(tmpdir(), "stagehand package contract with spaces "), ); @@ -48,9 +48,7 @@ describe("published TypeScript SDK", () => { if (typeof Stagehand !== "function") throw new Error("Stagehand export is unavailable"); const entryUrl = import.meta.resolve("@browserbasehq/stagehand"); - const archiveUrl = new URL("./assets/stagehand-extension.zip", entryUrl); const manifestUrl = new URL("./extension/manifest.json", entryUrl); - await access(fileURLToPath(archiveUrl)); await access(fileURLToPath(manifestUrl)); const manifest = JSON.parse(await readFile(fileURLToPath(manifestUrl), "utf8")); if (manifest.manifest_version !== 3) throw new Error("Invalid packaged manifest"); From ecf2a00330e1f6dd7c8c1416b7f689c91e8c318a Mon Sep 17 00:00:00 2001 From: Miguel <36487034+miguelg719@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:12:32 +0000 Subject: [PATCH 6/7] fix(extension): remove unused Chrome permissions --- .../browser-runtime/resident-browser-proxy-smoke.test.ts | 8 ++++++++ packages/server/manifest.json | 2 +- packages/server/tests/extension-build.test.ts | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/protocol/tests/browser-runtime/resident-browser-proxy-smoke.test.ts b/packages/protocol/tests/browser-runtime/resident-browser-proxy-smoke.test.ts index 7ddc92837..ba389de96 100644 --- a/packages/protocol/tests/browser-runtime/resident-browser-proxy-smoke.test.ts +++ b/packages/protocol/tests/browser-runtime/resident-browser-proxy-smoke.test.ts @@ -66,6 +66,14 @@ describe("resident browser proxy", () => { const page = await rpcClient.send(StagehandMethods.contextNewPage, { url: "data:text/html,Resident proxy smoke", }); + await expect( + rpcClient.send(StagehandMethods.contextSetActivePage, { + pageId: page.pageId, + }), + ).resolves.toStrictEqual({ ok: true }); + await expect(rpcClient.send(StagehandMethods.contextActivePage, {})).resolves.toMatchObject({ + pageId: page.pageId, + }); await expect( rpcClient.send(StagehandMethods.pageEvaluate, { pageId: page.pageId, diff --git a/packages/server/manifest.json b/packages/server/manifest.json index 9cbd4e6d0..3e2eb9155 100644 --- a/packages/server/manifest.json +++ b/packages/server/manifest.json @@ -3,7 +3,7 @@ "name": "Stagehand Runtime", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4T0m6kKxyC5WzHGSJabgBmm+YlruD5QfYipxafleAaumezh6fQ5pjEEQtXTNg9w835UIXDNS7UZOEjbhJEpv5Nx8K377mCSCOFPGZ9fwiirG8KoBw+yOqDwuSaXu+hJnc9n6nxSwbPfF9m1fpOtAprVMKNpr0Tdjt718RRkEJlDxIcw9PeF12d72yjk/P4bbBN+Mv5lLYXNTzCdY1kCJ4ZUqBoYl7ivAior/Y7OSHYKv/n853AmicKfFZy/z3ad3ocMd28gbgjij5ad/FlFqd0hfTEqlCE+CIxn1VpwuAkPyyRkybCWjeIbcmqRUDnCMFMXr89uCbbOWWEviUPe9eQIDAQAB", "minimum_chrome_version": "116", - "permissions": ["debugger", "offscreen", "scripting", "tabs"], + "permissions": ["debugger", "offscreen"], "host_permissions": [""], "background": { "service_worker": "service-worker.js", diff --git a/packages/server/tests/extension-build.test.ts b/packages/server/tests/extension-build.test.ts index 1f79167c1..3a57f3a1f 100644 --- a/packages/server/tests/extension-build.test.ts +++ b/packages/server/tests/extension-build.test.ts @@ -113,7 +113,7 @@ describe("extension build", () => { ], options_page: "wake-service-worker.html", }); - expect(manifest.permissions).toEqual(["debugger", "offscreen", "scripting", "tabs"]); + expect(manifest.permissions).toEqual(["debugger", "offscreen"]); expect(manifest.host_permissions).toEqual([""]); expect(serviceWorker).toContain("__stagehandReceiveFromHost"); expect(serviceWorker).toContain("offscreen/service-worker-heartbeat.html"); From 6ce2d8cb527e118412adcee97c6d79e0688217e8 Mon Sep 17 00:00:00 2001 From: Miguel <36487034+miguelg719@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:12:32 +0000 Subject: [PATCH 7/7] chore: format resident lifecycle tests --- .../server/tests/understudy-context-lifecycle.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/server/tests/understudy-context-lifecycle.test.ts b/packages/server/tests/understudy-context-lifecycle.test.ts index 4400055ba..96f97879a 100644 --- a/packages/server/tests/understudy-context-lifecycle.test.ts +++ b/packages/server/tests/understudy-context-lifecycle.test.ts @@ -31,7 +31,10 @@ describe("BrowserContext connection lifecycle", () => { const onConnected = vi.fn(); const onDisconnected = vi.fn(); - await BrowserContext.create("ws://browser.example", contextOptions(onConnected, onDisconnected)); + await BrowserContext.create( + "ws://browser.example", + contextOptions(onConnected, onDisconnected), + ); transportClosed?.(); transportClosed?.(); @@ -48,7 +51,9 @@ describe("BrowserContext connection lifecycle", () => { close: vi.fn(async () => transportClosed?.()), }; vi.spyOn(CdpConnection, "connect").mockResolvedValue(connection as never); - vi.spyOn(BrowserContext.prototype, "bootstrap").mockRejectedValue(new Error("bootstrap failed")); + vi.spyOn(BrowserContext.prototype, "bootstrap").mockRejectedValue( + new Error("bootstrap failed"), + ); const onConnected = vi.fn(); const onDisconnected = vi.fn();