UNPKG

agents

Version:

A home for your AI agents

938 lines (937 loc) • 41 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 { t as LifecycleCapability } from "../capability-DBAKc6UZ.js"; import { t as browserCapabilityId } from "../capability-id-B3J8Nok_.js"; import { a as conversationScope, c as CdpConnection, d as connectUrl, i as DEFAULT_BROWSER_SCOPE, n as BrowserSession, o as browserFunctionDocs, t as ACTIVE_PAGE_SESSION, u as CdpSession } from "../session-CRY0bGiI.js"; import { C as getBrowserRecording, S as deleteBrowserSession, T as listBrowserTargets, _ as acquireBrowserSession, a as loadCdpSpec, b as connectBrowserSession, c as browserExtract, d as browserPdf, f as browserScrape, g as BrowserRenderingError, h as runQuickAction, i as DurableBrowserSessionStore, l as browserLinks, m as browserSnapshot, n as DEFAULT_EXEC_SWEEP_IDLE_MS, o as createLiveView, p as browserScreenshot, r as DEFAULT_SWEEP_IDLE_MS, s as browserContent, t as BrowserConnector, u as browserMarkdown, v as closeBrowserSession, w as isMissingBrowserSession, x as createBrowserSession, y as connectBrowser } from "../connector-Dl098To-.js"; import { WorkerEntrypoint } from "cloudflare:workers"; import { CodemodeRuntime } from "@cloudflare/codemode"; import { Validator } from "@cfworker/json-schema"; //#region src/browser/internal-hosts.ts /** * Internal hostnames: sites only this agent's browser can open. * * A host maps a hostname such as `preview.internal` to code in your agent * (`fetch`) or to a deployed Worker (`worker`). Browser Run sends the * browser's requests for that hostname to it instead of the internet, so a * page can be served from a sandbox port or a workspace folder, without exposing anything publicly. * * How a request travels: Browser Run accepts only entrypoints of your own * Worker as routes, and keeps them for the whole browser session (observed * across redeploys and Durable Object eviction; Browser Run doesn't document * this yet). So every `fetch` host routes to {@link InternalHostRouter}, which * you export from your main module. Its props say which agent and browser * own the host, and nothing else. It calls the agent's `fetchInternalHost()` * over RPC, and the agent's `Browser` runs the host's `fetch`. `Agent` has * that method built in; a plain Durable Object adds it (see * {@link InternalHostMessage}). A `worker` host goes straight to the Worker * you gave it. */ /** The most hostnames one browser can have. A Browser Run limit. */ const MAX_INTERNAL_HOSTS = 8; const HOSTNAME = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/; /** * Suffixes reserved so they never resolve on the internet. A real domain * would be served by the host over http but reach the real site over https * (and Chrome tries https first), so internal hostnames must use one. * Single labels like `preview` would work, but Browser Run doesn't route * labels that are real TLDs (`docs`, `app`) and gives no error for them, so * they're rejected rather than checked against a TLD list. */ const RESERVED_SUFFIXES = [ ".internal", ".test", ".example", ".invalid" ]; /** Why `host` can't be an internal hostname, or undefined when it can. */ function hostnameProblem(host) { if (host.includes("://")) return "drop the scheme"; if (host.includes("/")) return "drop the path"; if (host.includes(":")) return "drop the port (only port 80 is routed)"; if (host !== host.toLowerCase()) return "use lowercase"; if (/^\d+(\.\d+){3}$/.test(host)) return "use a name, not an IP address"; if (host === "localhost" || host.endsWith(".localhost")) return "Chrome sends localhost to the browser's own machine"; if (!HOSTNAME.test(host)) return "not a valid hostname"; if (!RESERVED_SUFFIXES.some((suffix) => host.endsWith(suffix))) return `use a name that can't be a real site: end it in ${RESERVED_SUFFIXES.join(", ")}`; } /** * Check `internalHosts` when the provider is created, so a mistake fails at * startup instead of at the first browser launch. */ function validateInternalHosts(options) { const hosts = Object.entries(options.hosts); if (hosts.length === 0) throw new Error("internalHosts.hosts needs at least one hostname"); if (hosts.length > 8) throw new Error(`internalHosts has ${hosts.length} hostnames; Browser Run allows at most 8 per browser`); for (const [host, config] of hosts) { const problem = hostnameProblem(host); if (problem) throw new Error(`Internal hostname "${host}": ${problem}`); const hasFetch = typeof config.fetch === "function"; const hasWorker = config.worker !== void 0; if (hasFetch === hasWorker) throw new Error(`Internal hostname "${host}" needs exactly one of fetch or worker`); if (hasWorker && config.target !== void 0) throw new Error(`Internal hostname "${host}": target needs fetch; a worker host can't be assigned`); if (hasFetch && typeof options.router !== "function") throw new Error(`Internal hostname "${host}" uses fetch, which needs internalHosts.router: this.ctx.exports.InternalHostRouter (and \`export { InternalHostRouter } from "agents/browser"\` in your main module)`); if (config.target !== void 0) targetJsonSchema(host, config.target); } } /** * Identifies the set of hostnames and how each is routed. A browser started * with a different set is replaced: Browser Run fixes routes at launch. */ function internalHostsSignature(options) { if (!options) return void 0; return Object.entries(options.hosts).map(([host, config]) => `${host}=${config.worker ? "worker" : "fetch"}`).sort().join(","); } /** * The route Browser Run gets for a `worker` host. A `ctx.exports` * entrypoint is a loopback stub that Browser Run can't take as is, so it's * called (with empty props) to get a Fetcher it can. */ function workerRoute(worker) { if (typeof worker !== "function") return worker; return worker({ props: {} }); } function isStandardSchema(schema) { return typeof schema === "object" && schema !== null && "~standard" in schema; } /** A host's target schema as JSON Schema, for the model and for plain validation. */ function targetJsonSchema(host, schema) { if (!isStandardSchema(schema)) { if (typeof schema !== "object" || schema === null) throw new Error(`Internal hostname "${host}": target must be a schema`); return schema; } const { jsonSchema } = schema["~standard"]; if (!jsonSchema?.input) throw new Error(`Internal hostname "${host}": target needs a schema that can describe itself as JSON Schema (Standard JSON Schema, such as zod 4.2+), or a plain JSON Schema object`); try { return jsonSchema.input({ target: "draft-07" }); } catch { return jsonSchema.input({ target: "draft-2020-12" }); } } const plainValidators = /* @__PURE__ */ new WeakMap(); function formatIssues(issues) { return issues.map((issue) => issue.path ? `${issue.path}: ${issue.message}` : issue.message).join("; "); } function pathString(path) { if (!path?.length) return void 0; return path.map((part) => String(typeof part === "object" ? part.key : part)).join("."); } function plainIssues(errors) { const specific = errors.filter((error) => error.keyword !== "properties"); return (specific.length > 0 ? specific : errors).map((error) => ({ message: error.error, path: error.instanceLocation.replace(/^#\/?/, "").replaceAll("/", ".") || void 0 })); } /** * Validate a target against a host's schema. Returns the validated value * (a Standard Schema's output, which may differ from its input), or a * message saying what's wrong. */ async function validateTarget(schema, value) { if (isStandardSchema(schema)) { const result = await schema["~standard"].validate(value); if (result.issues) return { ok: false, error: formatIssues(result.issues.map((issue) => ({ message: issue.message, path: pathString(issue.path) }))) }; return { ok: true, value: result.value }; } let validator = plainValidators.get(schema); if (!validator) { validator = new Validator(schema, "7", false); plainValidators.set(schema, validator); } const result = validator.validate(value); return result.valid ? { ok: true, value } : { ok: false, error: formatIssues(plainIssues(result.errors)) }; } function isInternalHostMessage(value) { if (typeof value !== "object" || value === null) return false; const message = value; return typeof message.browser === "string" && typeof message.host === "string" && message.request instanceof Request; } /** A plain-text response the browser shows for a failed internal request. */ function internalHostError(status, message) { return new Response(message, { status, headers: { "content-type": "text/plain; charset=utf-8" } }); } /** * Whether an RPC call failed because the agent has no `fetchInternalHost()`. * workerd says: `The RPC receiver does not implement the method * "fetchInternalHost".` If that wording changes, the router falls back to its * generic 502, which names the method too. */ function isMissingMethod(error) { return error instanceof Error && error.message.includes("fetchInternalHost") && /does not implement|is not a function/.test(error.message); } /** * Carries a browser's requests for an internal hostname to the agent that * owns it. Export it from your Worker's main module, so * `this.ctx.exports.InternalHostRouter` exists: * * ```ts * export { InternalHostRouter } from "agents/browser"; * ``` * * It trusts its props: Browser Run sets them from your routes, but another * Worker in your account with a service binding to this entrypoint could * set its own and reach any agent's hosts. That Worker could already call * your agents directly, so this grants nothing new. * * It holds no logic of its own: it calls the agent's `fetchInternalHost()` * with an {@link InternalHostMessage}, and the agent's `Browser` runs the * host's `fetch`. `Agent` has `fetchInternalHost()` built in; a plain * Durable Object adds it (see {@link InternalHostMessage}). * * @experimental The API may change before it stabilizes. */ var InternalHostRouter = class extends WorkerEntrypoint { async fetch(request) { const props = this.ctx.props; if (!isRouterProps(props)) return internalHostError(502, "InternalHostRouter was called without route props. Only Browser Run calls it, with the routes browserRun({ internalHosts }) gives it."); const namespace = this.ctx.exports?.[props.className]; if (!namespace?.getByName) return internalHostError(502, `${props.host}: the agent class "${props.className}" isn't exported from this Worker`); try { const response = await namespace.getByName(props.name).fetchInternalHost({ browser: props.browser, host: props.host, request }); if (response instanceof Response) return response; return internalHostError(502, `${props.host}: the agent didn't answer with a Response`); } catch (error) { if (isMissingMethod(error)) return internalHostError(502, `${props.host}: ${props.className} has no fetchInternalHost() method. Agent has it built in; on a plain Durable Object add: fetchInternalHost(message) { return this.browser.fetchInternalHost(message); }`); console.error(`[agents/browser] ${props.className} failed to serve ${props.host}`, error); return internalHostError(502, `${props.host}: the agent failed to serve this request`); } } }; function isRouterProps(props) { return props?.v === 1 && typeof props.className === "string" && typeof props.name === "string" && typeof props.browser === "string" && typeof props.host === "string"; } //#endregion //#region src/browser/providers/provider-adapter.ts /** * Thrown by {@link BrowserProviderAdapter.connect} when the session no longer * exists upstream (closed, expired, or reclaimed). `Browser` treats it as * "this browser is lost" and replaces it; any other error is passed on. */ var BrowserSessionGoneError = class extends Error { constructor(sessionId, options) { super(`Browser session gone: ${sessionId}`, options); this.sessionId = sessionId; this.name = "BrowserSessionGoneError"; } }; /** * Run the browser on Cloudflare Browser Run. * * @example * ```ts * new Browser({ provider: browserRun(env.BROWSER, { recording: true }) }); * ``` */ function browserRun(binding, options = {}) { const erased = options; if (erased.internalHosts) validateInternalHosts(erased.internalHosts); return { kind: "browser-run", binding, options: erased }; } function isDataCloneError(error) { return error instanceof Error && error.name === "DataCloneError"; } /** One `outboundByHost` route per internal hostname. */ function internalHostRoutes(internalHosts, owner) { const routes = {}; const hosts = Object.entries(internalHosts.hosts); for (const [host, config] of hosts) { if (config.worker) { routes[host] = workerRoute(config.worker); continue; } routes[host] = internalHosts.router({ props: { v: 1, ...owner, host } }); } return routes; } /** The code behind {@link browserRun}. */ function browserRunAdapter(provider) { const { binding, options } = provider; const keepAliveMs = () => options.keepAliveMs ?? 6e5; return { get keepAliveMs() { return keepAliveMs(); }, internalHosts: options.internalHosts, internalHostsSignature: internalHostsSignature(options.internalHosts), async create(context) { const { internalHosts } = options; if (internalHosts && !context?.owner) throw new Error("Internal hostnames need the Browser installed with Lifecycle.use() on a top-level agent"); try { return await acquireBrowserSession(binding, { keepAliveMs: keepAliveMs(), recording: options.recording, guardrails: options.guardrails, ...internalHosts && context?.owner ? { outboundByHost: internalHostRoutes(internalHosts, context.owner) } : {} }); } catch (error) { if (internalHosts && isDataCloneError(error)) throw new Error("Browser Run couldn't take the internal hostname routes: a worker host must be a service binding or an entrypoint of this Worker (this.ctx.exports.Name), not a Dynamic Worker or another object it can't hold", { cause: error }); throw error; } }, async isAlive(sessionId) { try { await listBrowserTargets(binding, sessionId); return true; } catch (error) { if (isMissingBrowserSession(error)) return false; throw error; } }, async connect(sessionId, connectOptions) { try { return await connectBrowserSession(binding, sessionId, connectOptions); } catch (error) { if (isMissingBrowserSession(error)) throw new BrowserSessionGoneError(sessionId, { cause: error }); throw error; } }, async close(sessionId) { try { await closeBrowserSession(binding, sessionId); } catch (error) { if (!isMissingBrowserSession(error)) throw error; } }, spec(sessionId) { return loadCdpSpec({ browser: binding, sessionId }); }, async liveView(sessionId, mode) { try { return createLiveView(sessionId, await listBrowserTargets(binding, sessionId), mode); } catch (error) { if (isMissingBrowserSession(error)) return void 0; throw error; } } }; } /** * Scope records kept per browser. Writing a new one past the cap deletes the * least recently used; a scope that comes back afterwards starts fresh. */ const MAX_BROWSER_SCOPES = 100; const NAMED_SESSION_KEY_PREFIX = "browser:session:"; /** The store key holding the named browser's current record. */ function namedBrowserSessionKey(name) { return `${NAMED_SESSION_KEY_PREFIX}${name}`; } /** * Where a closed or lost browser's record moves: permanent evidence that the * name once owned a browser, so a later resolve reports `restarted: true`. * Kept outside {@link NAMED_SESSION_KEY_PREFIX} so listing that prefix * yields only names that currently own a browser. Grows with the number of * distinct names the host has ever used. */ const RETIRED_SESSION_KEY_PREFIX = "browser:retired:"; function retiredBrowserSessionKey(name) { return `${RETIRED_SESSION_KEY_PREFIX}${name}`; } /** * Where a browser's scope records live. The name's length is part of the * prefix, so one browser's prefix never matches another name's (`a` with * scope `b:c` vs. `a:b` with scope `c`). */ function browserScopeKeyPrefix(name) { return `browser:scope:${name.length}:${name}:`; } /** * Where a browser's internal-host targets live: one key per host, under a * prefix per browser (length-prefixed like scope records, so one browser's * prefix never matches another's). Kept apart from the session record, so * targets survive the browser being replaced. */ function internalHostTargetsPrefix(name) { return `browser:hosts:${name.length}:${name}:`; } /** The adapter behind a provider: one explicit case per provider kind. */ function providerAdapterFor(provider) { switch (provider.kind) { case "browser-run": return browserRunAdapter(provider); } } var _options = /* @__PURE__ */ new WeakMap(); var _key = /* @__PURE__ */ new WeakMap(); var _adapter = /* @__PURE__ */ new WeakMap(); var _store = /* @__PURE__ */ new WeakMap(); var _Browser_brand = /* @__PURE__ */ new WeakSet(); /** * One named, persistent browser. * * Install it with `Lifecycle.use()` — on an Agent subclass or any Durable * Object that composes `Lifecycle` — and it stores its record in the * object's own storage. It schedules nothing: the provider reclaims idle * browsers itself (Browser Run once `keep_alive` elapses), and the next use * replaces the lost browser with `restarted: true`. * * Everything here is host-side. Models never see browser names, provider * session ids, or Live View URLs through this surface. * * Store discipline: locks wrap storage operations only — liveness probes and * provider create/close calls always happen outside any lock, with a commit * re-check to detect concurrent swaps. * * @experimental The API surface may change before stabilizing. */ var Browser = class extends LifecycleCapability { constructor(options) { const name = options.name ?? "default"; super(browserCapabilityId(name)); _classPrivateMethodInitSpec(this, _Browser_brand); _classPrivateFieldInitSpec(this, _options, void 0); _classPrivateFieldInitSpec(this, _key, void 0); _classPrivateFieldInitSpec(this, _adapter, void 0); _classPrivateFieldInitSpec(this, _store, void 0); if (name.trim() === "") throw new Error("Browser names must be non-empty"); this.name = name; _classPrivateFieldSet2(_options, this, options); _classPrivateFieldSet2(_key, this, namedBrowserSessionKey(name)); _classPrivateFieldSet2(_adapter, this, providerAdapterFor(options.provider)); } /** * Reattach to the browser when it is still alive, otherwise create a fresh * one. See {@link ResolvedBrowser.restarted} for the mortality signal. */ async resolve() { const { name } = this; const key = _classPrivateFieldGet2(_key, this); for (let attempt = 0; attempt < 4; attempt++) { const existing = await _assertClassBrand(_Browser_brand, this, _readStored).call(this); if (existing === void 0) { const { session, restarted } = await _assertClassBrand(_Browser_brand, this, _createAndCommit).call(this); return { name, restarted, ...resolvedFields(session) }; } const routesMatch = existing.internalHosts === _classPrivateFieldGet2(_adapter, this).internalHostsSignature; const alive = routesMatch && await _classPrivateFieldGet2(_adapter, this).isAlive(existing.sessionId); const lock = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).acquireLock(key); try { const current = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).get(key); if (current?.sessionId !== existing.sessionId) continue; if (alive) { const refreshed = { ...current, updatedAt: Date.now() }; await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).set(key, refreshed); return { name, restarted: false, ...resolvedFields(refreshed) }; } await _assertClassBrand(_Browser_brand, this, _retire).call(this, current); } finally { await lock.release(); } if (!routesMatch) await _assertClassBrand(_Browser_brand, this, _closeQuietly).call(this, existing.sessionId); } throw new Error(`Browser "${name}" kept changing concurrently — retry`); } /** * Resolve the browser and open a CDP connection to it for one scope (see * {@link BrowserConnectOptions.scope}). Commands sent over the connection * refresh the record's `updatedAt` (throttled to * {@link SESSION_TOUCH_INTERVAL_MS}), so hosts can tell an actively used * browser from one the platform has likely reclaimed. * * If the browser expires between the liveness probe and the WebSocket * upgrade, the record is retired and the browser resolved once more, so * the caller gets a fresh browser instead of an error. */ async connect(options = {}) { const scope = options.scope ?? "shared"; if (scope.trim() === "") throw new Error("Browser scopes must be non-empty"); let resolved = await this.resolve(); let cdp; try { cdp = await _assertClassBrand(_Browser_brand, this, _open).call(this, resolved); } catch (error) { if (!(error instanceof BrowserSessionGoneError)) throw error; await _assertClassBrand(_Browser_brand, this, _retireIfCurrent).call(this, resolved.sessionId); resolved = await this.resolve(); cdp = await _assertClassBrand(_Browser_brand, this, _open).call(this, resolved); } try { const entered = await _assertClassBrand(_Browser_brand, this, _enterScope).call(this, scope, resolved.sessionId); return _assertClassBrand(_Browser_brand, this, _connection).call(this, resolved, scope, cdp, entered); } catch (error) { cdp.disconnect(); throw error; } } /** * Start a session on this browser for one run: connects on its first * call, resolves `sessionId: "active"` to the scope's tab, and reports * what happened to the browser when you close it. `browserTool` opens * one per run. */ session(options) { return new BrowserSession(this, options); } /** * What the model needs to know to use this browser's `browser.*` * functions ({@link BrowserSession.functions}): the functions in Markdown, * including the internal sites and hosts functions this browser has, and * the rules for using them. For a tool or module description; add rules * about how your code runs (timeouts, screenshots) yourself. */ functionDocs() { return browserFunctionDocs(this.internalHosts); } /** * Close the browser: retire the record (so the next resolve reports * `restarted: true`) and close its provider session. Returns `false` when * there was nothing to close. * * The provider close is best-effort: the retired record is the durable * outcome, and a browser whose close failed is unreachable through this * store, so the provider reclaims it once idle (Browser Run: after * `keepAliveMs`, at most 20 minutes). */ async close() { let stored; const lock = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).acquireLock(_classPrivateFieldGet2(_key, this)); try { const current = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).get(_classPrivateFieldGet2(_key, this)); if (!current) return false; stored = current; await _assertClassBrand(_Browser_brand, this, _retire).call(this, current); } finally { await lock.release(); } try { await _classPrivateFieldGet2(_adapter, this).close(stored.sessionId); } catch (error) { console.warn(`[agents/browser] Failed to close browser session ${stored.sessionId}`, error); } return true; } /** * Mint Live View URLs for the browser's open tabs, fresh from a target * listing. `undefined` when the browser was never created, is closed, or * is gone. The URLs are bearer credentials with a short connect window * (~5 min) — hand them to a trusted human, never to the model, and never * store them: re-mint instead. */ async liveView(options) { const entry = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).get(_classPrivateFieldGet2(_key, this)); if (!entry) return void 0; const view = await _classPrivateFieldGet2(_adapter, this).liveView(entry.sessionId, options?.mode); if (!view) return void 0; try { if (!await _assertClassBrand(_Browser_brand, this, _touch).call(this, entry.sessionId)) return void 0; } catch (error) { console.warn(`[agents/browser] Failed to refresh activity for browser "${this.name}"`, error); } return view; } /** * The browser's internal hostnames, or `undefined` when its provider has * none. See {@link BrowserInternalHosts}. */ get internalHosts() { const config = _classPrivateFieldGet2(_adapter, this).internalHosts; if (!config) return void 0; const hosts = config.hosts; const describe = () => Object.entries(hosts).map(([host, entry]) => ({ host, url: `http://${host}/`, ...entry.description ? { description: entry.description } : {}, assignable: entry.target !== void 0, ...entry.target !== void 0 ? { targetSchema: targetJsonSchema(host, entry.target) } : {} })); const assignable = (host) => { const entry = hosts[host]; if (!entry || !Object.hasOwn(hosts, host)) throw new Error(`"${host}" isn't an internal hostname. The hostnames are: ${Object.keys(hosts).join(", ")}`); if (entry.target === void 0) throw new Error(`"${host}" always serves the same thing; it has no target to assign`); return entry.target; }; return { describe, list: async () => { const prefix = internalHostTargetsPrefix(this.name); const stored = await this.lifecycle.storage.list({ prefix }); return describe().map((info) => { const key = prefix + info.host; return stored.has(key) ? { ...info, target: stored.get(key) } : info; }); }, assign: async (host, target) => { const schema = assignable(host); if (target === void 0) throw new Error(`browser.assignHost needs a target for ${host}; use browser.clearHost to unassign it`); const json = JSON.parse(JSON.stringify(target)); const checked = await validateTarget(schema, json); if (!checked.ok) throw new Error(`Invalid target for ${host}: ${checked.error}`); await this.lifecycle.storage.put(_assertClassBrand(_Browser_brand, this, _targetKey).call(this, host), json); return { url: `http://${host}/` }; }, clear: async (host) => { assignable(host); return this.lifecycle.storage.delete(_assertClassBrand(_Browser_brand, this, _targetKey).call(this, host)); } }; } /** * Serve one browser request to an internal hostname. `InternalHostRouter` * calls the agent's `fetchInternalHost()` with the message; `Agent` passes * it here on its own. A plain Durable Object passes it on itself: * * ```ts * fetchInternalHost(message: InternalHostMessage) { * return this.browser.fetchInternalHost(message); * } * ``` * * Answers 502 with the reason when this browser doesn't serve the host. */ async fetchInternalHost(message) { if (message.browser !== this.name) return internalHostError(502, `${message.host} belongs to browser "${message.browser}", but was passed to browser "${this.name}"`); return _assertClassBrand(_Browser_brand, this, _serveInternalHost).call(this, message.host, message.request); } /** * `Agent.fetchInternalHost()` routes here through Lifecycle, so `Agent` * needn't import the browser. * * @internal Called by Lifecycle; not part of the host API. */ async onRoute(context) { const { payload } = context; if (!isInternalHostMessage(payload)) throw new Error(`Browser "${this.name}" got an unknown routed message`); return this.fetchInternalHost(payload); } }; function _connection(resolved, scope, cdp, entered) { const { sessionId } = resolved; return { name: resolved.name, sessionId, scope, restarted: entered.restarted, activeTargetId: entered.activeTargetId, cdp, setActiveTarget: (targetId) => _assertClassBrand(_Browser_brand, this, _updateScope).call(this, scope, sessionId, targetId), targetsInOtherScopes: async () => activeTargets(await _assertClassBrand(_Browser_brand, this, _listScopes).call(this), scope, sessionId), spec: () => _classPrivateFieldGet2(_adapter, this).spec(sessionId) }; } async function _open(resolved) { let lastTouchAt = Date.now(); let touchInFlight = false; return _classPrivateFieldGet2(_adapter, this).connect(resolved.sessionId, { timeoutMs: _classPrivateFieldGet2(_options, this).timeoutMs, onActivity: () => { const now = Date.now(); if (touchInFlight || now - lastTouchAt < _get_touchIntervalMs.call(_assertClassBrand(_Browser_brand, this))) return; touchInFlight = true; lastTouchAt = now; _assertClassBrand(_Browser_brand, this, _touch).call(this, resolved.sessionId).catch((error) => { console.warn(`[agents/browser] Failed to refresh activity for browser "${this.name}"`, error); }).finally(() => { touchInFlight = false; }); } }); } async function _serveInternalHost(host, request) { const hosts = _classPrivateFieldGet2(_adapter, this).internalHosts?.hosts; const entry = hosts && Object.hasOwn(hosts, host) ? hosts[host] : void 0; if (!entry?.fetch) return internalHostError(502, `${host} is no longer an internal hostname of browser "${this.name}"`); let target; if (entry.target !== void 0) { const assigned = await this.lifecycle.storage.get(_assertClassBrand(_Browser_brand, this, _targetKey).call(this, host)); if (assigned === void 0) return internalHostError(502, `${host} isn't pointed anywhere yet. Call browser.assignHost({ host: "${host}", target }) first.`); const checked = await validateTarget(entry.target, assigned); if (!checked.ok) return internalHostError(502, `${host}'s target no longer matches its schema (${checked.error}). Call browser.assignHost again.`); target = checked.value; } const fetch = entry.fetch; try { const response = await this.lifecycle.runInHostContext(() => fetch(request, target), { request }); if (response instanceof Response) return response; return internalHostError(502, `${host}: its fetch didn't return a Response`); } catch (error) { console.error(`[agents/browser] ${host}: its fetch threw`, error); return internalHostError(502, `${host}: its fetch failed`); } } /** * Which agent and browser the session's routes lead back to. `undefined` * without internal hostnames. */ function _internalHostOwner() { if (!_classPrivateFieldGet2(_adapter, this).internalHosts) return void 0; const services = this.lifecycleServices; if (!services) throw new Error(`Browser "${this.name}" has internal hostnames, which need it installed with Lifecycle.use()`); if (services.routes.source !== void 0) throw new Error(`Browser "${this.name}" has internal hostnames, which sub-agents don't support yet`); return { className: services.className, name: services.name, browser: this.name }; } /** The storage key of one host's target. */ function _targetKey(host) { return internalHostTargetsPrefix(this.name) + host; } async function _closeQuietly(sessionId) { try { await _classPrivateFieldGet2(_adapter, this).close(sessionId); } catch (error) { console.warn(`[agents/browser] Failed to close browser session ${sessionId}`, error); } } /** * Capped at half the provider's keep-alive window: a browser with * continuous CDP traffic must never look older than the window the * provider uses to reclaim it. */ function _get_touchIntervalMs() { const interval = _classPrivateFieldGet2(_options, this).touchIntervalMs ?? 6e4; const { keepAliveMs } = _classPrivateFieldGet2(_adapter, this); return keepAliveMs === void 0 ? interval : Math.min(interval, Math.floor(keepAliveMs / 2)); } /** Lazy: `lifecycle.storage` exists only once `Lifecycle.use()` ran. */ function _get_sessionStore() { _classPrivateFieldGet2(_store, this) ?? _classPrivateFieldSet2(_store, this, _classPrivateFieldGet2(_options, this).store ?? new DurableBrowserSessionStore(this.lifecycle.storage)); return _classPrivateFieldGet2(_store, this); } /** * Refresh `updatedAt` while the record still holds `sessionId`. Returns * false when a concurrent close or replacement already retired it. */ function _touch(sessionId) { return _assertClassBrand(_Browser_brand, this, _update).call(this, sessionId, (current) => ({ ...current, updatedAt: Date.now() })); } /** * Rewrite the record only while it still holds `sessionId`: a replaced or * retired record is never resurrected. */ async function _update(sessionId, change) { const lock = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).acquireLock(_classPrivateFieldGet2(_key, this)); try { const current = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).get(_classPrivateFieldGet2(_key, this)); if (current?.sessionId !== sessionId) return false; await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).set(_classPrivateFieldGet2(_key, this), change(current)); return true; } finally { await lock.release(); } } function _scopeKey(scope) { return `${browserScopeKeyPrefix(this.name)}${scope}`; } /** The lock every scope record of this browser is written under. */ function _get_scopesLockKey() { return browserScopeKeyPrefix(this.name); } /** Every scope record of this browser, by scope. */ async function _listScopes() { const prefix = browserScopeKeyPrefix(this.name); const entries = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).list(prefix); const scopes = /* @__PURE__ */ new Map(); for (const [key, entry] of entries) scopes.set(key.slice(prefix.length), entry); return scopes; } /** * Start a connection's scope: a scope whose record names an earlier * browser was restarted, and its record moves to this one. A new record * past {@link MAX_BROWSER_SCOPES} evicts the least recently used. */ async function _enterScope(scope, sessionId) { const key = _assertClassBrand(_Browser_brand, this, _scopeKey).call(this, scope); const lock = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).acquireLock(_get_scopesLockKey.call(_assertClassBrand(_Browser_brand, this))); try { const entry = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).get(key); const now = Date.now(); if (entry?.sessionId === sessionId) { await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).set(key, { ...entry, updatedAt: now }); return { restarted: false, activeTargetId: entry.activeTargetId }; } await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).set(key, { sessionId, createdAt: now, updatedAt: now }); if (!entry) await _assertClassBrand(_Browser_brand, this, _evictScopes).call(this); return { restarted: entry !== void 0 }; } finally { await lock.release(); } } /** Delete the least recently used scope records past the cap. */ async function _evictScopes() { const scopes = await _assertClassBrand(_Browser_brand, this, _listScopes).call(this); if (scopes.size <= 100) return; const oldest = [...scopes].sort(([, a], [, b]) => a.updatedAt - b.updatedAt).slice(0, scopes.size - 100); for (const [scope] of oldest) await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).delete(_assertClassBrand(_Browser_brand, this, _scopeKey).call(this, scope)); } /** * Set a scope's tab (or clear it with `undefined`). Never resurrects: * does nothing (and returns `false`) once the browser this connection * resolved was closed or replaced. */ async function _updateScope(scope, sessionId, targetId) { const key = _assertClassBrand(_Browser_brand, this, _scopeKey).call(this, scope); const lock = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).acquireLock(_get_scopesLockKey.call(_assertClassBrand(_Browser_brand, this))); try { const current = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).get(_classPrivateFieldGet2(_key, this)); const entry = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).get(key); if (current?.sessionId !== sessionId || entry?.sessionId !== sessionId) return false; await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).set(key, { ...entry, activeTargetId: targetId, updatedAt: Date.now() }); return true; } finally { await lock.release(); } } /** Retire the record if it still holds `sessionId`. */ async function _retireIfCurrent(sessionId) { const lock = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).acquireLock(_classPrivateFieldGet2(_key, this)); try { const current = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).get(_classPrivateFieldGet2(_key, this)); if (current?.sessionId === sessionId) await _assertClassBrand(_Browser_brand, this, _retire).call(this, current); } finally { await lock.release(); } } /** * Move the record to its retired marker. Callers hold the key lock, so a * resolver always sees either the record or the marker. */ async function _retire(current) { await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).set(retiredBrowserSessionKey(this.name), { ...current, closedAt: Date.now() }); await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).delete(_classPrivateFieldGet2(_key, this)); } /** * Create a provider session (outside any lock) and commit it. The adapter * applies the provider's creation options. If a concurrent caller * committed an entry first, theirs wins and the redundant session is * closed best-effort. * * `restarted` is read under the commit lock: a retired marker — left by * close() or a dead-browser recovery — is evidence a prior browser * existed, and only first-ever use of the name is not a restart. Markers * are permanent, so reading at commit time also catches a browser that * was created and closed while this create was in flight. */ async function _createAndCommit() { const info = await _classPrivateFieldGet2(_adapter, this).create({ owner: _assertClassBrand(_Browser_brand, this, _internalHostOwner).call(this) }); const now = Date.now(); const signature = _classPrivateFieldGet2(_adapter, this).internalHostsSignature; const stored = { sessionId: info.sessionId, createdAt: now, updatedAt: now, ...signature !== void 0 ? { internalHosts: signature } : {} }; let winner; let restarted; const lock = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).acquireLock(_classPrivateFieldGet2(_key, this)); try { restarted = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).get(retiredBrowserSessionKey(this.name)) !== void 0; const current = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).get(_classPrivateFieldGet2(_key, this)); if (current === void 0) await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).set(_classPrivateFieldGet2(_key, this), stored); else winner = current; } finally { await lock.release(); } if (winner) { try { await _classPrivateFieldGet2(_adapter, this).close(stored.sessionId); } catch (error) { console.warn(`[agents/browser] Failed to close redundant browser session ${stored.sessionId}`, error); } return { session: winner, restarted }; } return { session: stored, restarted }; } async function _readStored() { const lock = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).acquireLock(_classPrivateFieldGet2(_key, this)); try { return await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).get(_classPrivateFieldGet2(_key, this)); } finally { await lock.release(); } } /** A store record without fields that belong to scope records. */ function resolvedFields(session) { const { sessionId, createdAt, updatedAt } = session; return { sessionId, createdAt, updatedAt }; } /** The tabs scopes other than `scope` are working in, in `sessionId`. */ function activeTargets(scopes, scope, sessionId) { const targets = /* @__PURE__ */ new Set(); for (const [other, entry] of scopes) { if (other === scope || entry.sessionId !== sessionId) continue; if (entry.activeTargetId) targets.add(entry.activeTargetId); } return targets; } //#endregion export { ACTIVE_PAGE_SESSION, Browser, BrowserConnector, BrowserRenderingError, BrowserSession, CdpConnection, CdpSession, CodemodeRuntime, DEFAULT_BROWSER_SCOPE, DEFAULT_EXEC_SWEEP_IDLE_MS, DEFAULT_SWEEP_IDLE_MS, DurableBrowserSessionStore, InternalHostRouter, MAX_BROWSER_SCOPES, MAX_INTERNAL_HOSTS, browserContent, browserExtract, browserLinks, browserMarkdown, browserPdf, browserRun, browserScrape, browserScreenshot, browserSnapshot, connectBrowser, connectBrowserSession, connectUrl, conversationScope, createBrowserSession, deleteBrowserSession, getBrowserRecording, listBrowserTargets, loadCdpSpec, runQuickAction }; //# sourceMappingURL=index.js.map