From 59c3589835ae138da8711f19085192c2a3b51950 Mon Sep 17 00:00:00 2001 From: miguel Date: Sun, 26 Jul 2026 21:41:09 -0700 Subject: [PATCH 1/6] Make stagehand.init the first runtime RPC --- packages/protocol/schema-registry.ts | 7 - packages/protocol/schemas.ts | 22 +- packages/protocol/stagehand.v4.json | 194 ++++++------------ .../browser-runtime/rpc-client-smoke.test.ts | 9 +- .../tests/protocol/client-llm.test.ts | 2 + .../generated-schema-integrity.test.ts | 2 +- .../tests/protocol/loopback-protocol.test.ts | 64 ++---- .../protocol/object-model-protocol.test.ts | 14 +- .../tests/protocol/wire-casing.test.ts | 6 + .../tests/rpc-client/cdp-client.test.ts | 24 +-- .../protocol/tests/rpc-client/options.test.ts | 34 +-- packages/protocol/types.ts | 4 - .../src/stagehand/_generated/models.py | 28 +-- .../src/stagehand/browser_source.py | 2 + .../sdk-python/src/stagehand/cdp_client.py | 4 +- .../sdk-python/src/stagehand/client_models.py | 12 +- .../sdk-python/src/stagehand/rpc_client.py | 23 +-- .../sdk-python/src/stagehand/stagehand.py | 18 +- packages/sdk-python/tests/_support.py | 2 + packages/sdk-python/tests/test_cdp_client.py | 1 + packages/sdk-python/tests/test_rpc_client.py | 27 +-- packages/sdk-python/tests/test_stagehand.py | 6 +- packages/sdk-ts/src/browserSource.ts | 6 +- packages/sdk-ts/src/cdpClient.ts | 32 ++- packages/sdk-ts/src/clientSchemas.ts | 20 +- packages/sdk-ts/src/rpcClient.ts | 31 +-- packages/sdk-ts/src/stagehand.ts | 25 ++- .../stagehand-launch-connect-smoke.test.ts | 18 ++ packages/sdk-ts/tests/browserSource.test.ts | 17 ++ packages/sdk-ts/tests/object-wrapper.test.ts | 5 + .../stagehand-client-init-params.test.ts | 9 + packages/sdk-ts/tests/stagehand.test.ts | 70 ++++--- .../server/controllers/runtimeController.ts | 10 +- .../server/controllers/stagehandController.ts | 19 +- packages/server/rpcRouter.ts | 26 ++- packages/server/runtime.ts | 17 +- .../server/tests/client-llm-client.test.ts | 7 +- packages/server/tests/runtime-state.test.ts | 30 +-- .../server/tests/stagehand-clients.test.ts | 85 ++++---- 39 files changed, 430 insertions(+), 502 deletions(-) diff --git a/packages/protocol/schema-registry.ts b/packages/protocol/schema-registry.ts index e2d91a3cd..4b4ee9a32 100644 --- a/packages/protocol/schema-registry.ts +++ b/packages/protocol/schema-registry.ts @@ -89,8 +89,6 @@ import { PageWaitForSelectorParamsSchema, PageWaitForSelectorResultSchema, PageWaitForTimeoutParamsSchema, - RuntimeConfigureParamsSchema, - RuntimeConfigureResultSchema, RuntimeLoopbackStatusResultSchema, StagehandActParamsSchema, StagehandCloseResultSchema, @@ -111,11 +109,6 @@ export const StagehandSendToHostBindingSchema = z export const StagehandMethods = { ping: { name: "ping", params: EmptyParamsSchema, result: StagehandPingResultSchema }, - runtimeConfigure: { - name: "runtime.configure", - params: RuntimeConfigureParamsSchema, - result: RuntimeConfigureResultSchema, - }, runtimeLoopbackStatus: { name: "runtime.loopback_status", params: EmptyParamsSchema, diff --git a/packages/protocol/schemas.ts b/packages/protocol/schemas.ts index d553b8aac..5f50ac255 100644 --- a/packages/protocol/schemas.ts +++ b/packages/protocol/schemas.ts @@ -1314,10 +1314,14 @@ export const TelemetryConfigSchema = z export const StagehandInitParamsSchema = z .object({ + protocolVersion: z.literal(STAGEHAND_PROTOCOL_VERSION), + clientInfo: ImplementationInfoSchema, + browserCdpUrl: z.string().min(1).optional(), apiKey: z.string().min(1).optional(), browser: BrowserbaseBrowserSourceSchema.optional(), model: z.union([ModelConfigSchema, ClientModelReferenceSchema]).optional(), telemetry: TelemetryConfigSchema.default(DEFAULT_TELEMETRY_CONFIG), + logLevel: z.enum(["off", "error", "warn", "info", "debug"]).default("info"), systemPrompt: z.string().optional(), selfHeal: z.boolean().optional(), domSettleTimeoutMs: z.number().int().positive().optional(), @@ -1329,17 +1333,6 @@ export const StagehandInitParamsSchema = z .strict() .meta({ id: "StagehandInitParams" }); -export const RuntimeConfigureParamsSchema = z - .object({ - protocolVersion: z.int().positive().optional(), - clientInfo: ImplementationInfoSchema.optional(), - cdpUrl: z.string().min(1), - telemetry: TelemetryConfigSchema.default(DEFAULT_TELEMETRY_CONFIG), - logLevel: z.enum(["off", "error", "warn", "info", "debug"]).default("info"), - }) - .strict() - .meta({ id: "RuntimeConfigureParams" }); - export const StagehandActParamsSchema = z .object({ pageId: z.string().min(1), @@ -1773,13 +1766,6 @@ export const StagehandPingResultSchema = z .strict() .meta({ id: "StagehandPingResult" }); -export const RuntimeConfigureResultSchema = z - .object({ - configured: z.literal(true), - }) - .strict() - .meta({ id: "RuntimeConfigureResult" }); - export const RuntimeLoopbackStatusResultSchema = z .object({ configured: z.boolean(), diff --git a/packages/protocol/stagehand.v4.json b/packages/protocol/stagehand.v4.json index 32294180d..9514a4e54 100644 --- a/packages/protocol/stagehand.v4.json +++ b/packages/protocol/stagehand.v4.json @@ -19,19 +19,6 @@ "required": ["params", "result"], "additionalProperties": false }, - "runtime.configure": { - "type": "object", - "properties": { - "params": { - "$ref": "#/$defs/RuntimeConfigureParams" - }, - "result": { - "$ref": "#/$defs/RuntimeConfigureResult" - } - }, - "required": ["params", "result"], - "additionalProperties": false - }, "runtime.loopback_status": { "type": "object", "properties": { @@ -880,7 +867,6 @@ }, "required": [ "ping", - "runtime.configure", "runtime.loopback_status", "browser.get_version", "stagehand.init", @@ -1010,96 +996,6 @@ "required": ["ok", "runtime"], "additionalProperties": false }, - "RuntimeConfigureParams": { - "type": "object", - "properties": { - "protocol_version": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 - }, - "client_info": { - "$ref": "#/$defs/ImplementationInfo" - }, - "cdp_url": { - "type": "string", - "minLength": 1 - }, - "telemetry": { - "default": { - "traces": { - "endpoint": "https://example.com/v1/traces", - "headers": {} - } - }, - "$ref": "#/$defs/TelemetryConfig" - }, - "log_level": { - "default": "info", - "type": "string", - "enum": ["off", "error", "warn", "info", "debug"] - } - }, - "required": ["cdp_url"], - "additionalProperties": false - }, - "ImplementationInfo": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1 - }, - "version": { - "type": "string", - "minLength": 1 - } - }, - "required": ["name", "version"], - "additionalProperties": false - }, - "TelemetryConfig": { - "type": "object", - "properties": { - "traces": { - "$ref": "#/$defs/TelemetryTraces" - } - }, - "required": ["traces"], - "additionalProperties": false - }, - "TelemetryTraces": { - "type": "object", - "properties": { - "endpoint": { - "type": "string", - "format": "uri" - }, - "headers": { - "default": {}, - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - } - }, - "required": ["endpoint"], - "additionalProperties": false - }, - "RuntimeConfigureResult": { - "type": "object", - "properties": { - "configured": { - "type": "boolean", - "const": true - } - }, - "required": ["configured"], - "additionalProperties": false - }, "RuntimeLoopbackStatusResult": { "type": "object", "properties": { @@ -1137,6 +1033,17 @@ "StagehandInitParams": { "type": "object", "properties": { + "protocol_version": { + "type": "number", + "const": 4 + }, + "client_info": { + "$ref": "#/$defs/ImplementationInfo" + }, + "browser_cdp_url": { + "type": "string", + "minLength": 1 + }, "api_key": { "type": "string", "minLength": 1 @@ -1163,6 +1070,11 @@ }, "$ref": "#/$defs/TelemetryConfig" }, + "log_level": { + "default": "info", + "type": "string", + "enum": ["off", "error", "warn", "info", "debug"] + }, "system_prompt": { "type": "string" }, @@ -1179,6 +1091,22 @@ "$ref": "#/$defs/Caching" } }, + "required": ["protocol_version", "client_info"], + "additionalProperties": false + }, + "ImplementationInfo": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + } + }, + "required": ["name", "version"], "additionalProperties": false }, "BrowserbaseBrowserSource": { @@ -1545,6 +1473,37 @@ "required": ["source"], "additionalProperties": false }, + "TelemetryConfig": { + "type": "object", + "properties": { + "traces": { + "$ref": "#/$defs/TelemetryTraces" + } + }, + "required": ["traces"], + "additionalProperties": false + }, + "TelemetryTraces": { + "type": "object", + "properties": { + "endpoint": { + "type": "string", + "format": "uri" + }, + "headers": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["endpoint"], + "additionalProperties": false + }, "Caching": { "anyOf": [ { @@ -4395,33 +4354,6 @@ "required": ["jsonrpc", "id", "method", "params"], "additionalProperties": false }, - { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "const": "2.0" - }, - "id": { - "$ref": "#/$defs/JSONRPCRequestId" - }, - "method": { - "type": "string", - "const": "runtime.configure" - }, - "params": { - "$ref": "#/$defs/RuntimeConfigureParams" - }, - "traceparent": { - "type": "string" - }, - "tracestate": { - "type": "string" - } - }, - "required": ["jsonrpc", "id", "method", "params"], - "additionalProperties": false - }, { "type": "object", "properties": { diff --git a/packages/protocol/tests/browser-runtime/rpc-client-smoke.test.ts b/packages/protocol/tests/browser-runtime/rpc-client-smoke.test.ts index b2871e5b3..a9e4f2af5 100644 --- a/packages/protocol/tests/browser-runtime/rpc-client-smoke.test.ts +++ b/packages/protocol/tests/browser-runtime/rpc-client-smoke.test.ts @@ -34,7 +34,12 @@ describe("Stagehand service worker RPC client smoke", () => { serviceWorkerUrlIncludes: "service-worker.js", discoveryTimeoutMs: 15_000, commandTimeoutMs: 15_000, + }); + await rpcClient.send(StagehandMethods.stagehandInit, { + protocolVersion: 4, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, logLevel: "debug", + browserCdpUrl: rpcClient.browserWebSocketDebuggerUrl ?? `http://127.0.0.1:${chrome.port}`, }); }, 45_000); @@ -68,8 +73,8 @@ describe("Stagehand service worker RPC client smoke", () => { jsonrpc: "2.0", method: "stagehand.log", params: { - level: "debug", - message: "runtime.configure", + level: "info", + message: "stagehand.init", data: {}, }, }); diff --git a/packages/protocol/tests/protocol/client-llm.test.ts b/packages/protocol/tests/protocol/client-llm.test.ts index 02944b2ed..c612724af 100644 --- a/packages/protocol/tests/protocol/client-llm.test.ts +++ b/packages/protocol/tests/protocol/client-llm.test.ts @@ -39,6 +39,8 @@ describe("client-side LLM protocol", () => { it("selects a serializable client model during Stagehand initialization", () => { expect( StagehandInitParamsSchema.parse({ + protocolVersion: 4, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, model: { source: "client", }, diff --git a/packages/protocol/tests/protocol/generated-schema-integrity.test.ts b/packages/protocol/tests/protocol/generated-schema-integrity.test.ts index ec848eca2..78fac9c15 100644 --- a/packages/protocol/tests/protocol/generated-schema-integrity.test.ts +++ b/packages/protocol/tests/protocol/generated-schema-integrity.test.ts @@ -92,7 +92,7 @@ describe("generated Stagehand schema integrity", () => { const methods = asRecord(asRecord(asRecord(protocol.properties).methods).properties); const telemetryDefault = DEFAULT_TELEMETRY_CONFIG; - for (const methodName of ["runtime.configure", "stagehand.init"]) { + for (const methodName of ["stagehand.init"]) { const method = asRecord(methods[methodName]); const params = resolveLocalReference( protocol, diff --git a/packages/protocol/tests/protocol/loopback-protocol.test.ts b/packages/protocol/tests/protocol/loopback-protocol.test.ts index 2b7d864f2..9f96282bf 100644 --- a/packages/protocol/tests/protocol/loopback-protocol.test.ts +++ b/packages/protocol/tests/protocol/loopback-protocol.test.ts @@ -2,33 +2,18 @@ import { describe, expect, it } from "vitest"; import { StagehandMethods, StagehandRpcRequestSchema } from "../../schema-registry.js"; describe("Stagehand loopback protocol", () => { - it("defines runtime.configure as a JSON-RPC method", () => { - const params = StagehandMethods.runtimeConfigure.params.parse({ - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", - }); - - expect(params).toStrictEqual({ - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", - logLevel: "info", - telemetry: { - traces: { - endpoint: "https://example.com/v1/traces", - headers: {}, - }, - }, - }); - - expect( - StagehandMethods.runtimeConfigure.result.parse({ - configured: true, + it("does not expose a separate runtime.configure method", () => { + expect(Object.values(StagehandMethods).map((method) => method.name)).not.toContain( + "runtime.configure", + ); + expect(() => + StagehandRpcRequestSchema.parse({ + jsonrpc: "2.0", + id: 1, + method: "runtime.configure", + params: {}, }), - ).toStrictEqual({ - configured: true, - }); - }); - - it("rejects runtime.configure without a CDP URL", () => { - expect(() => StagehandMethods.runtimeConfigure.params.parse({})).toThrow(); + ).toThrow(); }); it("defines runtime.loopback_status as a JSON-RPC method", () => { @@ -68,37 +53,12 @@ describe("Stagehand loopback protocol", () => { StagehandRpcRequestSchema.parse({ jsonrpc: "2.0", id: 1, - method: "runtime.configure", - params: { - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", - }, - }), - ).toStrictEqual({ - jsonrpc: "2.0", - id: 1, - method: "runtime.configure", - params: { - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", - logLevel: "info", - telemetry: { - traces: { - endpoint: "https://example.com/v1/traces", - headers: {}, - }, - }, - }, - }); - - expect( - StagehandRpcRequestSchema.parse({ - jsonrpc: "2.0", - id: 2, method: "browser.get_version", params: {}, }), ).toStrictEqual({ jsonrpc: "2.0", - id: 2, + id: 1, method: "browser.get_version", params: {}, }); diff --git a/packages/protocol/tests/protocol/object-model-protocol.test.ts b/packages/protocol/tests/protocol/object-model-protocol.test.ts index 52e44be8e..be4b12c67 100644 --- a/packages/protocol/tests/protocol/object-model-protocol.test.ts +++ b/packages/protocol/tests/protocol/object-model-protocol.test.ts @@ -16,6 +16,9 @@ describe("Stagehand object-model protocol", () => { it("defines stagehand init as a JSON-RPC method", () => { const params = StagehandMethods.stagehandInit.params.parse({ + protocolVersion: 4, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, + logLevel: "info", apiKey: "bb_key", browser: { type: "browserbase", @@ -27,6 +30,9 @@ describe("Stagehand object-model protocol", () => { }); expect(params).toStrictEqual({ + protocolVersion: 4, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, + logLevel: "info", apiKey: "bb_key", browser: { type: "browserbase", @@ -66,6 +72,8 @@ describe("Stagehand object-model protocol", () => { it("rejects model names without a provider prefix", () => { expect(() => StagehandMethods.stagehandInit.params.parse({ + protocolVersion: 4, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, model: { modelName: "gpt-5-mini" }, }), ).toThrow(); @@ -543,8 +551,10 @@ describe("Stagehand object-model protocol", () => { it("accepts telemetry configuration as protocol data", () => { expect( - StagehandMethods.runtimeConfigure.params.parse({ - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", + StagehandMethods.stagehandInit.params.parse({ + protocolVersion: 4, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, + browserCdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", telemetry: { traces: { endpoint: "https://otel.example.com/v1/traces", diff --git a/packages/protocol/tests/protocol/wire-casing.test.ts b/packages/protocol/tests/protocol/wire-casing.test.ts index 8638b2987..ac8b90be4 100644 --- a/packages/protocol/tests/protocol/wire-casing.test.ts +++ b/packages/protocol/tests/protocol/wire-casing.test.ts @@ -334,6 +334,9 @@ describe("JSON-RPC wire casing", () => { it("preserves arbitrary map keys while encoding nested configuration", () => { const definition = StagehandMethods.stagehandInit; const apiValue = { + protocolVersion: 4 as const, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, + logLevel: "info" as const, apiKey: "bb_key", browser: { type: "browserbase" as const, @@ -354,6 +357,9 @@ describe("JSON-RPC wire casing", () => { }; const wireValue = { + protocol_version: 4, + client_info: { name: "stagehand-sdk-ts", version: "4.0.0" }, + log_level: "info", api_key: "bb_key", browser: { type: "browserbase", diff --git a/packages/protocol/tests/rpc-client/cdp-client.test.ts b/packages/protocol/tests/rpc-client/cdp-client.test.ts index 3b3c6622d..5b2e37dc0 100644 --- a/packages/protocol/tests/rpc-client/cdp-client.test.ts +++ b/packages/protocol/tests/rpc-client/cdp-client.test.ts @@ -4,7 +4,7 @@ import { resolveBrowserWebSocketUrl, StagehandRuntimeIncompatibleError, waitForPreloadedStagehandServiceWorker, - waitForRuntimeReady, + waitForRuntimeReceiver, waitForServiceWorker, } from "../../../sdk-ts/src/cdpClient.ts"; @@ -393,7 +393,7 @@ describe("waitForPreloadedStagehandServiceWorker", () => { }); }); -describe("waitForRuntimeReady", () => { +describe("waitForRuntimeReceiver", () => { it("resolves when the attached runtime exposes the Stagehand marker and RPC receiver", async () => { const cdp = new FakeCdp().on("Runtime.evaluate", () => ({ result: { @@ -402,7 +402,7 @@ describe("waitForRuntimeReady", () => { })); await expect( - waitForRuntimeReady(cdp, "worker-session", { + waitForRuntimeReceiver(cdp, "worker-session", { timeout: 1_000, delayFn: async () => {}, }), @@ -441,7 +441,7 @@ describe("waitForRuntimeReady", () => { })); await expect( - waitForRuntimeReady(cdp, "worker-session", { + waitForRuntimeReceiver(cdp, "worker-session", { pollIntervalMs: 5, timeout: 100, nowFn: () => now, @@ -469,7 +469,7 @@ describe("waitForRuntimeReady", () => { })); await expect( - waitForRuntimeReady(cdp, "worker-session", { + waitForRuntimeReceiver(cdp, "worker-session", { pollIntervalMs: 1, timeout: 2, nowFn: () => now, @@ -477,7 +477,7 @@ describe("waitForRuntimeReady", () => { now += ms; }, }), - ).rejects.toThrow("Timed out waiting for the Stagehand extension runtime to become ready"); + ).rejects.toThrow("Timed out waiting for the Stagehand extension runtime RPC receiver"); }); it("keeps retrying when readiness evaluation throws", async () => { @@ -497,7 +497,7 @@ describe("waitForRuntimeReady", () => { const cdp = new FakeCdp().on("Runtime.evaluate", () => results.shift() ?? {}); await expect( - waitForRuntimeReady(cdp, "worker-session", { + waitForRuntimeReceiver(cdp, "worker-session", { pollIntervalMs: 1, timeout: 10, nowFn: () => now, @@ -515,7 +515,7 @@ describe("waitForRuntimeReady", () => { const cdp = new FakeCdp().on("Runtime.evaluate", () => ({})); const error = await rejectedError( - waitForRuntimeReady(cdp, "worker-session", { + waitForRuntimeReceiver(cdp, "worker-session", { pollIntervalMs: 1, timeout: 1, nowFn: () => now, @@ -537,7 +537,7 @@ describe("waitForRuntimeReady", () => { })); const error = await rejectedError( - waitForRuntimeReady(cdp, "worker-session", { + waitForRuntimeReceiver(cdp, "worker-session", { pollIntervalMs: 1, timeout: 1, nowFn: () => now, @@ -549,7 +549,7 @@ describe("waitForRuntimeReady", () => { expect(error).not.toBeInstanceOf(StagehandRuntimeIncompatibleError); expect(error.message).toContain( - "Timed out waiting for the Stagehand extension runtime to become ready", + "Timed out waiting for the Stagehand extension runtime RPC receiver", ); expect(error.message).toContain("protocolVersion=3"); expect(error.message).not.toContain("undefined"); @@ -561,7 +561,7 @@ describe("waitForRuntimeReady", () => { })); await expect( - waitForRuntimeReady(cdp, "worker-session", { + waitForRuntimeReceiver(cdp, "worker-session", { allowFallbackInstall: false, timeout: 1_000, nowFn: () => 0, @@ -581,7 +581,7 @@ describe("waitForRuntimeReady", () => { })); await expect( - waitForRuntimeReady(cdp, "worker-session", { + waitForRuntimeReceiver(cdp, "worker-session", { timeout: 1_000, delayFn: async () => {}, }), diff --git a/packages/protocol/tests/rpc-client/options.test.ts b/packages/protocol/tests/rpc-client/options.test.ts index 2b91b1271..6a499dab7 100644 --- a/packages/protocol/tests/rpc-client/options.test.ts +++ b/packages/protocol/tests/rpc-client/options.test.ts @@ -11,13 +11,6 @@ describe("RPCClientOptionsSchema", () => { ).toStrictEqual({ cdpUrl: "http://127.0.0.1:9222", extensionDir: "/tmp/stagehand-extension", - logLevel: "info", - telemetry: { - traces: { - endpoint: "https://example.com/v1/traces", - headers: {}, - }, - }, }); }); @@ -30,13 +23,6 @@ describe("RPCClientOptionsSchema", () => { ).toStrictEqual({ cdpUrl: "http://127.0.0.1:9222", extensionId: "abcdefghijklmnopabcdefghijklmnop", - logLevel: "info", - telemetry: { - traces: { - endpoint: "https://example.com/v1/traces", - headers: {}, - }, - }, }); }); @@ -48,19 +34,12 @@ describe("RPCClientOptionsSchema", () => { }), ).toStrictEqual({ cdpUrl: "wss://connect.browserbase.com/devtools/browser/session", - logLevel: "info", preloadedExtension: true, - telemetry: { - traces: { - endpoint: "https://example.com/v1/traces", - headers: {}, - }, - }, }); }); - it("accepts a custom OTLP traces destination", () => { - expect( + it("rejects telemetry because it belongs to stagehand.init", () => { + expect(() => RPCClientOptionsSchema.parse({ cdpUrl: "http://127.0.0.1:9222", extensionId: "abcdefghijklmnopabcdefghijklmnop", @@ -71,14 +50,7 @@ describe("RPCClientOptionsSchema", () => { }, }, }), - ).toMatchObject({ - telemetry: { - traces: { - endpoint: "https://collector.example.com/v1/traces", - headers: { Authorization: "Bearer test" }, - }, - }, - }); + ).toThrow(); }); it("rejects options without an explicit extension load mode", () => { diff --git a/packages/protocol/types.ts b/packages/protocol/types.ts index 5f22bd7d1..26a6f08be 100644 --- a/packages/protocol/types.ts +++ b/packages/protocol/types.ts @@ -162,8 +162,6 @@ import type { PageWaitForSelectorResultSchema, PageWaitForTimeoutParamsSchema, ProxyConfigSchema, - RuntimeConfigureParamsSchema, - RuntimeConfigureResultSchema, RuntimeDescriptorSchema, RuntimeLoopbackStatusResultSchema, RgbaColorSchema, @@ -323,7 +321,6 @@ export type StagehandInitParams = z.infer; export type TelemetryConfig = z.infer; export type ImplementationInfo = z.infer; export type RuntimeDescriptor = z.infer; -export type RuntimeConfigureParams = z.infer; export type StagehandActParams = z.infer; export type StagehandObserveParams = z.infer; export type StagehandExtractParams = z.infer; @@ -374,7 +371,6 @@ export type LocatorSendClickEventParams = z.infer; export type LocatorSelectOptionParams = z.infer; export type StagehandPingResult = z.infer; -export type RuntimeConfigureResult = z.infer; export type RuntimeLoopbackStatusResult = z.infer; export type BrowserGetVersionResult = z.infer; export type StagehandInitResult = z.infer; diff --git a/packages/sdk-python/src/stagehand/_generated/models.py b/packages/sdk-python/src/stagehand/_generated/models.py index 14ff92a52..04084fd54 100644 --- a/packages/sdk-python/src/stagehand/_generated/models.py +++ b/packages/sdk-python/src/stagehand/_generated/models.py @@ -1815,30 +1815,6 @@ class RgbaColor(WireModel): a: Optional[StrictFloat] = None -class RuntimeConfigureParams(WireModel): - model_config = ConfigDict( - extra="forbid", - validate_by_name=True, - ) - protocol_version: Annotated[Optional[StrictInt], Field(gt=0, le=9007199254740991)] = ( - None - ) - client_info: Optional[ImplementationInfo] = None - cdp_url: Annotated[StrictStr, Field(min_length=1)] - telemetry: Annotated[TelemetryConfig, Field(validate_default=True)] = { - "traces": {"endpoint": "https://example.com/v1/traces", "headers": {}} - } - log_level: LogLevel = LogLevel.info - - -class RuntimeConfigureResult(WireModel): - model_config = ConfigDict( - extra="forbid", - validate_by_name=True, - ) - configured: Literal[True] - - class RuntimeLoopbackStatusResult(WireModel): model_config = ConfigDict( extra="forbid", @@ -1909,12 +1885,16 @@ class StagehandInitParams(WireModel): extra="forbid", validate_by_name=True, ) + protocol_version: Literal[4] + client_info: ImplementationInfo + browser_cdp_url: Annotated[Optional[StrictStr], Field(min_length=1)] = None api_key: Annotated[Optional[StrictStr], Field(min_length=1)] = None browser: Optional[BrowserbaseBrowserSource] = None model: Optional[Union[ModelConfig, ClientModelReference]] = None telemetry: Annotated[TelemetryConfig, Field(validate_default=True)] = { "traces": {"endpoint": "https://example.com/v1/traces", "headers": {}} } + log_level: LogLevel = LogLevel.info system_prompt: Optional[StrictStr] = None self_heal: Optional[StrictBool] = None dom_settle_timeout_ms: Annotated[ diff --git a/packages/sdk-python/src/stagehand/browser_source.py b/packages/sdk-python/src/stagehand/browser_source.py index a844556af..b8c7b5f3b 100644 --- a/packages/sdk-python/src/stagehand/browser_source.py +++ b/packages/sdk-python/src/stagehand/browser_source.py @@ -46,6 +46,7 @@ class ResolvedBrowserSource: cdp_url: str keep_alive: bool + auto_attach: bool = False cdp_headers: dict[str, str] | None = None connect_timeout_ms: int | None = None _close_callback: Callable[[], Awaitable[None]] | None = field(default=None, repr=False) @@ -161,6 +162,7 @@ async def close() -> None: return ResolvedBrowserSource( cdp_url=f"http://127.0.0.1:{port}", keep_alive=options.keep_alive or False, + auto_attach=False, connect_timeout_ms=options.connect_timeout_ms, _close_callback=close, ) diff --git a/packages/sdk-python/src/stagehand/cdp_client.py b/packages/sdk-python/src/stagehand/cdp_client.py index 36fc9950a..8c0f0d4d1 100644 --- a/packages/sdk-python/src/stagehand/cdp_client.py +++ b/packages/sdk-python/src/stagehand/cdp_client.py @@ -145,7 +145,7 @@ async def connect( {"name": STAGEHAND_SEND_TO_HOST_BINDING}, session_id=session_id, ) - await client._wait_for_runtime_ready(session_id, discovery_timeout_ms) + await client._wait_for_runtime_receiver(session_id, discovery_timeout_ms) return client except BaseException: await client.close() @@ -409,7 +409,7 @@ async def _wait_for_service_worker( f"Observed targets: {observed}" ) - async def _wait_for_runtime_ready(self, session_id: str, timeout_ms: int) -> None: + async def _wait_for_runtime_receiver(self, session_id: str, timeout_ms: int) -> None: started = time.monotonic() last_error = "" diff --git a/packages/sdk-python/src/stagehand/client_models.py b/packages/sdk-python/src/stagehand/client_models.py index d726632f2..d532a79b6 100644 --- a/packages/sdk-python/src/stagehand/client_models.py +++ b/packages/sdk-python/src/stagehand/client_models.py @@ -17,8 +17,8 @@ LLMStructuredGenerateResult, ModelConfig, ProxyConfig, - StagehandInitParams, StagehandLog, + TelemetryConfig, ) from ._validation import WireModel @@ -127,9 +127,17 @@ class StagehandClientLoggingConfig(WireModel): on_log: StagehandOnLog | None = None -class StagehandClientInitParams(StagehandInitParams): +class StagehandClientInitParams(WireModel): + model_config = ConfigDict(extra="forbid") + + api_key: Annotated[str | None, Field(min_length=1)] = None browser: BrowserSource = BrowserbaseBrowserSource(type="browserbase") model: ModelConfig | ClientLLM | None = None + telemetry: TelemetryConfig | None = None + system_prompt: str | None = None + self_heal: bool | None = None + dom_settle_timeout_ms: Annotated[int | None, Field(gt=0)] = None + cache: _models.Caching | None = None logging: StagehandClientLoggingConfig = Field(default_factory=StagehandClientLoggingConfig) @model_validator(mode="after") diff --git a/packages/sdk-python/src/stagehand/rpc_client.py b/packages/sdk-python/src/stagehand/rpc_client.py index 5fa24d088..28721cddf 100644 --- a/packages/sdk-python/src/stagehand/rpc_client.py +++ b/packages/sdk-python/src/stagehand/rpc_client.py @@ -9,8 +9,6 @@ from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter, ValidationError -from ._generated import models - _MAX_REQUEST_ID = 9_007_199_254_740_991 _MAX_PENDING_NOTIFICATIONS = 100 @@ -106,6 +104,11 @@ def __init__(self, transport: _Transport, *, request_timeout_ms: int = 10_000) - self._close_reason: BaseException | None = None self._reader = asyncio.create_task(self._read(), name="stagehand-rpc-reader") + @property + def browser_web_socket_debugger_url(self) -> str | None: + value = getattr(getattr(self, "_transport", None), "web_socket_debugger_url", None) + return value if isinstance(value, str) else None + async def send( self, method: str, @@ -463,8 +466,6 @@ async def connect_rpc_client( discovery_timeout_ms: int = 10_000, command_timeout_ms: int = 10_000, cdp_connect_timeout_ms: int = 10_000, - telemetry: models.TelemetryConfig | None = None, - log_level: str = "info", ) -> RPCClient: from .cdp_client import CDPClient @@ -477,16 +478,4 @@ async def connect_rpc_client( command_timeout_ms=command_timeout_ms, cdp_connect_timeout_ms=cdp_connect_timeout_ms, ) - client = RPCClient(cdp, request_timeout_ms=command_timeout_ms) - configure = models.RuntimeConfigureParams( - cdp_url=cdp.web_socket_debugger_url, - **({"telemetry": telemetry} if telemetry is not None else {}), - log_level=models.LogLevel(log_level), - ) - - try: - await client.send("runtime.configure", configure, models.RuntimeConfigureResult) - except BaseException: - await client.close() - raise - return client + return RPCClient(cdp, request_timeout_ms=command_timeout_ms) diff --git a/packages/sdk-python/src/stagehand/stagehand.py b/packages/sdk-python/src/stagehand/stagehand.py index 80a23615b..8fe62df55 100644 --- a/packages/sdk-python/src/stagehand/stagehand.py +++ b/packages/sdk-python/src/stagehand/stagehand.py @@ -26,6 +26,7 @@ ExternalProxyConfig, ExtractOptions, ExtractResult, + ImplementationInfo, LLMGenerateParams, LLMGenerateResult, ModelConfig, @@ -428,8 +429,6 @@ async def init(self) -> None: extension_dir=str(extension_dir), service_worker_url_includes="service-worker.js", cdp_connect_timeout_ms=browser.connect_timeout_ms or 10_000, - telemetry=self.init_params.telemetry, - log_level=self.init_params.logging.level, ) self._rpc_client = rpc_client self._remove_notification_listener = rpc_client.on_notification( @@ -450,9 +449,12 @@ async def generate(params: LLMGenerateParams) -> LLMGenerateResult: generate, ) + browser_cdp_url = rpc_client.browser_web_socket_debugger_url + if not browser.auto_attach and browser_cdp_url is None: + raise RuntimeError("The browser CDP WebSocket URL is unavailable") await rpc_client.send( "stagehand.init", - self._worker_init_params(), + self._worker_init_params(None if browser.auto_attach else browser_cdp_url), StagehandInitResult, ) self._browser_context = BrowserContext(rpc_client) @@ -606,7 +608,7 @@ def _connected_rpc_client(self) -> RPCClient: ) return self._rpc_client - def _worker_init_params(self) -> StagehandInitParams: + def _worker_init_params(self, browser_cdp_url: str | None) -> StagehandInitParams: values = self.init_params.model_dump( exclude={"browser", "logging", "model"}, exclude_unset=True, @@ -615,6 +617,14 @@ def _worker_init_params(self) -> StagehandInitParams: values["model"] = ClientModelReference(source="client") elif self.init_params.model is not None: values["model"] = self.init_params.model + values["protocol_version"] = 4 + values["log_level"] = self.init_params.logging.level + values["client_info"] = ImplementationInfo( + name="stagehand-sdk-python", + version="0.1.0", + ) + if browser_cdp_url is not None: + values["browser_cdp_url"] = browser_cdp_url return StagehandInitParams.model_validate(values) async def _handle_stagehand_notification(self, notification: StagehandLog) -> None: diff --git a/packages/sdk-python/tests/_support.py b/packages/sdk-python/tests/_support.py index 353cd3096..c62b5b65a 100644 --- a/packages/sdk-python/tests/_support.py +++ b/packages/sdk-python/tests/_support.py @@ -10,6 +10,8 @@ class RecordingRPCClient: + browser_web_socket_debugger_url = "ws://resolved.example/devtools/browser/1" + def __init__(self, responses: dict[str, object] | None = None) -> None: self.responses = responses or {} self.calls: list[tuple[str, BaseModel, object]] = [] diff --git a/packages/sdk-python/tests/test_cdp_client.py b/packages/sdk-python/tests/test_cdp_client.py index 1c04cfd16..a28ce64d1 100644 --- a/packages/sdk-python/tests/test_cdp_client.py +++ b/packages/sdk-python/tests/test_cdp_client.py @@ -19,6 +19,7 @@ def _ready_marker() -> dict[str, object]: "marker": { "protocolVersion": 4, "serverInfo": {"name": "stagehand", "version": "4.0.0"}, + "state": "ready", }, "hasReceiver": True, } diff --git a/packages/sdk-python/tests/test_rpc_client.py b/packages/sdk-python/tests/test_rpc_client.py index 8b14c413c..9cbe15b57 100644 --- a/packages/sdk-python/tests/test_rpc_client.py +++ b/packages/sdk-python/tests/test_rpc_client.py @@ -449,16 +449,10 @@ async def connect(cls, **kwargs: object) -> "FakeCDPClient": async def send(self, message: JSON) -> None: await super().send(message) - if message.get("method") == "runtime.configure": - await self.incoming.put({ - "jsonrpc": "2.0", - "id": message["id"], - "result": {"configured": True}, - }) @pytest.mark.asyncio -async def test_connect_rpc_client_passes_cdp_options_and_configures_the_runtime( +async def test_connect_rpc_client_passes_cdp_options_without_sending_an_rpc( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(cdp_client, "CDPClient", FakeCDPClient) @@ -482,20 +476,9 @@ async def test_connect_rpc_client_passes_cdp_options_and_configures_the_runtime( "cdp_connect_timeout_ms": 1_003, } transport = FakeCDPClient.instances[-1] - assert transport.sent[0] == { - "jsonrpc": "2.0", - "id": 1, - "method": "runtime.configure", - "params": { - "cdp_url": "ws://resolved.example/devtools/browser/1", - "log_level": "info", - "telemetry": { - "traces": { - "endpoint": "https://example.com/v1/traces", - "headers": {}, - } - }, - }, - } + assert transport.sent == [] + assert client.browser_web_socket_debugger_url == ( + "ws://resolved.example/devtools/browser/1" + ) finally: await client.close() diff --git a/packages/sdk-python/tests/test_stagehand.py b/packages/sdk-python/tests/test_stagehand.py index be9649b59..15155563d 100644 --- a/packages/sdk-python/tests/test_stagehand.py +++ b/packages/sdk-python/tests/test_stagehand.py @@ -109,13 +109,11 @@ async def test_stagehand_prints_info_and_higher_logs_while_hiding_debug_by_defau recording = RecordingRPCClient({ "stagehand.init": StagehandInitResult(initialized=True, pages=[]), }) - connect_args: dict[str, object] = {} async def resolve(_: StagehandClientInitParams) -> ResolvedBrowserSource: return ResolvedBrowserSource(cdp_url="test://browser", keep_alive=True) - async def connect(**kwargs: object) -> RPCClient: - connect_args.update(kwargs) + async def connect(**_: object) -> RPCClient: return cast(RPCClient, recording) monkeypatch.setattr(stagehand_module, "resolve_browser_source", resolve) @@ -133,7 +131,7 @@ async def connect(**kwargs: object) -> RPCClient: ]: await notification_listener(StagehandLog.model_validate(log)) - assert connect_args["log_level"] == "info" + assert cast(StagehandInitParams, recording.calls[0][1]).log_level == "info" assert capsys.readouterr().err.splitlines() == [ '[stagehand] INFO Page opened {"pageId":"page-1"}', "[stagehand] WARN Selector fallback", diff --git a/packages/sdk-ts/src/browserSource.ts b/packages/sdk-ts/src/browserSource.ts index 3fbf63950..cfb852c51 100644 --- a/packages/sdk-ts/src/browserSource.ts +++ b/packages/sdk-ts/src/browserSource.ts @@ -17,6 +17,7 @@ export type ResolvedBrowserSource = { cdpHeaders?: Record; browserbaseSessionId?: string; preloadedExtension?: boolean; + autoAttach: boolean; keepAlive: boolean; close?: () => Promise | void; }; @@ -52,6 +53,7 @@ export async function resolveBrowserSource( cdpUrl: session.cdpUrl, browserbaseSessionId: session.sessionId, preloadedExtension: true, + autoAttach: false, keepAlive: browser.keepAlive ?? false, close: session.close, }; @@ -62,6 +64,7 @@ export async function resolveBrowserSource( const launched = await (dependencies.launchLocalBrowser ?? launchLocalBrowser)(launchOptions); return { cdpUrl: launched.cdpUrl, + autoAttach: false, keepAlive: launchOptions.keepAlive ?? false, close: launched.close, }; @@ -70,6 +73,7 @@ export async function resolveBrowserSource( return { cdpUrl: browser.cdpUrl, ...(browser.headers === undefined ? {} : { cdpHeaders: browser.headers }), + autoAttach: false, keepAlive: true, }; } @@ -92,7 +96,7 @@ async function launchLocalBrowser( ...(process.env.CI ? ["--no-sandbox"] : []), ], userDataDir: options.userDataDir, - port: options.port, + ...(options.port === undefined ? {} : { port: options.port }), logLevel: "silent", }); diff --git a/packages/sdk-ts/src/cdpClient.ts b/packages/sdk-ts/src/cdpClient.ts index d48ebf496..529aabc29 100644 --- a/packages/sdk-ts/src/cdpClient.ts +++ b/packages/sdk-ts/src/cdpClient.ts @@ -248,7 +248,7 @@ export class CDPClient { { name: STAGEHAND_SEND_TO_HOST_BINDING }, attached.sessionId, ); - await waitForRuntimeReady(client, attached.sessionId, { + await waitForRuntimeReceiver(client, attached.sessionId, { timeout: options.discoveryTimeoutMs, runtimeRequirement: options.runtimeRequirement, allowFallbackInstall: options.allowFallbackInstall, @@ -385,17 +385,27 @@ export class CDPClient { type CDPCommandSender = Pick; -export async function waitForRuntimeReady( +type RuntimeWaitOptions = { + timeout: number; + pollIntervalMs?: number; + delayFn?: (ms: number) => Promise; + nowFn?: () => number; + runtimeRequirement?: RuntimeRequirement; + allowFallbackInstall?: boolean; +}; + +export async function waitForRuntimeReceiver( cdp: CDPCommandSender, sessionId: string, - options: { - timeout: number; - pollIntervalMs?: number; - delayFn?: (ms: number) => Promise; - nowFn?: () => number; - runtimeRequirement?: RuntimeRequirement; - allowFallbackInstall?: boolean; - }, + options: RuntimeWaitOptions, +): Promise { + await waitForRuntime(cdp, sessionId, options); +} + +async function waitForRuntime( + cdp: CDPCommandSender, + sessionId: string, + options: RuntimeWaitOptions, ): Promise { const pollIntervalMs = options.pollIntervalMs ?? 100; const delayFn = options.delayFn ?? delay; @@ -438,7 +448,7 @@ export async function waitForRuntimeReady( } throw new Error( - `Timed out waiting for the Stagehand extension runtime to become ready${ + `Timed out waiting for the Stagehand extension runtime RPC receiver${ lastError ? ` (${lastError})` : "" }`, { cause: lastReadiness }, diff --git a/packages/sdk-ts/src/clientSchemas.ts b/packages/sdk-ts/src/clientSchemas.ts index 228217974..f413ba13d 100644 --- a/packages/sdk-ts/src/clientSchemas.ts +++ b/packages/sdk-ts/src/clientSchemas.ts @@ -116,14 +116,20 @@ export const StagehandClientExtractOptionsSchema = ExtractOptionsSchema.unwrap() .strict() .meta({ id: "StagehandClientExtractOptions" }); -export const StagehandClientInitParamsSchema = StagehandInitParamsSchema.extend({ - browser: BrowserSourceSchema.default({ type: "browserbase" }), - model: z.union([ModelConfigSchema, ClientLLMSchema]).optional(), - logging: StagehandClientLoggingConfigSchema.default({ - level: "info", - format: "pretty", - }), +export const StagehandClientInitParamsSchema = StagehandInitParamsSchema.omit({ + protocolVersion: true, + clientInfo: true, + browserCdpUrl: true, + logLevel: true, }) + .extend({ + browser: BrowserSourceSchema.default({ type: "browserbase" }), + model: z.union([ModelConfigSchema, ClientLLMSchema]).optional(), + logging: StagehandClientLoggingConfigSchema.default({ + level: "info", + format: "pretty", + }), + }) .strict() .superRefine((params, context) => { if (params.browser.type === "browserbase" && params.apiKey === undefined) { diff --git a/packages/sdk-ts/src/rpcClient.ts b/packages/sdk-ts/src/rpcClient.ts index a2a98adaa..66ca63012 100644 --- a/packages/sdk-ts/src/rpcClient.ts +++ b/packages/sdk-ts/src/rpcClient.ts @@ -30,15 +30,8 @@ import type { import { encodeWireValue, wireSchema } from "../../protocol/json-rpc/wire-casing.js"; import { StagehandNotifications, - StagehandMethods, StagehandRpcNotificationSchema, } from "../../protocol/schema-registry.js"; -import { - DEFAULT_TELEMETRY_CONFIG, - RuntimeConfigureParamsSchema, - STAGEHAND_PROTOCOL_VERSION, - TelemetryConfigSchema, -} from "../../protocol/schemas.js"; import type { StagehandRpcNotification } from "../../protocol/types.js"; import { z } from "zod/v4"; import { CDPClient, type ServiceWorkerInfo } from "./cdpClient.js"; @@ -58,11 +51,6 @@ type RegisteredRequestHandler = { const TRACER = trace.getTracer("@browserbasehq/stagehand"); const W3C_TRACE_CONTEXT_PROPAGATOR = new W3CTraceContextPropagator(); const MAX_PENDING_NOTIFICATIONS = 100; -const STAGEHAND_SDK_CLIENT_INFO = { - name: "stagehand-sdk-ts", - version: "4.0.0", -} as const; - const RPCClientOptionsBaseSchema = z .object({ cdpUrl: z.string().min(1), @@ -70,8 +58,6 @@ const RPCClientOptionsBaseSchema = z discoveryTimeoutMs: z.number().int().positive().optional(), commandTimeoutMs: z.number().int().positive().optional(), cdpConnectTimeoutMs: z.number().int().positive().optional(), - telemetry: TelemetryConfigSchema.default(DEFAULT_TELEMETRY_CONFIG), - logLevel: RuntimeConfigureParamsSchema.shape.logLevel, }) .strict(); @@ -97,6 +83,7 @@ export type RPCClientOptions = z.input; export type CDPTransport = { readonly serviceWorker: ServiceWorkerInfo; + readonly webSocketDebuggerUrl?: string; onmessage?: (message: unknown) => void | Promise; onclose?: (reason?: Error) => void; onerror?: (error: Error) => void; @@ -106,6 +93,7 @@ export type CDPTransport = { export class RPCClient { readonly serviceWorker: ServiceWorkerInfo; + readonly browserWebSocketDebuggerUrl?: string; nextRequestId = 1; pending = new Map(); requestHandlers = new Map(); @@ -119,6 +107,7 @@ export class RPCClient { this.cdp = cdp; this.requestTimeoutMs = requestTimeoutMs; this.serviceWorker = cdp.serviceWorker; + this.browserWebSocketDebuggerUrl = cdp.webSocketDebuggerUrl; this.cdp.onmessage = (message) => this.receive(message); this.cdp.onclose = (reason) => this.close(reason); this.cdp.onerror = (error) => this.close(error); @@ -415,19 +404,7 @@ export async function connectRPCClient(input: RPCClientOptions): Promise(); +const STAGEHAND_SDK_CLIENT_INFO = { + name: "stagehand-sdk-ts", + version: "4.0.0", +} as const; export class Stagehand { browserContext: BrowserContext | undefined; @@ -98,8 +102,6 @@ export class Stagehand { ? { preloadedExtension: true as const } : { extensionDir: STAGEHAND_EXTENSION_DIRECTORY_PATH }), serviceWorkerUrlIncludes: "service-worker.js", - telemetry: clientInitParams.telemetry, - logLevel: clientInitParams.logging.level, }); this.rpcClient = rpcClient; this.removeNotificationListener = rpcClient.onNotification((notification) => @@ -114,7 +116,7 @@ export class Stagehand { await rpcClient.send( StagehandMethods.stagehandInit, - stagehandInitParamsForWorker(clientInitParams, browser), + stagehandInitParamsForWorker(clientInitParams, browser, rpcClient), ); this.browserContext = new BrowserContext(rpcClient); } catch (error) { @@ -231,15 +233,27 @@ export class Stagehand { function stagehandInitParamsForWorker( initParams: ResolvedStagehandClientInitParams, resolvedBrowser: ResolvedBrowserSource, + rpcClient: RPCClient, ) { - const { browser, logging: _logging, model, ...protocolParams } = initParams; + const { browser, logging, model, ...protocolParams } = initParams; const protocolModel = model && "generate" in model ? { source: "client" as const } : model; if (browser.type === "browserbase" && !resolvedBrowser.browserbaseSessionId) { throw new Error("Resolved Browserbase source is missing its session ID"); } + if (!resolvedBrowser.autoAttach && !rpcClient.browserWebSocketDebuggerUrl) { + throw new Error("The browser CDP WebSocket URL is unavailable"); + } return StagehandInitParamsSchema.parse({ + protocolVersion: STAGEHAND_PROTOCOL_VERSION, + clientInfo: STAGEHAND_SDK_CLIENT_INFO, + logLevel: logging.level, + ...(resolvedBrowser.autoAttach + ? {} + : { + browserCdpUrl: rpcClient.browserWebSocketDebuggerUrl, + }), ...protocolParams, ...(browser.type === "browserbase" ? { @@ -264,6 +278,7 @@ export function createStagehandWithClientForTest(client: RPCClient): Stagehand { { resolveBrowserSource: async () => ({ cdpUrl: "test://stagehand", + autoAttach: false, keepAlive: true, }), connectRpcClient: async () => client, diff --git a/packages/sdk-ts/tests/browser-runtime/stagehand-launch-connect-smoke.test.ts b/packages/sdk-ts/tests/browser-runtime/stagehand-launch-connect-smoke.test.ts index 2cdfb4179..6d5612f01 100644 --- a/packages/sdk-ts/tests/browser-runtime/stagehand-launch-connect-smoke.test.ts +++ b/packages/sdk-ts/tests/browser-runtime/stagehand-launch-connect-smoke.test.ts @@ -15,10 +15,12 @@ describe("Stagehand TS SDK launch/connect smoke", () => { beforeAll(async () => { fixtureServer = await startFixtureServer(); + const browserPort = await availablePort(); stagehand = new Stagehand({ browser: { type: "local", headless: true, + port: browserPort, }, model: { generate: async (params): Promise => { @@ -478,6 +480,22 @@ describe("Stagehand TS SDK launch/connect smoke", () => { }); }); +async function availablePort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + if (!address || typeof address === "string") { + throw new Error("Failed to reserve a local Chrome debugging port"); + } + return address.port; +} + function requireStagehand(value: Stagehand | undefined): Stagehand { if (!value) { throw new Error("Stagehand was not initialized"); diff --git a/packages/sdk-ts/tests/browserSource.test.ts b/packages/sdk-ts/tests/browserSource.test.ts index e3a089869..ef45ea202 100644 --- a/packages/sdk-ts/tests/browserSource.test.ts +++ b/packages/sdk-ts/tests/browserSource.test.ts @@ -24,6 +24,7 @@ describe("resolveBrowserSource", () => { cdpUrl: "wss://connect.browserbase.com/devtools/browser/new-session", browserbaseSessionId: "new-session", preloadedExtension: true, + autoAttach: false, keepAlive: false, close, }); @@ -95,6 +96,7 @@ describe("resolveBrowserSource", () => { ), ).resolves.toStrictEqual({ cdpUrl: "http://127.0.0.1:9222", + autoAttach: false, keepAlive: true, close, }); @@ -104,6 +106,20 @@ describe("resolveBrowserSource", () => { }); }); + it("uses client configuration for a local browser launched on another port", async () => { + const launchLocalBrowser = vi.fn(async () => ({ + cdpUrl: "http://127.0.0.1:9333", + close: vi.fn(), + })); + + await expect( + resolveBrowserSource({ browser: { type: "local", port: 9333 } }, { launchLocalBrowser }), + ).resolves.toMatchObject({ + cdpUrl: "http://127.0.0.1:9333", + autoAttach: false, + }); + }); + it("connects to an existing CDP browser without owning its cleanup", async () => { await expect( resolveBrowserSource({ @@ -116,6 +132,7 @@ describe("resolveBrowserSource", () => { ).resolves.toStrictEqual({ cdpUrl: "wss://browser.example/devtools/browser/session", cdpHeaders: { Authorization: "Bearer secret" }, + autoAttach: false, keepAlive: true, }); }); diff --git a/packages/sdk-ts/tests/object-wrapper.test.ts b/packages/sdk-ts/tests/object-wrapper.test.ts index 912c0cc50..40bfb93c6 100644 --- a/packages/sdk-ts/tests/object-wrapper.test.ts +++ b/packages/sdk-ts/tests/object-wrapper.test.ts @@ -18,6 +18,7 @@ class FakeProtocolClient extends RPCClient { constructor() { super( { + webSocketDebuggerUrl: "test://stagehand", serviceWorker: { targetId: "worker-target", url: "chrome-extension://stagehand/service-worker.js", @@ -68,6 +69,10 @@ function requestCall( } const stagehandInitCall = requestCall(StagehandMethods.stagehandInit, { + protocolVersion: 4, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, + logLevel: "info", + browserCdpUrl: "test://stagehand", telemetry: { traces: { endpoint: "https://example.com/v1/traces", 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 45831b571..671a56bfc 100644 --- a/packages/sdk-ts/tests/stagehand-client-init-params.test.ts +++ b/packages/sdk-ts/tests/stagehand-client-init-params.test.ts @@ -265,6 +265,8 @@ describe("Stagehand client browser sources", () => { expect(() => StagehandInitParamsSchema.parse(clientInitParams)).toThrow(); expect( StagehandInitParamsSchema.parse({ + protocolVersion: 4, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, ...protocolParams, browser: { ...clientInitParams.browser, @@ -272,6 +274,9 @@ describe("Stagehand client browser sources", () => { }, }), ).toStrictEqual({ + protocolVersion: 4, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, + logLevel: "info", apiKey: "bb_key", browser: { type: "browserbase", @@ -312,11 +317,15 @@ describe("Stagehand client browser sources", () => { it("keeps local and CDP browser connection settings out of the worker schema", () => { expect(() => StagehandInitParamsSchema.parse({ + protocolVersion: 4, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, browser: { type: "local" }, }), ).toThrow(); expect(() => StagehandInitParamsSchema.parse({ + protocolVersion: 4, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, browser: { type: "cdp", cdpUrl: "wss://browser.example/devtools/browser/session", diff --git a/packages/sdk-ts/tests/stagehand.test.ts b/packages/sdk-ts/tests/stagehand.test.ts index c5c5ef538..3b67cfde2 100644 --- a/packages/sdk-ts/tests/stagehand.test.ts +++ b/packages/sdk-ts/tests/stagehand.test.ts @@ -6,7 +6,7 @@ import type { StagehandRpcNotification } from "../../protocol/types.js"; import { Stagehand } from "../src/index.js"; import type { ResolvedBrowserSource } from "../src/browserSource.js"; import { CDPConnectionClosedError } from "../src/cdpClient.js"; -import { RPCClient, type RPCClientOptions } from "../src/rpcClient.js"; +import { RPCClient } from "../src/rpcClient.js"; import { createStagehandWithDependenciesForTest } from "../src/stagehand.js"; type ProtocolCall = { method: string; params: unknown }; @@ -17,9 +17,10 @@ class FakeRPCClient extends RPCClient { responses = new Map(); notificationListeners = new Set<(notification: StagehandRpcNotification) => void>(); - constructor() { + constructor(webSocketDebuggerUrl = "ws://127.0.0.1:9222/devtools/browser/test") { super( { + webSocketDebuggerUrl, serviceWorker: { targetId: "worker-target", url: "chrome-extension://stagehand/service-worker.js", @@ -89,13 +90,14 @@ describe("Stagehand", () => { }); it("initializes through browser source resolution and RPC client connection", async () => { - const rpcClient = new FakeRPCClient(); + const rpcClient = new FakeRPCClient("ws://127.0.0.1:9222/devtools/browser/exact-session"); rpcClient.queueResponse(StagehandMethods.contextPages, [ { pageId: "page-1", url: "about:blank" }, ]); const resolveBrowserSource = vi.fn(async (): Promise => { return { cdpUrl: "http://127.0.0.1:9222", + autoAttach: false, keepAlive: true, }; }); @@ -141,19 +143,16 @@ describe("Stagehand", () => { cdpUrl: "http://127.0.0.1:9222", extensionDir: expect.stringContaining("packages/sdk-ts/dist/extension") as string, serviceWorkerUrlIncludes: "service-worker.js", - logLevel: "info", - telemetry: { - traces: { - endpoint: "https://example.com/v1/traces", - headers: {}, - }, - }, - } satisfies RPCClientOptions); + }); expect(pages[0]?.pageId).toBe("page-1"); expect(rpcClient.calls).toStrictEqual([ { method: "stagehand.init", params: { + protocolVersion: 4, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, + logLevel: "info", + browserCdpUrl: "ws://127.0.0.1:9222/devtools/browser/exact-session", apiKey: "bb_key", telemetry: { traces: { @@ -185,6 +184,7 @@ describe("Stagehand", () => { cdpUrl: "wss://connect.browserbase.com/devtools/browser/session", browserbaseSessionId: "session_123", preloadedExtension: true, + autoAttach: true, keepAlive: true, }), connectRpcClient, @@ -195,21 +195,17 @@ describe("Stagehand", () => { expect(connectRpcClient).toHaveBeenCalledWith({ cdpUrl: "wss://connect.browserbase.com/devtools/browser/session", - logLevel: "info", preloadedExtension: true, serviceWorkerUrlIncludes: "service-worker.js", - telemetry: { - traces: { - endpoint: "https://example.com/v1/traces", - headers: {}, - }, - }, - } satisfies RPCClientOptions); + }); expect(rpcClient.calls).toStrictEqual([ { method: "stagehand.init", params: { + protocolVersion: 4, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, + logLevel: "info", apiKey: "bb_key", browser: { type: "browserbase", @@ -248,6 +244,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", + autoAttach: false, keepAlive: true, }), connectRpcClient, @@ -259,15 +256,23 @@ describe("Stagehand", () => { expect(connectRpcClient).toHaveBeenCalledWith({ cdpUrl: "http://127.0.0.1:9222", extensionDir: expect.stringContaining("packages/sdk-ts/dist/extension") as string, - logLevel: "info", serviceWorkerUrlIncludes: "service-worker.js", - telemetry: { - traces: { - endpoint: "https://collector.example.com/v1/traces", - headers: { Authorization: "Bearer test" }, + }); + expect(rpcClient.calls[0]).toMatchObject({ + method: "stagehand.init", + params: { + protocolVersion: 4, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, + logLevel: "info", + browserCdpUrl: "ws://127.0.0.1:9222/devtools/browser/test", + telemetry: { + traces: { + endpoint: "https://collector.example.com/v1/traces", + headers: { Authorization: "Bearer test" }, + }, }, }, - } satisfies RPCClientOptions); + }); }); it("routes public runtime status and metrics methods through the protocol", async () => { @@ -312,6 +317,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", + autoAttach: false, keepAlive: true, }), connectRpcClient: async () => rpcClient, @@ -360,6 +366,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", + autoAttach: false, keepAlive: true, }), connectRpcClient: async () => rpcClient, @@ -373,6 +380,10 @@ describe("Stagehand", () => { { method: "stagehand.init", params: { + protocolVersion: 4, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, + logLevel: "info", + browserCdpUrl: "ws://127.0.0.1:9222/devtools/browser/test", model: { source: "client" }, telemetry: { traces: { @@ -396,6 +407,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", + autoAttach: true, keepAlive: false, close: closeBrowser, }), @@ -411,6 +423,9 @@ describe("Stagehand", () => { { method: "stagehand.init", params: { + protocolVersion: 4, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, + logLevel: "info", telemetry: { traces: { endpoint: "https://example.com/v1/traces", @@ -440,6 +455,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", + autoAttach: false, keepAlive: true, }), connectRpcClient: async () => rpcClient, @@ -516,6 +532,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", + autoAttach: false, keepAlive: true, }), connectRpcClient: async () => rpcClient, @@ -554,6 +571,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", + autoAttach: true, keepAlive: true, close: closeBrowser, }), @@ -577,6 +595,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", + autoAttach: true, keepAlive: false, close: closeBrowser, }), @@ -601,6 +620,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", + autoAttach: true, keepAlive: false, close: async () => { throw cleanupError; diff --git a/packages/server/controllers/runtimeController.ts b/packages/server/controllers/runtimeController.ts index 57b85f564..bf7c4f5fd 100644 --- a/packages/server/controllers/runtimeController.ts +++ b/packages/server/controllers/runtimeController.ts @@ -1,4 +1,4 @@ -import type { EmptyParams, RuntimeConfigureParams } from "../../protocol/types.js"; +import type { EmptyParams } from "../../protocol/types.js"; import type { HandlerContext } from "../rpcRouter.js"; import type { StagehandRuntime } from "../runtime.js"; @@ -11,13 +11,6 @@ export function createRuntimeController(runtime: StagehandRuntime) { }; } - async function configure(params: RuntimeConfigureParams, { logger }: HandlerContext) { - logger.setLevel(params.logLevel); - logger.debug("runtime.configure", {}); - runtime.tracing.configure(params.telemetry); - return runtime.configureLoopback(params); - } - async function loopbackStatus(_params: EmptyParams, { logger }: HandlerContext) { logger.debug("runtime.loopback_status", {}); return runtime.loopbackStatus(); @@ -25,7 +18,6 @@ export function createRuntimeController(runtime: StagehandRuntime) { return { ping, - configure, loopbackStatus, }; } diff --git a/packages/server/controllers/stagehandController.ts b/packages/server/controllers/stagehandController.ts index 543ee41a5..d42720239 100644 --- a/packages/server/controllers/stagehandController.ts +++ b/packages/server/controllers/stagehandController.ts @@ -3,6 +3,7 @@ import type { StagehandActParams, StagehandExtractParams, StagehandInitParams, + StagehandInitResult, StagehandObserveParams, } from "../../protocol/types.js"; import type { HandlerContext } from "../rpcRouter.js"; @@ -12,15 +13,27 @@ import * as cacheService from "../services/cacheService.js"; import * as extractService from "../services/extractService.js"; import * as observeService from "../services/observeService.js"; -export function createStagehandController(runtime: StagehandRuntime) { +export type StagehandControllerOptions = { + initialize?: (params: StagehandInitParams) => Promise; + close?: () => Promise; +}; + +export function createStagehandController( + runtime: StagehandRuntime, + options: StagehandControllerOptions = {}, +) { + const initialize = options.initialize ?? ((params) => runtime.initialize(params)); + const closeRuntime = options.close ?? (() => runtime.close()); + async function init(params: StagehandInitParams, { logger }: HandlerContext) { + logger.setLevel(params.logLevel); logger.info("stagehand.init", {}); - return await runtime.initialize(params); + return await initialize(params); } async function close(_params: EmptyParams, { logger }: HandlerContext) { logger.info("stagehand.close", {}); - await runtime.close(); + await closeRuntime(); return { closed: true as const }; } diff --git a/packages/server/rpcRouter.ts b/packages/server/rpcRouter.ts index eb9e8eefb..2a26abf43 100644 --- a/packages/server/rpcRouter.ts +++ b/packages/server/rpcRouter.ts @@ -10,7 +10,11 @@ import { W3CTraceContextPropagator } from "@opentelemetry/core"; import type { RPCMethod } from "../protocol/json-rpc/schemas.js"; import { wireSchema } from "../protocol/json-rpc/wire-casing.js"; import { StagehandMethods } from "../protocol/schema-registry.js"; -import type { StagehandRpcRequest } from "../protocol/types.js"; +import type { + StagehandInitParams, + StagehandInitResult, + StagehandRpcRequest, +} from "../protocol/types.js"; import { z } from "zod/v4"; import { createBrowserController } from "./controllers/browserController.js"; import { createContextController } from "./controllers/contextController.js"; @@ -27,6 +31,11 @@ export type HandlerContext = { logger: StagehandLogger; }; +export type RPCRouterOptions = { + initializeStagehand?: (params: StagehandInitParams) => Promise; + closeStagehand?: () => Promise; +}; + export class RPCRouter { readonly runtimeController; readonly browserController; @@ -35,10 +44,16 @@ export class RPCRouter { readonly pageController; readonly locatorController; - constructor(readonly runtime: StagehandRuntime) { + constructor( + readonly runtime: StagehandRuntime, + options: RPCRouterOptions = {}, + ) { this.runtimeController = createRuntimeController(runtime); this.browserController = createBrowserController(runtime); - this.stagehandController = createStagehandController(runtime); + this.stagehandController = createStagehandController(runtime, { + ...(options.initializeStagehand ? { initialize: options.initializeStagehand } : {}), + ...(options.closeStagehand ? { close: options.closeStagehand } : {}), + }); this.contextController = createContextController(runtime); this.pageController = createPageController(runtime); this.locatorController = createLocatorController(runtime); @@ -89,11 +104,6 @@ export class RPCRouter { parseParams(StagehandMethods.ping, request.params), context, ); - case "runtime.configure": - return this.runtimeController.configure( - parseParams(StagehandMethods.runtimeConfigure, request.params), - context, - ); case "runtime.loopback_status": return this.runtimeController.loopbackStatus( parseParams(StagehandMethods.runtimeLoopbackStatus, request.params), diff --git a/packages/server/runtime.ts b/packages/server/runtime.ts index ac8a6e0cd..7fe8dda4f 100644 --- a/packages/server/runtime.ts +++ b/packages/server/runtime.ts @@ -86,8 +86,6 @@ import type { PageWaitForSelectorParams, PageWaitForSelectorResult, PageWaitForTimeoutParams, - RuntimeConfigureParams, - RuntimeConfigureResult, RuntimeLoopbackStatusResult, StagehandInitParams, StagehandInitResult, @@ -199,6 +197,7 @@ export type UnderstudyRuntimeLocator = { export type StagehandBrowserSession = { readonly connected: boolean; + prepareForInitialization?(): Promise; getVersion(): Promise; pages(): UnderstudyRuntimePage[]; newPage(url?: string): Promise; @@ -272,8 +271,7 @@ export class StagehandRuntime { }; } - async configureLoopback(params: RuntimeConfigureParams): Promise { - this.logger.setLevel(params.logLevel); + async configureLoopback(params: { cdpUrl: string }): Promise { const { cdpUrl } = params; const previousSession = this.browserSession; this.browserSession = undefined; @@ -287,8 +285,6 @@ export class StagehandRuntime { this.browserSession = undefined; throw error; } - - return { configured: true }; } async initialize(params: StagehandInitParams): Promise { @@ -296,7 +292,16 @@ export class StagehandRuntime { throw new Error("Stagehand has already been initialized"); } + this.logger.setLevel(params.logLevel); + if (!this.browserSession) { + if (!params.browserCdpUrl) { + throw new Error("stagehand.init requires browserCdpUrl until resident mode is active"); + } + await this.configureLoopback({ cdpUrl: params.browserCdpUrl }); + } + await this.browserSession?.prepareForInitialization?.(); const pages = await this.contextPages(); + this.tracing.configure(params.telemetry); this.state.setState( StagehandRuntimeStateSchema.parse({ status: "initialized", diff --git a/packages/server/tests/client-llm-client.test.ts b/packages/server/tests/client-llm-client.test.ts index 9f44e21a7..5ebac435b 100644 --- a/packages/server/tests/client-llm-client.test.ts +++ b/packages/server/tests/client-llm-client.test.ts @@ -81,12 +81,11 @@ describe("client LLM generation", () => { await runtime.configureLoopback({ cdpUrl: "ws://browser.example", - logLevel: "info", - telemetry: { - traces: { endpoint: "https://collector.example.com/v1/traces", headers: {} }, - }, }); await runtime.initialize({ + protocolVersion: 4, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, + logLevel: "info", model: { source: "client" }, telemetry: { traces: { endpoint: "https://collector.example.com/v1/traces", headers: {} }, diff --git a/packages/server/tests/runtime-state.test.ts b/packages/server/tests/runtime-state.test.ts index 3775fd57d..b3ca75795 100644 --- a/packages/server/tests/runtime-state.test.ts +++ b/packages/server/tests/runtime-state.test.ts @@ -2,6 +2,12 @@ import { describe, expect, it, vi } from "vitest"; import type { StagehandBrowserSession } from "../runtime.js"; import { createStagehandRuntime } from "../runtime.js"; +const clientMetadata = { + protocolVersion: 4 as const, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, + logLevel: "info" as const, +}; + function createBrowserSession( overrides: Partial = {}, ): StagehandBrowserSession { @@ -39,15 +45,13 @@ describe("Stagehand runtime state", () => { const runtime = createStagehandRuntime({ browserSessionFactory: async () => createBrowserSession(), }); + const configureTracing = vi.spyOn(runtime.tracing, "configure").mockImplementation(() => {}); await runtime.configureLoopback({ cdpUrl: "ws://browser.example", - logLevel: "info", - telemetry: { - traces: { endpoint: "https://collector.example.com/v1/traces", headers: {} }, - }, }); await runtime.initialize({ + ...clientMetadata, model: { modelName: "openai/gpt-5" }, telemetry: { traces: { @@ -61,6 +65,7 @@ describe("Stagehand runtime state", () => { expect(runtime.state.getState()).toStrictEqual({ status: "initialized", initParams: { + ...clientMetadata, model: { modelName: "openai/gpt-5" }, telemetry: { traces: { @@ -71,6 +76,13 @@ describe("Stagehand runtime state", () => { selfHeal: true, }, }); + expect(configureTracing).toHaveBeenCalledOnce(); + expect(configureTracing).toHaveBeenCalledWith({ + traces: { + endpoint: "https://collector.example.com/v1/traces", + headers: { Authorization: "Bearer test" }, + }, + }); }); it("leaves server state unchanged when initialization fails", async () => { @@ -85,14 +97,11 @@ describe("Stagehand runtime state", () => { await runtime.configureLoopback({ cdpUrl: "ws://browser.example", - logLevel: "info", - telemetry: { - traces: { endpoint: "https://collector.example.com/v1/traces", headers: {} }, - }, }); await expect( runtime.initialize({ + ...clientMetadata, telemetry: { traces: { endpoint: "https://collector.example.com/v1/traces", headers: {} }, }, @@ -109,12 +118,9 @@ describe("Stagehand runtime state", () => { await runtime.configureLoopback({ cdpUrl: "ws://browser.example", - logLevel: "info", - telemetry: { - traces: { endpoint: "https://collector.example.com/v1/traces", headers: {} }, - }, }); await runtime.initialize({ + ...clientMetadata, model: { modelName: "openai/gpt-5", apiKey: "secret" }, telemetry: { traces: { endpoint: "https://collector.example.com/v1/traces", headers: {} }, diff --git a/packages/server/tests/stagehand-clients.test.ts b/packages/server/tests/stagehand-clients.test.ts index f5369a060..10a2bda16 100644 --- a/packages/server/tests/stagehand-clients.test.ts +++ b/packages/server/tests/stagehand-clients.test.ts @@ -549,10 +549,8 @@ async function createConfiguredHandler( await handle({ jsonrpc: "2.0", id: 1, - method: "runtime.configure", - params: { - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", - }, + method: "stagehand.init", + params: configuredInitParams("ws://127.0.0.1:9222/devtools/browser/session"), }); return handle; @@ -565,18 +563,19 @@ async function createConfiguredRuntime(session: FakeBrowserSession) { await runtime.configureLoopback({ cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", - logLevel: "info", - telemetry: { - traces: { - endpoint: "https://example.com/v1/traces", - headers: {}, - }, - }, }); return runtime; } +function configuredInitParams(cdpUrl: string) { + return { + protocol_version: 4, + client_info: { name: "stagehand-sdk-ts", version: "4.0.0" }, + browser_cdp_url: cdpUrl, + }; +} + describe("Stagehand worker clients", () => { it("accepts only the shared Stagehand Chrome binding name", () => { expect(StagehandSendToHostBindingSchema.parse(STAGEHAND_SEND_TO_HOST_BINDING)).toBe( @@ -732,7 +731,7 @@ describe("Stagehand worker clients", () => { }); }); - it("configures the browser session and reports connected status", async () => { + it("configures the browser session during stagehand.init and reports connected status", async () => { const sessions: FakeBrowserSession[] = []; const handle = createHandle({ browserSessionFactory: async () => { @@ -746,16 +745,15 @@ describe("Stagehand worker clients", () => { handle({ jsonrpc: "2.0", id: 1, - method: "runtime.configure", - params: { - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", - }, + method: "stagehand.init", + params: configuredInitParams("ws://127.0.0.1:9222/devtools/browser/session"), }), ).resolves.toStrictEqual({ jsonrpc: "2.0", id: 1, result: { - configured: true, + initialized: true, + pages: [], }, }); @@ -778,7 +776,7 @@ describe("Stagehand worker clients", () => { }); }); - it("closes the previous browser session when reconfigured", async () => { + it("rejects a second stagehand.init without replacing the browser session", async () => { const sessions: FakeBrowserSession[] = []; const handle = createHandle({ browserSessionFactory: async () => { @@ -791,23 +789,20 @@ describe("Stagehand worker clients", () => { await handle({ jsonrpc: "2.0", id: 1, - method: "runtime.configure", - params: { - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/first", - }, - }); - await handle({ - jsonrpc: "2.0", - id: 2, - method: "runtime.configure", - params: { - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/second", - }, + method: "stagehand.init", + params: configuredInitParams("ws://127.0.0.1:9222/devtools/browser/first"), }); + await expect( + handle({ + jsonrpc: "2.0", + id: 2, + method: "stagehand.init", + params: configuredInitParams("ws://127.0.0.1:9222/devtools/browser/second"), + }), + ).resolves.toMatchObject({ error: { message: "Stagehand has already been initialized" } }); - expect(sessions).toHaveLength(2); - expect(sessions[0]?.closed).toBe(true); - expect(sessions[1]?.closed).toBe(false); + expect(sessions).toHaveLength(1); + expect(sessions[0]?.closed).toBe(false); }); it("closes the browser session on stagehand.close", async () => { @@ -819,10 +814,8 @@ describe("Stagehand worker clients", () => { await handle({ jsonrpc: "2.0", id: 1, - method: "runtime.configure", - params: { - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", - }, + method: "stagehand.init", + params: configuredInitParams("ws://127.0.0.1:9222/devtools/browser/session"), }); await expect( @@ -874,10 +867,8 @@ describe("Stagehand worker clients", () => { await handle({ jsonrpc: "2.0", id: 1, - method: "runtime.configure", - params: { - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", - }, + method: "stagehand.init", + params: configuredInitParams("ws://127.0.0.1:9222/devtools/browser/session"), }); await expect( @@ -935,10 +926,8 @@ describe("Stagehand worker clients", () => { await handle({ jsonrpc: "2.0", id: 1, - method: "runtime.configure", - params: { - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", - }, + method: "stagehand.init", + params: configuredInitParams("ws://127.0.0.1:9222/devtools/browser/session"), }); await expect( @@ -973,10 +962,8 @@ describe("Stagehand worker clients", () => { await handle({ jsonrpc: "2.0", id: 1, - method: "runtime.configure", - params: { - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", - }, + method: "stagehand.init", + params: configuredInitParams("ws://127.0.0.1:9222/devtools/browser/session"), }); await expect( From 30a7788ef185a838d28c618887c554217d086aa4 Mon Sep 17 00:00:00 2001 From: miguel Date: Sun, 26 Jul 2026 23:25:14 -0700 Subject: [PATCH 2/6] Cover init handoff contracts --- packages/sdk-python/tests/_support.py | 2 +- packages/sdk-python/tests/test_stagehand.py | 72 +++++++++++++++++++ .../stagehand-launch-connect-smoke.test.ts | 18 ----- packages/server/tests/rpc-router.test.ts | 39 +++++++++- .../server/tests/stagehand-clients.test.ts | 6 ++ 5 files changed, 117 insertions(+), 20 deletions(-) diff --git a/packages/sdk-python/tests/_support.py b/packages/sdk-python/tests/_support.py index c62b5b65a..e4c63f5f8 100644 --- a/packages/sdk-python/tests/_support.py +++ b/packages/sdk-python/tests/_support.py @@ -10,7 +10,7 @@ class RecordingRPCClient: - browser_web_socket_debugger_url = "ws://resolved.example/devtools/browser/1" + browser_web_socket_debugger_url: str | None = "ws://resolved.example/devtools/browser/1" def __init__(self, responses: dict[str, object] | None = None) -> None: self.responses = responses or {} diff --git a/packages/sdk-python/tests/test_stagehand.py b/packages/sdk-python/tests/test_stagehand.py index 15155563d..faedce587 100644 --- a/packages/sdk-python/tests/test_stagehand.py +++ b/packages/sdk-python/tests/test_stagehand.py @@ -36,6 +36,7 @@ StagehandMetrics, StagehandObserveParams, StagehandPingResult, + TelemetryConfig, ) from stagehand.browser_source import ResolvedBrowserSource from stagehand.cdp_client import CDPConnectionClosedError @@ -139,6 +140,77 @@ async def connect(**_: object) -> RPCClient: ] +@pytest.mark.asyncio +async def test_stagehand_forwards_client_initialization_options_and_resolved_cdp_url( + monkeypatch: pytest.MonkeyPatch, +) -> None: + recording = RecordingRPCClient({ + "stagehand.init": StagehandInitResult(initialized=True, pages=[]), + }) + + async def resolve(_: StagehandClientInitParams) -> ResolvedBrowserSource: + return ResolvedBrowserSource(cdp_url="test://browser", keep_alive=True) + + async def connect(**_: object) -> RPCClient: + return cast(RPCClient, recording) + + monkeypatch.setattr(stagehand_module, "resolve_browser_source", resolve) + monkeypatch.setattr(stagehand_module, "connect_rpc_client", connect) + stagehand = Stagehand( + browser="cdp", + cdp_url="test://browser", + telemetry=TelemetryConfig.model_validate({ + "traces": { + "endpoint": "https://telemetry.example/v1/traces", + "headers": {"authorization": "secret"}, + } + }), + system_prompt="Use the test policy", + self_heal=True, + dom_settle_timeout_ms=2_500, + cache=CacheOptions(threshold=3), + ) + + await stagehand.init() + + init_params = cast(StagehandInitParams, recording.calls[0][1]) + assert init_params.browser_cdp_url == recording.browser_web_socket_debugger_url + assert init_params.telemetry.traces.endpoint == "https://telemetry.example/v1/traces" + assert init_params.telemetry.traces.headers == {"authorization": "secret"} + assert init_params.system_prompt == "Use the test policy" + assert init_params.self_heal is True + assert init_params.dom_settle_timeout_ms == 2_500 + assert init_params.cache is not None + cache = init_params.cache.root + assert not isinstance(cache, bool) + assert cache.threshold == 3 + + +@pytest.mark.asyncio +async def test_stagehand_rejects_missing_resolved_cdp_url_before_init( + monkeypatch: pytest.MonkeyPatch, +) -> None: + recording = RecordingRPCClient({ + "stagehand.init": StagehandInitResult(initialized=True, pages=[]), + }) + recording.browser_web_socket_debugger_url = None + + async def resolve(_: StagehandClientInitParams) -> ResolvedBrowserSource: + return ResolvedBrowserSource(cdp_url="test://browser", keep_alive=True) + + async def connect(**_: object) -> RPCClient: + return cast(RPCClient, recording) + + monkeypatch.setattr(stagehand_module, "resolve_browser_source", resolve) + monkeypatch.setattr(stagehand_module, "connect_rpc_client", connect) + stagehand = Stagehand(browser="cdp", cdp_url="test://browser") + + with pytest.raises(RuntimeError, match="CDP WebSocket URL is unavailable"): + await stagehand.init() + + assert recording.calls == [] + + @pytest.mark.asyncio async def test_stagehand_writes_one_json_object_and_calls_on_log_with_the_structured_event( monkeypatch: pytest.MonkeyPatch, diff --git a/packages/sdk-ts/tests/browser-runtime/stagehand-launch-connect-smoke.test.ts b/packages/sdk-ts/tests/browser-runtime/stagehand-launch-connect-smoke.test.ts index 6d5612f01..2cdfb4179 100644 --- a/packages/sdk-ts/tests/browser-runtime/stagehand-launch-connect-smoke.test.ts +++ b/packages/sdk-ts/tests/browser-runtime/stagehand-launch-connect-smoke.test.ts @@ -15,12 +15,10 @@ describe("Stagehand TS SDK launch/connect smoke", () => { beforeAll(async () => { fixtureServer = await startFixtureServer(); - const browserPort = await availablePort(); stagehand = new Stagehand({ browser: { type: "local", headless: true, - port: browserPort, }, model: { generate: async (params): Promise => { @@ -480,22 +478,6 @@ describe("Stagehand TS SDK launch/connect smoke", () => { }); }); -async function availablePort(): Promise { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); - const address = server.address(); - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - if (!address || typeof address === "string") { - throw new Error("Failed to reserve a local Chrome debugging port"); - } - return address.port; -} - function requireStagehand(value: Stagehand | undefined): Stagehand { if (!value) { throw new Error("Stagehand was not initialized"); diff --git a/packages/server/tests/rpc-router.test.ts b/packages/server/tests/rpc-router.test.ts index 028aa8113..3c3c5b660 100644 --- a/packages/server/tests/rpc-router.test.ts +++ b/packages/server/tests/rpc-router.test.ts @@ -4,7 +4,7 @@ import { SimpleSpanProcessor, type SpanProcessor, } from "@opentelemetry/sdk-trace-web"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { StagehandRpcRequestSchema } from "../../protocol/schema-registry.ts"; import { createStagehandRuntime } from "../runtime.ts"; import { RPCRouter } from "../rpcRouter.ts"; @@ -137,6 +137,43 @@ describe("Stagehand RPC router", () => { expect(logSpan?.parentSpanContext?.spanId).toBe(requestSpan?.spanContext().spanId); await tracing.shutdown(); }); + + it("applies the init log level before logging and delegates lifecycle overrides", async () => { + const tracing = configuredTracing(createStagehandTracingRuntime({ registerGlobals: false })); + const logs: string[] = []; + const initializeStagehand = vi.fn(async () => ({ initialized: true as const, pages: [] })); + const closeStagehand = vi.fn(async () => {}); + const runtime = createStagehandRuntime( + { + emitLog: (log) => logs.push(log.message), + }, + tracing, + ); + const router = new RPCRouter(runtime, { initializeStagehand, closeStagehand }); + const initRequest = request({ + id: 15, + method: "stagehand.init", + params: { + protocol_version: 4, + client_info: { name: "stagehand-sdk-ts", version: "4.0.0" }, + browser_cdp_url: "ws://127.0.0.1:9222/devtools/browser/session", + log_level: "off", + }, + }); + + await expect(router.handle(initRequest)).resolves.toStrictEqual({ + initialized: true, + pages: [], + }); + expect(logs).not.toContain("stagehand.init"); + expect(initializeStagehand).toHaveBeenCalledOnce(); + expect(initializeStagehand).toHaveBeenCalledWith(initRequest.params); + + await expect( + router.handle(request({ id: 16, method: "stagehand.close", params: {} })), + ).resolves.toStrictEqual({ closed: true }); + expect(closeStagehand).toHaveBeenCalledOnce(); + }); }); function createRouter(tracing: StagehandTracing): RPCRouter { diff --git a/packages/server/tests/stagehand-clients.test.ts b/packages/server/tests/stagehand-clients.test.ts index 10a2bda16..80b784af9 100644 --- a/packages/server/tests/stagehand-clients.test.ts +++ b/packages/server/tests/stagehand-clients.test.ts @@ -99,6 +99,7 @@ class FakeBrowserSession implements StagehandBrowserSession { closed = false; connected = true; getVersionCalls = 0; + prepareForInitializationCalls = 0; readonly pageRefs: FakeUnderstudyRuntimePage[]; activePageRef: UnderstudyRuntimePage | undefined; readonly setActivePageCalls: UnderstudyRuntimePage[] = []; @@ -131,6 +132,10 @@ class FakeBrowserSession implements StagehandBrowserSession { return this.version; } + async prepareForInitialization(): Promise { + this.prepareForInitializationCalls += 1; + } + pages(): UnderstudyRuntimePage[] { return this.pageRefs; } @@ -758,6 +763,7 @@ describe("Stagehand worker clients", () => { }); expect(sessions).toHaveLength(1); + expect(sessions[0]?.prepareForInitializationCalls).toBe(1); await expect( handle({ From 82b87653628f3b992eaccdcc7d68d5e5f723ffc3 Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 27 Jul 2026 16:07:24 -0700 Subject: [PATCH 3/6] Remove public browser connection status RPC --- packages/docs/v4/reference/stagehand.mdx | 39 ------------- packages/protocol/schema-registry.ts | 6 -- packages/protocol/schemas.ts | 8 --- packages/protocol/stagehand.v4.json | 54 ------------------ .../tests/protocol/loopback-protocol.test.ts | 13 ----- packages/protocol/types.ts | 2 - packages/sdk-python/src/stagehand/__init__.py | 2 - .../src/stagehand/_generated/models.py | 9 --- .../src/stagehand/browser_source.py | 4 +- .../sdk-python/src/stagehand/stagehand.py | 12 +--- packages/sdk-python/tests/test_stagehand.py | 10 ---- packages/sdk-ts/src/browserSource.ts | 8 +-- packages/sdk-ts/src/index.ts | 1 - packages/sdk-ts/src/stagehand.ts | 11 +--- packages/sdk-ts/tests/browserSource.test.ts | 8 +-- packages/sdk-ts/tests/stagehand.test.ts | 33 ++++------- .../server/controllers/runtimeController.ts | 6 -- packages/server/rpcRouter.ts | 5 -- packages/server/runtime.ts | 7 +-- .../server/tests/client-llm-client.test.ts | 2 +- packages/server/tests/runtime-state.test.ts | 6 +- .../server/tests/stagehand-clients.test.ts | 56 +------------------ 22 files changed, 36 insertions(+), 266 deletions(-) diff --git a/packages/docs/v4/reference/stagehand.mdx b/packages/docs/v4/reference/stagehand.mdx index b4408f9ad..48bca0858 100644 --- a/packages/docs/v4/reference/stagehand.mdx +++ b/packages/docs/v4/reference/stagehand.mdx @@ -61,26 +61,6 @@ const status = await stagehand.ping(); -## runtimeLoopbackStatus() - -Inspect the runtime loopback connection status. - -```typescript -const status = await stagehand.runtimeLoopbackStatus(); -``` - - - The operation result. - - - Whether loopback is configured. - - - - Whether loopback is connected. - - - ## browserGetVersion() Read version information from the connected browser. @@ -544,25 +524,6 @@ status = await stagehand.ping() -## runtime_loopback_status() - -Inspect the runtime loopback connection status. - -```python -status = await stagehand.runtime_loopback_status() -``` - - - The operation result. - - - Whether loopback is configured. - - - - Whether loopback is connected. - - ## browser_get_version() diff --git a/packages/protocol/schema-registry.ts b/packages/protocol/schema-registry.ts index 4b4ee9a32..7be939377 100644 --- a/packages/protocol/schema-registry.ts +++ b/packages/protocol/schema-registry.ts @@ -89,7 +89,6 @@ import { PageWaitForSelectorParamsSchema, PageWaitForSelectorResultSchema, PageWaitForTimeoutParamsSchema, - RuntimeLoopbackStatusResultSchema, StagehandActParamsSchema, StagehandCloseResultSchema, StagehandExtractParamsSchema, @@ -109,11 +108,6 @@ export const StagehandSendToHostBindingSchema = z export const StagehandMethods = { ping: { name: "ping", params: EmptyParamsSchema, result: StagehandPingResultSchema }, - runtimeLoopbackStatus: { - name: "runtime.loopback_status", - params: EmptyParamsSchema, - result: RuntimeLoopbackStatusResultSchema, - }, browserGetVersion: { name: "browser.get_version", params: EmptyParamsSchema, diff --git a/packages/protocol/schemas.ts b/packages/protocol/schemas.ts index 68290bc80..cb7d48f40 100644 --- a/packages/protocol/schemas.ts +++ b/packages/protocol/schemas.ts @@ -1856,14 +1856,6 @@ export const StagehandPingResultSchema = z .strict() .meta({ id: "StagehandPingResult" }); -export const RuntimeLoopbackStatusResultSchema = z - .object({ - configured: z.boolean(), - connected: z.boolean(), - }) - .strict() - .meta({ id: "RuntimeLoopbackStatusResult" }); - export const BrowserGetVersionResultSchema = z .object({ protocolVersion: z.string().optional(), diff --git a/packages/protocol/stagehand.v4.json b/packages/protocol/stagehand.v4.json index e71a6f1aa..e44432f3b 100644 --- a/packages/protocol/stagehand.v4.json +++ b/packages/protocol/stagehand.v4.json @@ -19,19 +19,6 @@ "required": ["params", "result"], "additionalProperties": false }, - "runtime.loopback_status": { - "type": "object", - "properties": { - "params": { - "$ref": "#/$defs/EmptyParams" - }, - "result": { - "$ref": "#/$defs/RuntimeLoopbackStatusResult" - } - }, - "required": ["params", "result"], - "additionalProperties": false - }, "browser.get_version": { "type": "object", "properties": { @@ -867,7 +854,6 @@ }, "required": [ "ping", - "runtime.loopback_status", "browser.get_version", "stagehand.init", "stagehand.close", @@ -996,19 +982,6 @@ "required": ["ok", "runtime"], "additionalProperties": false }, - "RuntimeLoopbackStatusResult": { - "type": "object", - "properties": { - "configured": { - "type": "boolean" - }, - "connected": { - "type": "boolean" - } - }, - "required": ["configured", "connected"], - "additionalProperties": false - }, "BrowserGetVersionResult": { "type": "object", "properties": { @@ -4354,33 +4327,6 @@ "required": ["jsonrpc", "id", "method", "params"], "additionalProperties": false }, - { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "const": "2.0" - }, - "id": { - "$ref": "#/$defs/JSONRPCRequestId" - }, - "method": { - "type": "string", - "const": "runtime.loopback_status" - }, - "params": { - "$ref": "#/$defs/EmptyParams" - }, - "traceparent": { - "type": "string" - }, - "tracestate": { - "type": "string" - } - }, - "required": ["jsonrpc", "id", "method", "params"], - "additionalProperties": false - }, { "type": "object", "properties": { diff --git a/packages/protocol/tests/protocol/loopback-protocol.test.ts b/packages/protocol/tests/protocol/loopback-protocol.test.ts index 9f96282bf..d9f83c919 100644 --- a/packages/protocol/tests/protocol/loopback-protocol.test.ts +++ b/packages/protocol/tests/protocol/loopback-protocol.test.ts @@ -16,19 +16,6 @@ describe("Stagehand loopback protocol", () => { ).toThrow(); }); - it("defines runtime.loopback_status as a JSON-RPC method", () => { - expect(StagehandMethods.runtimeLoopbackStatus.params.parse({})).toStrictEqual({}); - expect( - StagehandMethods.runtimeLoopbackStatus.result.parse({ - configured: true, - connected: false, - }), - ).toStrictEqual({ - configured: true, - connected: false, - }); - }); - it("defines browser.get_version as a JSON-RPC method", () => { expect(StagehandMethods.browserGetVersion.params.parse({})).toStrictEqual({}); expect( diff --git a/packages/protocol/types.ts b/packages/protocol/types.ts index f8e97804e..cc0062e9b 100644 --- a/packages/protocol/types.ts +++ b/packages/protocol/types.ts @@ -170,7 +170,6 @@ import type { PageWaitForTimeoutParamsSchema, ProxyConfigSchema, RuntimeDescriptorSchema, - RuntimeLoopbackStatusResultSchema, RgbaColorSchema, StagehandActParamsSchema, StagehandCloseResultSchema, @@ -321,7 +320,6 @@ export type LocatorSendClickEventParams = z.infer; export type LocatorSelectOptionParams = z.infer; export type StagehandPingResult = z.infer; -export type RuntimeLoopbackStatusResult = z.infer; export type BrowserGetVersionResult = z.infer; export type StagehandInitResult = z.infer; export type StagehandCloseResult = z.infer; diff --git a/packages/sdk-python/src/stagehand/__init__.py b/packages/sdk-python/src/stagehand/__init__.py index 8a33047e8..4cc7e414b 100644 --- a/packages/sdk-python/src/stagehand/__init__.py +++ b/packages/sdk-python/src/stagehand/__init__.py @@ -25,7 +25,6 @@ MouseButton, PageScreenshotClip, RgbaColor, - RuntimeLoopbackStatusResult, Scale, StagehandMetrics, StagehandPingResult, @@ -86,7 +85,6 @@ "PageScreenshotClip", "ProtocolLocator", "RgbaColor", - "RuntimeLoopbackStatusResult", "Scale", "ScreenshotType", "Stagehand", diff --git a/packages/sdk-python/src/stagehand/_generated/models.py b/packages/sdk-python/src/stagehand/_generated/models.py index c35abc87d..fbe3b99a2 100644 --- a/packages/sdk-python/src/stagehand/_generated/models.py +++ b/packages/sdk-python/src/stagehand/_generated/models.py @@ -1815,15 +1815,6 @@ class RgbaColor(WireModel): a: Optional[StrictFloat] = None -class RuntimeLoopbackStatusResult(WireModel): - model_config = ConfigDict( - extra="forbid", - validate_by_name=True, - ) - configured: StrictBool - connected: StrictBool - - class SameSite(StrEnum): strict = "Strict" lax = "Lax" diff --git a/packages/sdk-python/src/stagehand/browser_source.py b/packages/sdk-python/src/stagehand/browser_source.py index b8c7b5f3b..018e862ee 100644 --- a/packages/sdk-python/src/stagehand/browser_source.py +++ b/packages/sdk-python/src/stagehand/browser_source.py @@ -46,7 +46,7 @@ class ResolvedBrowserSource: cdp_url: str keep_alive: bool - auto_attach: bool = False + resident_browser_connection: bool = False cdp_headers: dict[str, str] | None = None connect_timeout_ms: int | None = None _close_callback: Callable[[], Awaitable[None]] | None = field(default=None, repr=False) @@ -162,7 +162,7 @@ async def close() -> None: return ResolvedBrowserSource( cdp_url=f"http://127.0.0.1:{port}", keep_alive=options.keep_alive or False, - auto_attach=False, + resident_browser_connection=False, connect_timeout_ms=options.connect_timeout_ms, _close_callback=close, ) diff --git a/packages/sdk-python/src/stagehand/stagehand.py b/packages/sdk-python/src/stagehand/stagehand.py index bbef82587..86601fcec 100644 --- a/packages/sdk-python/src/stagehand/stagehand.py +++ b/packages/sdk-python/src/stagehand/stagehand.py @@ -34,7 +34,6 @@ ObserveOptions, ObserveResult, ProxyConfig, - RuntimeLoopbackStatusResult, StagehandActParams, StagehandCloseResult, StagehandExtractParams, @@ -392,13 +391,6 @@ async def ping(self) -> StagehandPingResult: StagehandPingResult, ) - async def runtime_loopback_status(self) -> RuntimeLoopbackStatusResult: - return await self._connected_rpc_client.send( - "runtime.loopback_status", - EmptyParams(), - RuntimeLoopbackStatusResult, - ) - async def browser_get_version(self) -> BrowserGetVersionResult: return await self._connected_rpc_client.send( "browser.get_version", @@ -451,11 +443,11 @@ async def generate(params: LLMGenerateParams) -> LLMGenerateResult: ) browser_cdp_url = rpc_client.browser_web_socket_debugger_url - if not browser.auto_attach and browser_cdp_url is None: + if not browser.resident_browser_connection and browser_cdp_url is None: raise RuntimeError("The browser CDP WebSocket URL is unavailable") await rpc_client.send( "stagehand.init", - self._worker_init_params(None if browser.auto_attach else browser_cdp_url), + self._worker_init_params(None if browser.resident_browser_connection else browser_cdp_url), StagehandInitResult, ) self._browser_context = BrowserContext(rpc_client) diff --git a/packages/sdk-python/tests/test_stagehand.py b/packages/sdk-python/tests/test_stagehand.py index faedce587..8a888600b 100644 --- a/packages/sdk-python/tests/test_stagehand.py +++ b/packages/sdk-python/tests/test_stagehand.py @@ -26,7 +26,6 @@ ModelConfig, ObserveResult, PageRef, - RuntimeLoopbackStatusResult, StagehandActParams, StagehandCloseResult, StagehandExtractParams, @@ -267,10 +266,6 @@ async def test_stagehand_routes_public_runtime_status_and_metrics_methods( }) recording = RecordingRPCClient({ "ping": StagehandPingResult(ok=True, runtime="service_worker"), - "runtime.loopback_status": RuntimeLoopbackStatusResult( - configured=True, - connected=True, - ), "browser.get_version": BrowserGetVersionResult( protocol_version="1.3", product="Chrome/1", @@ -292,10 +287,6 @@ async def connect(**_: object) -> RPCClient: assert stagehand.browser.cdp_url == "test://browser" assert await stagehand.ping() == StagehandPingResult(ok=True, runtime="service_worker") - assert await stagehand.runtime_loopback_status() == RuntimeLoopbackStatusResult( - configured=True, - connected=True, - ) assert await stagehand.browser_get_version() == BrowserGetVersionResult( protocol_version="1.3", product="Chrome/1", @@ -303,7 +294,6 @@ async def connect(**_: object) -> RPCClient: assert await stagehand.metrics() == metrics assert [method for method, _, _ in recording.calls[1:]] == [ "ping", - "runtime.loopback_status", "browser.get_version", "stagehand.metrics", ] diff --git a/packages/sdk-ts/src/browserSource.ts b/packages/sdk-ts/src/browserSource.ts index 819156c87..e21691b6a 100644 --- a/packages/sdk-ts/src/browserSource.ts +++ b/packages/sdk-ts/src/browserSource.ts @@ -19,7 +19,7 @@ export type ResolvedBrowserSource = { cdpHeaders?: Record; browserbaseSessionId?: string; preloadedExtension?: boolean; - autoAttach: boolean; + residentBrowserConnection: boolean; keepAlive: boolean; close?: () => Promise | void; }; @@ -55,7 +55,7 @@ export async function resolveBrowserSource( cdpUrl: session.cdpUrl, browserbaseSessionId: session.sessionId, preloadedExtension: true, - autoAttach: false, + residentBrowserConnection: false, keepAlive: browser.keepAlive ?? false, close: session.close, }; @@ -66,7 +66,7 @@ export async function resolveBrowserSource( const launched = await (dependencies.launchLocalBrowser ?? launchLocalBrowser)(launchOptions); return { cdpUrl: launched.cdpUrl, - autoAttach: false, + residentBrowserConnection: false, keepAlive: launchOptions.keepAlive ?? false, close: launched.close, }; @@ -75,7 +75,7 @@ export async function resolveBrowserSource( return { cdpUrl: browser.cdpUrl, ...(browser.headers === undefined ? {} : { cdpHeaders: browser.headers }), - autoAttach: false, + residentBrowserConnection: false, keepAlive: true, }; } diff --git a/packages/sdk-ts/src/index.ts b/packages/sdk-ts/src/index.ts index e80fcd7a9..ce639b2b3 100644 --- a/packages/sdk-ts/src/index.ts +++ b/packages/sdk-ts/src/index.ts @@ -16,7 +16,6 @@ export type { InitScriptSource } from "./pageScripts.js"; export { Stagehand } from "./stagehand.js"; export type { BrowserGetVersionResult, - RuntimeLoopbackStatusResult, StagehandMetrics, StagehandPingResult, } from "../../protocol/types.js"; diff --git a/packages/sdk-ts/src/stagehand.ts b/packages/sdk-ts/src/stagehand.ts index 1a02d0902..8e9ef0a74 100644 --- a/packages/sdk-ts/src/stagehand.ts +++ b/packages/sdk-ts/src/stagehand.ts @@ -5,7 +5,6 @@ import type { ActResultData, Action, BrowserGetVersionResult, - RuntimeLoopbackStatusResult, StagehandMetrics, StagehandPingResult, StagehandRpcNotification, @@ -73,10 +72,6 @@ export class Stagehand { return this.connectedRpcClient.send(StagehandMethods.ping, {}); } - async runtimeLoopbackStatus(): Promise { - return this.connectedRpcClient.send(StagehandMethods.runtimeLoopbackStatus, {}); - } - async browserGetVersion(): Promise { return this.connectedRpcClient.send(StagehandMethods.browserGetVersion, {}); } @@ -242,7 +237,7 @@ function stagehandInitParamsForWorker( if (browser.type === "browserbase" && !resolvedBrowser.browserbaseSessionId) { throw new Error("Resolved Browserbase source is missing its session ID"); } - if (!resolvedBrowser.autoAttach && !rpcClient.browserWebSocketDebuggerUrl) { + if (!resolvedBrowser.residentBrowserConnection && !rpcClient.browserWebSocketDebuggerUrl) { throw new Error("The browser CDP WebSocket URL is unavailable"); } @@ -250,7 +245,7 @@ function stagehandInitParamsForWorker( protocolVersion: STAGEHAND_PROTOCOL_VERSION, clientInfo: STAGEHAND_SDK_CLIENT_INFO, logLevel: logging.level, - ...(resolvedBrowser.autoAttach + ...(resolvedBrowser.residentBrowserConnection ? {} : { browserCdpUrl: rpcClient.browserWebSocketDebuggerUrl, @@ -279,7 +274,7 @@ export function createStagehandWithClientForTest(client: RPCClient): Stagehand { { resolveBrowserSource: async () => ({ cdpUrl: "test://stagehand", - autoAttach: false, + residentBrowserConnection: false, keepAlive: true, }), connectRpcClient: async () => client, diff --git a/packages/sdk-ts/tests/browserSource.test.ts b/packages/sdk-ts/tests/browserSource.test.ts index ef45ea202..e8b35134d 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, - autoAttach: false, + residentBrowserConnection: false, keepAlive: false, close, }); @@ -96,7 +96,7 @@ describe("resolveBrowserSource", () => { ), ).resolves.toStrictEqual({ cdpUrl: "http://127.0.0.1:9222", - autoAttach: false, + residentBrowserConnection: false, keepAlive: true, close, }); @@ -116,7 +116,7 @@ describe("resolveBrowserSource", () => { resolveBrowserSource({ browser: { type: "local", port: 9333 } }, { launchLocalBrowser }), ).resolves.toMatchObject({ cdpUrl: "http://127.0.0.1:9333", - autoAttach: false, + residentBrowserConnection: false, }); }); @@ -132,7 +132,7 @@ describe("resolveBrowserSource", () => { ).resolves.toStrictEqual({ cdpUrl: "wss://browser.example/devtools/browser/session", cdpHeaders: { Authorization: "Bearer secret" }, - autoAttach: false, + residentBrowserConnection: false, keepAlive: true, }); }); diff --git a/packages/sdk-ts/tests/stagehand.test.ts b/packages/sdk-ts/tests/stagehand.test.ts index 32a249c89..a4cfdd185 100644 --- a/packages/sdk-ts/tests/stagehand.test.ts +++ b/packages/sdk-ts/tests/stagehand.test.ts @@ -99,7 +99,7 @@ describe("Stagehand", () => { const resolveBrowserSource = vi.fn(async (): Promise => { return { cdpUrl: "http://127.0.0.1:9222", - autoAttach: false, + residentBrowserConnection: false, keepAlive: true, }; }); @@ -186,7 +186,7 @@ describe("Stagehand", () => { cdpUrl: "wss://connect.browserbase.com/devtools/browser/session", browserbaseSessionId: "session_123", preloadedExtension: true, - autoAttach: true, + residentBrowserConnection: true, keepAlive: true, }), connectRpcClient, @@ -246,7 +246,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", - autoAttach: false, + residentBrowserConnection: false, keepAlive: true, }), connectRpcClient, @@ -277,16 +277,12 @@ describe("Stagehand", () => { }); }); - it("routes public runtime status and metrics methods through the protocol", async () => { + it("routes public runtime and metrics methods through the protocol", async () => { const rpcClient = new FakeRPCClient(); rpcClient.queueResponse(StagehandMethods.ping, { ok: true, runtime: "service_worker", }); - rpcClient.queueResponse(StagehandMethods.runtimeLoopbackStatus, { - configured: true, - connected: true, - }); rpcClient.queueResponse(StagehandMethods.browserGetVersion, { protocolVersion: "1.3", product: "Chrome/1", @@ -319,7 +315,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", - autoAttach: false, + residentBrowserConnection: false, keepAlive: true, }), connectRpcClient: async () => rpcClient, @@ -332,10 +328,6 @@ describe("Stagehand", () => { ok: true, runtime: "service_worker", }); - await expect(stagehand.runtimeLoopbackStatus()).resolves.toStrictEqual({ - configured: true, - connected: true, - }); await expect(stagehand.browserGetVersion()).resolves.toStrictEqual({ protocolVersion: "1.3", product: "Chrome/1", @@ -343,7 +335,6 @@ describe("Stagehand", () => { await expect(stagehand.metrics()).resolves.toStrictEqual(metrics); expect(rpcClient.calls.slice(1)).toStrictEqual([ { method: "ping", params: {} }, - { method: "runtime.loopback_status", params: {} }, { method: "browser.get_version", params: {} }, { method: "stagehand.metrics", params: {} }, ]); @@ -368,7 +359,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", - autoAttach: false, + residentBrowserConnection: false, keepAlive: true, }), connectRpcClient: async () => rpcClient, @@ -409,7 +400,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", - autoAttach: true, + residentBrowserConnection: true, keepAlive: false, close: closeBrowser, }), @@ -457,7 +448,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", - autoAttach: false, + residentBrowserConnection: false, keepAlive: true, }), connectRpcClient: async () => rpcClient, @@ -534,7 +525,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", - autoAttach: false, + residentBrowserConnection: false, keepAlive: true, }), connectRpcClient: async () => rpcClient, @@ -573,7 +564,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", - autoAttach: true, + residentBrowserConnection: true, keepAlive: true, close: closeBrowser, }), @@ -597,7 +588,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", - autoAttach: true, + residentBrowserConnection: true, keepAlive: false, close: closeBrowser, }), @@ -622,7 +613,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", - autoAttach: true, + residentBrowserConnection: true, keepAlive: false, close: async () => { throw cleanupError; diff --git a/packages/server/controllers/runtimeController.ts b/packages/server/controllers/runtimeController.ts index bf7c4f5fd..ed63009a8 100644 --- a/packages/server/controllers/runtimeController.ts +++ b/packages/server/controllers/runtimeController.ts @@ -11,13 +11,7 @@ export function createRuntimeController(runtime: StagehandRuntime) { }; } - async function loopbackStatus(_params: EmptyParams, { logger }: HandlerContext) { - logger.debug("runtime.loopback_status", {}); - return runtime.loopbackStatus(); - } - return { ping, - loopbackStatus, }; } diff --git a/packages/server/rpcRouter.ts b/packages/server/rpcRouter.ts index 2a26abf43..0f432119b 100644 --- a/packages/server/rpcRouter.ts +++ b/packages/server/rpcRouter.ts @@ -104,11 +104,6 @@ export class RPCRouter { parseParams(StagehandMethods.ping, request.params), context, ); - case "runtime.loopback_status": - return this.runtimeController.loopbackStatus( - parseParams(StagehandMethods.runtimeLoopbackStatus, request.params), - context, - ); case "browser.get_version": return this.browserController.getVersion( parseParams(StagehandMethods.browserGetVersion, request.params), diff --git a/packages/server/runtime.ts b/packages/server/runtime.ts index 7fe8dda4f..b24f2a68d 100644 --- a/packages/server/runtime.ts +++ b/packages/server/runtime.ts @@ -86,7 +86,6 @@ import type { PageWaitForSelectorParams, PageWaitForSelectorResult, PageWaitForTimeoutParams, - RuntimeLoopbackStatusResult, StagehandInitParams, StagehandInitResult, SnapshotResult, @@ -264,14 +263,14 @@ export class StagehandRuntime { this.logger = new StagehandLogger(tracing, adapters.emitLog); } - loopbackStatus(): RuntimeLoopbackStatusResult { + browserConnectionStatus(): { configured: boolean; connected: boolean } { return { configured: this.browserSession !== undefined, connected: this.browserSession?.connected ?? false, }; } - async configureLoopback(params: { cdpUrl: string }): Promise { + async replaceBrowserConnection(params: { cdpUrl: string }): Promise { const { cdpUrl } = params; const previousSession = this.browserSession; this.browserSession = undefined; @@ -297,7 +296,7 @@ export class StagehandRuntime { if (!params.browserCdpUrl) { throw new Error("stagehand.init requires browserCdpUrl until resident mode is active"); } - await this.configureLoopback({ cdpUrl: params.browserCdpUrl }); + await this.replaceBrowserConnection({ cdpUrl: params.browserCdpUrl }); } await this.browserSession?.prepareForInitialization?.(); const pages = await this.contextPages(); diff --git a/packages/server/tests/client-llm-client.test.ts b/packages/server/tests/client-llm-client.test.ts index ac383196b..24deb1dbc 100644 --- a/packages/server/tests/client-llm-client.test.ts +++ b/packages/server/tests/client-llm-client.test.ts @@ -80,7 +80,7 @@ describe("client LLM generation", () => { clientLLMGenerate: request, }); - await runtime.configureLoopback({ + await runtime.replaceBrowserConnection({ cdpUrl: "ws://browser.example", }); await runtime.initialize({ diff --git a/packages/server/tests/runtime-state.test.ts b/packages/server/tests/runtime-state.test.ts index 4d33db10a..9235f531d 100644 --- a/packages/server/tests/runtime-state.test.ts +++ b/packages/server/tests/runtime-state.test.ts @@ -48,7 +48,7 @@ describe("Stagehand runtime state", () => { }); const configureTracing = vi.spyOn(runtime.tracing, "configure").mockImplementation(() => {}); - await runtime.configureLoopback({ + await runtime.replaceBrowserConnection({ cdpUrl: "ws://browser.example", }); await runtime.initialize({ @@ -96,7 +96,7 @@ describe("Stagehand runtime state", () => { }), }); - await runtime.configureLoopback({ + await runtime.replaceBrowserConnection({ cdpUrl: "ws://browser.example", }); @@ -117,7 +117,7 @@ describe("Stagehand runtime state", () => { browserSessionFactory: async () => createBrowserSession({ close }), }); - await runtime.configureLoopback({ + await runtime.replaceBrowserConnection({ cdpUrl: "ws://browser.example", }); await runtime.initialize({ diff --git a/packages/server/tests/stagehand-clients.test.ts b/packages/server/tests/stagehand-clients.test.ts index 91b9da49d..01d8fa039 100644 --- a/packages/server/tests/stagehand-clients.test.ts +++ b/packages/server/tests/stagehand-clients.test.ts @@ -567,7 +567,7 @@ async function createConfiguredRuntime(session: FakeBrowserSession) { browserSessionFactory: async () => session, }); - await runtime.configureLoopback({ + await runtime.replaceBrowserConnection({ cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", }); @@ -717,27 +717,7 @@ describe("Stagehand worker clients", () => { }); }); - it("reports unconfigured loopback status", async () => { - const handle = createHandle(); - - await expect( - handle({ - jsonrpc: "2.0", - id: 3, - method: "runtime.loopback_status", - params: {}, - }), - ).resolves.toStrictEqual({ - jsonrpc: "2.0", - id: 3, - result: { - configured: false, - connected: false, - }, - }); - }); - - it("configures the browser session during stagehand.init and reports connected status", async () => { + it("configures the browser session during stagehand.init", async () => { const sessions: FakeBrowserSession[] = []; const handle = createHandle({ browserSessionFactory: async () => { @@ -765,22 +745,6 @@ describe("Stagehand worker clients", () => { expect(sessions).toHaveLength(1); expect(sessions[0]?.prepareForInitializationCalls).toBe(1); - - await expect( - handle({ - jsonrpc: "2.0", - id: 4, - method: "runtime.loopback_status", - params: {}, - }), - ).resolves.toStrictEqual({ - jsonrpc: "2.0", - id: 4, - result: { - configured: true, - connected: true, - }, - }); }); it("rejects a second stagehand.init without replacing the browser session", async () => { @@ -841,22 +805,6 @@ describe("Stagehand worker clients", () => { }); expect(session.closed).toBe(true); - - await expect( - handle({ - jsonrpc: "2.0", - id: 3, - method: "runtime.loopback_status", - params: {}, - }), - ).resolves.toStrictEqual({ - jsonrpc: "2.0", - id: 3, - result: { - configured: false, - connected: false, - }, - }); }); it("calls Browser.getVersion through the browser session", async () => { From 1d037f19df03c8300ab3fe1d01ca40f861667989 Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 27 Jul 2026 16:17:58 -0700 Subject: [PATCH 4/6] Format resident browser initialization --- packages/sdk-python/src/stagehand/stagehand.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/sdk-python/src/stagehand/stagehand.py b/packages/sdk-python/src/stagehand/stagehand.py index 86601fcec..a4f241d7e 100644 --- a/packages/sdk-python/src/stagehand/stagehand.py +++ b/packages/sdk-python/src/stagehand/stagehand.py @@ -447,7 +447,9 @@ async def generate(params: LLMGenerateParams) -> LLMGenerateResult: raise RuntimeError("The browser CDP WebSocket URL is unavailable") await rpc_client.send( "stagehand.init", - self._worker_init_params(None if browser.resident_browser_connection else browser_cdp_url), + self._worker_init_params( + None if browser.resident_browser_connection else browser_cdp_url + ), StagehandInitResult, ) self._browser_context = BrowserContext(rpc_client) From 1451d8c60584e01cc9242ad00d6bbf1aa72b1bff Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 27 Jul 2026 16:20:32 -0700 Subject: [PATCH 5/6] Sort generated protocol imports --- packages/sdk-python/src/stagehand/stagehand.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk-python/src/stagehand/stagehand.py b/packages/sdk-python/src/stagehand/stagehand.py index a4f241d7e..62288aec5 100644 --- a/packages/sdk-python/src/stagehand/stagehand.py +++ b/packages/sdk-python/src/stagehand/stagehand.py @@ -12,7 +12,6 @@ from pydantic import BaseModel -from ._generated.protocol_version import STAGEHAND_PROTOCOL_VERSION from ._generated.models import ( Action, ActOptions, @@ -49,6 +48,7 @@ from ._generated.models import ( Locator as ProtocolLocator, ) +from ._generated.protocol_version import STAGEHAND_PROTOCOL_VERSION from .browser_context import BrowserContext from .browser_source import ResolvedBrowserSource, resolve_browser_source from .cdp_client import CDPConnectionClosedError From f732df40a5287003fb6a79b188a25c4d0cf13b14 Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 27 Jul 2026 16:24:18 -0700 Subject: [PATCH 6/6] Harden Stagehand initialization boundaries --- .../sdk-python/src/stagehand/client_models.py | 4 +- .../sdk-python/tests/test_client_models.py | 9 +++ packages/server/runtime.ts | 56 ++++++++++--------- packages/server/tests/runtime-state.test.ts | 27 +++++++++ 4 files changed, 68 insertions(+), 28 deletions(-) diff --git a/packages/sdk-python/src/stagehand/client_models.py b/packages/sdk-python/src/stagehand/client_models.py index d532a79b6..19aad99ba 100644 --- a/packages/sdk-python/src/stagehand/client_models.py +++ b/packages/sdk-python/src/stagehand/client_models.py @@ -136,7 +136,9 @@ class StagehandClientInitParams(WireModel): telemetry: TelemetryConfig | None = None system_prompt: str | None = None self_heal: bool | None = None - dom_settle_timeout_ms: Annotated[int | None, Field(gt=0)] = None + dom_settle_timeout_ms: Annotated[ + int | None, Field(gt=0, le=9_007_199_254_740_991, strict=True) + ] = None cache: _models.Caching | None = None logging: StagehandClientLoggingConfig = Field(default_factory=StagehandClientLoggingConfig) diff --git a/packages/sdk-python/tests/test_client_models.py b/packages/sdk-python/tests/test_client_models.py index e4466c233..01e5d5e6a 100644 --- a/packages/sdk-python/tests/test_client_models.py +++ b/packages/sdk-python/tests/test_client_models.py @@ -37,6 +37,15 @@ def test_client_configuration_rejects_unknown_sdk_options() -> None: }) +@pytest.mark.parametrize("timeout", [True, 9_007_199_254_740_992]) +def test_client_configuration_rejects_invalid_dom_settle_timeouts(timeout: object) -> None: + with pytest.raises(ValidationError): + StagehandClientInitParams.model_validate({ + "browser": {"type": "local"}, + "dom_settle_timeout_ms": timeout, + }) + + def test_client_logging_uses_info_and_pretty_output_by_default() -> None: params = StagehandClientInitParams.model_validate({"browser": {"type": "local"}}) diff --git a/packages/server/runtime.ts b/packages/server/runtime.ts index b24f2a68d..e8479009b 100644 --- a/packages/server/runtime.ts +++ b/packages/server/runtime.ts @@ -255,6 +255,7 @@ export class StagehandRuntime { ); browserSession?: StagehandBrowserSession; pagesById = new Map(); + private initializationInProgress = false; constructor( readonly adapters: ResolvedStagehandRuntimeAdapters, @@ -263,13 +264,6 @@ export class StagehandRuntime { this.logger = new StagehandLogger(tracing, adapters.emitLog); } - browserConnectionStatus(): { configured: boolean; connected: boolean } { - return { - configured: this.browserSession !== undefined, - connected: this.browserSession?.connected ?? false, - }; - } - async replaceBrowserConnection(params: { cdpUrl: string }): Promise { const { cdpUrl } = params; const previousSession = this.browserSession; @@ -290,29 +284,37 @@ export class StagehandRuntime { if (this.state.getState().status !== "created") { throw new Error("Stagehand has already been initialized"); } + if (this.initializationInProgress) { + throw new Error("Stagehand initialization is already in progress"); + } + this.initializationInProgress = true; - this.logger.setLevel(params.logLevel); - if (!this.browserSession) { - if (!params.browserCdpUrl) { - throw new Error("stagehand.init requires browserCdpUrl until resident mode is active"); + try { + this.logger.setLevel(params.logLevel); + if (!this.browserSession) { + if (!params.browserCdpUrl) { + throw new Error("stagehand.init requires browserCdpUrl until resident mode is active"); + } + await this.replaceBrowserConnection({ cdpUrl: params.browserCdpUrl }); } - await this.replaceBrowserConnection({ cdpUrl: params.browserCdpUrl }); + await this.browserSession?.prepareForInitialization?.(); + const pages = await this.contextPages(); + this.tracing.configure(params.telemetry); + this.state.setState( + StagehandRuntimeStateSchema.parse({ + status: "initialized", + initParams: params, + }), + true, + ); + + return { + initialized: true, + pages, + }; + } finally { + this.initializationInProgress = false; } - await this.browserSession?.prepareForInitialization?.(); - const pages = await this.contextPages(); - this.tracing.configure(params.telemetry); - this.state.setState( - StagehandRuntimeStateSchema.parse({ - status: "initialized", - initParams: params, - }), - true, - ); - - return { - initialized: true, - pages, - }; } async browserGetVersion(): Promise { diff --git a/packages/server/tests/runtime-state.test.ts b/packages/server/tests/runtime-state.test.ts index 9235f531d..57dd21ab7 100644 --- a/packages/server/tests/runtime-state.test.ts +++ b/packages/server/tests/runtime-state.test.ts @@ -111,6 +111,33 @@ describe("Stagehand runtime state", () => { expect(runtime.state.getState()).toStrictEqual({ status: "created" }); }); + it("rejects a concurrent initialization before creating another browser session", async () => { + let releaseInitialization!: () => void; + const initializationGate = new Promise((resolve) => { + releaseInitialization = resolve; + }); + const browserSessionFactory = vi.fn(async () => + createBrowserSession({ prepareForInitialization: async () => await initializationGate }), + ); + const runtime = createStagehandRuntime({ browserSessionFactory }); + const params = { + ...clientMetadata, + browserCdpUrl: "ws://browser.example", + telemetry: { + traces: { endpoint: "https://collector.example.com/v1/traces", headers: {} }, + }, + }; + + const firstInitialization = runtime.initialize(params); + await expect(runtime.initialize(params)).rejects.toThrow( + "Stagehand initialization is already in progress", + ); + expect(browserSessionFactory).toHaveBeenCalledOnce(); + + releaseInitialization(); + await expect(firstInitialization).resolves.toMatchObject({ initialized: true }); + }); + it("clears initialized configuration when Stagehand closes", async () => { const close = vi.fn(); const runtime = createStagehandRuntime({