Files
UHD_Media_Tool/packages/adapters/src/browser/camofox/client.ts
T
rootandClaude Opus 5.5 c17d4b90bd feat(browser): complete camofox BrowserProvider with tabs, interaction, download capture and control lease
Contracts for browser API and browser.* agent capabilities; adapter implements
close/list/restore sessions, tabs + reconcile, history, click/type/select/scroll/press,
extract, waitForDownload (staged via DownloadSink, sha256), stale-ref guard and
automation/human actor check. testkit mock camofox gains an interactive site
(login redirect, captcha, click-driven page changes, downloads, PNG screenshots).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 20:17:46 +08:00

976 lines
39 KiB
TypeScript

/**
* camofox-browser 适配器(BrowserProvider 首个实现)。
* 对照:jo-inc/camofox-browser master openapi.json(info.version 1.17.0)与 README,仅读文档,未连接真实实例。
*
* 上游模型与本项目合同的映射:
* - camofox userId = 一个隔离 BrowserContext(cookies/storage),本项目用 profileKey(站点/账号)充当;
* - camofox sessionKey = 标签组,本项目用 browser_session.id;
* - 上游没有"建会话"端点,首个 POST /tabs 隐式创建;会话由本适配器在内存登记,持久化由服务层写 browser_session。
* 认证:README/OpenAPI 为 `Authorization: Bearer <CAMOFOX_ACCESS_KEY>`(设置后除 /health 外全部路由要求);
* 凭据每次请求经 BrowserSecretResolver 解出,不缓存、不入日志、不进错误信息。
*
* 已实现(仅对 mock 与上游文档验证):health、capabilities、会话开/关/列举、标签页增删与对账、navigate(白名单/超时/重试)、
* 后退/前进/刷新、snapshot(脱敏)、screenshot、click/type/select/scroll/press、extract、waitForDownload、
* 内存版控制权租约与串行锁。会话/标签/租约的持久化与对账由服务层(apps/server modules/browser)以数据库为准。
* 未核验的上游行为(坐标点击、无目标输入、downloads 字段形状)见 docs/runbook/browser.md。
*/
import type {
BrowserCapabilities,
BrowserControlLease,
BrowserHealth,
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,
profileKeyFor,
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?: typeof fetch;
logger?: BrowserLogger;
now?: () => Date;
newId?: () => string;
}
interface SessionState {
session: BrowserSession;
tabs: Map<string, BrowserTab>;
currentTabId: string;
/** 未脱敏地址只留在内存,用于判断截图能否进入模型;不对外返回 */
rawUrl: string | null;
budget: NavigationBudget;
/** 最近一次快照之后没有导航/点击/交接:eN 引用可用 */
refsValid: boolean;
/** 已见过的下载(consume 失败时防重复) */
seenDownloads: Set<string>;
}
/** 下载产物的受控暂存:返回引用,字节不进合同/日志 */
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<string, Uint8Array> } {
const blobs = new Map<string, Uint8Array>();
const sink = (async (bytes, meta) => {
const ref = `mem:${meta.id}`;
blobs.set(ref, bytes);
return { kind: "blob" as const, ref };
}) as DownloadSink & { blobs: Map<string, Uint8Array> };
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<string> {
const d = await crypto.subtle.digest("SHA-256", bytes as Uint8Array<ArrayBuffer>);
return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
type Json = Record<string, unknown>;
const str = (v: unknown) => (typeof v === "string" ? v : undefined);
const int = (v: unknown) => (typeof v === "number" && Number.isInteger(v) ? v : undefined);
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: typeof fetch;
private readonly lock: ProfileLock;
private readonly sessions = new Map<string, SessionState>();
private readonly leases = new Map<string, BrowserControlLease>();
private readonly sink: DownloadSink;
private readonly now: () => Date;
private readonly newId: () => string;
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<Record<string, string>> {
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" | "DELETE",
path: string,
opts: { query?: Record<string, string | undefined>; body?: Json; timeoutMs?: number; auth?: boolean } = {},
): Promise<Response> {
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<string, string> = {
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<Json> {
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<T>(s: SessionState | string, fn: () => Promise<T>): Promise<T> {
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<void> {
const s = this.state(sessionId);
await this.withProfile(s, async () => {});
}
// ---------- 健康与能力 ----------
async health(): Promise<BrowserHealth> {
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<BrowserCapabilities> {
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);
}
}
// ---------- 会话 ----------
async openSession(req: OpenSessionRequest): Promise<OpenSessionResult> {
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();
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 };
});
}
/** 重定向落到白名单外:报 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)}`);
}
/** DELETE /sessions/{userId}:关闭该 profile 的全部上游标签页;上游 404(已被回收)视为已关闭 */
async closeSession(sessionId: string, reason = "closed"): Promise<void> {
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<BrowserSession[]> {
return [...this.sessions.values()].map((s) => ({ ...s.session }));
}
// ---------- 标签页 ----------
async openTab(
sessionId: string,
opts: { url?: string; budget: NavigationBudget; as?: ActorKind },
): Promise<BrowserTab> {
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<void> {
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] ?? "";
return { alive, lost };
});
}
// ---------- 页面 ----------
async navigate(ref: TabRef, url: string, budget: NavigationBudget): Promise<NavigationResult> {
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<BrowserSnapshot> {
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 red = redactSnapshot({ text, url: rawUrl, structure }, { extra: opts.extraRedactions ?? [] });
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,
};
});
}
async screenshot(ref: TabRef, opts: { maxBytes?: number } = {}): Promise<BrowserScreenshot> {
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,
};
});
}
// ---------- 交互 ----------
/** 交互动作的公共路径:控制者校验 → 串行锁 → POST /tabs/{id}/<action> */
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);
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);
});
}
/** 点击可能跳转或改动页面:之后旧引用作废 */
async click(ref: TabRef, target: ElementTarget): Promise<void> {
await this.act(
ref,
"click",
(s) => this.assertTarget(s, target),
(s) => {
s.refsValid = false;
},
);
}
/** 凭证型输入在服务端解析后直接下发;明文不进日志、错误信息与返回值 */
async type(ref: TabRef, target: ElementTarget | null, input: TypeInput): Promise<void> {
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) => ({
...(target ? this.assertTarget(s, target) : {}),
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<void> {
await this.act(ref, "select", (s) => ({ ...this.assertTarget(s, target), value }));
}
async scroll(ref: TabRef, opts: { direction: "up" | "down" | "left" | "right"; amount: number }): Promise<void> {
await this.act(ref, "scroll", () => ({ direction: opts.direction, amount: opts.amount }));
}
/** 单个按键(Enter / Tab / Backspace …);Enter 可能提交表单,之后旧引用作废 */
async press(ref: TabRef, key: string): Promise<void> {
await this.act(
ref,
"press",
() => ({ key }),
(s) => {
s.refsValid = false;
},
);
}
async history(
ref: TabRef,
action: "back" | "forward" | "reload",
budget: NavigationBudget,
): Promise<NavigationResult> {
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<string, unknown>): Promise<Record<string, unknown>> {
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;
const red = redactSnapshot({ text: "", url: "", structure: data });
this.touch(s);
return (red.structure ?? {}) as Record<string, unknown>;
});
}
/**
* 轮询 GET /tabs/{id}/downloads?includeData&consume&maxBytes,拿到第一个未见过的完成下载:
* 字节交 downloadSink 暂存、算 sha256,只返回引用。超时报 timeout,超限报 budget_exceeded。
*/
async waitForDownload(ref: TabRef, opts: { timeoutMs: number; maxBytes: number }): Promise<DownloadArtifact> {
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;
const b64 = 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<BrowserControlLease> {
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<void> {
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);
}