diff --git a/packages/protocol/schemas.ts b/packages/protocol/schemas.ts index cb7d48f40..cb0b72cc3 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(), @@ -968,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 e44432f3b..f02461aec 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" @@ -1175,7 +1178,8 @@ "viewport": { "$ref": "#/$defs/BrowserbaseViewport" } - } + }, + "additionalProperties": false }, "BrowserbaseContext": { "type": "object", @@ -1189,6 +1193,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..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 @@ -169,7 +170,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 +204,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..a773e5ff7 100644 --- a/packages/sdk-python/tests/test_browser_source.py +++ b/packages/sdk-python/tests/test_browser_source.py @@ -1,9 +1,32 @@ 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 +from stagehand.client_models import BrowserbaseBrowserSource, 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 isinstance(params.browser, BrowserbaseBrowserSource) + 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 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/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/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/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"); 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", () => { 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",