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:
1 parent
62290d4477
commit
c17d4b90bd
12 files changed
+1832
-101
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user