stagehand 4.3.0a0.dev1620__tar.gz → 4.3.0a0.dev1626__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/PKG-INFO +1 -1
  2. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/pyproject.toml +1 -1
  3. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/_extension/service-worker.js +191 -44
  4. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/_generated/input_types.py +22 -4
  5. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/_generated/models.py +45 -7
  6. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/_generated/protocol_version.py +1 -1
  7. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/_validation.py +61 -2
  8. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/browser.py +9 -0
  9. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/cdp_client.py +98 -10
  10. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/client_models.py +5 -2
  11. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/client_types.py +2 -0
  12. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/page.py +45 -0
  13. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/rpc_client.py +8 -4
  14. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/README.md +0 -0
  15. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/__init__.py +0 -0
  16. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/_extension/blank.html +0 -0
  17. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/_extension/content-script.js +0 -0
  18. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/_extension/manifest.json +0 -0
  19. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/_extension/offscreen/service-worker-heartbeat.html +0 -0
  20. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/_extension/offscreen/service-worker-heartbeat.js +0 -0
  21. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/_extension/wake-service-worker.html +0 -0
  22. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/_extension/wake-service-worker.js +0 -0
  23. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/_generated/__init__.py +0 -0
  24. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/_sdk_identity.py +0 -0
  25. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/browser_clipboard.py +0 -0
  26. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/browser_context.py +0 -0
  27. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/browserbase_connection.py +0 -0
  28. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/browserbase_rpc.py +0 -0
  29. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/browserbase_services.py +0 -0
  30. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/browserbase_session.py +0 -0
  31. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/browserbase_transport.py +0 -0
  32. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/extension_assets.py +0 -0
  33. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/file_upload.py +0 -0
  34. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/locator.py +0 -0
  35. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/py.typed +0 -0
  36. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/response.py +0 -0
  37. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/stagehand.py +0 -0
  38. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/timeouts.py +0 -0
  39. {stagehand-4.3.0a0.dev1620 → stagehand-4.3.0a0.dev1626}/src/stagehand/webmcp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stagehand
3
- Version: 4.3.0a0.dev1620
3
+ Version: 4.3.0a0.dev1626
4
4
  Summary: Browserbase's SDK for building browser agents
5
5
  Keywords: ai,agent,ai-agent,browser,browser-agent,browser-automation,computer-use,headless,playwright,cdp,llm,automation,web-scraping,testing
6
6
  Author: Browserbase
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stagehand"
3
- version = "4.3.0a0.dev1620"
3
+ version = "4.3.0a0.dev1626"
4
4
  description = "Browserbase's SDK for building browser agents"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -7363,7 +7363,7 @@ function isRecord$1(value) {
7363
7363
  }
