UNPKG

agents

Version:

A home for your AI agents

1,093 lines (1,092 loc) • 48.9 kB
import { i as _classPrivateFieldInitSpec, n as _classPrivateFieldSet2, r as _assertClassBrand, t as _classPrivateFieldGet2 } from "./classPrivateFieldGet2-DZBYAB34.js"; import { t as _classPrivateMethodInitSpec } from "./classPrivateMethodInitSpec-qMjJ6sHQ.js"; import { Validator } from "@cfworker/json-schema"; //#region src/browser/cdp-connection.ts const DEFAULT_TIMEOUT_MS = 1e4; /** JSON-RPC's "method not found" code, which Chrome also uses for CDP. */ const CDP_METHOD_NOT_FOUND = -32601; /** The browser answered a command with a JSON-RPC error. */ var CdpProtocolError = class extends Error { constructor(method, code, message) { super(`CDP error ${code ?? "unknown"}: ${message} for ${method}`); this.name = "CdpProtocolError"; this.code = code; this.method = method; } }; const MAX_DEBUG_ENTRIES = 400; var _socket = /* @__PURE__ */ new WeakMap(); var _nextId = /* @__PURE__ */ new WeakMap(); var _pending = /* @__PURE__ */ new WeakMap(); var _debugLog = /* @__PURE__ */ new WeakMap(); var _defaultTimeoutMs = /* @__PURE__ */ new WeakMap(); var _dispose = /* @__PURE__ */ new WeakMap(); var _disposed = /* @__PURE__ */ new WeakMap(); var _onActivity = /* @__PURE__ */ new WeakMap(); var _CdpConnection_brand = /* @__PURE__ */ new WeakSet(); /** * One Chrome DevTools Protocol connection over an open WebSocket. Manages * command correlation, timeouts, per-tab CDP sessions, and a debug event * ring buffer. * * Used host-side (not in the sandbox) — the sandbox calls into this * via DynamicWorkerExecutor's ToolDispatcher RPC. */ var CdpConnection = class { constructor(socket, optionsOrTimeoutMs, onClose, sessionId) { _classPrivateMethodInitSpec(this, _CdpConnection_brand); _classPrivateFieldInitSpec(this, _socket, void 0); _classPrivateFieldInitSpec(this, _nextId, 1); _classPrivateFieldInitSpec(this, _pending, /* @__PURE__ */ new Map()); _classPrivateFieldInitSpec(this, _debugLog, []); _classPrivateFieldInitSpec(this, _defaultTimeoutMs, void 0); _classPrivateFieldInitSpec(this, _dispose, void 0); _classPrivateFieldInitSpec(this, _disposed, false); _classPrivateFieldInitSpec(this, _onActivity, void 0); const options = typeof optionsOrTimeoutMs === "object" ? optionsOrTimeoutMs : { timeoutMs: optionsOrTimeoutMs, onClose, sessionId }; _classPrivateFieldSet2(_socket, this, socket); _classPrivateFieldSet2(_defaultTimeoutMs, this, options.timeoutMs ?? DEFAULT_TIMEOUT_MS); _classPrivateFieldSet2(_dispose, this, options.onClose); this.sessionId = options.sessionId; _classPrivateFieldSet2(_onActivity, this, options.onActivity); socket.addEventListener("message", (event) => _assertClassBrand(_CdpConnection_brand, this, _handleMessage).call(this, event)); socket.addEventListener("error", () => { _assertClassBrand(_CdpConnection_brand, this, _rejectAll).call(this, /* @__PURE__ */ new Error("CDP socket error")); _assertClassBrand(_CdpConnection_brand, this, _runDispose).call(this); }); socket.addEventListener("close", () => { _assertClassBrand(_CdpConnection_brand, this, _rejectAll).call(this, /* @__PURE__ */ new Error("CDP connection closed")); _assertClassBrand(_CdpConnection_brand, this, _runDispose).call(this); }); } send(method, params, options = {}) { var _this$nextId, _this$nextId2; _classPrivateFieldGet2(_onActivity, this)?.call(this); const id = (_classPrivateFieldSet2(_nextId, this, (_this$nextId = _classPrivateFieldGet2(_nextId, this), _this$nextId2 = _this$nextId++, _this$nextId)), _this$nextId2); const timeoutMs = options.timeoutMs ?? _classPrivateFieldGet2(_defaultTimeoutMs, this); const sessionId = typeof options.sessionId === "string" && options.sessionId.length > 0 ? options.sessionId : void 0; const domain = typeof method === "string" ? method.split(".")[0] : ""; if (!sessionId && domain && !["Browser", "Target"].includes(domain)) _assertClassBrand(_CdpConnection_brand, this, _recordDebug).call(this, "warning", { id, method, reason: "target-scoped method sent without sessionId" }); const result = new Promise((resolve, reject) => { const startedAt = performance.now(); const timeoutId = setTimeout(() => { _classPrivateFieldGet2(_pending, this).delete(id); reject(/* @__PURE__ */ new Error(`CDP command timed out after ${timeoutMs}ms: ${method}`)); }, timeoutMs); _classPrivateFieldGet2(_pending, this).set(id, { resolve, reject, timeoutId, method, sessionId, startedAt }); }); _assertClassBrand(_CdpConnection_brand, this, _recordDebug).call(this, "send", { id, method, sessionId, timeoutMs }); try { _classPrivateFieldGet2(_socket, this).send(JSON.stringify({ id, method, params, sessionId })); } catch (error) { const pending = _classPrivateFieldGet2(_pending, this).get(id); if (pending) { clearTimeout(pending.timeoutId); _classPrivateFieldGet2(_pending, this).delete(id); pending.reject(error instanceof Error ? error : new Error(String(error))); } } return result; } async attachToTarget(targetId, options = {}) { if (typeof targetId !== "string" || !targetId) throw new Error("attachToTarget requires a targetId — list open tabs with send('Target.getTargets') or create one with send('Target.createTarget', { url })"); const sessionId = (await this.send("Target.attachToTarget", { targetId, flatten: true }, { timeoutMs: options.timeoutMs }))?.sessionId ?? ""; if (!sessionId) throw new Error(`Target.attachToTarget did not return a sessionId for target ${targetId}`); _assertClassBrand(_CdpConnection_brand, this, _recordDebug).call(this, "attach", { targetId, sessionId }); return sessionId; } getDebugLog(limit = 50) { const normalized = Number.isFinite(limit) ? Math.max(1, Math.floor(limit)) : 50; return _classPrivateFieldGet2(_debugLog, this).slice(-normalized); } clearDebugLog() { _classPrivateFieldSet2(_debugLog, this, []); } disconnect() { _assertClassBrand(_CdpConnection_brand, this, _rejectAll).call(this, /* @__PURE__ */ new Error("CDP session disconnected")); try { _classPrivateFieldGet2(_socket, this).close(1e3, "Done"); } catch {} } close() { this.disconnect(); _assertClassBrand(_CdpConnection_brand, this, _runDispose).call(this); } }; /** * Run the dispose callback exactly once, on the first terminal event — * an explicit {@link close}, peer closure, or a socket error. */ function _runDispose() { if (_classPrivateFieldGet2(_disposed, this)) return; _classPrivateFieldSet2(_disposed, this, true); _classPrivateFieldGet2(_dispose, this)?.call(this); } function _rejectAll(error) { for (const [id, pending] of _classPrivateFieldGet2(_pending, this).entries()) { clearTimeout(pending.timeoutId); _classPrivateFieldGet2(_pending, this).delete(id); pending.reject(error); } } function _handleMessage(event) { if (typeof event.data !== "string") return; let payload; try { payload = JSON.parse(event.data); } catch { return; } _assertClassBrand(_CdpConnection_brand, this, _recordDebug).call(this, "receive", { id: payload.id, method: payload.method, sessionId: payload.sessionId, hasError: !!payload.error }); if (typeof payload.id !== "number") return; const pending = _classPrivateFieldGet2(_pending, this).get(payload.id); if (!pending) return; clearTimeout(pending.timeoutId); _classPrivateFieldGet2(_pending, this).delete(payload.id); if (payload.error) { const err = payload.error; pending.reject(new CdpProtocolError(pending.method, typeof err.code === "number" ? err.code : void 0, err.message ?? "CDP error")); return; } pending.resolve(payload.result); } function _recordDebug(type, data) { _classPrivateFieldGet2(_debugLog, this).push({ at: (/* @__PURE__ */ new Date()).toISOString(), type, ...data }); if (_classPrivateFieldGet2(_debugLog, this).length > MAX_DEBUG_ENTRIES) _classPrivateFieldGet2(_debugLog, this).splice(0, _classPrivateFieldGet2(_debugLog, this).length - MAX_DEBUG_ENTRIES); } const LOCALHOST_HOSTS = /* @__PURE__ */ new Set([ "localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]" ]); /** * Connect to a browser via a CDP base URL (e.g. http://localhost:9222). * Discovers the WebSocket debugger URL via /json/version, * rewrites localhost URLs to the base URL host, and opens the WebSocket. * * Useful for local development with `chrome --remote-debugging-port=9222` * or when connecting through a tunnel. */ async function connectUrl(baseUrl, options) { const endpoint = new URL("/json/version", baseUrl).toString(); const response = await fetch(endpoint, { headers: options?.headers }); if (!response.ok) throw new Error(`Failed to discover CDP endpoint at ${endpoint}: ${response.status}`); const payload = await response.json(); if (!payload.webSocketDebuggerUrl) throw new Error("CDP /json/version did not include webSocketDebuggerUrl"); let wsUrl = payload.webSocketDebuggerUrl; const parsed = new URL(wsUrl); if (LOCALHOST_HOSTS.has(parsed.hostname)) { const base = new URL(baseUrl); parsed.hostname = base.hostname; parsed.port = base.port; parsed.protocol = base.protocol; } else parsed.protocol = parsed.protocol === "wss:" ? "https:" : "http:"; const fetchUrl = parsed.toString(); const wsResponse = await fetch(fetchUrl, { headers: { ...options?.headers, Upgrade: "websocket" } }); const ws = wsResponse.webSocket; if (!ws) throw new Error(`Failed to establish CDP WebSocket at ${fetchUrl} (status ${wsResponse.status})`); ws.accept(); return new CdpConnection(ws, { timeoutMs: options?.timeoutMs }); } /** @deprecated Renamed to {@link CdpConnection}. */ const CdpSession = CdpConnection; //#endregion //#region src/browser/validate.ts /** * Input validation for the `browser.*` functions. Internal — not an entry * point. Used by `session.ts` and `browser-functions.ts`. */ const validators = /* @__PURE__ */ new WeakMap(); /** * Check a method's input against its schema before it touches the browser, * so the model gets a precise error. An omitted input counts as `{}`. */ function validate(name, schema, input) { let validator = validators.get(schema); if (!validator) { validator = new Validator(schema, "7", false); validators.set(schema, validator); } const value = input ?? {}; const result = validator.validate(value); if (!result.valid) throw new Error(formatValidationError(name, result.errors)); return value; } function formatValidationError(name, errors) { const specific = errors.filter((error) => error.keyword !== "properties"); return `Invalid arguments for ${name}${(specific.length > 0 ? specific : errors).map((error) => { const location = error.instanceLocation.replace(/^#\/?/, "").replaceAll("~1", "/").replaceAll("~0", "~").replaceAll("/", "."); return `${location ? ` at ${location}` : ""}: ${error.error}`; }).join(";")}`; } //#endregion //#region src/browser/browser-functions.ts const SPEC_SCHEMA = { type: "object", properties: { domain: { type: "string", minLength: 1 } } }; const BROWSER_FUNCTIONS = { cdp: { doc: "`browser.cdp({ method, params?, sessionId?, timeoutMs? })` sends a CDP command and returns its result without the JSON-RPC envelope. Page-scoped commands take `sessionId: \"active\"`: `await browser.cdp({ method: \"Page.navigate\", params: { url }, sessionId: \"active\" })` returns `{ frameId, errorText? }`. `timeoutMs` limits this one command. A failed command throws, and the error often says how to fix the call.", call: (session, input) => session.send(input) }, attachToTarget: { doc: "`browser.attachToTarget({ targetId })` switches to an open tab, with `targetId` from `Target.getTargets`, and makes it active: `sessionId: \"active\"` points at it from then on. Returns `{ sessionId }` for that tab.", call: (session, input) => session.attachToTarget(input) }, spec: { doc: "`browser.spec()` lists the protocol's domains as `{ domains: [{ name, description }] }`; `browser.spec({ domain: \"Page\" })` returns one domain's `{ commands, events, types }`, each command a `{ name, method, description, parameters, returns }` (`name` is `\"navigate\"`, `method` is `\"Page.navigate\"`). Big domains get cut short in your result, so filter in code: `(await browser.spec({ domain: \"Network\" })).commands.find((c) => c.name === \"enable\")`.", call: async (session, input) => { const { domain } = validate("browser.spec", SPEC_SCHEMA, input); return specResult(await session.spec(), domain); } }, getDebugLog: { doc: "`browser.getDebugLog({ limit? })` returns recent protocol traffic, newest last (50 entries unless `limit` says otherwise), each `{ at, type, ... }`. Use it when a command fails or times out.", call: (session, input) => session.getDebugLog(input) }, clearDebugLog: { doc: "`browser.clearDebugLog()` forgets the traffic recorded so far, so the next `getDebugLog` shows only what follows.", call: (session) => session.clearDebugLog() } }; /** * Without a domain, every domain's name and first sentence, which is small; * with one, that domain's commands, events and types. The whole protocol is * about 650 KB, too much for one result. */ function specResult(spec, domain) { const { domains } = spec; if (domain === void 0) return { domains: domains.map(({ name, description }) => ({ name, ...description ? { description: firstSentence(description) } : {} })), hint: "browser.spec({ domain: \"Page\" }) returns one domain's commands, events and types." }; const found = domains.find((d) => d.name === domain); if (found) return found; const near = domains.find((d) => d.name.toLowerCase() === domain.toLowerCase()); throw new Error(`No CDP domain named '${domain}'.` + (near ? ` Did you mean '${near.name}'?` : " browser.spec() lists the domains.")); } /** A CDP description's first sentence, for the list of domains. */ function firstSentence(text) { return text.match(/^[\s\S]*?\.(?=\s|$)/)?.[0] ?? text; } /** The `host` field of a hosts function's input, or a fix-it error. */ function hostOf(name, input) { const host = typeof input === "object" && input !== null ? input.host : void 0; if (typeof host !== "string") throw new Error(`browser.${name} needs { host }: the bare hostname, like "preview.internal".`); return host; } const HOSTS_FUNCTIONS = { listHosts: { doc: "`browser.listHosts()` returns each internal site and the target it points at.", call: (hosts) => hosts.list() }, assignHost: { doc: "`browser.assignHost({ host, target })` points an assignable site at a target matching its schema and returns `{ url }`; `host` is the bare hostname, like \"preview.internal\". Navigate to the url next. It stays assigned across runs.", assignable: true, check: (input) => { const host = hostOf("assignHost", input); if (input.target === void 0) throw new Error(`browser.assignHost needs a target for ${host}; use browser.clearHost to unassign it`); }, call: (hosts, input) => hosts.assign(hostOf("assignHost", input), input.target) }, clearHost: { doc: "`browser.clearHost({ host })` unassigns a site and returns `{ cleared }`. An unassigned site answers 502.", assignable: true, check: (input) => hostOf("clearHost", input), call: async (hosts, input) => ({ cleared: await hosts.clear(hostOf("clearHost", input)) }) } }; /** The hosts functions to offer: assign and clear only if any host can be. */ function hostsFunctions(infos) { const assignable = infos.some((info) => info.assignable); return Object.entries(HOSTS_FUNCTIONS).filter(([, definition]) => assignable || !definition.assignable); } /** * The `browser` object's functions, bound to one run's session, plus the * hosts functions when the browser has internal hostnames. Each hosts * function checks its input, then waits for `admit`, then calls the hosts. */ function browserFunctions(session, sessionHosts) { const functions = Object.fromEntries(Object.entries(BROWSER_FUNCTIONS).map(([name, definition]) => [name, (input) => definition.call(session, input)])); if (sessionHosts) { const { hosts, admit } = sessionHosts; for (const [name, definition] of hostsFunctions(hosts.describe())) functions[name] = async (input) => { definition.check?.(input); await admit(); return definition.call(hosts, input); }; } return functions; } /** How one internal site reads in the docs. */ function describeHost(info) { const description = info.description ? ` — ${info.description}` : ""; const target = info.assignable ? `. Assignable; target schema: ${JSON.stringify(info.targetSchema ?? {})}` : ""; return `- ${info.url}${description}${target}`; } /** * The `browser` object's docs for the model: the functions offered for * `hosts`, and the rules. */ function browserFunctionDocs(hosts) { return { functions: functionsMarkdown(hosts), rules: [...BROWSER_RULES] }; } /** * The functions in Markdown: one line per function, then the internal sites * when the browser has any. */ function functionsMarkdown(hosts) { const infos = hosts?.describe() ?? []; const lines = [...Object.values(BROWSER_FUNCTIONS), ...hosts ? hostsFunctions(infos).map(([, definition]) => definition) : []].map((definition) => `- ${definition.doc}`); if (!hosts) return lines.join("\n"); return [ ...lines, "", "## Internal sites", "", "The browser can open these sites, which aren't on the public internet. Plain http only: no https:// and no WebSockets, so a dev server's hot reload won't connect; reload the page instead.", "", ...infos.map(describeHost) ].join("\n"); } /** * How to use the browser, one rule per line. Each rule fixes a mistake * models make with CDP, or states something about this browser they can't * know; leave out what a model knows already. Rules about how the code runs * (timeouts, screenshots) belong to whatever runs it. */ const BROWSER_RULES = [ "The browser persists between runs: tabs, cookies, and logins are still there next time. You never start or close it.", "If the browser was replaced (idle timeout, crash), a notice says so and earlier tabs and logins are gone. Before an action that matters (submitting a form, a purchase), check you're on the page you expect.", "Page-scoped commands (Page.*, Runtime.*, DOM.*, Input.*, Network.*, Emulation.*) need sessionId: \"active\", your tab, which is remembered between runs. Browser.* and Target.* commands take no sessionId.", "Other open tabs (Target.getTargets) may belong to other conversations using this browser: don't close, navigate, or attach to a tab you didn't open unless the user asks.", "Target.createTarget opens a tab and makes it active. To switch to another open tab, call browser.attachToTarget({ targetId }); the sessionId it returns keeps working in later runs, while one from a raw Target.attachToTarget only works in the current run.", "Tabs a page opens itself (popups, target=_blank links) don't become active; a notice lists them.", "Runtime.evaluate: pass returnByValue: true, or objects come back as a remote reference with no value, and awaitPromise: true for async expressions. The value is at result.value. A thrown error doesn't reject: check exceptionDetails.", "Page.navigate returns before the page loads and doesn't throw when it fails: check errorText, then poll document.readyState with Runtime.evaluate until it is 'complete'.", "Pick out what you need inside Runtime.evaluate and return only that. Page.captureScreenshot throws when its result is over 1 MB." ]; //#endregion //#region src/browser/scope.ts /** * Browser scopes: who is driving a shared `Browser`. Sessions with the same * scope share an active tab (and, when they lock, a lock); each scope * keeps its own. Cookies and logins are shared by every scope. */ /** * The scope used when a caller doesn't pick one: every caller that leaves * `scope` out shares this one active tab. See `BrowserConnectOptions`. */ const DEFAULT_BROWSER_SCOPE = "shared"; /** * The scope for one conversation: `conversation:<id>`. pi's `browserTool` * gives each conversation this scope by default, so app code that opens * sessions with the same scope (a Workspace's `codemode:browser`, say) lands * in the same tab as the tool. pi's conversation ids are numbers: pass * `String(conversationId)`. * * Throws on a missing or empty id rather than returning * `"conversation:undefined"`, which would quietly put every conversation * without an id in one tab. * * @experimental The API surface may change before stabilizing. */ function conversationScope(id) { if (typeof id !== "string" || id === "") throw new Error(`conversationScope needs a conversation id, got ${String(id)}.`); return `conversation:${id}`; } //#endregion //#region src/browser/session.ts /** * `BrowserSession`: one run's connection to a persistent `Browser`, with the * rules that make CDP usable from model-written code. * * The model never manages CDP sessions. `sessionId: "active"` addresses the * tab the scope last worked in, `attachToTarget` returns a handle that keeps * working in later runs, and common protocol mistakes come back as * instructions. When the session closes it saves the scope's active tab and * reports what happened to the browser: a restart, a tab closed by someone * else, a tab shared with another scope, tabs the page opened. * * A session opened with `lock: true` holds its scope's lock from its first * call (or `lock()`) until it closes; other locking sessions in the scope * wait for it, in order. * * `browserTool` opens one per run. */ const RESTARTED_NOTICE = "The browser was restarted before this run (it expired or was closed): earlier tabs, logins, and page state are gone. Your code ran in a fresh browser — navigate again before relying on page state."; const TAB_CLOSED_NOTICE = "The tab you were working in was closed (by another conversation using this browser, or a person). `sessionId: \"active\"` now opens a blank tab — navigate again before relying on page state."; const TAB_SHARED_NOTICE = "Another conversation using this browser is also working in your tab, so its page may change between your runs. Open a tab of your own with Target.createTarget if you need one."; /** What the report says, in sentences the model reads. */ function reportNotices(report) { return [ report.restarted && RESTARTED_NOTICE, report.tabClosed && !report.restarted && TAB_CLOSED_NOTICE, report.tabShared && TAB_SHARED_NOTICE, report.newTabs.length > 0 && `New tabs opened by the page: ${JSON.stringify(report.newTabs)}` ].filter((notice) => typeof notice === "string"); } var _held = /* @__PURE__ */ new WeakMap(); var _waiting = /* @__PURE__ */ new WeakMap(); var _onIdle = /* @__PURE__ */ new WeakMap(); /** * Who holds a scope's lock, and who waits for it, in order. A waiter that * gives up is removed at once, so nobody behind it waits on it. */ var LockQueue = class { /** `onIdle` runs when the lock is freed with nobody waiting. */ constructor(onIdle) { _classPrivateFieldInitSpec(this, _held, false); _classPrivateFieldInitSpec(this, _waiting, []); _classPrivateFieldInitSpec(this, _onIdle, void 0); _classPrivateFieldSet2(_onIdle, this, onIdle); } /** Resolve once this caller holds the lock. */ take(signal) { if (signal.aborted) { if (!_classPrivateFieldGet2(_held, this) && _classPrivateFieldGet2(_waiting, this).length === 0) _classPrivateFieldGet2(_onIdle, this).call(this); return Promise.reject(signal.reason); } if (!_classPrivateFieldGet2(_held, this)) { _classPrivateFieldSet2(_held, this, true); return Promise.resolve(); } return new Promise((resolve, reject) => { const onAbort = () => { const index = _classPrivateFieldGet2(_waiting, this).indexOf(waiter); if (index !== -1) _classPrivateFieldGet2(_waiting, this).splice(index, 1); reject(signal.reason); }; const waiter = { grant: () => { signal.removeEventListener("abort", onAbort); resolve(); } }; _classPrivateFieldGet2(_waiting, this).push(waiter); signal.addEventListener("abort", onAbort, { once: true }); }); } /** Hand the lock to the next waiter, or free the scope. */ release() { const next = _classPrivateFieldGet2(_waiting, this).shift(); if (next) { next.grant(); return; } _classPrivateFieldSet2(_held, this, false); _classPrivateFieldGet2(_onIdle, this).call(this); } }; /** * The lock queues per browser and scope. Keyed by the source, so custom * sources lock too, and dropped with it. */ const lockQueues = /* @__PURE__ */ new WeakMap(); function lockQueue(browser, scope) { let queues = lockQueues.get(browser); if (!queues) lockQueues.set(browser, queues = /* @__PURE__ */ new Map()); let queue = queues.get(scope); if (!queue) { const scopes = queues; queue = new LockQueue(() => scopes.delete(scope)); queues.set(scope, queue); } return queue; } /** `sessionId` value that addresses the tab the agent is working in. */ const ACTIVE_PAGE_SESSION = "active"; /** * `attachToTarget` returns `{ sessionId: "target:<targetId>" }`, a stable * handle rather than a connection-scoped CDP session id, so it keeps working * in later runs. `send` resolves it on the live socket. */ const ATTACH_HANDLE_PREFIX = "target:"; /** Chrome's JSON-RPC code for a CDP session id it doesn't know. */ const CDP_SESSION_NOT_FOUND = -32001; /** * Screenshots are kept whole in the chat message for the UI, and a stored * message must fit SQLite's 2 MB row (see `ROW_MAX_BYTES` in chat/sanitize.ts), * or the image is compacted away. Failing early lets the model retake it * smaller. Other results are cut to a preview before they're stored. * * @internal The tools also refuse to keep a returned screenshot over it. */ const DEFAULT_MAX_RESULT_BYTES = 1e6; /** `#liveParams`: nothing to detach. */ const DETACHED = Symbol("detached"); const MANAGED_BROWSER = "this browser is managed for you and persists between runs. To close a tab, send Target.closeTarget with its targetId."; const HOST_ONLY = "it is for the host, not your code."; const NO_TUNNELING = "send each command with browser.cdp instead, passing sessionId to reach a tab."; /** * Commands model code can't send, and why. * * - Ending the browser: the host owns its lifetime, so the model can't close * it out from under the agent (and lose its logins). * - Live View and handoff: a Live View link controls the whole browser, so it * goes to a trusted person through `Browser.liveView()`, never to the model. * - Tunnels: these carry raw protocol messages past this check. */ const REFUSED_COMMANDS = /* @__PURE__ */ new Map([ ["Browser.close", MANAGED_BROWSER], ["Browser.crash", MANAGED_BROWSER], ["Browser.crashGpuProcess", MANAGED_BROWSER], ["Cloudflare.getLiveView", HOST_ONLY], ["Cloudflare.handoff", HOST_ONLY], ["Target.sendMessageToTarget", NO_TUNNELING], ["Target.exposeDevToolsProtocol", NO_TUNNELING] ]); const SEND_SCHEMA = { type: "object", properties: { method: { type: "string", minLength: 1 }, params: { type: "object" }, sessionId: { type: "string" }, timeoutMs: { type: "number", exclusiveMinimum: 0 } }, required: ["method"] }; const ATTACH_SCHEMA = { type: "object", properties: { targetId: { type: "string", minLength: 1 }, timeoutMs: { type: "number", exclusiveMinimum: 0 } }, required: ["targetId"] }; const DEBUG_LOG_SCHEMA = { type: "object", properties: { limit: { type: "number", minimum: 1 } } }; var _browser = /* @__PURE__ */ new WeakMap(); var _scope = /* @__PURE__ */ new WeakMap(); var _maxResultBytes = /* @__PURE__ */ new WeakMap(); var _locksScope = /* @__PURE__ */ new WeakMap(); var _state = /* @__PURE__ */ new WeakMap(); var _connecting = /* @__PURE__ */ new WeakMap(); var _closed = /* @__PURE__ */ new WeakMap(); var _functions = /* @__PURE__ */ new WeakMap(); var _acquiring = /* @__PURE__ */ new WeakMap(); var _heldLock = /* @__PURE__ */ new WeakMap(); var _closing = /* @__PURE__ */ new WeakMap(); var _waitedForLockMs = /* @__PURE__ */ new WeakMap(); var _BrowserSession_brand = /* @__PURE__ */ new WeakSet(); /** * One run's connection to a persistent browser, for one scope. * * It connects on its first call and stays connected until {@link close}. * Concurrent first calls share one connect. Every method takes one options * object, validated before anything touches the browser, because the input * usually comes from model-written code. * * Create one per run with `browser.session({ scope })`, and close it when * the run ends. The browser itself stays open. Pass `lock: true` when * runs in one scope may overlap; see {@link BrowserSessionOptions.lock}. * * @experimental The API surface may change before stabilizing. */ var BrowserSession = class { constructor(browser, options = {}) { _classPrivateMethodInitSpec(this, _BrowserSession_brand); _classPrivateFieldInitSpec(this, _browser, void 0); _classPrivateFieldInitSpec(this, _scope, void 0); _classPrivateFieldInitSpec(this, _maxResultBytes, void 0); _classPrivateFieldInitSpec(this, _locksScope, void 0); _classPrivateFieldInitSpec(this, _state, void 0); _classPrivateFieldInitSpec(this, _connecting, void 0); _classPrivateFieldInitSpec(this, _closed, false); _classPrivateFieldInitSpec(this, _functions, void 0); _classPrivateFieldInitSpec(this, _acquiring, void 0); _classPrivateFieldInitSpec(this, _heldLock, void 0); _classPrivateFieldInitSpec(this, _closing, new AbortController()); _classPrivateFieldInitSpec(this, _waitedForLockMs, 0); _classPrivateFieldSet2(_browser, this, browser); _classPrivateFieldSet2(_scope, this, options.scope); _classPrivateFieldSet2(_maxResultBytes, this, options.maxResultBytes ?? 1e6); _classPrivateFieldSet2(_locksScope, this, options.lock ?? false); } /** * The `browser.*` functions bound to this session, by name: the ones * `Browser.functionDocs()` describes. Each takes one input object and * validates it, so model-written code can call them directly. Like the * session's methods, every one rejects once the session is closed, and * waits for the scope's lock when the session locks. */ get functions() { _classPrivateFieldGet2(_functions, this) ?? _classPrivateFieldSet2(_functions, this, Object.freeze(browserFunctions(this, _assertClassBrand(_BrowserSession_brand, this, _sessionHosts).call(this)))); return _classPrivateFieldGet2(_functions, this); } /** * Take this scope's lock now rather than at the first call, and hold it * until {@link close}. For sessions opened with `lock: true`; a * caller with its own time limit calls it first, so the wait doesn't count * against the limit, or passes `signal` to bound the wait. * * Resolves once the session holds the lock, after the locking sessions * ahead of it close; later and concurrent calls resolve with the same * lock. Rejects when the session closes while waiting. Each call's * `signal` bounds only that call's wait: it rejects with the signal's * reason, and the session leaves the queue once no call is still * waiting (see {@link BrowserLockOptions.signal}); a later call or first * call waits again. */ lock(options = {}) { if (!_classPrivateFieldGet2(_locksScope, this)) return Promise.reject(/* @__PURE__ */ new Error("This browser session doesn't lock its scope. Open it with { lock: true }.")); if (_classPrivateFieldGet2(_closed, this)) return Promise.reject(/* @__PURE__ */ new Error("This browser session is closed.")); if (_classPrivateFieldGet2(_heldLock, this)) return Promise.resolve(); const { signal } = options; if (signal?.aborted) return Promise.reject(signal.reason); const acquisition = _classPrivateFieldGet2(_acquiring, this) ?? _classPrivateFieldSet2(_acquiring, this, _assertClassBrand(_BrowserSession_brand, this, _acquire).call(this)); if (acquisition.giveUp.signal.aborted) return acquisition.promise.then(() => void 0, () => this.lock(options)); if (!signal) { acquisition.unbounded = true; return acquisition.promise; } acquisition.bounded++; return new Promise((resolve, reject) => { const onAbort = () => { acquisition.bounded--; if (acquisition.bounded === 0 && !acquisition.unbounded) acquisition.giveUp.abort(signal.reason); reject(signal.reason); }; signal.addEventListener("abort", onAbort, { once: true }); acquisition.promise.then(() => { signal.removeEventListener("abort", onAbort); resolve(); }, (error) => { signal.removeEventListener("abort", onAbort); reject(error); }); }); } /** * How long this session waited for its lock, in milliseconds: `0` until * it has waited, and for sessions that don't lock. */ get waitedForLockMs() { return _classPrivateFieldGet2(_waitedForLockMs, this); } /** * Send a CDP command and resolve with its result, without the JSON-RPC * envelope. `sessionId: "active"` runs it in the scope's tab. */ async send(input) { const { method, params, sessionId, timeoutMs } = validate("browser.cdp", SEND_SCHEMA, input); const refused = REFUSED_COMMANDS.get(method); if (refused) throw new Error(`${method} is not available: ${refused}`); const state = await _assertClassBrand(_BrowserSession_brand, this, _connect).call(this); const live = await _assertClassBrand(_BrowserSession_brand, this, _resolveSessionId).call(this, state, sessionId); const liveParams = _assertClassBrand(_BrowserSession_brand, this, _liveParams).call(this, state, method, params); if (liveParams === DETACHED) return {}; let result; try { result = await state.connected.cdp.send(method, liveParams, { sessionId: live, timeoutMs }); } catch (error) { throw await _assertClassBrand(_BrowserSession_brand, this, _teach).call(this, state, error, method, sessionId); } _assertClassBrand(_BrowserSession_brand, this, _observe).call(this, state, method, liveParams, result); if (method === "Page.captureScreenshot") _assertClassBrand(_BrowserSession_brand, this, _assertFits).call(this, result); return result; } /** * Make an open tab the scope's active tab, and resolve with a `sessionId` * handle for it that keeps working in later runs. */ async attachToTarget(input) { const { targetId, timeoutMs } = validate("browser.attachToTarget", ATTACH_SCHEMA, input); const state = await _assertClassBrand(_BrowserSession_brand, this, _connect).call(this); const target = targetId; await _assertClassBrand(_BrowserSession_brand, this, _attach).call(this, state, target, timeoutMs); _assertClassBrand(_BrowserSession_brand, this, _setActive).call(this, state, target); return { sessionId: attachHandle(target) }; } /** The Chrome DevTools Protocol description the browser serves. */ async spec() { return (await _assertClassBrand(_BrowserSession_brand, this, _connect).call(this)).connected.spec(); } /** Recent CDP traffic on this session's connection, newest last. */ async getDebugLog(input) { const { limit } = validate("browser.getDebugLog", DEBUG_LOG_SCHEMA, input); return (await _assertClassBrand(_BrowserSession_brand, this, _connect).call(this)).connected.cdp.getDebugLog(limit); } /** Forget the CDP traffic recorded so far. */ async clearDebugLog() { (await _assertClassBrand(_BrowserSession_brand, this, _connect).call(this)).connected.cdp.clearDebugLog(); return null; } /** * End the session: save the scope's active tab, work out what happened to * the browser, and close the connection. The browser stays open. * * Resolves with the report, or `undefined` when the session never * connected. Never rejects: failures to read the browser's state are * logged and leave the report incomplete. Later calls resolve with * `undefined`, and other methods reject. * * A locking session releases the lock once the active tab is saved, or * stops waiting for it if it was still waiting. */ async close() { if (_classPrivateFieldGet2(_closed, this)) return void 0; _classPrivateFieldSet2(_closed, this, true); _classPrivateFieldGet2(_closing, this).abort(/* @__PURE__ */ new Error("This browser session is closed.")); try { let state; try { state = _classPrivateFieldGet2(_state, this) ?? await _classPrivateFieldGet2(_connecting, this); } catch { return; } if (!state) return void 0; _classPrivateFieldSet2(_state, this, void 0); try { const open = await _assertClassBrand(_BrowserSession_brand, this, _recordNewTabs).call(this, state); const active = await _assertClassBrand(_BrowserSession_brand, this, _saveActiveTarget).call(this, state, open); if (active && state.activeResolved) await _assertClassBrand(_BrowserSession_brand, this, _recordSharedTab).call(this, state, active); } finally { state.connected.cdp.disconnect(); } return { ...state.report, notices: reportNotices(state.report) }; } finally { await _classPrivateFieldGet2(_acquiring, this)?.promise.catch(() => void 0); _classPrivateFieldGet2(_heldLock, this)?.release(); _classPrivateFieldSet2(_heldLock, this, void 0); } } }; /** * Connect once, after taking the lock if this session locks its scope. * Concurrent first calls share one connect. */ function _connect() { if (_classPrivateFieldGet2(_closed, this)) return Promise.reject(/* @__PURE__ */ new Error("This browser session is closed.")); if (_classPrivateFieldGet2(_state, this)) return Promise.resolve(_classPrivateFieldGet2(_state, this)); _classPrivateFieldGet2(_connecting, this) ?? _classPrivateFieldSet2(_connecting, this, _assertClassBrand(_BrowserSession_brand, this, _admitThenOpen).call(this).finally(() => { _classPrivateFieldSet2(_connecting, this, void 0); })); return _classPrivateFieldGet2(_connecting, this); } /** The browser's internal hosts, behind this session's admission check. */ function _sessionHosts() { const hosts = _classPrivateFieldGet2(_browser, this).internalHosts; return hosts && { hosts, admit: () => _assertClassBrand(_BrowserSession_brand, this, _admit).call(this) }; } async function _admitThenOpen() { await _assertClassBrand(_BrowserSession_brand, this, _admit).call(this); return _assertClassBrand(_BrowserSession_brand, this, _open).call(this); } /** * The check every function makes before it touches the browser: reject * once the session is closed, and wait for the lock when the session * locks. It doesn't connect. */ async function _admit() { if (_classPrivateFieldGet2(_closed, this)) throw new Error("This browser session is closed."); if (!_classPrivateFieldGet2(_locksScope, this)) return; await this.lock(); if (_classPrivateFieldGet2(_closed, this)) throw new Error("This browser session is closed."); } /** Start waiting for the lock, given up when its calls do or on close. */ function _acquire() { const giveUp = new AbortController(); const promise = _assertClassBrand(_BrowserSession_brand, this, _waitForLock).call(this, AbortSignal.any([giveUp.signal, _classPrivateFieldGet2(_closing, this).signal])).finally(() => { if (_classPrivateFieldGet2(_acquiring, this)?.promise === promise) _classPrivateFieldSet2(_acquiring, this, void 0); }); promise.catch(() => void 0); return { promise, bounded: 0, unbounded: false, giveUp }; } async function _waitForLock(signal) { signal.throwIfAborted(); const queue = lockQueue(_classPrivateFieldGet2(_browser, this), _classPrivateFieldGet2(_scope, this) ?? "shared"); const started = Date.now(); try { await queue.take(signal); _classPrivateFieldSet2(_heldLock, this, queue); } finally { _classPrivateFieldSet2(_waitedForLockMs, this, _classPrivateFieldGet2(_waitedForLockMs, this) + (Date.now() - started)); } } async function _open() { const connected = await _classPrivateFieldGet2(_browser, this).connect(_classPrivateFieldGet2(_scope, this) === void 0 ? void 0 : { scope: _classPrivateFieldGet2(_scope, this) }); let initialPages; try { initialPages = await _assertClassBrand(_BrowserSession_brand, this, _pages).call(this, connected.cdp); } catch (error) { connected.cdp.disconnect(); throw error; } const state = { connected, attached: /* @__PURE__ */ new Map(), workedIn: /* @__PURE__ */ new Set(), initialPages: new Set(initialPages.map((page) => page.targetId)), activeResolved: false, closed: /* @__PURE__ */ new Set(), report: { restarted: connected.restarted, newTabs: [], tabClosed: false, tabShared: false } }; if (_classPrivateFieldGet2(_closed, this)) return state; _classPrivateFieldSet2(_state, this, state); return state; } /** * A screenshot over the limit is too big to keep in the chat message * (see `DEFAULT_MAX_RESULT_BYTES`), so fail with how to take a smaller one. */ function _assertFits(result) { const size = JSON.stringify(result ?? null).length; if (size <= _classPrivateFieldGet2(_maxResultBytes, this)) return; const megabytes = (size / 1e6).toFixed(1); const limit = (_classPrivateFieldGet2(_maxResultBytes, this) / 1e6).toFixed(0); throw new Error(`The screenshot result is ${megabytes} MB, over the ${limit} MB limit on a send result. Capture just the viewport (no captureBeyondViewport or clip larger than the window), or pass format: "jpeg" with quality: 60.`); } /** * Add tabs the page opened during this session to the report. Returns the * open tabs, or `undefined` when the browser couldn't be asked. */ async function _recordNewTabs(state) { let pages; try { pages = await _assertClassBrand(_BrowserSession_brand, this, _pages).call(this, state.connected.cdp); } catch (error) { _assertClassBrand(_BrowserSession_brand, this, _warn).call(this, "list the open tabs", error); return; } const opened = /* @__PURE__ */ new Set(); let grew = true; while (grew) { grew = false; for (const page of pages) { const opener = page.openerId; if (!opener || opened.has(page.targetId) || state.initialPages.has(page.targetId)) continue; if (state.workedIn.has(opener) || opened.has(opener)) { opened.add(page.targetId); grew = true; } } } state.report.newTabs = pages.filter((page) => opened.has(page.targetId)).map(({ targetId, url, title }) => ({ targetId, url, title })); return new Set(pages.map((page) => page.targetId)); } /** * Save the tab this session settled on, forgetting one that has since * closed, and return it. Without the list of open tabs, save the choice * unchecked: the next session checks the stored tab is still open before * using it. */ async function _saveActiveTarget(state, open) { const stored = state.connected.activeTargetId; if (!state.activeResolved && stored && open && !open.has(stored) && !state.closed.has(stored)) state.report.tabClosed = true; const candidate = state.activeResolved ? state.activeTargetId : stored; const active = candidate && (!open || open.has(candidate)) ? candidate : void 0; if (active === stored) return active; try { await state.connected.setActiveTarget(active); } catch (error) { _assertClassBrand(_BrowserSession_brand, this, _warn).call(this, "save the active tab", error); } return active; } /** Report when another scope is working in this session's tab too. */ async function _recordSharedTab(state, active) { try { if ((await state.connected.targetsInOtherScopes()).has(active)) state.report.tabShared = true; } catch (error) { _assertClassBrand(_BrowserSession_brand, this, _warn).call(this, "check whether the active tab is shared", error); } } function _warn(action, error) { console.warn(`[agents/browser] Failed to ${action} for browser "${_classPrivateFieldGet2(_browser, this).name}"`, error); } async function _pages(cdp) { return ((await cdp.send("Target.getTargets"))?.targetInfos ?? []).filter((info) => info.type === "page"); } async function _resolveSessionId(state, sessionId) { if (sessionId === "active") return _assertClassBrand(_BrowserSession_brand, this, _attach).call(this, state, await _assertClassBrand(_BrowserSession_brand, this, _activeTarget).call(this, state)); if (isAttachHandle(sessionId)) return _assertClassBrand(_BrowserSession_brand, this, _attach).call(this, state, targetOfHandle(sessionId)); return sessionId; } /** * Decide which tab `"active"` means, once per session: the scope's stored * tab if it is still open, else a new blank tab. Never a tab the scope * didn't open, even a blank one: it may be another scope's, or a popup. */ async function _activeTarget(state) { if (state.activeTargetId) return state.activeTargetId; const { connected } = state; const pages = await _assertClassBrand(_BrowserSession_brand, this, _pages).call(this, connected.cdp); const stored = connected.activeTargetId; let targetId = pages.find((page) => page.targetId === stored)?.targetId; if (!targetId && stored && !state.closed.has(stored)) state.report.tabClosed = true; if (!targetId) targetId = (await connected.cdp.send("Target.createTarget", { url: "about:blank" })).targetId; _assertClassBrand(_BrowserSession_brand, this, _setActive).call(this, state, targetId); return targetId; } function _setActive(state, targetId) { state.activeTargetId = targetId; state.activeResolved = true; } async function _attach(state, targetId, timeoutMs) { const existing = state.attached.get(targetId); if (existing) return existing; const live = await state.connected.cdp.attachToTarget(targetId, { timeoutMs }); state.attached.set(targetId, live); state.workedIn.add(targetId); return live; } /** Track tab changes the model makes through raw Target commands. */ function _observe(state, method, params, result) { const targetId = typeof params?.targetId === "string" ? params.targetId : void 0; if (method === "Target.createTarget") { const created = result?.targetId; if (typeof created === "string") _assertClassBrand(_BrowserSession_brand, this, _setActive).call(this, state, created); } else if (method === "Target.attachToTarget" && targetId) { _assertClassBrand(_BrowserSession_brand, this, _setActive).call(this, state, targetId); state.workedIn.add(targetId); } else if (method === "Target.closeTarget" && targetId) { state.attached.delete(targetId); state.closed.add(targetId); if (state.activeTargetId === targetId) state.activeTargetId = void 0; } else if (method === "Target.detachFromTarget") { for (const [target, live] of state.attached) if (target === targetId || live === params?.sessionId) state.attached.delete(target); } } /** * Swap a handle in `Target.detachFromTarget`'s `sessionId` param for the * live CDP session id. Returns {@link DETACHED} when the handle's tab isn't * attached on this socket, so there is nothing to detach. */ function _liveParams(state, method, params) { const handle = params?.sessionId; if (method !== "Target.detachFromTarget" || typeof handle !== "string") return params; if (!isAttachHandle(handle)) return params; const live = state.attached.get(targetOfHandle(handle)); return live ? { ...params, sessionId: live } : DETACHED; } /** * Turn the common protocol mistakes into instructions: an expired CDP * session id, an event sent as a command, and a page-scoped command * without a session. */ async function _teach(state, error, method, sessionId) { if (!(error instanceof CdpProtocolError)) return error; if (error.code === CDP_SESSION_NOT_FOUND && sessionId && sessionId !== "active" && !isAttachHandle(sessionId)) return /* @__PURE__ */ new Error(`${error.message}. CDP session ids don't carry over between runs. Use sessionId: "active" for the current tab, or the handle from browser.attachToTarget({ targetId }) for another tab.`); if (error.code !== -32601) return error; if (await _assertClassBrand(_BrowserSession_brand, this, _isEvent).call(this, state, method)) return /* @__PURE__ */ new Error(`${error.message}. '${method}' is a CDP *event*, not a command, so it cannot be sent. To wait for page state, poll instead (e.g. Runtime.evaluate of document.readyState until "complete").`); if (!sessionId) return /* @__PURE__ */ new Error(`${error.message}. '${method}' is page-scoped: pass sessionId: "active" to run it in the current tab: browser.cdp({ method: "${method}", params, sessionId: "active" }).`); return error; } async function _isEvent(state, method) { try { const spec = await state.connected.spec(); const domain = method.split(".")[0]; return spec.domains.some((d) => d.name === domain && d.events.some((e) => e.event === method)); } catch { return false; } } function attachHandle(targetId) { return `${ATTACH_HANDLE_PREFIX}${targetId}`; } function isAttachHandle(sessionId) { return sessionId?.startsWith(ATTACH_HANDLE_PREFIX) ?? false; } function targetOfHandle(handle) { return handle.slice(7); } //#endregion export { conversationScope as a, CdpConnection as c, connectUrl as d, DEFAULT_BROWSER_SCOPE as i, CdpProtocolError as l, BrowserSession as n, browserFunctionDocs as o, DEFAULT_MAX_RESULT_BYTES as r, CDP_METHOD_NOT_FOUND as s, ACTIVE_PAGE_SESSION as t, CdpSession as u }; //# sourceMappingURL=session-CRY0bGiI.js.map