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 e2d91a3cd..7be939377 100644
--- a/packages/protocol/schema-registry.ts
+++ b/packages/protocol/schema-registry.ts
@@ -89,9 +89,6 @@ import {
PageWaitForSelectorParamsSchema,
PageWaitForSelectorResultSchema,
PageWaitForTimeoutParamsSchema,
- RuntimeConfigureParamsSchema,
- RuntimeConfigureResultSchema,
- RuntimeLoopbackStatusResultSchema,
StagehandActParamsSchema,
StagehandCloseResultSchema,
StagehandExtractParamsSchema,
@@ -111,16 +108,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,
- result: RuntimeLoopbackStatusResultSchema,
- },
browserGetVersion: {
name: "browser.get_version",
params: EmptyParamsSchema,
diff --git a/packages/protocol/schemas.ts b/packages/protocol/schemas.ts
index d709fda25..cb7d48f40 100644
--- a/packages/protocol/schemas.ts
+++ b/packages/protocol/schemas.ts
@@ -1404,10 +1404,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(),
@@ -1419,17 +1423,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),
@@ -1863,21 +1856,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(),
- 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 32294180d..e44432f3b 100644
--- a/packages/protocol/stagehand.v4.json
+++ b/packages/protocol/stagehand.v4.json
@@ -19,32 +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": {
- "params": {
- "$ref": "#/$defs/EmptyParams"
- },
- "result": {
- "$ref": "#/$defs/RuntimeLoopbackStatusResult"
- }
- },
- "required": ["params", "result"],
- "additionalProperties": false
- },
"browser.get_version": {
"type": "object",
"properties": {
@@ -880,8 +854,6 @@
},
"required": [
"ping",
- "runtime.configure",
- "runtime.loopback_status",
"browser.get_version",
"stagehand.init",
"stagehand.close",
@@ -1010,109 +982,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": {
- "configured": {
- "type": "boolean"
- },
- "connected": {
- "type": "boolean"
- }
- },
- "required": ["configured", "connected"],
- "additionalProperties": false
- },
"BrowserGetVersionResult": {
"type": "object",
"properties": {
@@ -1137,6 +1006,17 @@
"StagehandInitParams": {
"type": "object",
"properties": {
+ "protocol_version": {
+ "type": "number",
+ "const": 1
+ },
+ "client_info": {
+ "$ref": "#/$defs/ImplementationInfo"
+ },
+ "browser_cdp_url": {
+ "type": "string",
+ "minLength": 1
+ },
"api_key": {
"type": "string",
"minLength": 1
@@ -1163,6 +1043,11 @@
},
"$ref": "#/$defs/TelemetryConfig"
},
+ "log_level": {
+ "default": "info",
+ "type": "string",
+ "enum": ["off", "error", "warn", "info", "debug"]
+ },
"system_prompt": {
"type": "string"
},
@@ -1179,6 +1064,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 +1446,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,60 +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.configure"
- },
- "params": {
- "$ref": "#/$defs/RuntimeConfigureParams"
- },
- "traceparent": {
- "type": "string"
- },
- "tracestate": {
- "type": "string"
- }
- },
- "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/browser-runtime/rpc-client-smoke.test.ts b/packages/protocol/tests/browser-runtime/rpc-client-smoke.test.ts
index b2871e5b3..d962f1ee9 100644
--- a/packages/protocol/tests/browser-runtime/rpc-client-smoke.test.ts
+++ b/packages/protocol/tests/browser-runtime/rpc-client-smoke.test.ts
@@ -9,6 +9,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { z } from "zod/v4";
import { connectRPCClient, type RPCClient } from "../../../sdk-ts/src/rpcClient.js";
import { StagehandMethods } from "../../schema-registry.js";
+import { STAGEHAND_PROTOCOL_VERSION } from "../../schemas.js";
import type { StagehandRpcNotification } from "../../types.js";
const stagehandExtensionDistDir = new URL("../../../server/dist", import.meta.url).pathname;
@@ -34,7 +35,12 @@ describe("Stagehand service worker RPC client smoke", () => {
serviceWorkerUrlIncludes: "service-worker.js",
discoveryTimeoutMs: 15_000,
commandTimeoutMs: 15_000,
+ });
+ await rpcClient.send(StagehandMethods.stagehandInit, {
+ protocolVersion: STAGEHAND_PROTOCOL_VERSION,
+ 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 +74,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..a66c7ea7c 100644
--- a/packages/protocol/tests/protocol/client-llm.test.ts
+++ b/packages/protocol/tests/protocol/client-llm.test.ts
@@ -4,6 +4,7 @@ import { StagehandMethods } from "../../schema-registry.js";
import {
LLMGenerateParamsSchema,
LLMGenerateResultSchema,
+ STAGEHAND_PROTOCOL_VERSION,
StagehandInitParamsSchema,
} from "../../schemas.js";
@@ -39,6 +40,8 @@ describe("client-side LLM protocol", () => {
it("selects a serializable client model during Stagehand initialization", () => {
expect(
StagehandInitParamsSchema.parse({
+ protocolVersion: STAGEHAND_PROTOCOL_VERSION,
+ 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..d9f83c919 100644
--- a/packages/protocol/tests/protocol/loopback-protocol.test.ts
+++ b/packages/protocol/tests/protocol/loopback-protocol.test.ts
@@ -2,46 +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,
- }),
- ).toStrictEqual({
- configured: true,
- });
- });
-
- it("rejects runtime.configure without a CDP URL", () => {
- expect(() => StagehandMethods.runtimeConfigure.params.parse({})).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,
+ 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,
- connected: false,
- });
+ ).toThrow();
});
it("defines browser.get_version as a JSON-RPC method", () => {
@@ -68,37 +40,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..583a6ab73 100644
--- a/packages/protocol/tests/protocol/object-model-protocol.test.ts
+++ b/packages/protocol/tests/protocol/object-model-protocol.test.ts
@@ -6,6 +6,7 @@ import {
StagehandRpcNotificationSchema,
StagehandRpcRequestSchema,
} from "../../schema-registry.js";
+import { STAGEHAND_PROTOCOL_VERSION } from "../../schemas.js";
describe("Stagehand object-model protocol", () => {
it("derives every Stagehand method name from the RPC definitions", () => {
@@ -16,6 +17,9 @@ describe("Stagehand object-model protocol", () => {
it("defines stagehand init as a JSON-RPC method", () => {
const params = StagehandMethods.stagehandInit.params.parse({
+ protocolVersion: STAGEHAND_PROTOCOL_VERSION,
+ clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" },
+ logLevel: "info",
apiKey: "bb_key",
browser: {
type: "browserbase",
@@ -27,6 +31,9 @@ describe("Stagehand object-model protocol", () => {
});
expect(params).toStrictEqual({
+ protocolVersion: STAGEHAND_PROTOCOL_VERSION,
+ clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" },
+ logLevel: "info",
apiKey: "bb_key",
browser: {
type: "browserbase",
@@ -66,6 +73,8 @@ describe("Stagehand object-model protocol", () => {
it("rejects model names without a provider prefix", () => {
expect(() =>
StagehandMethods.stagehandInit.params.parse({
+ protocolVersion: STAGEHAND_PROTOCOL_VERSION,
+ clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" },
model: { modelName: "gpt-5-mini" },
}),
).toThrow();
@@ -543,8 +552,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: STAGEHAND_PROTOCOL_VERSION,
+ 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..1c2805c3b 100644
--- a/packages/protocol/tests/protocol/wire-casing.test.ts
+++ b/packages/protocol/tests/protocol/wire-casing.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { z } from "zod/v4";
import { encodeWireValue, toWireJsonSchema, wireSchema } from "../../json-rpc/wire-casing.js";
import { StagehandNotifications, StagehandMethods } from "../../schema-registry.js";
+import { STAGEHAND_PROTOCOL_VERSION } from "../../schemas.js";
const snakeCaseKey = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/;
const snakeCaseMethodSegment = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/;
@@ -334,6 +335,9 @@ describe("JSON-RPC wire casing", () => {
it("preserves arbitrary map keys while encoding nested configuration", () => {
const definition = StagehandMethods.stagehandInit;
const apiValue = {
+ protocolVersion: STAGEHAND_PROTOCOL_VERSION,
+ clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" },
+ logLevel: "info" as const,
apiKey: "bb_key",
browser: {
type: "browserbase" as const,
@@ -354,6 +358,9 @@ describe("JSON-RPC wire casing", () => {
};
const wireValue = {
+ protocol_version: STAGEHAND_PROTOCOL_VERSION,
+ 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 f515b2c38..87ade6920 100644
--- a/packages/protocol/tests/rpc-client/cdp-client.test.ts
+++ b/packages/protocol/tests/rpc-client/cdp-client.test.ts
@@ -5,7 +5,7 @@ import {
resolveBrowserWebSocketUrl,
StagehandRuntimeIncompatibleError,
waitForPreloadedStagehandServiceWorker,
- waitForRuntimeReady,
+ waitForRuntimeReceiver,
waitForServiceWorker,
} from "../../../sdk-ts/src/cdpClient.ts";
@@ -394,7 +394,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: {
@@ -403,7 +403,7 @@ describe("waitForRuntimeReady", () => {
}));
await expect(
- waitForRuntimeReady(cdp, "worker-session", {
+ waitForRuntimeReceiver(cdp, "worker-session", {
timeout: 1_000,
delayFn: async () => {},
}),
@@ -442,7 +442,7 @@ describe("waitForRuntimeReady", () => {
}));
await expect(
- waitForRuntimeReady(cdp, "worker-session", {
+ waitForRuntimeReceiver(cdp, "worker-session", {
pollIntervalMs: 5,
timeout: 100,
nowFn: () => now,
@@ -470,7 +470,7 @@ describe("waitForRuntimeReady", () => {
}));
await expect(
- waitForRuntimeReady(cdp, "worker-session", {
+ waitForRuntimeReceiver(cdp, "worker-session", {
pollIntervalMs: 1,
timeout: 2,
nowFn: () => now,
@@ -478,7 +478,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 () => {
@@ -498,7 +498,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,
@@ -516,7 +516,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,
@@ -538,7 +538,7 @@ describe("waitForRuntimeReady", () => {
}));
const error = await rejectedError(
- waitForRuntimeReady(cdp, "worker-session", {
+ waitForRuntimeReceiver(cdp, "worker-session", {
pollIntervalMs: 1,
timeout: 1,
nowFn: () => now,
@@ -550,7 +550,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");
@@ -562,7 +562,7 @@ describe("waitForRuntimeReady", () => {
}));
await expect(
- waitForRuntimeReady(cdp, "worker-session", {
+ waitForRuntimeReceiver(cdp, "worker-session", {
allowFallbackInstall: false,
timeout: 1_000,
nowFn: () => 0,
@@ -582,7 +582,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 ba16a9234..cc0062e9b 100644
--- a/packages/protocol/types.ts
+++ b/packages/protocol/types.ts
@@ -169,10 +169,7 @@ import type {
PageWaitForSelectorResultSchema,
PageWaitForTimeoutParamsSchema,
ProxyConfigSchema,
- RuntimeConfigureParamsSchema,
- RuntimeConfigureResultSchema,
RuntimeDescriptorSchema,
- RuntimeLoopbackStatusResultSchema,
RgbaColorSchema,
StagehandActParamsSchema,
StagehandCloseResultSchema,
@@ -273,7 +270,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;
@@ -324,8 +320,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;
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 14ff92a52..fbe3b99a2 100644
--- a/packages/sdk-python/src/stagehand/_generated/models.py
+++ b/packages/sdk-python/src/stagehand/_generated/models.py
@@ -1815,39 +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",
- validate_by_name=True,
- )
- configured: StrictBool
- connected: StrictBool
-
-
class SameSite(StrEnum):
strict = "Strict"
lax = "Lax"
@@ -1909,12 +1876,16 @@ class StagehandInitParams(WireModel):
extra="forbid",
validate_by_name=True,
)
+ protocol_version: Literal[1]
+ 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..018e862ee 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
+ 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)
@@ -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,
+ resident_browser_connection=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 f2e1e6bc1..80267d03c 100644
--- a/packages/sdk-python/src/stagehand/cdp_client.py
+++ b/packages/sdk-python/src/stagehand/cdp_client.py
@@ -147,7 +147,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()
@@ -411,7 +411,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..19aad99ba 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,19 @@ 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, le=9_007_199_254_740_991, strict=True)
+ ] = 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..62288aec5 100644
--- a/packages/sdk-python/src/stagehand/stagehand.py
+++ b/packages/sdk-python/src/stagehand/stagehand.py
@@ -26,13 +26,13 @@
ExternalProxyConfig,
ExtractOptions,
ExtractResult,
+ ImplementationInfo,
LLMGenerateParams,
LLMGenerateResult,
ModelConfig,
ObserveOptions,
ObserveResult,
ProxyConfig,
- RuntimeLoopbackStatusResult,
StagehandActParams,
StagehandCloseResult,
StagehandExtractParams,
@@ -48,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
@@ -390,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",
@@ -428,8 +422,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 +442,14 @@ async def generate(params: LLMGenerateParams) -> LLMGenerateResult:
generate,
)
+ browser_cdp_url = rpc_client.browser_web_socket_debugger_url
+ 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(),
+ self._worker_init_params(
+ None if browser.resident_browser_connection else browser_cdp_url
+ ),
StagehandInitResult,
)
self._browser_context = BrowserContext(rpc_client)
@@ -606,7 +603,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 +612,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"] = STAGEHAND_PROTOCOL_VERSION
+ 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..e4c63f5f8 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: str | None = "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 120fa6c8e..eeb77b74b 100644
--- a/packages/sdk-python/tests/test_cdp_client.py
+++ b/packages/sdk-python/tests/test_cdp_client.py
@@ -20,6 +20,7 @@ def _ready_marker() -> dict[str, object]:
"marker": {
"protocolVersion": STAGEHAND_PROTOCOL_VERSION,
"serverInfo": {"name": "stagehand", "version": "4.0.0"},
+ "state": "ready",
},
"hasReceiver": True,
}
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/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..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,
@@ -36,6 +35,7 @@
StagehandMetrics,
StagehandObserveParams,
StagehandPingResult,
+ TelemetryConfig,
)
from stagehand.browser_source import ResolvedBrowserSource
from stagehand.cdp_client import CDPConnectionClosedError
@@ -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",
@@ -141,6 +139,77 @@ async def connect(**kwargs: 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,
@@ -197,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",
@@ -222,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",
@@ -233,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 992f4bde5..e21691b6a 100644
--- a/packages/sdk-ts/src/browserSource.ts
+++ b/packages/sdk-ts/src/browserSource.ts
@@ -19,6 +19,7 @@ export type ResolvedBrowserSource = {
cdpHeaders?: Record;
browserbaseSessionId?: string;
preloadedExtension?: boolean;
+ residentBrowserConnection: boolean;
keepAlive: boolean;
close?: () => Promise | void;
};
@@ -54,6 +55,7 @@ export async function resolveBrowserSource(
cdpUrl: session.cdpUrl,
browserbaseSessionId: session.sessionId,
preloadedExtension: true,
+ residentBrowserConnection: false,
keepAlive: browser.keepAlive ?? false,
close: session.close,
};
@@ -64,6 +66,7 @@ export async function resolveBrowserSource(
const launched = await (dependencies.launchLocalBrowser ?? launchLocalBrowser)(launchOptions);
return {
cdpUrl: launched.cdpUrl,
+ residentBrowserConnection: false,
keepAlive: launchOptions.keepAlive ?? false,
close: launched.close,
};
@@ -72,6 +75,7 @@ export async function resolveBrowserSource(
return {
cdpUrl: browser.cdpUrl,
...(browser.headers === undefined ? {} : { cdpHeaders: browser.headers }),
+ residentBrowserConnection: false,
keepAlive: true,
};
}
@@ -94,7 +98,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 0eefc8a50..51ed07b14 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/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/rpcClient.ts b/packages/sdk-ts/src/rpcClient.ts
index 39e2d5963..66ca63012 100644
--- a/packages/sdk-ts/src/rpcClient.ts
+++ b/packages/sdk-ts/src/rpcClient.ts
@@ -30,19 +30,11 @@ 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";
-import { STAGEHAND_SDK_VERSION } from "./version.js";
type PendingRequest = {
method: RPCMethod;
@@ -59,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: STAGEHAND_SDK_VERSION,
-} as const;
-
const RPCClientOptionsBaseSchema = z
.object({
cdpUrl: z.string().min(1),
@@ -71,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();
@@ -98,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;
@@ -107,6 +93,7 @@ export type CDPTransport = {
export class RPCClient {
readonly serviceWorker: ServiceWorkerInfo;
+ readonly browserWebSocketDebuggerUrl?: string;
nextRequestId = 1;
pending = new Map();
requestHandlers = new Map();
@@ -120,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);
@@ -416,19 +404,7 @@ export async function connectRPCClient(input: RPCClientOptions): Promise Promise;
@@ -34,6 +34,10 @@ type StagehandAdapters = {
};
const stagehandAdapters = new WeakMap();
+const STAGEHAND_SDK_CLIENT_INFO = {
+ name: "stagehand-sdk-ts",
+ version: STAGEHAND_SDK_VERSION,
+} as const;
export class Stagehand {
browserContext: BrowserContext | undefined;
@@ -68,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, {});
}
@@ -98,8 +98,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 +112,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 +229,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.residentBrowserConnection && !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.residentBrowserConnection
+ ? {}
+ : {
+ browserCdpUrl: rpcClient.browserWebSocketDebuggerUrl,
+ }),
...protocolParams,
...(browser.type === "browserbase"
? {
@@ -264,6 +274,7 @@ export function createStagehandWithClientForTest(client: RPCClient): Stagehand {
{
resolveBrowserSource: async () => ({
cdpUrl: "test://stagehand",
+ 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 e3a089869..e8b35134d 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,
+ residentBrowserConnection: false,
keepAlive: false,
close,
});
@@ -95,6 +96,7 @@ describe("resolveBrowserSource", () => {
),
).resolves.toStrictEqual({
cdpUrl: "http://127.0.0.1:9222",
+ residentBrowserConnection: 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",
+ residentBrowserConnection: 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" },
+ residentBrowserConnection: 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..5be21b6db 100644
--- a/packages/sdk-ts/tests/object-wrapper.test.ts
+++ b/packages/sdk-ts/tests/object-wrapper.test.ts
@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
import { z } from "zod/v4";
import type { RPCMethod } from "../../protocol/json-rpc/schemas.js";
import { StagehandMethods } from "../../protocol/schema-registry.js";
+import { STAGEHAND_PROTOCOL_VERSION } from "../../protocol/schemas.js";
import { BrowserClipboard, BrowserContext, Locator, Page } from "../src/index.js";
import { RPCClient } from "../src/rpcClient.js";
import { createStagehandWithClientForTest } from "../src/stagehand.js";
@@ -18,6 +19,7 @@ class FakeProtocolClient extends RPCClient {
constructor() {
super(
{
+ webSocketDebuggerUrl: "test://stagehand",
serviceWorker: {
targetId: "worker-target",
url: "chrome-extension://stagehand/service-worker.js",
@@ -68,6 +70,10 @@ function requestCall(
}
const stagehandInitCall = requestCall(StagehandMethods.stagehandInit, {
+ protocolVersion: STAGEHAND_PROTOCOL_VERSION,
+ 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/rpcClient.test.ts b/packages/sdk-ts/tests/rpcClient.test.ts
index af335d290..9202f03cb 100644
--- a/packages/sdk-ts/tests/rpcClient.test.ts
+++ b/packages/sdk-ts/tests/rpcClient.test.ts
@@ -1,11 +1,9 @@
-import { describe, expect, expectTypeOf, it, vi } from "vitest";
+import { describe, expect, expectTypeOf, it } from "vitest";
import { z } from "zod/v4";
import { JSONRPCErrorCodes, type RPCMethod } from "../../protocol/json-rpc/schemas.js";
import type { JSONRPCMessage } from "../../protocol/json-rpc/types.js";
import { StagehandMethods } from "../../protocol/schema-registry.js";
-import sdkPackageJson from "../package.json" with { type: "json" };
-import { CDPClient } from "../src/cdpClient.js";
-import { connectRPCClient, RPCClient, type CDPTransport } from "../src/rpcClient.js";
+import { RPCClient, type CDPTransport } from "../src/rpcClient.js";
const UppercaseMethod = {
name: "test.uppercase",
@@ -61,34 +59,6 @@ class ManualCDPTransport implements CDPTransport {
}
describe("RPCClient", () => {
- it("reports the SDK package version when configuring the runtime", async () => {
- const cdp = new FakeCDPTransport({ configured: true });
- const connect = vi.spyOn(CDPClient, "connect").mockResolvedValue(cdp as unknown as CDPClient);
-
- try {
- const client = await connectRPCClient({
- cdpUrl: "ws://127.0.0.1:9222/devtools/browser/test",
- extensionId: "stagehand",
- });
- try {
- expect(cdp.sent[0]).toMatchObject({
- jsonrpc: "2.0",
- method: "runtime.configure",
- params: {
- client_info: {
- name: "stagehand-sdk-ts",
- version: sdkPackageJson.version,
- },
- },
- });
- } finally {
- client.close();
- }
- } finally {
- connect.mockRestore();
- }
- });
-
it("accepts page methods without SDK wrapper methods", async () => {
const cdp = new FakeCDPTransport({ matched: true });
const client = new RPCClient(cdp, 1_000);
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..75f68a2d1 100644
--- a/packages/sdk-ts/tests/stagehand-client-init-params.test.ts
+++ b/packages/sdk-ts/tests/stagehand-client-init-params.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { StagehandInitParamsSchema } from "../../protocol/schemas.js";
+import { STAGEHAND_PROTOCOL_VERSION, StagehandInitParamsSchema } from "../../protocol/schemas.js";
import {
StagehandClientActOptionsSchema,
StagehandClientExtractOptionsSchema,
@@ -265,6 +265,8 @@ describe("Stagehand client browser sources", () => {
expect(() => StagehandInitParamsSchema.parse(clientInitParams)).toThrow();
expect(
StagehandInitParamsSchema.parse({
+ protocolVersion: STAGEHAND_PROTOCOL_VERSION,
+ clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" },
...protocolParams,
browser: {
...clientInitParams.browser,
@@ -272,6 +274,9 @@ describe("Stagehand client browser sources", () => {
},
}),
).toStrictEqual({
+ protocolVersion: STAGEHAND_PROTOCOL_VERSION,
+ 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: STAGEHAND_PROTOCOL_VERSION,
+ clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" },
browser: { type: "local" },
}),
).toThrow();
expect(() =>
StagehandInitParamsSchema.parse({
+ protocolVersion: STAGEHAND_PROTOCOL_VERSION,
+ 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..a4cfdd185 100644
--- a/packages/sdk-ts/tests/stagehand.test.ts
+++ b/packages/sdk-ts/tests/stagehand.test.ts
@@ -2,12 +2,14 @@ import { describe, expect, it, vi } from "vitest";
import { z } from "zod/v4";
import type { RPCMethod } from "../../protocol/json-rpc/schemas.js";
import { StagehandMethods } from "../../protocol/schema-registry.js";
+import { STAGEHAND_PROTOCOL_VERSION } from "../../protocol/schemas.js";
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";
+import { STAGEHAND_SDK_VERSION } from "../src/version.js";
type ProtocolCall = { method: string; params: unknown };
@@ -17,9 +19,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 +92,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",
+ residentBrowserConnection: false,
keepAlive: true,
};
});
@@ -141,19 +145,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: STAGEHAND_PROTOCOL_VERSION,
+ clientInfo: { name: "stagehand-sdk-ts", version: STAGEHAND_SDK_VERSION },
+ logLevel: "info",
+ browserCdpUrl: "ws://127.0.0.1:9222/devtools/browser/exact-session",
apiKey: "bb_key",
telemetry: {
traces: {
@@ -185,6 +186,7 @@ describe("Stagehand", () => {
cdpUrl: "wss://connect.browserbase.com/devtools/browser/session",
browserbaseSessionId: "session_123",
preloadedExtension: true,
+ residentBrowserConnection: true,
keepAlive: true,
}),
connectRpcClient,
@@ -195,21 +197,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: STAGEHAND_PROTOCOL_VERSION,
+ clientInfo: { name: "stagehand-sdk-ts", version: STAGEHAND_SDK_VERSION },
+ logLevel: "info",
apiKey: "bb_key",
browser: {
type: "browserbase",
@@ -248,6 +246,7 @@ describe("Stagehand", () => {
{
resolveBrowserSource: async () => ({
cdpUrl: "http://127.0.0.1:9222",
+ residentBrowserConnection: false,
keepAlive: true,
}),
connectRpcClient,
@@ -259,27 +258,31 @@ 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: STAGEHAND_PROTOCOL_VERSION,
+ clientInfo: { name: "stagehand-sdk-ts", version: STAGEHAND_SDK_VERSION },
+ 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 () => {
+ 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",
@@ -312,6 +315,7 @@ describe("Stagehand", () => {
{
resolveBrowserSource: async () => ({
cdpUrl: "http://127.0.0.1:9222",
+ residentBrowserConnection: false,
keepAlive: true,
}),
connectRpcClient: async () => rpcClient,
@@ -324,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",
@@ -335,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: {} },
]);
@@ -360,6 +359,7 @@ describe("Stagehand", () => {
{
resolveBrowserSource: async () => ({
cdpUrl: "http://127.0.0.1:9222",
+ residentBrowserConnection: false,
keepAlive: true,
}),
connectRpcClient: async () => rpcClient,
@@ -373,6 +373,10 @@ describe("Stagehand", () => {
{
method: "stagehand.init",
params: {
+ protocolVersion: STAGEHAND_PROTOCOL_VERSION,
+ clientInfo: { name: "stagehand-sdk-ts", version: STAGEHAND_SDK_VERSION },
+ logLevel: "info",
+ browserCdpUrl: "ws://127.0.0.1:9222/devtools/browser/test",
model: { source: "client" },
telemetry: {
traces: {
@@ -396,6 +400,7 @@ describe("Stagehand", () => {
{
resolveBrowserSource: async () => ({
cdpUrl: "http://127.0.0.1:9222",
+ residentBrowserConnection: true,
keepAlive: false,
close: closeBrowser,
}),
@@ -411,6 +416,9 @@ describe("Stagehand", () => {
{
method: "stagehand.init",
params: {
+ protocolVersion: STAGEHAND_PROTOCOL_VERSION,
+ clientInfo: { name: "stagehand-sdk-ts", version: STAGEHAND_SDK_VERSION },
+ logLevel: "info",
telemetry: {
traces: {
endpoint: "https://example.com/v1/traces",
@@ -440,6 +448,7 @@ describe("Stagehand", () => {
{
resolveBrowserSource: async () => ({
cdpUrl: "http://127.0.0.1:9222",
+ residentBrowserConnection: false,
keepAlive: true,
}),
connectRpcClient: async () => rpcClient,
@@ -516,6 +525,7 @@ describe("Stagehand", () => {
{
resolveBrowserSource: async () => ({
cdpUrl: "http://127.0.0.1:9222",
+ residentBrowserConnection: false,
keepAlive: true,
}),
connectRpcClient: async () => rpcClient,
@@ -554,6 +564,7 @@ describe("Stagehand", () => {
{
resolveBrowserSource: async () => ({
cdpUrl: "http://127.0.0.1:9222",
+ residentBrowserConnection: true,
keepAlive: true,
close: closeBrowser,
}),
@@ -577,6 +588,7 @@ describe("Stagehand", () => {
{
resolveBrowserSource: async () => ({
cdpUrl: "http://127.0.0.1:9222",
+ residentBrowserConnection: true,
keepAlive: false,
close: closeBrowser,
}),
@@ -601,6 +613,7 @@ describe("Stagehand", () => {
{
resolveBrowserSource: async () => ({
cdpUrl: "http://127.0.0.1:9222",
+ residentBrowserConnection: true,
keepAlive: false,
close: async () => {
throw cleanupError;
diff --git a/packages/server/controllers/runtimeController.ts b/packages/server/controllers/runtimeController.ts
index 57b85f564..ed63009a8 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,21 +11,7 @@ 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();
- }
-
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..0f432119b 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,16 +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),
- 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 ac8a6e0cd..e8479009b 100644
--- a/packages/server/runtime.ts
+++ b/packages/server/runtime.ts
@@ -86,9 +86,6 @@ import type {
PageWaitForSelectorParams,
PageWaitForSelectorResult,
PageWaitForTimeoutParams,
- RuntimeConfigureParams,
- RuntimeConfigureResult,
- RuntimeLoopbackStatusResult,
StagehandInitParams,
StagehandInitResult,
SnapshotResult,
@@ -199,6 +196,7 @@ export type UnderstudyRuntimeLocator = {
export type StagehandBrowserSession = {
readonly connected: boolean;
+ prepareForInitialization?(): Promise;
getVersion(): Promise;
pages(): UnderstudyRuntimePage[];
newPage(url?: string): Promise;
@@ -257,6 +255,7 @@ export class StagehandRuntime {
);
browserSession?: StagehandBrowserSession;
pagesById = new Map();
+ private initializationInProgress = false;
constructor(
readonly adapters: ResolvedStagehandRuntimeAdapters,
@@ -265,15 +264,7 @@ export class StagehandRuntime {
this.logger = new StagehandLogger(tracing, adapters.emitLog);
}
- loopbackStatus(): RuntimeLoopbackStatusResult {
- return {
- configured: this.browserSession !== undefined,
- connected: this.browserSession?.connected ?? false,
- };
- }
-
- async configureLoopback(params: RuntimeConfigureParams): Promise {
- this.logger.setLevel(params.logLevel);
+ async replaceBrowserConnection(params: { cdpUrl: string }): Promise {
const { cdpUrl } = params;
const previousSession = this.browserSession;
this.browserSession = undefined;
@@ -287,28 +278,43 @@ export class StagehandRuntime {
this.browserSession = undefined;
throw error;
}
-
- return { configured: true };
}
async initialize(params: StagehandInitParams): Promise {
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;
- const pages = await this.contextPages();
- this.state.setState(
- StagehandRuntimeStateSchema.parse({
- status: "initialized",
- initParams: params,
- }),
- true,
- );
-
- return {
- initialized: true,
- pages,
- };
+ 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.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;
+ }
}
async browserGetVersion(): Promise {
diff --git a/packages/server/tests/client-llm-client.test.ts b/packages/server/tests/client-llm-client.test.ts
index 9f44e21a7..24deb1dbc 100644
--- a/packages/server/tests/client-llm-client.test.ts
+++ b/packages/server/tests/client-llm-client.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
+import { STAGEHAND_PROTOCOL_VERSION } from "../../protocol/schemas.js";
import { generateWithClientLlm } from "../llm/clientLlmClient.js";
import * as llmService from "../services/llmService.js";
import { createStagehandRuntime } from "../runtime.js";
@@ -79,14 +80,13 @@ describe("client LLM generation", () => {
clientLLMGenerate: request,
});
- await runtime.configureLoopback({
+ await runtime.replaceBrowserConnection({
cdpUrl: "ws://browser.example",
- logLevel: "info",
- telemetry: {
- traces: { endpoint: "https://collector.example.com/v1/traces", headers: {} },
- },
});
await runtime.initialize({
+ protocolVersion: STAGEHAND_PROTOCOL_VERSION,
+ 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/rpc-router.test.ts b/packages/server/tests/rpc-router.test.ts
index 028aa8113..60aa8f8fb 100644
--- a/packages/server/tests/rpc-router.test.ts
+++ b/packages/server/tests/rpc-router.test.ts
@@ -4,8 +4,9 @@ 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 { STAGEHAND_PROTOCOL_VERSION } from "../../protocol/schemas.ts";
import { createStagehandRuntime } from "../runtime.ts";
import { RPCRouter } from "../rpcRouter.ts";
import { createStagehandTracingRuntime, type StagehandTracing } from "../tracing.ts";
@@ -137,6 +138,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: STAGEHAND_PROTOCOL_VERSION,
+ 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/runtime-state.test.ts b/packages/server/tests/runtime-state.test.ts
index 3775fd57d..57dd21ab7 100644
--- a/packages/server/tests/runtime-state.test.ts
+++ b/packages/server/tests/runtime-state.test.ts
@@ -1,7 +1,14 @@
import { describe, expect, it, vi } from "vitest";
+import { STAGEHAND_PROTOCOL_VERSION } from "../../protocol/schemas.js";
import type { StagehandBrowserSession } from "../runtime.js";
import { createStagehandRuntime } from "../runtime.js";
+const clientMetadata = {
+ protocolVersion: STAGEHAND_PROTOCOL_VERSION,
+ clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" },
+ logLevel: "info" as const,
+};
+
function createBrowserSession(
overrides: Partial = {},
): StagehandBrowserSession {
@@ -39,15 +46,13 @@ describe("Stagehand runtime state", () => {
const runtime = createStagehandRuntime({
browserSessionFactory: async () => createBrowserSession(),
});
+ const configureTracing = vi.spyOn(runtime.tracing, "configure").mockImplementation(() => {});
- await runtime.configureLoopback({
+ await runtime.replaceBrowserConnection({
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 +66,7 @@ describe("Stagehand runtime state", () => {
expect(runtime.state.getState()).toStrictEqual({
status: "initialized",
initParams: {
+ ...clientMetadata,
model: { modelName: "openai/gpt-5" },
telemetry: {
traces: {
@@ -71,6 +77,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 () => {
@@ -83,16 +96,13 @@ describe("Stagehand runtime state", () => {
}),
});
- await runtime.configureLoopback({
+ await runtime.replaceBrowserConnection({
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: {} },
},
@@ -101,20 +111,44 @@ 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({
browserSessionFactory: async () => createBrowserSession({ close }),
});
- await runtime.configureLoopback({
+ await runtime.replaceBrowserConnection({
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 5783dc7d3..01d8fa039 100644
--- a/packages/server/tests/stagehand-clients.test.ts
+++ b/packages/server/tests/stagehand-clients.test.ts
@@ -2,6 +2,7 @@ import { trace } from "@opentelemetry/api";
import { describe, expect, it, vi } from "vitest";
import { JSONRPCRequestSchema, JSONRPCResponseSchema } from "../../protocol/json-rpc/schemas.ts";
import type { JSONRPCResponse } from "../../protocol/json-rpc/types.ts";
+import { STAGEHAND_PROTOCOL_VERSION } from "../../protocol/schemas.ts";
import {
STAGEHAND_SEND_TO_HOST_BINDING,
StagehandRpcNotificationSchema,
@@ -99,6 +100,7 @@ class FakeBrowserSession implements StagehandBrowserSession {
closed = false;
connected = true;
getVersionCalls = 0;
+ prepareForInitializationCalls = 0;
readonly pageRefs: FakeUnderstudyRuntimePage[];
activePageRef: UnderstudyRuntimePage | undefined;
readonly setActivePageCalls: UnderstudyRuntimePage[] = [];
@@ -131,6 +133,10 @@ class FakeBrowserSession implements StagehandBrowserSession {
return this.version;
}
+ async prepareForInitialization(): Promise {
+ this.prepareForInitializationCalls += 1;
+ }
+
pages(): UnderstudyRuntimePage[] {
return this.pageRefs;
}
@@ -549,10 +555,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;
@@ -563,20 +567,21 @@ async function createConfiguredRuntime(session: FakeBrowserSession) {
browserSessionFactory: async () => session,
});
- await runtime.configureLoopback({
+ await runtime.replaceBrowserConnection({
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: STAGEHAND_PROTOCOL_VERSION,
+ 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(
@@ -712,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 and reports connected status", async () => {
+ it("configures the browser session during stagehand.init", async () => {
const sessions: FakeBrowserSession[] = [];
const handle = createHandle({
browserSessionFactory: async () => {
@@ -746,39 +731,23 @@ 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: [],
},
});
expect(sessions).toHaveLength(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,
- },
- });
+ expect(sessions[0]?.prepareForInitializationCalls).toBe(1);
});
- 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 +760,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 +785,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(
@@ -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 () => {
@@ -874,10 +822,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 +881,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 +917,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(