7364
7364
  var package_default$1 = {
7365
7365
  name: "@browserbasehq/stagehand-protocol",
7366
- version: "3.0.0-alpha-76cd7e0f2ff04b0fb5c07e27e0db70746d0ad3c8",
7366
+ version: "3.0.0-alpha-901eb276429702cddd4bfe3a3656946f3ebc166a",
7367
7367
  "private": true,
7368
7368
  type: "module",
7369
7369
  exports: {
@@ -8553,6 +8553,13 @@ var PageWaitForLoadStateParamsSchema = PageIdParamsSchema.extend({
8553
8553
  timeout: number$1().int().nonnegative().optional()
8554
8554
  }).meta({ id: "PageWaitForLoadStateParams" });
8555
8555
  var PageWaitForTimeoutParamsSchema = PageIdParamsSchema.extend({ ms: number$1().int().nonnegative() }).meta({ id: "PageWaitForTimeoutParams" });
8556
+ var PageWaitForFunctionParamsSchema = PageIdParamsSchema.extend({
8557
+ expression: string(),
8558
+ options: strictObject({
8559
+ polling: union([literal("raf"), number$1().positive().max(2147483647)]).optional(),
8560
+ timeout: number$1().int().nonnegative().optional()
8561
+ }).meta({ id: "PageWaitForFunctionOptions" }).optional()
8562
+ }).meta({ id: "PageWaitForFunctionParams" });
8556
8563
  var PageWaitForSelectorParamsSchema = PageIdParamsSchema.extend({
8557
8564
  selector: string().min(1),
8558
8565
  options: strictObject({
@@ -8626,6 +8633,7 @@ var PageEvaluateResultSchema = strictObject({ value: json$1() }).meta({ id: "Pag
8626
8633
  var PageScreenshotResultSchema = strictObject({ data: base64().meta({ format: "byte" }) }).meta({ id: "PageScreenshotResult" });
8627
8634
  var PagePDFResultSchema = strictObject({ data: base64().meta({ format: "byte" }) }).meta({ id: "PagePDFResult" });
8628
8635
  var PageWaitForSelectorResultSchema = strictObject({ matched: boolean() }).meta({ id: "PageWaitForSelectorResult" });
8636
+ var PageWaitForFunctionResultSchema = strictObject({ value: json$1() }).meta({ id: "PageWaitForFunctionResult" });
8629
8637
  var LocatorClickResultSchema = strictObject({ clicked: literal(true) }).meta({ id: "LocatorClickResult" });
8630
8638
  var LocatorFillResultSchema = strictObject({ filled: literal(true) }).meta({ id: "LocatorFillResult" });
8631
8639
  var LocatorHoverResultSchema = strictObject({ hovered: literal(true) }).meta({ id: "LocatorHoverResult" });
@@ -8940,6 +8948,12 @@ var StagehandMethods = {
8940
8948
  params: PageWaitForSelectorParamsSchema,
8941
8949
  result: PageWaitForSelectorResultSchema
8942
8950
  },
8951
+ pageWaitForFunction: {
8952
+ name: "page.wait_for_function",
8953
+ params: PageWaitForFunctionParamsSchema,
8954
+ result: PageWaitForFunctionResultSchema,
8955
+ resultWire: { opaqueKeys: ["value"] }
8956
+ },
8943
8957
  pageWebMCPTools: {
8944
8958
  name: "page.webmcp_tools",
8945
8959
  params: PageWebMCPToolsParamsSchema,
@@ -12001,9 +12015,18 @@ var Locator$1 = class Locator$1 {
12001
12015
  };
12002
12016
  //#endregion
12003
12017
  //#region ../sdk-ts/src/pageScripts.ts
12004
- function normalizeEvaluationExpression(expression, arg) {
12018
+ function normalizeEvaluationExpression(expression, arg, caller = "page.evaluate") {
12005
12019
  if (typeof expression === "string") return expression;
12006
- return `(${expression.toString()})(${serializeArgument(arg, "page.evaluate")})`;
12020
+ return `(${expression.toString()})(${serializeArgument(arg, caller)})`;
12021
+ }
12022
+ function normalizeFunctionWaitExpression(expression, arg, caller = "page.waitForFunction") {
12023
+ if (typeof expression === "function") return `() => (${expression.toString()})(${parsedArgument(arg, caller)})`;
12024
+ if (arg === void 0) return expression;
12025
+ return `((value) => typeof value === "function" ? () => value(${parsedArgument(arg, caller)}) : value)((${expression}))`;
12026
+ }
12027
+ function parsedArgument(arg, caller) {
12028
+ if (arg === void 0) return "undefined";
12029
+ return `JSON.parse(${JSON.stringify(serializeArgument(arg, caller))})`;
12007
12030
  }
12008
12031
  async function normalizeInitScriptSource$1(script, arg, caller = "page.addInitScript") {
12009
12032
  if (typeof script === "function") return `(${script.toString()})(${serializeArgument(arg, caller)})`;
@@ -12026,9 +12049,8 @@ async function normalizeInitScriptSource$1(script, arg, caller = "page.addInitSc
12026
12049
  }
12027
12050
  function serializeArgument(arg, caller) {
12028
12051
  if (arg === void 0) return "undefined";
12029
- const parsed = json$1().safeParse(arg);
12030
- if (!parsed.success) throw new TypeError(`${caller}: 'arg' must be JSON-serializable.`);
12031
- const serialized = JSON.stringify(parsed.data);
12052
+ if (!json$1().safeParse(arg).success) throw new TypeError(`${caller}: 'arg' must be JSON-serializable.`);
12053
+ const serialized = JSON.stringify(arg);
12032
12054
  if (serialized === void 0) throw new TypeError(`${caller}: 'arg' must be JSON-serializable.`);
12033
12055
  return serialized;
12034
12056
  }
@@ -12362,6 +12384,14 @@ var Page$1 = class {
12362
12384
  ...options ? { options } : {}
12363
12385
  })).matched;
12364
12386
  }
12387
+ /** Poll in the page's main world until the expression returns a truthy JSON value. */
12388
+ async waitForFunction(expression, arg, options) {
12389
+ return (await this.rpcClient.send(StagehandMethods.pageWaitForFunction, {
12390
+ pageId: this.pageId,
12391
+ expression: normalizeFunctionWaitExpression(expression, arg),
12392
+ ...options ? { options } : {}
12393
+ })).value;
12394
+ }
12365
12395
  async screenshot(options) {
12366
12396
  const { path, mask, ...screenshotOptions } = options ?? {};
12367
12397
  return await decodeCaptureResult((await this.rpcClient.send(StagehandMethods.pageScreenshot, {
@@ -12527,8 +12557,20 @@ strictObject({
12527
12557
  acceptDownloads: boolean().optional(),
12528
12558
  keepAlive: boolean().optional()
12529
12559
  }).meta({ id: "LocalBrowserLaunchOptions" });
12560
+ var CDPHeadersSchema = preprocess((value) => {
12561
+ try {
12562
+ if (typeof value !== "object" || value === null || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) return null;
12563
+ const entries = Object.entries(value);
12564
+ if (!entries.every(([name, content]) => name.length > 0 && !/[^!#$%&'*+\-.^_`|~0-9A-Za-z]/.test(name) && typeof content === "string" && !/[^\t\x20-\x7e]/.test(content) && content === content.replace(/^[ \t]+|[ \t]+$/g, ""))) return null;
12565
+ return Object.fromEntries(entries);
12566
+ } catch {
12567
+ return null;
12568
+ }
12569
+ }, record(string(), string(), { error: "headers must map valid HTTP header names to ASCII string values without leading or trailing spaces or tabs" }));
12530
12570
  strictObject({
12531
12571
  cdpUrl: string().min(1),
12572
+ /** Headers for client-side CDP discovery and WebSocket connections. */
12573
+ headers: CDPHeadersSchema.optional(),
12532
12574
  extensionId: string().optional(),
12533
12575
  /**
12534
12576
  * Browser-level CDP WebSocket URL dialed by the extension inside Chrome.
@@ -13005,6 +13047,10 @@ function createPageController(runtime) {
13005
13047
  logger.debug("page.wait_for_selector", {});
13006
13048
  return runtime.pageWaitForSelector(params);
13007
13049
  }
13050
+ async function waitForFunction(params, { logger }) {
13051
+ logger.debug("page.wait_for_function", {});
13052
+ return runtime.pageWaitForFunction(params);
13053
+ }
13008
13054
  async function screenshot(params, { logger }) {
13009
13055
  logger.debug("page.screenshot", {});
13010
13056
  return runtime.pageScreenshot(params);
@@ -13071,6 +13117,7 @@ function createPageController(runtime) {
13071
13117
  waitForLoadState,
13072
13118
  waitForTimeout,
13073
13119
  waitForSelector,
13120
+ waitForFunction,
13074
13121
  screenshot,
13075
13122
  pdf,
13076
13123
  snapshot,
@@ -16943,6 +16990,38 @@ async function act$1(params) {
16943
16990
  };
16944
16991
  }
16945
16992
  //#endregion
16993
+ //#region understudy/browserWait.ts
16994
+ /** Browser exceptions are not transient CDP context-loss errors. */
16995
+ var BrowserWaitError = class extends Error {};
16996
+ /** Own a browser-side promise and dispose its resources, even after late installation. */
16997
+ async function browserWait(session, progress, install, phase) {
16998
+ const dispose = (objectId) => Promise.all([progress.cleanup(() => session.send("Runtime.callFunctionOn", {
16999
+ objectId,
17000
+ functionDeclaration: "function() { this.dispose(); }",
17001
+ returnByValue: true
17002
+ })), progress.cleanup(() => session.send("Runtime.releaseObject", { objectId }))]);
17003
+ const response = await install(async (late) => {
17004
+ if (late.result.objectId) await dispose(late.result.objectId);
17005
+ });
17006
+ const objectId = response.result.objectId;
17007
+ let completed = false;
17008
+ try {
17009
+ if (response.exceptionDetails || !objectId) throw new BrowserWaitError(response.exceptionDetails?.exception?.description ?? response.exceptionDetails?.text ?? "Browser wait handle was not returned");
17010
+ const result = await progress.run(phase, () => session.send("Runtime.callFunctionOn", {
17011
+ objectId,
17012
+ functionDeclaration: "function() { return this.promise; }",
17013
+ awaitPromise: true,
17014
+ returnByValue: true
17015
+ }));
17016
+ if (result.exceptionDetails) throw new BrowserWaitError(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "Browser wait failed");
17017
+ completed = true;
17018
+ return result.result.value;
17019
+ } finally {
17020
+ if (objectId) if (completed) progress.cleanup(() => session.send("Runtime.releaseObject", { objectId }));
17021
+ else await dispose(objectId);
17022
+ }
17023
+ }
17024
+ //#endregion
16946
17025
  //#region understudy/frame.ts
16947
17026
  function base64ToBytes$1(base64) {
16948
17027
  const binary = globalThis.atob(base64);
@@ -17068,46 +17147,25 @@ var Frame = class {
17068
17147
  }
17069
17148
  /** Own a disposable browser wait, including a handle delivered after expiry. */
17070
17149
  async waitInLocatorWorld(buildExpression, progress) {
17071
- const dispose = (objectId) => Promise.all([progress.cleanup(() => this.session.send("Runtime.callFunctionOn", {
17072
- objectId,
17073
- functionDeclaration: "function() { this.dispose(); }",
17074
- returnByValue: true
17075
- })), progress.cleanup(() => this.session.send("Runtime.releaseObject", { objectId }))]);
17076
17150
  for (let attempt = 0;; attempt++) {
17077
17151
  progress.throwIfStopped();
17078
- const response = await this.evaluateLocatorExpression(buildExpression, {
17079
- progress,
17080
- returnByValue: false,
17081
- awaitPromise: false,
17082
- onLateResult: async (late) => {
17083
- if (late.result.objectId) await dispose(late.result.objectId);
17084
- }
17085
- });
17086
- const objectId = response.result.objectId;
17087
- let completed = false;
17152
+ let contextId;
17088
17153
  try {
17089
- if (response.exceptionDetails || !objectId) throw new Error(response.exceptionDetails?.exception?.description ?? response.exceptionDetails?.text ?? "Selector wait handle was not returned");
17090
- let result;
17091
- try {
17092
- result = await progress.run("waiting for selector", () => this.session.send("Runtime.callFunctionOn", {
17093
- objectId,
17094
- functionDeclaration: "function() { return this.promise; }",
17095
- awaitPromise: true,
17096
- returnByValue: true
17097
- }));
17098
- } catch (error) {
17099
- progress.throwIfStopped();
17100
- const message = error instanceof Error ? error.message : String(error);
17101
- if (attempt !== 0 || !message.includes("Cannot find context with specified id")) throw error;
17102
- executionContexts.unregisterLocatorContext(this.session, response.contextId);
17103
- continue;
17104
- }
17105
- if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "Selector wait failed");
17106
- completed = true;
17107
- return result.result.value;
17108
- } finally {
17109
- if (objectId) if (completed) progress.cleanup(() => this.session.send("Runtime.releaseObject", { objectId }));
17110
- else await dispose(objectId);
17154
+ return await browserWait(this.session, progress, async (onLateResult) => {
17155
+ const response = await this.evaluateLocatorExpression(buildExpression, {
17156
+ progress,
17157
+ returnByValue: false,
17158
+ awaitPromise: false,
17159
+ onLateResult
17160
+ });
17161
+ contextId = response.contextId;
17162
+ return response;
17163
+ }, "waiting for selector");
17164
+ } catch (error) {
17165
+ progress.throwIfStopped();
17166
+ const message = error instanceof Error ? error.message : String(error);
17167
+ if (attempt !== 0 || contextId === void 0 || error instanceof BrowserWaitError || !message.includes("Cannot find context with specified id")) throw error;
17168
+ executionContexts.unregisterLocatorContext(this.session, contextId);
17111
17169
  }
17112
17170
  }
17113
17171
  }
@@ -52076,6 +52134,7 @@ var RPCRouter = class {
52076
52134
  case "page.wait_for_load_state": return this.pageController.waitForLoadState(parseParams(StagehandMethods.pageWaitForLoadState, request.params), context);
52077
52135
  case "page.wait_for_timeout": return this.pageController.waitForTimeout(parseParams(StagehandMethods.pageWaitForTimeout, request.params), context);
52078
52136
  case "page.wait_for_selector": return this.pageController.waitForSelector(parseParams(StagehandMethods.pageWaitForSelector, request.params), context);
52137
+ case "page.wait_for_function": return this.pageController.waitForFunction(parseParams(StagehandMethods.pageWaitForFunction, request.params), context);
52079
52138
  case "page.screenshot": return this.pageController.screenshot(parseParams(StagehandMethods.pageScreenshot, request.params), context);
52080
52139
  case "page.pdf": return this.pageController.pdf(parseParams(StagehandMethods.pagePDF, request.params), context);
52081
52140
  case "page.snapshot": return this.pageController.snapshot(parseParams(StagehandMethods.pageSnapshot, request.params), context);
@@ -54471,7 +54530,7 @@ var WebTracerProvider = class extends BasicTracerProvider {
54471
54530
  };
54472
54531
  var package_default = {
54473
54532
  name: "@browserbasehq/stagehand-extension",
54474
- version: "1.2.0-alpha-76cd7e0f2ff04b0fb5c07e27e0db70746d0ad3c8",
54533
+ version: "1.2.0-alpha-901eb276429702cddd4bfe3a3656946f3ebc166a",
54475
54534
  "private": true,
54476
54535
  type: "module",
54477
54536
  scripts: {
@@ -55866,6 +55925,83 @@ async function normalizeInitScriptSource(script, arg, caller = DEFAULT_CALLER) {
55866
55925
  throw new TypeError(`${caller}: provide a string, function, or an object with content.`);
55867
55926
  }
55868
55927
  //#endregion
55928
+ //#region understudy/functionWait.ts
55929
+ /** Runs in the page's main world; keep this function self-contained. */
55930
+ function createFunctionWait(predicate, polling) {
55931
+ let stopped = false;
55932
+ let timer;
55933
+ let animationFrame;
55934
+ let resolve;
55935
+ let reject;
55936
+ const promise = new Promise((done, fail) => {
55937
+ resolve = done;
55938
+ reject = fail;
55939
+ });
55940
+ promise.catch(() => {});
55941
+ const stop = () => {
55942
+ stopped = true;
55943
+ clearTimeout(timer);
55944
+ if (animationFrame !== void 0) cancelAnimationFrame(animationFrame);
55945
+ };
55946
+ const poll = async () => {
55947
+ if (stopped) return;
55948
+ try {
55949
+ const evaluated = predicate();
55950
+ const value = await (typeof evaluated === "function" ? evaluated() : evaluated);
55951
+ if (stopped) return;
55952
+ if (value) {
55953
+ const serialized = JSON.stringify(value);
55954
+ if (serialized === void 0) throw new TypeError("waitForFunction result must be JSON-serializable");
55955
+ const result = JSON.parse(serialized);
55956
+ stop();
55957
+ resolve(result);
55958
+ } else if (polling === "raf") animationFrame = requestAnimationFrame(() => void poll());
55959
+ else timer = setTimeout(() => void poll(), polling);
55960
+ } catch (error) {
55961
+ stop();
55962
+ reject(error);
55963
+ }
55964
+ };
55965
+ poll();
55966
+ return {
55967
+ promise,
55968
+ dispose() {
55969
+ if (stopped) return;
55970
+ stop();
55971
+ reject(/* @__PURE__ */ new Error("Function wait disposed"));
55972
+ }
55973
+ };
55974
+ }
55975
+ async function waitForFunction(getFrame, expression, options = {}) {
55976
+ const polling = options.polling ?? "raf";
55977
+ if (polling !== "raf" && (!Number.isFinite(polling) || polling <= 0 || polling > 2147483647)) throw new RangeError("Polling must be 'raf' or a positive interval up to 2147483647ms");
55978
+ return runWithProgress({
55979
+ name: "waitForFunction",
55980
+ timeout: options.timeout ?? 0
55981
+ }, async (progress) => {
55982
+ for (;;) {
55983
+ progress.throwIfStopped();
55984
+ const frame = getFrame();
55985
+ try {
55986
+ const contextId = await frame.getMainWorldExecutionContextId(progress);
55987
+ if (getFrame() !== frame) continue;
55988
+ return await browserWait(frame.session, progress, (onLateResult) => progress.run("installing function wait", () => frame.session.send("Runtime.evaluate", {
55989
+ expression: `(${createFunctionWait.toString()})(() => (${expression}), ${JSON.stringify(polling)})`,
55990
+ contextId,
55991
+ awaitPromise: false,
55992
+ returnByValue: false
55993
+ }), onLateResult), "waiting for function");
55994
+ } catch (error) {
55995
+ progress.throwIfStopped();
55996
+ if (error instanceof BrowserWaitError) throw error;
55997
+ const message = error instanceof Error ? error.message : String(error);
55998
+ if (!message.includes("Cannot find context with specified id") && !message.includes("Execution context was destroyed") && !message.includes("Cannot find object with given id") && !message.includes("Inspected target navigated or closed") && !message.startsWith("main world not ready for frame ")) throw error;
55999
+ await progress.delay(25);
56000
+ }
56001
+ }
56002
+ });
56003
+ }
56004
+ //#endregion
55869
56005
  //#region dom/screenshotScripts/resolveMaskRect.ts
55870
56006
  function resolveMaskRect(maskToken) {
55871
56007
  function safeClosest(el, selector) {
@@ -57424,6 +57560,14 @@ var Page = class Page {
57424
57560
  async waitForTimeout(ms) {
57425
57561
  return new Promise((resolve) => setTimeout(resolve, ms));
57426
57562
  }
57563
+ /** Poll an expression in the page's main world until its value is truthy. */
57564
+ async waitForFunction(expression, options) {
57565
+ return waitForFunction(() => {
57566
+ if (this.disposed) throw new Error("Page is disposed");
57567
+ if (!this.conn.connected) throw new Error("CDP connection closed: function wait stopped");
57568
+ return this.mainFrameWrapper;
57569
+ }, expression, options);
57570
+ }
57427
57571
  /**
57428
57572
  * Wait for an element matching the selector to appear in the DOM.
57429
57573
  * Uses MutationObserver for efficiency
@@ -58633,6 +58777,9 @@ var StagehandRuntime = class {
58633
58777
  async pageWaitForSelector(params) {
58634
58778
  return { matched: await this.resolvePage(params.pageId).waitForSelector(params.selector, params.options) };
58635
58779
  }
58780
+ async pageWaitForFunction(params) {
58781
+ return { value: await this.resolvePage(params.pageId).waitForFunction(params.expression, params.options) };
58782
+ }
58636
58783
  async pageScreenshot(params) {
58637
58784
  const page = this.resolvePage(params.pageId);
58638
58785
  let options;
@@ -277,6 +277,9 @@ FieldSchema13: TypeAlias = Optional[Union[str, float, bool, list["FieldSchema13"
277
277
  FieldSchema14: TypeAlias = Optional[Union[str, float, bool, list["FieldSchema14"], dict[str, "FieldSchema14"]]]
278
278
 
279
279
 
280
+ FieldSchema15: TypeAlias = Optional[Union[str, float, bool, list["FieldSchema15"], dict[str, "FieldSchema15"]]]
281
+
282
+
280
283
  FieldSchema2: TypeAlias = Optional[Union[str, float, bool, list["FieldSchema2"], dict[str, "FieldSchema2"]]]
281
284
 
282
285
 
@@ -332,7 +335,7 @@ class InputFilePayload(TypedDict):
332
335
  class JSONRPCErrorObject(TypedDict):
333
336
  code: int
334
337
  message: str
335
- data: NotRequired[FieldSchema14]
338
+ data: NotRequired[FieldSchema15]
336
339
 
337
340
 
338
341
  JSONRPCRequestId: TypeAlias = int
@@ -718,7 +721,7 @@ class PageAddInitScriptParams(TypedDict):
718
721
  source: str
719
722
 
720
723
 
721
- PageCDPEventParams: TypeAlias = dict[str, FieldSchema12]
724
+ PageCDPEventParams: TypeAlias = dict[str, FieldSchema13]
722
725
 
723
726
 
724
727
  class PageCDPEvent(TypedDict):
@@ -984,6 +987,21 @@ class PageVoidResult(TypedDict):
984
987
  ok: Literal[True]
985
988
 
986
989
 
990
+ class PageWaitForFunctionOptions(TypedDict):
991
+ polling: NotRequired[Literal["raf"] | float]
992
+ timeout: NotRequired[int]
993
+
994
+
995
+ class PageWaitForFunctionParams(TypedDict):
996
+ page_id: str
997
+ expression: str
998
+ options: NotRequired[PageWaitForFunctionOptions]
999
+
1000
+
1001
+ class PageWaitForFunctionResult(TypedDict):
1002
+ value: FieldSchema10
1003
+
1004
+
987
1005
  class PageWaitForLoadStateParams(TypedDict):
988
1006
  page_id: str
989
1007
  state: LoadState
@@ -1101,7 +1119,7 @@ class StagehandInitResult(TypedDict):
1101
1119
  pages: list[PageRef]
1102
1120
 
1103
1121
 
1104
- StagehandLogData: TypeAlias = dict[str, FieldSchema11]
1122
+ StagehandLogData: TypeAlias = dict[str, FieldSchema12]
1105
1123
 
1106
1124
 
1107
1125
  StagehandLogLevel: TypeAlias = Literal["debug", "info", "warn", "error"]
@@ -1242,7 +1260,7 @@ class WebMCPAnnotation(TypedDict):
1242
1260
  WebMCPInvocationStatus: TypeAlias = Literal["Completed", "Canceled", "Error"]
1243
1261
 
1244
1262
 
1245
- WebMCPJsonValue: TypeAlias = FieldSchema10
1263
+ WebMCPJsonValue: TypeAlias = FieldSchema11
1246
1264
 
1247
1265
 
1248
1266
  class PageWebMCPInvokeToolParams(TypedDict):
@@ -710,6 +710,12 @@ class FieldSchema14(
710
710
  root: Optional[Union[StrictStr, StrictInt, StrictFloat, StrictBool, list[Optional["FieldSchema14"]], dict[StrictStr, Optional["FieldSchema14"]]]]
711
711
 
712
712
 
713
+ class FieldSchema15(
714
+ RootModel[Optional[Union[StrictStr, StrictInt, StrictFloat, StrictBool, list[Optional["FieldSchema15"]], dict[StrictStr, Optional["FieldSchema15"]]]]]
715
+ ):
716
+ root: Optional[Union[StrictStr, StrictInt, StrictFloat, StrictBool, list[Optional["FieldSchema15"]], dict[StrictStr, Optional["FieldSchema15"]]]]
717
+
718
+
713
719
  class FieldSchema2(
714
720
  RootModel[Optional[Union[StrictStr, StrictInt, StrictFloat, StrictBool, list[Optional["FieldSchema2"]], dict[StrictStr, Optional["FieldSchema2"]]]]]
715
721
  ):
@@ -811,7 +817,7 @@ class JSONRPCErrorObject(WireModel):
811
817
  )
812
818
  code: Annotated[StrictInt, Field(ge=-9007199254740991, le=9007199254740991)]
813
819
  message: StrictStr
814
- data: Optional[FieldSchema14] = None
820
+ data: Optional[FieldSchema15] = None
815
821
 
816
822
 
817
823
  class JSONRPCRequestId(RootModel[StrictInt]):
@@ -1564,8 +1570,8 @@ class PageCDPEventNotification(WireModel):
1564
1570
  event: PageCDPEvent
1565
1571
 
1566
1572
 
1567
- class PageCDPEventParams(RootModel[dict[StrictStr, Optional[FieldSchema12]]]):
1568
- root: dict[StrictStr, Optional[FieldSchema12]]
1573
+ class PageCDPEventParams(RootModel[dict[StrictStr, Optional[FieldSchema13]]]):
1574
+ root: dict[StrictStr, Optional[FieldSchema13]]
1569
1575
 
1570
1576
 
1571
1577
  class PageClickOptions(WireModel):
@@ -2027,6 +2033,33 @@ class PageVoidResult(WireModel):
2027
2033
  ok: Literal[True]
2028
2034
 
2029
2035
 
2036
+ class PageWaitForFunctionOptions(WireModel):
2037
+ model_config = ConfigDict(
2038
+ extra="forbid",
2039
+ validate_by_name=True,
2040
+ )
2041
+ polling: Optional[Union[Literal["raf"], Polling]] = None
2042
+ timeout: Annotated[Optional[StrictInt], Field(ge=0, le=9007199254740991)] = None
2043
+
2044
+
2045
+ class PageWaitForFunctionParams(WireModel):
2046
+ model_config = ConfigDict(
2047
+ extra="forbid",
2048
+ validate_by_name=True,
2049
+ )
2050
+ page_id: StrictStr
2051
+ expression: StrictStr
2052
+ options: Optional[PageWaitForFunctionOptions] = None
2053
+
2054
+
2055
+ class PageWaitForFunctionResult(WireModel):
2056
+ model_config = ConfigDict(
2057
+ extra="forbid",
2058
+ validate_by_name=True,
2059
+ )
2060
+ value: Optional[FieldSchema10]
2061
+
2062
+
2030
2063
  class PageWaitForLoadStateParams(WireModel):
2031
2064
  model_config = ConfigDict(
2032
2065
  extra="forbid",
@@ -2123,6 +2156,10 @@ class PageWebMCPToolsResult(WireModel):
2123
2156
  tools: list[WebMCPToolDescriptor]
2124
2157
 
2125
2158
 
2159
+ class Polling(RootModel[StrictFloat]):
2160
+ root: Annotated[StrictFloat, Field(gt=0.0, le=2147483647.0)]
2161
+
2162
+
2126
2163
  class ProxyConfig(RootModel[Union[BrowserbaseProxyConfig, ExternalProxyConfig]]):
2127
2164
  root: Union[BrowserbaseProxyConfig, ExternalProxyConfig]
2128
2165
 
@@ -2321,8 +2358,8 @@ class StagehandLog(WireModel):
2321
2358
  data: StagehandLogData
2322
2359
 
2323
2360
 
2324
- class StagehandLogData(RootModel[dict[StrictStr, Optional[FieldSchema11]]]):
2325
- root: dict[StrictStr, Optional[FieldSchema11]]
2361
+ class StagehandLogData(RootModel[dict[StrictStr, Optional[FieldSchema12]]]):
2362
+ root: dict[StrictStr, Optional[FieldSchema12]]
2326
2363
 
2327
2364
 
2328
2365
  class StagehandLogLevel(StrEnum):
@@ -2481,8 +2518,8 @@ class WebMCPInvocationStatus(StrEnum):
2481
2518
  error = "Error"
2482
2519
 
2483
2520
 
2484
- class WebMCPJsonValue(RootModel[Optional[FieldSchema10]]):
2485
- root: Optional[FieldSchema10]
2521
+ class WebMCPJsonValue(RootModel[Optional[FieldSchema11]]):
2522
+ root: Optional[FieldSchema11]
2486
2523
 
2487
2524
 
2488
2525
  class WebMCPRemoteObject(RootModel[dict[StrictStr, WebMCPJsonValue]]):
@@ -2548,6 +2585,7 @@ FieldSchema11.model_rebuild()
2548
2585
  FieldSchema12.model_rebuild()
2549
2586
  FieldSchema13.model_rebuild()
2550
2587
  FieldSchema14.model_rebuild()
2588
+ FieldSchema15.model_rebuild()
2551
2589
  FieldSchema2.model_rebuild()
2552
2590
  FieldSchema3.model_rebuild()
2553
2591
  FieldSchema4.model_rebuild()
@@ -1,4 +1,4 @@
1
1
  """Generated from packages/protocol/package.json. Do not edit."""
2
2
 
3
3
  PROTOCOL_SEMVER_PATTERN = '^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$'
4
- STAGEHAND_PROTOCOL_VERSION = '3.0.0-alpha-76cd7e0f2ff04b0fb5c07e27e0db70746d0ad3c8'
4
+ STAGEHAND_PROTOCOL_VERSION = '3.0.0-alpha-901eb276429702cddd4bfe3a3656946f3ebc166a'
@@ -1,8 +1,19 @@
1
+ import re
1
2
  from collections.abc import Mapping
2
- from typing import Annotated
3
+ from typing import Annotated, cast
3
4
  from urllib.parse import urlparse
4
5
 
5
- from pydantic import AfterValidator, AnyUrl, BaseModel, ConfigDict, TypeAdapter, model_validator
6
+ from pydantic import (
7
+ AfterValidator,
8
+ AnyUrl,
9
+ BaseModel,
10
+ ConfigDict,
11
+ TypeAdapter,
12
+ ValidationError,
13
+ ValidatorFunctionWrapHandler,
14
+ WrapValidator,
15
+ model_validator,
16
+ )
6
17
  from typing_extensions import override
7
18
 
8
19
  _url_adapter = TypeAdapter(AnyUrl)
@@ -15,6 +26,54 @@ def _validate_url(value: str) -> str:
15
26
 
16
27
  WireUrl = Annotated[str, AfterValidator(_validate_url)]
17
28
 
29
+ # RFC 9110 field names are tokens. Restrict values to ASCII so HTTP discovery
30
+ # and WebSocket handshakes encode them identically; allow only internal whitespace.
31
+ _HEADER_NAME = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+")
32
+ _HEADER_VALUE = re.compile(r"[\t\x20-\x7e]*")
33
+
34
+
35
+ def _validate_headers(value: Mapping[str, str]) -> Mapping[str, str]:
36
+ for name, header_value in value.items():
37
+ if not _HEADER_NAME.fullmatch(name):
38
+ raise ValueError
39
+ if not _HEADER_VALUE.fullmatch(header_value) or header_value != header_value.strip(" \t"):
40
+ raise ValueError
41
+ return value
42
+
43
+
44
+ def _redact_header_validation_errors(
45
+ value: object, handler: ValidatorFunctionWrapHandler
46
+ ) -> dict[str, str]:
47
+ try:
48
+ return cast(dict[str, str], handler(value))
49
+ except ValidationError:
50
+ # Error inputs, locations, and validator context can all contain credentials.
51
+ # Rebuild the error rather than only hiding inputs in its formatted message.
52
+ raise ValidationError.from_exception_data(
53
+ "CDP headers",
54
+ [
55
+ {
56
+ "type": "value_error",
57
+ "loc": (),
58
+ "input": "[redacted]",
59
+ "ctx": {
60
+ "error": ValueError(
61
+ "headers must map valid HTTP header names to ASCII string values "
62
+ "without leading or trailing spaces or tabs"
63
+ )
64
+ },
65
+ }
66
+ ],
67
+ hide_input=True,
68
+ ) from None
69
+
70
+
71
+ WireHeaders = Annotated[
72
+ dict[str, str],
73
+ AfterValidator(_validate_headers),
74
+ WrapValidator(_redact_header_validation_errors),
75
+ ]
76
+
18
77
 
19
78
  def _validate_websocket_url(value: str) -> str:
20
79
  parsed = urlparse(value)
@@ -312,12 +312,14 @@ async def _connect_browser(
312
312
  preloaded_extension: bool = False,
313
313
  after_connect: Callable[[CDPClient], Awaitable[None]] | None = None,
314
314
  worker_init_metadata: _WorkerInitMetadata,
315
+ headers: Mapping[str, str] | None = None,
315
316
  ) -> StagehandBrowser:
316
317
  owns_source = origin == "launched" and not source.keep_alive
317
318
  cdp_client: CDPClient | None = None
318
319
  try:
319
320
  cdp_client = await CDPClient.connect(
320
321
  cdp_url=source.cdp_url,
322
+ **({"headers": headers} if headers else {}),
321
323
  extension_dir=extension_dir,
322
324
  local_extension_dir=local_extension_dir,
323
325
  extension_id=extension_id,
@@ -536,6 +538,7 @@ class LocalBrowser:
536
538
  cdp_url: str,
537
539
  extension_id: str | None = None,
538
540
  extension_cdp_url: str | None = None,
541
+ headers: Mapping[str, str] | None = None,
539
542
  ) -> StagehandBrowser:
540
543
  """Connect to an existing browser.
541
544
 
@@ -547,6 +550,10 @@ class LocalBrowser:
547
550
  client connected to. Set it when the client reaches Chrome through
548
551
  a proxy the browser cannot or should not use, such as an
549
552
  authenticated gateway whose URL carries credentials.
553
+ headers: HTTP headers to send with the CDP connection, such as a
554
+ gateway's credentials, so they need not ride in `cdp_url`. They
555
+ go to `cdp_url` and to the WebSocket it advertises when that is
556
+ on the same host, never elsewhere, and never to the extension.
550
557
  """
551
558
  options = LocalBrowserConnectOptions.model_validate({
552
559
  name: value
@@ -554,6 +561,7 @@ class LocalBrowser:
554
561
  ("cdp_url", cdp_url),
555
562
  ("extension_id", extension_id),
556
563
  ("extension_cdp_url", extension_cdp_url),
564
+ ("headers", dict(headers) if isinstance(headers, Mapping) else headers),
557
565
  )
558
566
  if value is not None
559
567
  })
@@ -567,6 +575,7 @@ class LocalBrowser:
567
575
  browser=None,
568
576
  browser_cdp_url=options.extension_cdp_url,
569
577
  ),
578
+ headers=options.headers,
570
579
  )
571
580
 
572
581
 
@@ -2,19 +2,28 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import json
5
+ import logging
5
6
  import re
6
7
  import time
7
8
  from collections.abc import Callable, Mapping
8
9
  from contextlib import suppress
9
10
  from dataclasses import dataclass
10
11
  from typing import Any, Literal, Protocol, cast
11
- from urllib.request import urlopen
12
+ from urllib.error import HTTPError
13
+ from urllib.parse import urljoin, urlsplit
14
+ from urllib.request import HTTPRedirectHandler, Request, build_opener, urlopen
15
+
16
+ from typing_extensions import override
17
+ from websockets.asyncio.client import connect as WebSocketConnect
18
+ from websockets.exceptions import InvalidStatus
12
19
 
13
20
  from stagehand._generated.protocol_version import (
14
21
  PROTOCOL_SEMVER_PATTERN,
15
22
  STAGEHAND_PROTOCOL_VERSION,
16
23
  )
17
24
 
25
+ _LOGGER = logging.getLogger(__name__)
26
+
18
27
  STAGEHAND_SEND_TO_HOST_BINDING = "__stagehandSendToHost"
19
28
  _RUNTIME_NAME = "stagehand"
20
29
  _STAGEHAND_EXTENSION_NAME = "Stagehand Runtime"
@@ -257,6 +266,7 @@ class CDPClient:
257
266
  extension_id: str | None = None,
258
267
  preloaded_extension: bool = False,
259
268
  service_worker_url_includes: str | None = None,
269
+ headers: Mapping[str, str] | None = None,
260
270
  ) -> CDPClient:
261
271
  """Connect without an independent lifecycle deadline.
262
272
 
@@ -278,8 +288,16 @@ class CDPClient:
278
288
  "or preloaded_extension is required"
279
289
  )
280
290
 
281
- web_socket_debugger_url = await _resolve_browser_web_socket_url(cdp_url)
282
- socket = await _connect_web_socket(web_socket_debugger_url)
291
+ web_socket_debugger_url = await _resolve_browser_web_socket_url(cdp_url, headers=headers)
292
+ web_socket_headers = headers or None
293
+ if web_socket_headers and not _can_forward_headers(cdp_url, web_socket_debugger_url):
294
+ web_socket_headers = None
295
+ _LOGGER.debug(
296
+ "CDP headers were withheld from the advertised WebSocket endpoint because "
297
+ "its host or port differs, or forwarding would downgrade transport security. "
298
+ "If the handshake is rejected, check the gateway's advertised CDP endpoint."
299
+ )
300
+ socket = await _connect_web_socket(web_socket_debugger_url, headers=web_socket_headers)
283
301
  client = cls(socket, web_socket_debugger_url)
284
302
 
285
303
  try:
@@ -659,33 +677,103 @@ class CDPClient:
659
677
  task.add_done_callback(finish)
660
678
 
661
679
 
662
- async def _connect_web_socket(url: str) -> _WebSocket:
663
- from websockets.asyncio.client import connect
680
+ class _UnsafeCDPRedirectError(RuntimeError):
681
+ """A redirect would expose caller headers to an unauthorized endpoint."""
682
+
683
+
684
+ _DEFAULT_PORTS = {"http": 80, "ws": 80, "https": 443, "wss": 443}
685
+
686
+
687
+ def _can_forward_headers(source: str, destination: str) -> bool:
688
+ """Require the same host and effective port, without a TLS downgrade."""
689
+ try:
690
+ first, second = urlsplit(source), urlsplit(destination)
691
+ return (
692
+ first.scheme in _DEFAULT_PORTS
693
+ and second.scheme in _DEFAULT_PORTS
694
+ and first.hostname is not None
695
+ and first.hostname == second.hostname
696
+ and (first.port if first.port is not None else _DEFAULT_PORTS[first.scheme])
697
+ == (second.port if second.port is not None else _DEFAULT_PORTS[second.scheme])
698
+ and not (first.scheme in ("https", "wss") and second.scheme in ("http", "ws"))
699
+ )
700
+ except ValueError:
701
+ return False
702
+
703
+
704
+ class _CDPRedirectHandler(HTTPRedirectHandler):
705
+ @override
706
+ def redirect_request(
707
+ self, req: Request, fp: Any, code: int, msg: str, headers: Any, newurl: str
708
+ ) -> Request | None:
709
+ if not _can_forward_headers(req.full_url, newurl):
710
+ raise _UnsafeCDPRedirectError(
711
+ "Refusing CDP HTTP redirect with headers to a different host or port, "
712
+ "or an insecure endpoint"
713
+ )
714
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
715
+
716
+
717
+ class _CDPWebSocketConnect(WebSocketConnect):
718
+ @override
719
+ def process_redirect(self, exc: Exception) -> Exception | str:
720
+ if (
721
+ self.additional_headers
722
+ and isinstance(exc, InvalidStatus)
723
+ and exc.response.status_code in (300, 301, 302, 303, 307, 308)
724
+ and "Location" in exc.response.headers
725
+ and not _can_forward_headers(
726
+ self.uri, urljoin(self.uri, exc.response.headers["Location"])
727
+ )
728
+ ):
729
+ return _UnsafeCDPRedirectError(
730
+ "Refusing CDP WebSocket redirect with headers to a different host or port, "
731
+ "or an insecure endpoint"
732
+ )
733
+ return super().process_redirect(exc)
734
+
664
735
 
736
+ async def _connect_web_socket(url: str, *, headers: Mapping[str, str] | None = None) -> _WebSocket:
665
737
  return cast(
666
738
  _WebSocket,
667
- await connect(url, open_timeout=None, max_size=None),
739
+ await _CDPWebSocketConnect(
740
+ url, open_timeout=None, max_size=None, additional_headers=headers
741
+ ),
668
742
  )
669
743
 
670
744
 
671
- async def _resolve_browser_web_socket_url(cdp_url: str) -> str:
745
+ async def _resolve_browser_web_socket_url(
746
+ cdp_url: str, *, headers: Mapping[str, str] | None = None
747
+ ) -> str:
672
748
  if cdp_url.startswith(("ws://", "wss://")):
673
749
  return cdp_url
674
750
 
675
751
  base_url = cdp_url.rstrip("/")
676
752
  while True:
677
753
  try:
678
- version = await asyncio.to_thread(_read_json, f"{base_url}/json/version")
754
+ version = await asyncio.to_thread(_read_json, f"{base_url}/json/version", headers)
679
755
  debugger_url = version.get("webSocketDebuggerUrl")
680
756
  if isinstance(debugger_url, str) and debugger_url:
681
757
  return debugger_url
758
+ except _UnsafeCDPRedirectError:
759
+ raise
760
+ except HTTPError as error:
761
+ error.close()
762
+ if error.code in (401, 403):
763
+ raise RuntimeError(
764
+ f"CDP discovery was rejected with HTTP {error.code}. "
765
+ "Check the gateway credentials and access permissions."
766
+ ) from None
682
767
  except Exception:
683
768
  pass
684
769
  await asyncio.sleep(0.25)
685
770
 
686
771
 
687
- def _read_json(url: str) -> dict[str, object]:
688
- with urlopen(url, timeout=2) as response: # noqa: S310 -- The user selects the CDP URL.
772
+ def _read_json(url: str, headers: Mapping[str, str] | None = None) -> dict[str, object]:
773
+ request = Request(url, headers=dict(headers or {}))
774
+ # Keep the existing redirect behavior for connections without caller headers.
775
+ open_url = build_opener(_CDPRedirectHandler()).open if headers else urlopen
776
+ with open_url(request, timeout=2) as response:
689
777
  value: Any = json.load(response)
690
778
  if not isinstance(value, dict):
691
779
  raise RuntimeError("CDP version endpoint returned invalid JSON")
@@ -18,7 +18,7 @@ from ._generated.models import (
18
18
  StagehandResultMetadata,
19
19
  TelemetryConfig,
20
20
  )
21
- from ._validation import WireModel, WireUrl, WireWebSocketUrl
21
+ from ._validation import WireHeaders, WireModel, WireUrl, WireWebSocketUrl
22
22
  from .browserbase_session import DEFAULT_BROWSERBASE_URL
23
23
  from .client_types import Cache as CacheInput
24
24
 
@@ -103,13 +103,16 @@ class LocalBrowserLaunchOptions(WireModel):
103
103
 
104
104
 
105
105
  class LocalBrowserConnectOptions(WireModel):
106
- model_config = ConfigDict(extra="forbid", strict=True)
106
+ # Headers may carry credentials, so validation errors never echo the input.
107
+ model_config = ConfigDict(extra="forbid", strict=True, hide_input_in_errors=True)
107
108
 
108
109
  cdp_url: Annotated[str, Field(min_length=1)]
109
110
  extension_id: str | None = None
110
111
  """Deprecated and ignored. Omit this field; Stagehand discovers the extension automatically."""
111
112
  extension_cdp_url: WireWebSocketUrl | None = None
112
113
  """Browser-level CDP WebSocket the extension dials, when it differs from `cdp_url`."""
114
+ headers: WireHeaders | None = None
115
+ """HTTP headers sent with the CDP connection, e.g. a gateway's credentials."""
113
116
 
114
117
 
115
118
  class BrowserbaseClientOptions(WireModel):
@@ -69,6 +69,8 @@ class LocalBrowserConnectOptions(TypedDict):
69
69
  """Deprecated and ignored. Omit this field; Stagehand discovers the extension automatically."""
70
70
  extension_cdp_url: NotRequired[str]
71
71
  """Browser-level CDP WebSocket the extension dials, when it differs from `cdp_url`."""
72
+ headers: NotRequired[dict[str, str]]
73
+ """HTTP headers sent with the CDP connection, e.g. a gateway's credentials."""
72
74
 
73
75
 
74
76
  class BrowserbaseClientOptions(TypedDict, total=False):
@@ -4,6 +4,7 @@ import asyncio
4
4
  import base64
5
5
  import builtins
6
6
  import inspect
7
+ import json
7
8
  from collections.abc import Awaitable, Callable, Mapping, Sequence
8
9
  from pathlib import Path
9
10
  from typing import Literal, TypeVar, cast, overload
@@ -67,6 +68,9 @@ from ._generated.models import (
67
68
  PageTypeParams,
68
69
  PageUrlResult,
69
70
  PageVoidResult,
71
+ PageWaitForFunctionOptions,
72
+ PageWaitForFunctionParams,
73
+ PageWaitForFunctionResult,
70
74
  PageWaitForLoadStateParams,
71
75
  PageWaitForSelectorOptions,
72
76
  PageWaitForSelectorParams,
@@ -94,6 +98,9 @@ ToolsAddedListener = Callable[[list[WebMCPTool]], object | Awaitable[object]]
94
98
  ToolsRemovedListener = Callable[[list[WebMCPToolIdentity]], object | Awaitable[object]]
95
99
  NotificationT = TypeVar("NotificationT", PageCDPEventNotification, PageEventNotification)
96
100
 
101
+ # Distinguishes an omitted wait_for_function ``arg`` from an explicit JSON null.
102
+ _UNSET_ARG = object()
103
+
97
104
 
98
105
  class CDPSubscription:
99
106
  def __init__(
@@ -539,6 +546,44 @@ class Page:
539
546
  PageVoidResult,
540
547
  )
541
548
 
549
+ async def wait_for_function(
550
+ self,
551
+ expression: str,
552
+ *,
553
+ arg: JsonValue = cast(JsonValue, _UNSET_ARG),
554
+ polling: Literal["raf"] | float | None = None,
555
+ timeout: int | None = None,
556
+ ) -> JsonValue:
557
+ """Poll JavaScript in the page until it returns a truthy JSON value."""
558
+ if arg is not _UNSET_ARG:
559
+ try:
560
+ serialized_arg = json.dumps(arg, allow_nan=False)
561
+ except (TypeError, ValueError) as error:
562
+ message = "page.wait_for_function: 'arg' must be JSON-serializable."
563
+ raise TypeError(message) from error
564
+ # Like Playwright, a function expression receives arg; other expressions ignore it.
565
+ # The browser invokes the returned thunk once per poll. JSON.parse keeps own
566
+ # "__proto__" keys that an object literal would treat as a prototype.
567
+ parsed_arg = f"JSON.parse({json.dumps(serialized_arg)})"
568
+ expression = (
569
+ f'((value) => typeof value === "function" ? () => value({parsed_arg}) : value)'
570
+ f"(({expression}))"
571
+ )
572
+ params = PageWaitForFunctionParams(page_id=self.page_id, expression=expression)
573
+ options = PageWaitForFunctionOptions.model_validate({
574
+ name: value
575
+ for name, value in (("polling", polling), ("timeout", timeout))
576
+ if value is not None
577
+ })
578
+ if options.model_fields_set:
579
+ params.options = options
580
+ result = await self._rpc_client.send(
581
+ "page.wait_for_function",
582
+ params,
583
+ PageWaitForFunctionResult,
584
+ )
585
+ return None if result.value is None else cast(JsonValue, result.value.model_dump())
586
+
542
587
  async def wait_for_selector(
543
588
  self,
544
589
  selector: str,
@@ -36,6 +36,7 @@ _DEFAULT_OPERATION_TIMEOUT_MS = {
36
36
  "page.webmcp_tools": 1_000,
37
37
  }
38
38
  _UNBOUNDED_BY_DEFAULT_METHODS = {
39
+ "page.wait_for_function",
39
40
  "stagehand.init",
40
41
  "stagehand.close",
41
42
  "stagehand.act",
@@ -602,6 +603,7 @@ def _rpc_response_timeout_seconds(method: str, params: BaseModel) -> float | Non
602
603
  "page.screenshot",
603
604
  "page.pdf",
604
605
  "page.snapshot",
606
+ "page.wait_for_function",
605
607
  "page.wait_for_selector",
606
608
  "page.webmcp_tools",
607
609
  "page.webmcp_invocation_result",
@@ -613,10 +615,12 @@ def _rpc_response_timeout_seconds(method: str, params: BaseModel) -> float | Non
613
615
  operation_timeout_ms = _numeric_property(params, "ms")
614
616
 
615
617
  if operation_timeout_ms is not None:
616
- if (
617
- method in {"page.pdf", "page.snapshot", "page.wait_for_selector"}
618
- and operation_timeout_ms == 0
619
- ):
618
+ if operation_timeout_ms == 0 and method in {
619
+ "page.pdf",
620
+ "page.snapshot",
621
+ "page.wait_for_function",
622
+ "page.wait_for_selector",
623
+ }:
620
624
  return None
621
625
  return (_RPC_RESPONSE_GRACE_MS + max(0, operation_timeout_ms)) / 1_000
622
626