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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 20:17:46 +08:00
1 parent 62290d4477
commit c17d4b90bd
12 files changed
+1832 -101

No files matched your search

@@ -18,30 +18,34 @@ export const CAMOFOX_ENDPOINTS: Record<BrowserCapabilityKey, readonly string[]>
interact: ["post /tabs/{tabId}/click", "post /tabs/{tabId}/type", "post /tabs/{tabId}/select"],
extract: ["post /tabs/{tabId}/extract"],
downloadCapture: ["get /tabs/{tabId}/downloads"],
// 上游 OpenAPI 无接管端点;VNC 插件(ENABLE_VNC,noVNC 独立端口)是整块虚拟显示,需原型验证
takeover: [],
// 原站视图接管 = 同一会话的定时截图 + 点击/输入映射(不另起浏览器);VNC 插件未接入
takeover: ["get /tabs/{tabId}/screenshot", "post /tabs/{tabId}/click", "post /tabs/{tabId}/type"],
// 持久化插件默认开启,无需端点;storage_state 端点属插件,不在 OpenAPI 中
persistentProfile: [],
};
/** 适配器已实现的能力;其余上游虽有端点,适配器在 M2 实现 */
/** 适配器已实现的能力(C1-E 起全部实现;是否可用仍取决于 verified) */
const ADAPTER_IMPLEMENTED: ReadonlySet<BrowserCapabilityKey> = new Set([
"sessions",
"tabs",
"navigate",
"snapshot",
"screenshot",
"interact",
"extract",
"downloadCapture",
"takeover",
"persistentProfile",
]);
const STATIC_NOTES: Partial<Record<BrowserCapabilityKey, string>> = {
sessions: "上游无显式建会话端点:userId 即会话(BrowserContext),首个 POST /tabs 隐式创建;关闭会话适配器 M2",
tabs: "仅建首个标签页;列举/关闭适配器 M2",
sessions: "上游无显式建会话端点:userId 即会话(BrowserContext),首个 POST /tabs 隐式创建;会话以数据库为准",
tabs: "新建/关闭/对账(GET /tabs?userId,上游回收的标 lost)",
snapshot: "可访问性树文本 + eN 元素引用,支持 offset 分页",
interact: "上游有 click/type/select;适配器 M2",
extract: "上游 POST /extract 需先 snapshot 建 ref 表(x-ref);适配器 M2",
downloadCapture: "上游 GET /downloads 支持 includeData/consume/maxBytes;适配器 M2",
takeover: "上游无接管 API;VNC 插件画面/输入与同会话连续操作待原型验证",
interact: "click/type/select + scroll/press(后两者不在 1.17.0 OpenAPI,待核验);坐标点击待核验",
extract: "POST /extract 需先 snapshot 建 ref 表(x-ref)",
downloadCapture: "GET /downloads?includeData&consume&maxBytes(README;字段形状待核验)",
takeover: "原站视图:同一会话定时截图 + 输入映射;坐标点击与无目标输入待核验,VNC 未接入",
persistentProfile: "上游持久化插件默认保存 cookies+localStorage(~/.camofox/profiles),重启恢复待验证",
};
+499 -33
View File
@@ -9,8 +9,10 @@
* 认证:README/OpenAPI 为 `Authorization: Bearer <CAMOFOX_ACCESS_KEY>`(设置后除 /health 外全部路由要求);
* 凭据每次请求经 BrowserSecretResolver 解出,不缓存、不入日志、不进错误信息。
*
* v0.1 实现:health、capabilities(静态 + /openapi.json 探测)、openSession、navigate、snapshot、screenshot;
* 其余方法抛 BrowserError('not_implemented'),见 README 的 M2 清单。
* 已实现(仅对 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,
@@ -23,19 +25,22 @@ import type {
NavigationBudget,
} from "@umt/contracts";
import { assertNavigable, hostAllowed } from "../budget.ts";
import { BrowserError, classifyHttpStatus, notImplemented } from "../errors.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,
@@ -58,6 +63,10 @@ export interface CamofoxConfig {
verified?: VerifiedMap;
/** 截图/快照默认字节上限(未传 budget 时) */
defaultMaxBytes?: number;
/** 下载产物落点(服务层写 DATA_DIR/tmp);缺省进程内内存(memoryDownloadSink) */
downloadSink?: DownloadSink;
/** waitForDownload 轮询间隔 */
downloadPollMs?: number;
fetch?: typeof fetch;
logger?: BrowserLogger;
now?: () => Date;
@@ -71,6 +80,46 @@ interface SessionState {
/** 未脱敏地址只留在内存,用于判断截图能否进入模型;不对外返回 */
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>;
@@ -85,6 +134,8 @@ export class CamofoxBrowserProvider implements BrowserProvider {
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;
@@ -94,6 +145,7 @@ export class CamofoxBrowserProvider implements BrowserProvider {
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 ----------
@@ -168,10 +220,29 @@ export class CamofoxBrowserProvider implements BrowserProvider {
return t;
}
/** 接管/等待/关闭中的会话拒绝自动化动作(REQ §4.7.3) */
private assertAutomation(s: SessionState): void {
if (s.session.controller !== "automation")
throw new BrowserError("control_conflict", `会话当前由 ${s.session.controller} 控制,自动化动作已停止`);
/** 动作发起方必须与会话控制者一致:接管/等待/关闭中的会话拒绝自动化动作(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 {
@@ -189,13 +260,46 @@ export class CamofoxBrowserProvider implements BrowserProvider {
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> {
@@ -295,6 +399,8 @@ export class CamofoxBrowserProvider implements BrowserProvider {
currentTabId: tabId,
rawUrl,
budget: req.budget,
refsValid: false,
seenDownloads: new Set(),
};
this.sessions.set(sessionId, st);
let navigation: NavigationResult | null = null;
@@ -324,22 +430,143 @@ export class CamofoxBrowserProvider implements BrowserProvider {
throw new BrowserError("domain_blocked", `导航被重定向到白名单外:${redactBrowserUrl(new URL(rawUrl).origin)}`);
}
async closeSession(_sessionId: string, _reason?: string): Promise<void> {
throw notImplemented("closeSession");
/** 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[]> {
throw notImplemented("listSessions");
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.assertAutomation(s);
this.assertActor(s, ref.as);
assertNavigable(url, budget);
return this.withProfile(s, async () => {
this.assertAutomation(s);
this.assertActor(s, ref.as);
const tab = this.tabOf(s, ref);
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/navigate`;
let attempts = 0;
@@ -356,6 +583,8 @@ export class CamofoxBrowserProvider implements BrowserProvider {
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),
@@ -411,6 +640,8 @@ export class CamofoxBrowserProvider implements BrowserProvider {
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,
@@ -457,7 +688,7 @@ export class CamofoxBrowserProvider implements BrowserProvider {
const b64 = str(shot.data);
if (!b64) throw new BrowserError("protocol", "camofox 截图响应缺少 data");
try {
data = Uint8Array.from(atob(b64.replace(/^data:[^,]*,/, "")), (c) => c.charCodeAt(0));
data = b64decode(b64);
} catch {
throw new BrowserError("protocol", "camofox 截图不是合法 base64");
}
@@ -466,12 +697,7 @@ export class CamofoxBrowserProvider implements BrowserProvider {
if (data.byteLength > maxBytes) throw new BrowserError("budget_exceeded", `截图超出字节预算(${maxBytes})`);
this.touch(s);
const raw = s.rawUrl;
let loginPage = false;
try {
loginPage = raw !== null && LOGIN_PATH.test(new URL(raw).pathname);
} catch {
/* ignore */
}
const loginPage = isLoginUrl(raw);
return {
mimeType,
data,
@@ -482,25 +708,265 @@ export class CamofoxBrowserProvider implements BrowserProvider {
});
}
// ---------- M2 ----------
// ---------- 交互 ----------
async click(_ref: TabRef, _target: ElementTarget): Promise<void> {
throw notImplemented("click");
/** 交互动作的公共路径:控制者校验 → 串行锁 → 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 type(_ref: TabRef, _target: ElementTarget, _text: TypeInput): Promise<void> {
throw notImplemented("type");
/** 点击可能跳转或改动页面:之后旧引用作废 */
async click(ref: TabRef, target: ElementTarget): Promise<void> {
await this.act(
ref,
"click",
(s) => this.assertTarget(s, target),
(s) => {
s.refsValid = false;
},
);
}
async extract(_ref: TabRef, _schema: Record<string, unknown>): Promise<Record<string, unknown>> {
throw notImplemented("extract");
/** 凭证型输入在服务端解析后直接下发;明文不进日志、错误信息与返回值 */
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 waitForDownload(_ref: TabRef, _opts: { timeoutMs: number; maxBytes: number }): Promise<DownloadArtifact> {
throw notImplemented("waitForDownload");
async select(ref: TabRef, target: ElementTarget, value: string): Promise<void> {
await this.act(ref, "select", (s) => ({ ...this.assertTarget(s, target), value }));
}
async acquireControl(_req: AcquireControlRequest): Promise<BrowserControlLease> {
throw notImplemented("acquireControl");
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 }));
}
async releaseControl(_leaseId: string, _reason?: string): Promise<void> {
throw notImplemented("releaseControl");
/** 单个按键(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");
}
}