/** * camofox-browser 适配器(BrowserProvider 首个实现)。 * 对照:jo-inc/camofox-browser v1.17.0(commit 389c996)openapi.json、README 与 server.js;2026-09-28 对真实容器核验, * 结果见 docs/runbook/browser.md「真机核验」。 * * 上游模型与本项目合同的映射: * - camofox userId = 一个隔离 BrowserContext(cookies/storage),本项目用 profileKey(站点/账号)充当; * - camofox sessionKey = 标签组,本项目用 browser_session.id; * - 上游没有"建会话"端点,首个 POST /tabs 隐式创建;会话由本适配器在内存登记,持久化由服务层写 browser_session。 * 认证:README/OpenAPI 为 `Authorization: Bearer `(设置后除 /health 外全部路由要求); * 凭据每次请求经 BrowserSecretResolver 解出,不缓存、不入日志、不进错误信息。 * * 已实现:health、capabilities、会话开/关/列举、标签页增删与对账、navigate(白名单/超时/重试)、 * 后退/前进/刷新、snapshot(脱敏)、screenshot、click/type/select/scroll/press、extract、waitForDownload、 * 内存版控制权租约与串行锁。会话/标签/租约的持久化与对账由服务层(apps/server modules/browser)以数据库为准。 * 与文档不同的实测行为(截图直接返回 image/png、click 不接受坐标、焦点输入需 mode=keyboard、downloads 字段名)已按实测适配。 */ import type { BrowserCapabilities, BrowserControlLease, BrowserCookie, BrowserEgressProxy, BrowserHealth, BrowserIdentity, BrowserSession, BrowserSnapshot, BrowserTab, DownloadArtifact, NavigationBudget, } from "@umt/contracts"; import { assertNavigable, hostAllowed } from "../budget.ts"; import { BrowserError, classifyHttpStatus } from "../errors.ts"; import { InMemoryProfileLock, type ProfileLock } from "../profile-lock.ts"; import { type AcquireControlRequest, type ActorKind, type BrowserLogger, type BrowserProvider, type BrowserScreenshot, type BrowserSecretResolver, type BrowserSessionInfo, type ElementTarget, type NavigationResult, type OpenSessionRequest, type OpenSessionResult, type PageContent, type PageMetrics, profileKeyFor, type ReopenSessionRequest, type ReopenSessionResult, type RestoreSessionState, type SnapshotOptions, type TabRef, type TypeInput, } from "../provider.ts"; import { redactBrowserUrl, redactSnapshot, urlHasCredential } from "../redact.ts"; import { camofoxCapabilitiesFromOpenApi, camofoxStaticCapabilities, type VerifiedMap } from "./capabilities.ts"; export interface CamofoxConfig { /** browser_provider.id */ providerId: string; /** 例 http://camofox:9377 */ baseUrl: string; secrets: BrowserSecretResolver; /** 单次请求默认超时;导航用 budget.timeoutMs */ timeoutMs?: number; /** 同一 profile 串行锁;缺省进程内内存锁 */ lock?: ProfileLock; lockWaitMs?: number; /** 已对真实实例验证通过的能力(来自原型验证记录);缺省全部未验证 */ verified?: VerifiedMap; /** 截图/快照默认字节上限(未传 budget 时) */ defaultMaxBytes?: number; /** 下载产物落点(服务层写 DATA_DIR/tmp);缺省进程内内存(memoryDownloadSink) */ downloadSink?: DownloadSink; /** waitForDownload 轮询间隔 */ downloadPollMs?: number; fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; logger?: BrowserLogger; now?: () => Date; newId?: () => string; } interface SessionState { session: BrowserSession; tabs: Map; currentTabId: string; /** 未脱敏地址只留在内存,用于判断截图能否进入模型;不对外返回 */ rawUrl: string | null; budget: NavigationBudget; /** 最近一次快照之后没有导航/点击/交接:eN 引用可用 */ refsValid: boolean; /** 已见过的下载(consume 失败时防重复) */ seenDownloads: Set; } /** 下载产物的受控暂存:返回引用,字节不进合同/日志 */ export type DownloadSink = ( bytes: Uint8Array, meta: { id: string; sessionId: string; filename: string; mimeType: string | null }, ) => Promise<{ kind: "blob" | "secret"; ref: string }>; /** 进程内内存暂存(测试/脚本);服务层换成 DATA_DIR/tmp 下的文件 */ export function memoryDownloadSink(): DownloadSink & { blobs: Map } { const blobs = new Map(); const sink = (async (bytes, meta) => { const ref = `mem:${meta.id}`; blobs.set(ref, bytes); return { kind: "blob" as const, ref }; }) as DownloadSink & { blobs: Map }; sink.blobs = blobs; return sink; } export function isLoginUrl(raw: string | null): boolean { if (!raw) return false; try { return LOGIN_PATH.test(new URL(raw).pathname); } catch { return false; } } function b64decode(b64: string): Uint8Array { return Uint8Array.from(atob(b64.replace(/^data:[^,]*,/, "").replace(/\s+/g, "")), (c) => c.charCodeAt(0)); } async function sha256Hex(bytes: Uint8Array): Promise { const d = await crypto.subtle.digest("SHA-256", bytes as Uint8Array); return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join(""); } type Json = Record; const str = (v: unknown) => (typeof v === "string" ? v : undefined); const int = (v: unknown) => (typeof v === "number" && Number.isInteger(v) ? v : undefined); /** ARIA 快照里带值的输入框:`- textbox [e2]: 值` / `- textbox "标签" [e2]: 值` */ const TEXTBOX_WITH_VALUE = /-\s*(?:textbox|searchbox)\b[^\n]*\[e\d+\][^\n]*?:\s*\S/; /** 兜底:遮蔽全部输入框的值(只替换冒号后的值部分) */ const TEXTBOX_VALUE_ANY = /(?<=-\s*(?:textbox|searchbox)\b[^\n]*\[e\d+\][^\n]*?:\s*)\S[^\n]*/g; /** 页面度量表达式(mock 按 umt-metrics 标记识别) */ const PAGE_METRICS_EXPR = `/* umt-metrics */ (() => ({ w: innerWidth, h: innerHeight, dpr: devicePixelRatio, pw: !!document.querySelector("input[type=password]"), text: String((document.body && document.body.innerText) || "").slice(0, 4000) }))()`; /** 页面内容表达式(mock 按 umt-page 标记识别) */ function pageContentExpr(maxChars: number): string { return `/* umt-page */ (() => ({ url: location.href, title: document.title, html: document.documentElement ? document.documentElement.outerHTML.slice(0, ${maxChars}) : "", text: String((document.body && document.body.innerText) || "").slice(0, 20000), ua: navigator.userAgent }))()`; } const LOGIN_PATH = /(?:^|\/)(?:login|takelogin|signin|sign_in|auth)(?:\.php)?(?:\/|$|\?)/i; export class CamofoxBrowserProvider implements BrowserProvider { readonly kind = "camofox" as const; private readonly base: string; private readonly fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise; private readonly lock: ProfileLock; private readonly sessions = new Map(); private readonly leases = new Map(); private readonly sink: DownloadSink; private readonly now: () => Date; private readonly newId: () => string; /** Cookie 接口:plugin = umt-cookies 插件(读写);upstream = 上游导入端点(只写,要求 CAMOFOX_API_KEY 与访问凭据相同);none = 都没有 */ private cookieApi: "plugin" | "upstream" | "none" | null = null; /** 实例标识接口:plugin = umt 插件 GET /umt/instance;health = 只能从 /health 里找;null = 未探测 */ private instanceApi: "plugin" | "health" | null = null; /** 真实鼠标坐标点击(CF-2):plugin = umt 插件 `POST /umt/tabs/{id}/mouse-click`;none = 插件没有,退回 elementFromPoint;null = 未探测 */ private mouseApi: "plugin" | "none" | null = null; constructor(private readonly cfg: CamofoxConfig) { this.base = cfg.baseUrl.replace(/\/+$/, ""); this.fetchImpl = cfg.fetch ?? fetch; this.lock = cfg.lock ?? new InMemoryProfileLock(cfg.lockWaitMs); this.now = cfg.now ?? (() => new Date()); this.newId = cfg.newId ?? (() => crypto.randomUUID()); this.sink = cfg.downloadSink ?? memoryDownloadSink(); } // ---------- HTTP ---------- private async authHeader(): Promise> { const key = await this.cfg.secrets.resolve({ kind: "provider_access_key", providerId: this.cfg.providerId }); return key ? { authorization: `Bearer ${key}` } : {}; } private async request( method: "GET" | "POST" | "PUT" | "DELETE", path: string, opts: { query?: Record; body?: Json; timeoutMs?: number; auth?: boolean } = {}, ): Promise { const u = new URL(this.base + path); for (const [k, v] of Object.entries(opts.query ?? {})) if (v !== undefined) u.searchParams.set(k, v); const headers: Record = { accept: "application/json", ...(opts.auth === false ? {} : await this.authHeader()), }; if (opts.body) headers["content-type"] = "application/json"; const timeoutMs = opts.timeoutMs ?? this.cfg.timeoutMs ?? 30_000; const started = Date.now(); let res: Response; try { res = await this.fetchImpl(u, { method, headers, ...(opts.body ? { body: JSON.stringify(opts.body) } : {}), signal: AbortSignal.timeout(timeoutMs), }); } catch (e) { const name = (e as { name?: string })?.name; const kind = name === "TimeoutError" || name === "AbortError" ? "timeout" : "network"; this.cfg.logger?.({ level: "warn", msg: `camofox ${kind}`, method, path, ms: Date.now() - started }); throw new BrowserError( kind, `camofox ${method} ${path} ${kind === "timeout" ? `超时(${timeoutMs}ms)` : "网络错误"}`, ); } this.cfg.logger?.({ level: "debug", msg: "camofox", method, path, status: res.status, ms: Date.now() - started }); if (!res.ok) { await res.body?.cancel(); throw new BrowserError(classifyHttpStatus(res.status), `camofox ${method} ${path} → HTTP ${res.status}`, { httpStatus: res.status, }); } return res; } private async json(res: Response, path: string): Promise { try { const v = (await res.json()) as unknown; if (v && typeof v === "object" && !Array.isArray(v)) return v as Json; } catch { /* fallthrough */ } throw new BrowserError("protocol", `camofox ${path} 返回非 JSON 对象`); } // ---------- 状态 ---------- private state(sessionId: string): SessionState { const s = this.sessions.get(sessionId); if (!s) throw new BrowserError("not_found", "会话不存在或已关闭"); return s; } private tabOf(s: SessionState, ref: TabRef): BrowserTab { const t = s.tabs.get(ref.tabId ?? s.currentTabId); if (t?.status !== "open") throw new BrowserError("not_found", "标签页不存在或已关闭"); return t; } /** 动作发起方必须与会话控制者一致:接管/等待/关闭中的会话拒绝自动化动作(REQ §4.7.3) */ private assertActor(s: SessionState, as: ActorKind = "automation"): void { const c = s.session.controller; if (c === as) return; if (c === "closed") throw new BrowserError("control_conflict", "会话已关闭"); if (as === "automation") throw new BrowserError( "control_conflict", c === "human" ? "会话正由人工接管,自动化动作已停止" : "会话在等待人工处理,自动化动作已停止", ); throw new BrowserError("control_conflict", "先接管会话再操作"); } /** 用 eN 引用前确认快照仍有效:导航、点击、交接之后旧引用一律作废,不重放 */ private assertTarget(s: SessionState, target: ElementTarget): Json { if ("ref" in target) { if (!s.refsValid) throw new BrowserError("stale_ref", "元素引用已失效(页面已变化或控制权已交接),先重新读取页面"); return { ref: target.ref }; } if ("selector" in target) return { selector: target.selector }; if (!Number.isFinite(target.x) || !Number.isFinite(target.y) || target.x < 0 || target.y < 0) throw new BrowserError("bad_request", "坐标无效"); return { x: Math.round(target.x), y: Math.round(target.y) }; } private touch(s: SessionState, rawUrl?: string | null): void { const at = this.now().toISOString(); if (rawUrl !== undefined) { s.rawUrl = rawUrl; s.session.currentUrl = rawUrl === null ? null : redactBrowserUrl(rawUrl); } s.session.lastActivityAt = at; s.session.updatedAt = at; } private withProfile(s: SessionState | string, fn: () => Promise): Promise { const key = typeof s === "string" ? s : s.session.profileKey; return this.lock.run(key, fn, this.cfg.lockWaitMs === undefined ? {} : { waitMs: this.cfg.lockWaitMs }); } /** 服务层把会话控制权变更(接管/交还/等待)同步给适配器;租约本身由服务层持久化。任何变更都让旧引用失效 */ setController(sessionId: string, controller: BrowserSession["controller"]): void { const s = this.state(sessionId); if (s.session.controller !== controller) s.refsValid = false; s.session.controller = controller; if (controller === "closed" && !s.session.closedAt) s.session.closedAt = this.now().toISOString(); this.touch(s); } restoreSession(st: RestoreSessionState): void { const tabs = new Map(st.tabs.map((t) => [t.upstreamTabId, { ...t }])); const open = st.tabs.find((t) => t.status === "open"); this.sessions.set(st.session.id, { session: { ...st.session }, tabs, currentTabId: st.currentTabId && tabs.has(st.currentTabId) ? st.currentTabId : (open?.upstreamTabId ?? ""), rawUrl: st.session.currentUrl, budget: st.budget, refsValid: false, seenDownloads: new Set(), }); } sessionInfo(sessionId: string): BrowserSessionInfo | null { const s = this.sessions.get(sessionId); if (!s) return null; return { session: { ...s.session }, tabs: [...s.tabs.values()].map((t) => ({ ...t })), currentTabId: s.currentTabId || null, refsValid: s.refsValid, loginPage: isLoginUrl(s.rawUrl), }; } async drain(sessionId: string): Promise { const s = this.state(sessionId); await this.withProfile(s, async () => {}); } // ---------- 健康与能力 ---------- async health(): Promise { const checkedAt = this.now().toISOString(); const u = `${this.base}/health`; try { // /health 在 CAMOFOX_ACCESS_KEY 下也免认证(README);503 表示恢复中 const res = await this.fetchImpl(u, { signal: AbortSignal.timeout(this.cfg.timeoutMs ?? 10_000) }); const body = (await res.json().catch(() => ({}))) as Json; const detail: Json = { httpStatus: res.status, engine: str(body.engine) ?? null, browserConnected: body.browserConnected ?? null, browserRunning: body.browserRunning ?? null, activeTabs: int(body.activeTabs) ?? null, activeSessions: int(body.activeSessions) ?? null, consecutiveFailures: int(body.consecutiveFailures) ?? null, recovering: body.recovering ?? null, }; if (res.status === 503) return { status: "degraded", kind: "camofox", detail, checkedAt }; if (!res.ok || body.ok === false) return { status: "down", kind: "camofox", detail, checkedAt }; return { status: "ok", kind: "camofox", detail, checkedAt }; } catch (e) { const name = (e as { name?: string })?.name; return { status: "down", kind: "camofox", detail: { error: name === "TimeoutError" ? "timeout" : "network" }, checkedAt, }; } } /** probe=true 时读取上游 /openapi.json 推导;失败回退静态声明(note 标注探测失败) */ async capabilities(opts: { probe?: boolean } = {}): Promise { const verified = this.cfg.verified ?? {}; if (!opts.probe) return camofoxStaticCapabilities(verified); try { const res = await this.request("GET", "/openapi.json"); return camofoxCapabilitiesFromOpenApi(await this.json(res, "/openapi.json"), verified, this.now()); } catch (e) { if (e instanceof BrowserError && e.kind === "auth") throw e; return camofoxStaticCapabilities(verified); } } /** * 实例标识(BR-3 重启检测):umt 插件 `GET /umt/instance` 的 instanceId(插件加载时生成,进程重启即变); * 插件不在(404)退回 `/health` 里的 instanceId / bootId / startedAt;都没有返回 null。 */ async instanceId(): Promise { if (this.instanceApi !== "health") { try { const body = await this.json(await this.request("GET", "/umt/instance", { timeoutMs: 5_000 }), "/umt/instance"); this.instanceApi = "plugin"; return str(body.instanceId) ?? str(body.startedAt) ?? null; } catch (e) { if (!(e instanceof BrowserError && e.httpStatus === 404)) throw e; this.instanceApi = "health"; } } const res = await this.fetchImpl(`${this.base}/health`, { signal: AbortSignal.timeout(5_000) }); const body = (await res.json().catch(() => ({}))) as Json; const v = body.instanceId ?? body.bootId ?? body.startedAt; return typeof v === "string" || typeof v === "number" ? String(v) : null; } /** * 固定浏览器身份(BR-4):umt 插件 `GET /umt/identity`(UA、OS、版本、生成时间,不含指纹细节); * 插件不在或身份关闭(404)返回 null。 */ async identity(): Promise { let body: Json; try { body = await this.json(await this.request("GET", "/umt/identity", { timeoutMs: 10_000 }), "/umt/identity"); } catch (e) { if (e instanceof BrowserError && e.httpStatus === 404) return null; throw e; } const userAgent = str(body.userAgent); if (!userAgent) return null; return { userAgent, os: str(body.os) ?? null, platform: str(body.platform) ?? null, firefoxVersion: str(body.firefoxVersion) ?? null, camoufoxVersion: str(body.camoufoxVersion) ?? null, fingerprintId: str(body.fingerprintId) ?? null, revision: int(body.revision) ?? 1, createdAt: str(body.createdAt) ?? null, updatedAt: str(body.updatedAt) ?? null, }; } /** * 该 profile 的出口(BR-4 出口 IP 按站点统一):umt 插件 `PUT /umt/sessions/{userId}/proxy`。 * proxy = null 直连;活动上下文出口不同时插件会先关掉它(落盘登录状态)。插件不在报 not_implemented。 */ async setEgress( profileKey: string, proxy: BrowserEgressProxy | null, ): Promise<{ restarted: boolean; mode?: string }> { try { const body = await this.json( await this.request("PUT", `/umt/sessions/${encodeURIComponent(profileKey)}/proxy`, { body: { proxy } as unknown as Json, timeoutMs: 15_000, }), "/umt/sessions/{userId}/proxy", ); const mode = str(body.mode); return { restarted: body.restarted === true, ...(mode ? { mode } : {}) }; } catch (e) { if (e instanceof BrowserError && e.httpStatus === 404) throw new BrowserError("not_implemented", "浏览器服务不支持按站点设出口(镜像未带新版 umt-cookies 插件)"); throw e; } } /** * 重建会话的上游上下文(BR-3):上游重启 / 回收后旧标签页都不在了。先(可选)注入 Cookie,再用同一 userId / sessionKey * 开新标签页并导航到 url;旧的打开中标签页标 lost,旧元素引用作废。不检查控制者:接管中的会话重连后仍归原控制者。 */ async reopenSession(sessionId: string, req: ReopenSessionRequest): Promise { const s = this.state(sessionId); assertNavigable(req.url, req.budget); return this.withProfile(s, async () => { let cookies: ReopenSessionResult["cookies"]; if (req.cookies?.length) { try { cookies = { status: "injected", count: (await this.putCookies(s.session.profileKey, req.cookies)).count }; } catch (e) { if (e instanceof BrowserError && e.kind === "not_implemented") cookies = { status: "unsupported", count: 0 }; else if (e instanceof BrowserError && e.kind !== "auth") cookies = { status: "failed", count: 0 }; else throw e; } } const res = await this.request("POST", "/tabs", { body: { userId: s.session.profileKey, sessionKey: s.session.id, url: req.url }, timeoutMs: req.budget.timeoutMs, }); const body = await this.json(res, "/tabs"); const tabId = str(body.tabId); if (!tabId) throw new BrowserError("protocol", "camofox POST /tabs 未返回 tabId"); const rawUrl = str(body.url) ?? req.url; this.touch(s, rawUrl); const at = s.session.lastActivityAt; const lost: string[] = []; for (const t of s.tabs.values()) if (t.status === "open") { t.status = "lost"; t.updatedAt = at; lost.push(t.upstreamTabId); } const tab: BrowserTab = { id: this.newId(), sessionId, upstreamTabId: tabId, url: s.session.currentUrl, title: null, status: "open", lastSnapshotAt: null, closedAt: null, createdAt: at, updatedAt: at, }; s.tabs.set(tabId, tab); s.currentTabId = tabId; s.refsValid = false; s.budget = req.budget; return { tab: { ...tab }, lost, ...(cookies ? { cookies } : {}) }; }); } // ---------- 会话 ---------- async openSession(req: OpenSessionRequest): Promise { const profileKey = req.profileKey ?? profileKeyFor(req.siteId, req.accountId); if (req.initialUrl) assertNavigable(req.initialUrl, req.budget); return this.withProfile(profileKey, async () => { for (const s of this.sessions.values()) if (s.session.profileKey === profileKey && s.session.controller !== "closed") throw new BrowserError("control_conflict", "该站点/账号已有未关闭的浏览器会话"); const sessionId = this.newId(); let cookies: OpenSessionResult["cookies"]; if (req.cookies?.length) { try { cookies = { status: "injected", count: (await this.putCookies(profileKey, req.cookies)).count }; } catch (e) { if (e instanceof BrowserError && e.kind === "not_implemented") cookies = { status: "unsupported", count: 0 }; else if (e instanceof BrowserError && e.kind !== "auth") cookies = { status: "failed", count: 0 }; else throw e; } } const res = await this.request("POST", "/tabs", { body: { userId: profileKey, sessionKey: sessionId, ...(req.initialUrl ? { url: req.initialUrl } : {}) }, timeoutMs: req.budget.timeoutMs, }); const body = await this.json(res, "/tabs"); const tabId = str(body.tabId); if (!tabId) throw new BrowserError("protocol", "camofox POST /tabs 未返回 tabId"); const rawUrl = str(body.url) ?? req.initialUrl ?? null; const at = this.now().toISOString(); const session: BrowserSession = { id: sessionId, providerId: this.cfg.providerId, siteId: req.siteId, accountId: req.accountId, profileKey, controller: "automation", currentUrl: rawUrl === null ? null : redactBrowserUrl(rawUrl), lastActivityAt: at, upstreamRef: { userId: profileKey, sessionKey: sessionId }, recoveryState: {}, closedAt: null, closeReason: null, createdAt: at, updatedAt: at, }; const tab: BrowserTab = { id: this.newId(), sessionId, upstreamTabId: tabId, url: session.currentUrl, title: null, status: "open", lastSnapshotAt: null, closedAt: null, createdAt: at, updatedAt: at, }; const st: SessionState = { session, tabs: new Map([[tabId, tab]]), currentTabId: tabId, rawUrl, budget: req.budget, refsValid: false, seenDownloads: new Set(), }; this.sessions.set(sessionId, st); let navigation: NavigationResult | null = null; if (req.initialUrl) { navigation = { url: session.currentUrl ?? "", httpStatus: int(body.httpStatus) ?? null, ok: body.navigationOk !== false, attempts: 1, }; this.assertLanded(rawUrl, req.budget); } return { session: { ...session }, tab: { ...tab }, navigation, ...(cookies ? { cookies } : {}) }; }); } /** 重定向落到白名单外:报 domain_blocked(页面已打开,调用方应停止后续动作并转人工) */ private assertLanded(rawUrl: string | null, budget: NavigationBudget): void { if (!rawUrl) return; let host: string; try { host = new URL(rawUrl).hostname; } catch { return; } if (!hostAllowed(host, budget.domainAllowlist)) throw new BrowserError("domain_blocked", `导航被重定向到白名单外:${redactBrowserUrl(new URL(rawUrl).origin)}`); } // ---------- Cookie(BR-2 凭据统一) ---------- /** * 写 Cookie:先走 umt-cookies 插件 `POST /umt/sessions/{userId}/cookies`(docker/browser-desktop 自带,访问凭据即可); * 插件不在(404)再试上游 `POST /sessions/{userId}/cookies`(1.17.0 要求 CAMOFOX_API_KEY,与访问凭据相同时可用); * 都不行报 not_implemented。两条路上游都会立即把 profile 落盘。 */ setCookies(profileKey: string, cookies: BrowserCookie[]): Promise<{ count: number }> { return this.withProfile(profileKey, () => this.putCookies(profileKey, cookies)); } private async putCookies(profileKey: string, cookies: BrowserCookie[]): Promise<{ count: number }> { const list = cookies.slice(0, 500); const body = { cookies: list } as unknown as Json; const path = `/sessions/${encodeURIComponent(profileKey)}/cookies`; if (this.cookieApi === null || this.cookieApi === "plugin") { try { const res = await this.json( await this.request("POST", `/umt${path}`, { body }), "/umt/sessions/{userId}/cookies", ); this.cookieApi = "plugin"; return { count: int(res.count) ?? list.length }; } catch (e) { if (!(e instanceof BrowserError && e.httpStatus === 404)) throw e; this.cookieApi = null; } } if (this.cookieApi === null || this.cookieApi === "upstream") { try { const res = await this.json(await this.request("POST", path, { body }), "/sessions/{userId}/cookies"); this.cookieApi = "upstream"; return { count: int(res.count) ?? list.length }; } catch (e) { // 403:上游导入口令(CAMOFOX_API_KEY)未设置或与访问凭据不同 if (!(e instanceof BrowserError && (e.httpStatus === 403 || e.httpStatus === 404))) throw e; this.cookieApi = "none"; } } throw new BrowserError("not_implemented", "浏览器服务没有 Cookie 接口(镜像未带 umt-cookies 插件)"); } /** 读 Cookie:只有 umt-cookies 插件提供(上游 1.17.0 没有读取端点);返回作用于这些地址的 Cookie */ async getCookies(profileKey: string, urls: string[]): Promise { if (this.cookieApi === "upstream" || this.cookieApi === "none") throw new BrowserError("not_implemented", "浏览器服务不支持读回 Cookie(镜像未带 umt-cookies 插件)"); let res: Json; try { res = await this.json( await this.request("GET", `/umt/sessions/${encodeURIComponent(profileKey)}/cookies`, { query: { urls: urls.join(",") }, }), "/umt/sessions/{userId}/cookies", ); } catch (e) { if (e instanceof BrowserError && e.httpStatus === 404) { throw new BrowserError("not_implemented", "浏览器服务不支持读回 Cookie(镜像未带 umt-cookies 插件)"); } throw e; } this.cookieApi = "plugin"; const list = Array.isArray(res.cookies) ? res.cookies : []; const out: BrowserCookie[] = []; for (const c of list) { if (!c || typeof c !== "object") continue; const o = c as Json; const name = str(o.name); const domain = str(o.domain); const value = str(o.value); if (!name || !domain || value === undefined) continue; const sameSite = str(o.sameSite); out.push({ name, value, domain, path: str(o.path) ?? "/", expires: typeof o.expires === "number" ? o.expires : -1, httpOnly: o.httpOnly === true, secure: o.secure === true, ...(sameSite === "Strict" || sameSite === "Lax" || sameSite === "None" ? { sameSite } : {}), }); } return out; } /** DELETE /sessions/{userId}:关闭该 profile 的全部上游标签页;上游 404(已被回收)视为已关闭 */ async closeSession(sessionId: string, reason = "closed"): Promise { const s = this.state(sessionId); await this.withProfile(s, async () => { try { await this.request("DELETE", `/sessions/${encodeURIComponent(s.session.profileKey)}`); } catch (e) { if (!(e instanceof BrowserError && e.kind === "not_found")) throw e; } const at = this.now().toISOString(); s.session.controller = "closed"; s.session.closedAt = at; s.session.closeReason = reason; s.refsValid = false; for (const t of s.tabs.values()) if (t.status === "open") { t.status = "closed"; t.closedAt = at; t.updatedAt = at; } for (const l of this.leases.values()) if (l.sessionId === sessionId && l.status === "active") { l.status = "revoked"; l.releasedAt = at; l.releaseReason = "session_closed"; } this.sessions.delete(sessionId); }); } /** 本进程登记的未关闭会话;服务层以数据库为准,这里只是内存镜像 */ async listSessions(): Promise { return [...this.sessions.values()].map((s) => ({ ...s.session })); } // ---------- 标签页 ---------- async openTab( sessionId: string, opts: { url?: string; budget: NavigationBudget; as?: ActorKind }, ): Promise { const s = this.state(sessionId); this.assertActor(s, opts.as); if (opts.url) assertNavigable(opts.url, opts.budget); return this.withProfile(s, async () => { this.assertActor(s, opts.as); const res = await this.request("POST", "/tabs", { body: { userId: s.session.profileKey, sessionKey: s.session.id, ...(opts.url ? { url: opts.url } : {}) }, timeoutMs: opts.budget.timeoutMs, }); const body = await this.json(res, "/tabs"); const tabId = str(body.tabId); if (!tabId) throw new BrowserError("protocol", "camofox POST /tabs 未返回 tabId"); const rawUrl = str(body.url) ?? opts.url ?? null; this.touch(s, rawUrl); const at = s.session.lastActivityAt; const tab: BrowserTab = { id: this.newId(), sessionId, upstreamTabId: tabId, url: s.session.currentUrl, title: null, status: "open", lastSnapshotAt: null, closedAt: null, createdAt: at, updatedAt: at, }; s.tabs.set(tabId, tab); s.currentTabId = tabId; s.refsValid = false; s.budget = opts.budget; if (opts.url) this.assertLanded(rawUrl, opts.budget); return { ...tab }; }); } async closeTab(ref: TabRef): Promise { const s = this.state(ref.sessionId); this.assertActor(s, ref.as); await this.withProfile(s, async () => { const tab = this.tabOf(s, ref); const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}`; try { await this.request("DELETE", path, { query: { userId: s.session.profileKey } }); } catch (e) { if (!(e instanceof BrowserError && e.kind === "not_found")) throw e; } const at = this.now().toISOString(); tab.status = "closed"; tab.closedAt = at; tab.updatedAt = at; if (s.currentTabId === tab.upstreamTabId) { const next = [...s.tabs.values()].reverse().find((t) => t.status === "open"); s.currentTabId = next?.upstreamTabId ?? ""; s.rawUrl = next?.url ?? null; s.session.currentUrl = next?.url ?? null; } s.refsValid = false; this.touch(s); }); } /** GET /tabs?userId:上游已回收(空闲超时/上限淘汰/重启)的标签页标 lost,不再对它发动作 */ async reconcileTabs(sessionId: string): Promise<{ alive: string[]; lost: string[] }> { const s = this.state(sessionId); return this.withProfile(s, async () => { const res = await this.request("GET", "/tabs", { query: { userId: s.session.profileKey } }); const body = await this.json(res, "/tabs"); const list = Array.isArray(body.tabs) ? (body.tabs as Json[]) : []; const upstream = new Set(list.map((t) => str(t.tabId) ?? str(t.targetId)).filter((x): x is string => !!x)); const alive: string[] = []; const lost: string[] = []; const at = this.now().toISOString(); for (const t of s.tabs.values()) { if (t.status !== "open") continue; if (upstream.has(t.upstreamTabId)) alive.push(t.upstreamTabId); else { t.status = "lost"; t.updatedAt = at; lost.push(t.upstreamTabId); } } if (lost.length) s.refsValid = false; if (s.tabs.get(s.currentTabId)?.status !== "open") s.currentTabId = alive[0] ?? ""; // 桌面模式下人在画面里导航不经本适配器:按上游报告的当前地址同步(登录页判定、回写登录态前要用最新地址) const cur = list.find((t) => (str(t.tabId) ?? str(t.targetId)) === s.currentTabId); const curUrl = cur ? str(cur.url) : undefined; if (curUrl && curUrl !== s.rawUrl && /^https?:/.test(curUrl)) { s.refsValid = false; this.touch(s, curUrl); const tab = s.tabs.get(s.currentTabId); if (tab) { tab.url = s.session.currentUrl; tab.updatedAt = at; } } return { alive, lost }; }); } /** * AG-4 保活:camofox 1.17 没有 keepalive / touch 接口。`GET /tabs/{id}/stats` 只刷新会话的 lastAccess, * 不算标签页活动(标签页回收看 toolCalls 有没有变化);最轻的会让 toolCalls 加一的只读调用是 * `GET /tabs/{id}/downloads`(不带 includeData / consume:只列下载元数据,不读页面、不清下载记录)。 */ async keepAlive(ref: TabRef): Promise { const s = this.state(ref.sessionId); await this.withProfile(s, async () => { const tab = this.tabOf(s, ref); const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/downloads`; const res = await this.request("GET", path, { query: { userId: s.session.profileKey }, timeoutMs: Math.min(this.cfg.timeoutMs ?? 30_000, 10_000), }); await res.body?.cancel(); }); } // ---------- 页面 ---------- async navigate(ref: TabRef, url: string, budget: NavigationBudget): Promise { const s = this.state(ref.sessionId); this.assertActor(s, ref.as); assertNavigable(url, budget); return this.withProfile(s, async () => { this.assertActor(s, ref.as); const tab = this.tabOf(s, ref); const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/navigate`; let attempts = 0; for (;;) { attempts++; try { const res = await this.request("POST", path, { body: { userId: s.session.profileKey, url }, timeoutMs: budget.timeoutMs, }); const body = await this.json(res, path); const rawUrl = str(body.url) ?? url; this.touch(s, rawUrl); tab.url = s.session.currentUrl; tab.updatedAt = s.session.updatedAt; s.budget = budget; s.currentTabId = tab.upstreamTabId; s.refsValid = false; this.assertLanded(rawUrl, budget); return { url: redactBrowserUrl(rawUrl), httpStatus: int(body.httpStatus) ?? null, ok: body.ok !== false && body.navigationOk !== false, attempts, }; } catch (e) { if (e instanceof BrowserError && e.retryable && attempts <= budget.maxRetries) continue; throw e; } } }); } async snapshot(ref: TabRef, opts: SnapshotOptions = {}): Promise { const s = this.state(ref.sessionId); return this.withProfile(s, async () => { const tab = this.tabOf(s, ref); const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/snapshot`; const res = await this.request("GET", path, { query: { userId: s.session.profileKey, format: "text", ...(opts.offset !== undefined ? { offset: String(opts.offset) } : {}), }, timeoutMs: s.budget.timeoutMs, }); const body = await this.json(res, path); let text = str(body.snapshot) ?? ""; let truncated = body.truncated === true || body.hasMore === true; const maxBytes = opts.maxBytes ?? s.budget.maxBytes; const enc = new TextEncoder(); if (enc.encode(text).byteLength > maxBytes) { // 按字节预算截断;在字符边界上截,避免半个 UTF-8 字符 let lo = 0; let hi = text.length; while (lo < hi) { const mid = Math.ceil((lo + hi) / 2); if (enc.encode(text.slice(0, mid)).byteLength <= maxBytes) lo = mid; else hi = mid - 1; } text = text.slice(0, lo); truncated = true; } const rawUrl = str(body.url) ?? s.rawUrl ?? ""; const structure = body.structure && typeof body.structure === "object" && !Array.isArray(body.structure) ? (body.structure as Json) : null; const pw = await this.passwordRedactions(s, tab, text); const red = redactSnapshot({ text, url: rawUrl, structure }, { extra: [...(opts.extraRedactions ?? []), ...pw] }); this.touch(s, rawUrl || s.rawUrl); const at = s.session.lastActivityAt; tab.lastSnapshotAt = at; tab.url = s.session.currentUrl; // 只有当前标签页的最新快照让引用生效 if (tab.upstreamTabId === s.currentTabId && s.session.controller !== "closed") s.refsValid = true; const refsCount = int(body.refsCount) ?? new Set(text.match(/\be\d+\b/g) ?? []).size; return { sessionId: s.session.id, tabId: tab.upstreamTabId, url: red.url, format: "a11y_text", text: red.text, structure: red.structure, refsCount, truncated, nextOffset: truncated ? (int(body.nextOffset) ?? null) : null, redactions: red.redactions, capturedAt: at, }; }); } /** * 1.17.0 实测:快照是 Playwright ARIA 快照(`- textbox [e2]: 值`),密码框与普通文本框同为 textbox 且带明文值, * 按标签名的规则抓不到。快照里出现带值的文本框时,经 evaluate 取页面上 type=password 输入框的当前值,按字面量遮蔽; * evaluate 失败时退而遮蔽全部文本框的值(宁可多遮)。 */ private async passwordRedactions(s: SessionState, tab: BrowserTab, text: string | null): Promise { if (text !== null && !TEXTBOX_WITH_VALUE.test(text)) return []; const esc = (v: string) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); try { const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/evaluate`; const res = await this.request("POST", path, { body: { userId: s.session.profileKey, expression: "Array.from(document.querySelectorAll('input[type=password]')).map((e) => e.value).filter((v) => v)", }, timeoutMs: s.budget.timeoutMs, }); const out = await this.json(res, path); const values = Array.isArray(out.result) ? out.result.filter((v): v is string => typeof v === "string") : []; // 过短的值按字面量遮蔽会误伤正文:改为遮蔽全部输入框的值 if (values.some((v) => v.length < 4)) return [TEXTBOX_VALUE_ANY, ...values.map((v) => new RegExp(esc(v), "g"))]; return values.map((v) => new RegExp(esc(v), "g")); } catch { return [TEXTBOX_VALUE_ANY]; } } async screenshot(ref: TabRef, opts: { maxBytes?: number } = {}): Promise { const s = this.state(ref.sessionId); return this.withProfile(s, async () => { const tab = this.tabOf(s, ref); const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/screenshot`; const res = await this.request("GET", path, { query: { userId: s.session.profileKey }, timeoutMs: s.budget.timeoutMs, }); const maxBytes = opts.maxBytes ?? s.budget.maxBytes ?? this.cfg.defaultMaxBytes ?? 8 * 1024 * 1024; const declared = Number(res.headers.get("content-length") ?? "NaN"); if (Number.isFinite(declared) && declared > maxBytes * 1.4) { await res.body?.cancel(); throw new BrowserError("budget_exceeded", `截图超出字节预算(${maxBytes})`); } const ctype = res.headers.get("content-type") ?? ""; let data: Uint8Array; let mimeType: string; if (ctype.startsWith("image/")) { // 部分版本/fork 直接返回二进制图片(待核验) data = new Uint8Array(await res.arrayBuffer()); mimeType = ctype.split(";")[0]!.trim(); } else { // OpenAPI 1.17.0:{ screenshot: { data: base64, mimeType } } const body = await this.json(res, path); const shot = (body.screenshot && typeof body.screenshot === "object" ? body.screenshot : body) as Json; const b64 = str(shot.data); if (!b64) throw new BrowserError("protocol", "camofox 截图响应缺少 data"); try { data = b64decode(b64); } catch { throw new BrowserError("protocol", "camofox 截图不是合法 base64"); } mimeType = str(shot.mimeType) ?? "image/png"; } if (data.byteLength > maxBytes) throw new BrowserError("budget_exceeded", `截图超出字节预算(${maxBytes})`); this.touch(s); const raw = s.rawUrl; const loginPage = isLoginUrl(raw); return { mimeType, data, url: raw === null ? null : redactBrowserUrl(raw), modelSafe: raw !== null && !urlHasCredential(raw) && !loginPage, capturedAt: s.session.lastActivityAt, }; }); } /** 视口 / DPR / 密码框 / 文字样本:一次 evaluate;失败(旧版本、页面禁止脚本)返回 null */ async pageMetrics(ref: TabRef): Promise { const s = this.state(ref.sessionId); return this.withProfile(s, async () => { const tab = this.tabOf(s, ref); const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/evaluate`; try { const res = await this.request("POST", path, { body: { userId: s.session.profileKey, expression: PAGE_METRICS_EXPR }, timeoutMs: s.budget.timeoutMs, }); const out = await this.json(res, path); const r = (out.result && typeof out.result === "object" ? out.result : null) as Json | null; const w = typeof r?.w === "number" ? r.w : NaN; const h = typeof r?.h === "number" ? r.h : NaN; if (!(w > 0) || !(h > 0)) return null; return { viewportWidth: w, viewportHeight: h, devicePixelRatio: typeof r?.dpr === "number" && r.dpr > 0 ? r.dpr : 1, passwordField: r?.pw === true, textSample: typeof r?.text === "string" ? r.text.slice(0, 4000) : "", }; } catch (e) { if (e instanceof BrowserError && e.kind === "not_found") throw e; return null; } }); } // ---------- 交互 ---------- /** 交互动作的公共路径:控制者校验 → 串行锁 → POST /tabs/{id}/ */ private async act(ref: TabRef, action: string, body: (s: SessionState) => Json, after?: (s: SessionState) => void) { const s = this.state(ref.sessionId); this.assertActor(s, ref.as); const payload = body(s); await this.withProfile(s, async () => { this.assertActor(s, ref.as); const tab = this.tabOf(s, ref); // 坐标点击先走 umt 插件的真实鼠标事件(page.mouse.click):跨域 iframe 里的控件(Cloudflare Turnstile)与画布也点得到(CF-2) if ( action === "click" && typeof payload.x === "number" && typeof payload.y === "number" && this.mouseApi !== "none" ) { const done = await this.mouseClick(s, tab, payload.x, payload.y); if (done) { this.touch(s, done.url ?? s.rawUrl); if (done.url) tab.url = s.session.currentUrl; s.currentTabId = tab.upstreamTabId; after?.(s); return; } } // 上游 click/type 只认 ref/selector(1.17.0 实测不接受 {x, y}):坐标先经 evaluate 标记该点元素,再按选择器操作, // 这样仍走上游完整的鼠标事件序列与下载守卫,而不是页面内合成 click if (typeof payload.x === "number" && typeof payload.y === "number") { payload.selector = await this.pointSelector(s, tab, payload.x, payload.y); delete payload.x; delete payload.y; } const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/${action}`; const res = await this.request("POST", path, { body: { userId: s.session.profileKey, ...payload }, timeoutMs: s.budget.timeoutMs, }); const out = await res.json().catch(() => ({}) as Json); const rawUrl = out && typeof out === "object" ? str((out as Json).url) : undefined; this.touch(s, rawUrl ?? s.rawUrl); if (rawUrl) tab.url = s.session.currentUrl; s.currentTabId = tab.upstreamTabId; after?.(s); }); } /** * 插件的真实鼠标点击:`POST /umt/tabs/{id}/mouse-click {userId, x, y}`(视口 CSS 像素)。插件没有这个端点(404)时记下, * 返回 null 让调用方退回 elementFromPoint。 */ private async mouseClick( s: SessionState, tab: BrowserTab, x: number, y: number, ): Promise<{ url: string | undefined } | null> { const path = `/umt/tabs/${encodeURIComponent(tab.upstreamTabId)}/mouse-click`; try { const res = await this.request("POST", path, { body: { userId: s.session.profileKey, x, y }, timeoutMs: s.budget.timeoutMs, }); const out = (await res.json().catch(() => ({}))) as Json; this.mouseApi = "plugin"; return { url: str(out.url) }; } catch (e) { // 404 = 插件没有这个端点(旧镜像);插件对"标签页不在"回 422,按标签页已不存在处理(BR-3 自愈) if (e instanceof BrowserError && e.httpStatus === 404) { this.mouseApi = "none"; return null; } if (e instanceof BrowserError && e.httpStatus === 422) throw new BrowserError("not_found", "标签页不存在或已关闭", { httpStatus: 404 }); throw e; } } /** * 当前页面内容(CF-2):地址(已脱敏)、标题、outerHTML、正文样本、navigator.userAgent。只读,不要求自动化控制权; * 上游不支持 evaluate 时返回 null。HTML 只给服务端解析器用,不进模型与日志。 */ async pageContent( ref: TabRef, opts: { maxHtmlBytes?: number; currentVisible?: boolean } = {}, ): Promise { const s = this.state(ref.sessionId); const max = Math.max(1024, Math.min(opts.maxHtmlBytes ?? 8 * 1024 * 1024, 16 * 1024 * 1024)); return this.withProfile(s, async () => { // The desktop can create popups outside this adapter. Resolve its visible tab only // for the explicit resource handoff; regular Agent actions keep their selected tab. if (opts.currentVisible && !ref.tabId) { const listed = await this.json( await this.request("GET", "/tabs", { query: { userId: s.session.profileKey } }), "/tabs", ); const tabs = Array.isArray(listed.tabs) ? (listed.tabs as Json[]) : []; const visible: string[] = []; let uncertain = false; for (const upstream of tabs) { const id = str(upstream.tabId) ?? str(upstream.targetId); if (!id) continue; const at = this.now().toISOString(); const url = str(upstream.url); if (!s.tabs.has(id)) s.tabs.set(id, { id: this.newId(), sessionId: ref.sessionId, upstreamTabId: id, url: url ? redactBrowserUrl(url) : null, title: str(upstream.title) ?? null, status: "open", lastSnapshotAt: null, closedAt: null, createdAt: at, updatedAt: at, }); try { const probePath = `/tabs/${encodeURIComponent(id)}/evaluate`; const probe = await this.json( await this.request("POST", probePath, { body: { userId: s.session.profileKey, expression: '/* umt-visible-tab */ (() => ({ visible: document.visibilityState === "visible" }))()', }, timeoutMs: s.budget.timeoutMs, }), probePath, ); const state = probe.result && typeof probe.result === "object" ? (probe.result as Json).visible : undefined; if (state === true) visible.push(id); else if (state !== false) uncertain = true; } catch { // Missing/unsupported visibility cannot prove that the old tab is current. uncertain = true; } } if (uncertain || visible.length !== 1) throw new BrowserError("bad_request", "无法唯一确定桌面当前标签页,请回到资源详情页后重试,或指定标签页"); if (s.currentTabId !== visible[0]) s.refsValid = false; s.currentTabId = visible[0]!; } const tab = this.tabOf(s, ref); const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/evaluate`; let out: Json; try { const res = await this.request("POST", path, { body: { userId: s.session.profileKey, expression: pageContentExpr(max) }, timeoutMs: s.budget.timeoutMs, }); out = await this.json(res, path); } catch (e) { if (e instanceof BrowserError && e.kind === "not_found") throw e; return null; } const r = (out.result && typeof out.result === "object" ? out.result : null) as Json | null; if (!r || typeof r.html !== "string") return null; const rawUrl = str(r.url) ?? s.rawUrl; this.touch(s, rawUrl ?? s.rawUrl); tab.url = s.session.currentUrl; return { url: rawUrl ? redactBrowserUrl(rawUrl) : null, title: str(r.title) ?? "", html: r.html, text: str(r.text) ?? "", userAgent: str(r.ua) ?? null, }; }); } /** 视口坐标 → 唯一属性选择器(POST /tabs/{id}/evaluate + elementFromPoint);坐标处无元素报 bad_request */ private async pointSelector(s: SessionState, tab: BrowserTab, x: number, y: number): Promise { const nonce = this.newId() .replace(/[^a-z0-9]/gi, "") .slice(0, 16); const expression = `(() => { const e = document.elementFromPoint(${x}, ${y}); if (!e) return false; for (const o of document.querySelectorAll("[data-umt-pt]")) o.removeAttribute("data-umt-pt"); e.setAttribute("data-umt-pt", "${nonce}"); return true; })()`; const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/evaluate`; const res = await this.request("POST", path, { body: { userId: s.session.profileKey, expression }, timeoutMs: s.budget.timeoutMs, }); const out = await this.json(res, path); if (out.result !== true) throw new BrowserError("bad_request", "该坐标处没有可操作的元素"); return `[data-umt-pt="${nonce}"]`; } /** 点击可能跳转或改动页面:之后旧引用作废 */ async click(ref: TabRef, target: ElementTarget): Promise { await this.act( ref, "click", (s) => this.assertTarget(s, target), (s) => { s.refsValid = false; }, ); } /** 凭证型输入在服务端解析后直接下发;明文不进日志、错误信息与返回值 */ async type(ref: TabRef, target: ElementTarget | null, input: TypeInput): Promise { let text: string; if (input.kind === "secret") { const v = await this.cfg.secrets.resolve({ kind: "site_login", siteId: input.siteId, accountId: input.accountId, field: input.field, }); if (!v) throw new BrowserError( "auth", `站点账号没有可用的${input.field === "username" ? "用户名" : input.field === "password" ? "密码" : "TOTP"}凭证`, ); text = v; } else text = input.text; await this.act( ref, "type", (s) => ({ // 无目标 = 输入到当前焦点:上游 mode=fill 必须带 ref/selector,焦点输入要 mode=keyboard(1.17.0 实测) ...(target ? this.assertTarget(s, target) : { mode: "keyboard" }), text, ...(input.clear !== undefined ? { clear: input.clear } : {}), ...(input.submit !== undefined ? { submit: input.submit } : {}), }), (s) => { if (input.submit) s.refsValid = false; }, ); } async select(ref: TabRef, target: ElementTarget, value: string): Promise { await this.act(ref, "select", (s) => ({ ...this.assertTarget(s, target), value })); } async scroll(ref: TabRef, opts: { direction: "up" | "down" | "left" | "right"; amount: number }): Promise { await this.act(ref, "scroll", () => ({ direction: opts.direction, amount: opts.amount })); } /** 单个按键(Enter / Tab / Backspace …);Enter 可能提交表单,之后旧引用作废 */ async press(ref: TabRef, key: string): Promise { await this.act( ref, "press", () => ({ key }), (s) => { s.refsValid = false; }, ); } async history( ref: TabRef, action: "back" | "forward" | "reload", budget: NavigationBudget, ): Promise { const s = this.state(ref.sessionId); this.assertActor(s, ref.as); return this.withProfile(s, async () => { this.assertActor(s, ref.as); const tab = this.tabOf(s, ref); const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/${action === "reload" ? "refresh" : action}`; const res = await this.request("POST", path, { body: { userId: s.session.profileKey }, timeoutMs: budget.timeoutMs, }); const body = await this.json(res, path); const rawUrl = str(body.url) ?? s.rawUrl; this.touch(s, rawUrl); tab.url = s.session.currentUrl; s.refsValid = false; this.assertLanded(rawUrl, budget); return { url: rawUrl ? redactBrowserUrl(rawUrl) : "", httpStatus: int(body.httpStatus) ?? null, ok: body.ok !== false && body.navigationOk !== false, attempts: 1, }; }); } /** POST /tabs/{id}/extract {schema}:属性 x-ref 指向快照元素,需先 snapshot;结果按快照同样规则脱敏 */ async extract(ref: TabRef, schema: Record): Promise> { const s = this.state(ref.sessionId); return this.withProfile(s, async () => { if (!s.refsValid) throw new BrowserError("stale_ref", "提取依赖最新快照,先重新读取页面"); const tab = this.tabOf(s, ref); const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/extract`; const res = await this.request("POST", path, { body: { userId: s.session.profileKey, schema }, timeoutMs: s.budget.timeoutMs, }); const body = await this.json(res, path); const data = (body.data && typeof body.data === "object" && !Array.isArray(body.data) ? body.data : body) as Json; // x-ref 可能指向密码框:同快照一样按页面上密码框的当前值遮蔽 const pw = Object.keys(data).length ? await this.passwordRedactions(s, tab, null) : []; const red = redactSnapshot({ text: "", url: "", structure: data }, { extra: pw }); this.touch(s); return (red.structure ?? {}) as Record; }); } /** * 轮询 GET /tabs/{id}/downloads?includeData&consume&maxBytes,拿到第一个未见过的完成下载: * 字节交 downloadSink 暂存、算 sha256,只返回引用。超时报 timeout,超限报 budget_exceeded。 */ async waitForDownload(ref: TabRef, opts: { timeoutMs: number; maxBytes: number }): Promise { const s = this.state(ref.sessionId); const deadline = Date.now() + opts.timeoutMs; const poll = this.cfg.downloadPollMs ?? 500; for (;;) { const found = await this.withProfile(s, async () => { const tab = this.tabOf(s, ref); const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/downloads`; const res = await this.request("GET", path, { query: { userId: s.session.profileKey, includeData: "true", consume: "true", maxBytes: String(opts.maxBytes), }, timeoutMs: Math.max(1_000, Math.min(this.cfg.timeoutMs ?? 30_000, deadline - Date.now() + 1_000)), }); const body = (await res.json().catch(() => null)) as unknown; const list = ( Array.isArray(body) ? body : Array.isArray((body as Json | null)?.downloads) ? (body as Json).downloads : [] ) as Json[]; for (const d of list) { const key = str(d.id) ?? `${str(d.filename) ?? str(d.suggestedFilename)}|${str(d.url)}|${d.size}`; if (s.seenDownloads.has(key)) continue; const status = str(d.status) ?? str(d.state); if (status && !["completed", "complete", "done", "finished"].includes(status)) continue; // 1.17.0 实测字段:{ id, url, suggestedFilename, mimeType, bytes, createdAt, failure, dataBase64 | dataSkipped | readError } if (d.failure) { s.seenDownloads.add(key); throw new BrowserError("upstream", "浏览器下载失败"); } if (d.dataSkipped) throw new BrowserError("budget_exceeded", `下载产物超出字节预算(${opts.maxBytes})`); const b64 = str(d.dataBase64) ?? str(d.data) ?? str(d.base64) ?? str(d.content); const size = int(d.size) ?? int(d.bytes) ?? null; if (size !== null && size > opts.maxBytes) throw new BrowserError("budget_exceeded", `下载产物超出字节预算(${opts.maxBytes})`); if (!b64) { if (d.truncated === true || d.tooLarge === true) throw new BrowserError("budget_exceeded", `下载产物超出字节预算(${opts.maxBytes})`); continue; } let bytes: Uint8Array; try { bytes = b64decode(b64); } catch { throw new BrowserError("protocol", "camofox 下载内容不是合法 base64"); } if (bytes.byteLength > opts.maxBytes) throw new BrowserError("budget_exceeded", `下载产物超出字节预算(${opts.maxBytes})`); s.seenDownloads.add(key); const id = this.newId(); const rawName = (str(d.filename) ?? str(d.suggestedFilename) ?? str(d.name) ?? "download.bin").slice(0, 255); // 去掉路径分隔符与控制字符 const filename = [...rawName].map((ch) => (ch < " " || ch === "/" || ch === "\\" ? "_" : ch)).join(""); const mimeType = str(d.mimeType) ?? str(d.contentType) ?? null; const blob = await this.sink(bytes, { id, sessionId: s.session.id, filename, mimeType }); const srcUrl = str(d.url); this.touch(s); return { id, sessionId: s.session.id, filename, mimeType, sizeBytes: bytes.byteLength, bytes: blob, sha256: await sha256Hex(bytes), sourceUrl: srcUrl ? redactBrowserUrl(srcUrl) : null, capturedAt: this.now().toISOString(), } satisfies DownloadArtifact; } return null; }); if (found) return found; if (Date.now() + poll > deadline) throw new BrowserError("timeout", `等待下载超时(${opts.timeoutMs}ms)`); await new Promise((ok) => setTimeout(ok, poll)); } } // ---------- 控制权(内存版;服务层以 browser_control_lease 持久化) ---------- /** 接管:先把控制者切走(新的自动化输入立刻被拒),再等进行中的操作收尾,然后发放租约 */ async acquireControl(req: AcquireControlRequest): Promise { const s = this.state(req.sessionId); if (s.session.controller === "closed") throw new BrowserError("control_conflict", "会话已关闭"); for (const l of this.leases.values()) if (l.sessionId === req.sessionId && l.status === "active") { if (l.holderKind === req.holderKind && l.holder === req.holder) return { ...l }; if (l.holderKind === "human" && req.holderKind === "human") throw new BrowserError("control_conflict", "会话已被他人接管"); l.status = "revoked"; l.releasedAt = this.now().toISOString(); l.releaseReason = `${req.holderKind}_takeover`; } this.setController(req.sessionId, req.holderKind); await this.drain(req.sessionId); const at = this.now(); const lease: BrowserControlLease = { id: this.newId(), sessionId: req.sessionId, holderKind: req.holderKind, holder: req.holder, reason: req.reason, status: "active", acquiredAt: at.toISOString(), expiresAt: new Date(at.getTime() + req.ttlMs).toISOString(), releasedAt: null, releaseReason: null, createdAt: at.toISOString(), updatedAt: at.toISOString(), }; this.leases.set(lease.id, lease); return { ...lease }; } /** 交还:人工租约释放后回到自动化,旧引用失效(Agent 必须重新读取页面) */ async releaseControl(leaseId: string, reason = "released"): Promise { const l = this.leases.get(leaseId); if (l?.status !== "active") throw new BrowserError("not_found", "租约不存在或已结束"); const at = this.now().toISOString(); l.status = "released"; l.releasedAt = at; l.releaseReason = reason; l.updatedAt = at; if (this.sessions.has(l.sessionId) && l.holderKind === "human") this.setController(l.sessionId, "automation"); } } export function createCamofoxBrowserProvider(cfg: CamofoxConfig): CamofoxBrowserProvider { return new CamofoxBrowserProvider(cfg); }