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

+9 -14
View File
@@ -8,19 +8,14 @@
- `profile-lock.ts`:同一 profile 串行锁(进程内内存实现;多进程部署换持久化实现,接口不变)。
- `budget.ts`:导航域名白名单(含子域),非 http(s) 拒绝。
## v0.1 已实现
## 已实现(C1-E,仅对 mock 与上游文档验证)
`health`(`/health`,免认证,503 → degraded)、`capabilities`(静态声明;`probe: true` 读上游 `/openapi.json` 推导,缺端点即降级)、`openSession`(`POST /tabs`)、`navigate`(`POST /tabs/{id}/navigate`,预算内重试 5xx/超时/网络错误)、`snapshot`(`GET /tabs/{id}/snapshot?format=text`,字节预算截断 + 脱敏)、`screenshot`(`GET /tabs/{id}/screenshot`,JSON base64 或二进制图片;登录页/含凭据地址标 `modelSafe: false`)。
- 会话:`openSession`(`POST /tabs`)、`closeSession`(`DELETE /sessions/{userId}`)、`listSessions`(内存镜像;服务层以数据库为准)、`restoreSession`(重启后按 DB 恢复,旧引用失效)。
- 标签页:`openTab`、`closeTab`(`DELETE /tabs/{id}`)、`reconcileTabs`(`GET /tabs?userId`,上游已回收的标 `lost`)。
- 导航:`navigate`(白名单 / 超时 / 预算内重试 5xx·超时·网络)、`history`(`/back` `/forward` `/refresh`)。
- 读取:`snapshot`(字节预算截断 + 脱敏)、`screenshot`(JSON base64 或 image/*;登录页 / 含凭据地址 `modelSafe: false`)、`extract`(`POST /extract`,需先快照)。
- 交互:`click`(ref / selector / 视口坐标)、`type`(明文或凭证引用,凭证经 `BrowserSecretResolver` 解出后直接下发)、`select`、`scroll`、`press`。
- 下载:`waitForDownload` 轮询 `GET /tabs/{id}/downloads?includeData&consume&maxBytes`,字节交 `DownloadSink` 暂存、算 sha256,只返回引用。
- 控制权:`TabRef.as` 标明发起方(automation / human),与会话 controller 不符报 `control_conflict`;`setController` 任何变更都让旧 `eN` 引用失效(`stale_ref`);`acquireControl` / `releaseControl` 为内存版,服务层用 `browser_control_lease` 持久化。
## M2(当前抛 `BrowserError('not_implemented')`)
| 方法 | 上游候选端点 | 待办 |
| --- | --- | --- |
| `closeSession` | `DELETE /sessions/{userId}` | 与 browser_session 持久化、租约释放一起落地 |
| `listSessions` | `GET /tabs?userId=` | 上游只列标签页,会话清单以 DB 为准,需对账 |
| `click` / `type` | `POST /tabs/{id}/click` / `type` / `select` | 接凭证代理(`TypeInput.kind = 'secret'`);README 示例 `pressEnter` 与 OpenAPI `submit` 字段名不一致,需核验 |
| `extract` | `POST /tabs/{id}/extract`(JSON Schema + `x-ref`) | 需先 snapshot 建 ref 表 |
| `waitForDownload` | `GET /tabs/{id}/downloads?includeData&consume&maxBytes` | 产物落 blob 暂存 + sha256,交后端核验 |
| `acquireControl` / `releaseControl` | 无(上游无接管 API) | 服务层以 `browser_control_lease` 持久化;画面/输入通道待 VNC 原型 |
仅对 mock 与上游文档验证,未连接真实实例。
未核验的上游行为(坐标点击、无目标输入、scroll / press / back 等端点、downloads 字段形状)见 `docs/runbook/browser.md` 第 6 节。
+9 -17
View File
@@ -84,14 +84,13 @@ async function rejectKind(p: Promise<unknown>): Promise<string> {
}
describe("能力声明", () => {
it("静态声明:已实现能力 supported、M2 能力 false 并注明,默认全部未验证", () => {
it("静态声明:全部能力 supported,verified 只来自配置;待核验项有注明", () => {
const c = camofoxStaticCapabilities({ snapshot: true, interact: true });
expect(c.navigate).toMatchObject({ supported: true, verified: false, source: "static" });
expect(c.snapshot).toMatchObject({ supported: true, verified: true });
// 未实现的能力即使配置了 verified 也不会被标为已验证
expect(c.interact).toMatchObject({ supported: false, verified: false });
expect(c.interact.note).toContain("M2");
expect(c.takeover.supported).toBe(false);
expect(c.interact).toMatchObject({ supported: true, verified: true });
expect(c.interact.note).toContain("待核验");
expect(c.takeover).toMatchObject({ supported: true, verified: false });
expect(c.upstreamVersion).toBeNull();
});
@@ -279,8 +278,8 @@ describe("串行锁", () => {
});
});
describe("M2 未实现分支", () => {
it("click/type/extract/waitForDownload/接管/关闭会话均抛 not_implemented", async () => {
describe("未知会话", () => {
it("交互/提取/下载/关闭对不存在的会话报 not_found;listSessions 只列内存镜像", async () => {
const { provider } = await setup();
const ref = { sessionId: "x" };
const calls: Promise<unknown>[] = [
@@ -288,18 +287,11 @@ describe("M2 未实现分支", () => {
provider.type(ref, { ref: "e1" }, { kind: "text", text: "x" }),
provider.extract(ref, {}),
provider.waitForDownload(ref, { timeoutMs: 10, maxBytes: 10 }),
provider.acquireControl({
sessionId: "x",
holderKind: "human",
holder: "admin",
reason: "登录验证",
ttlMs: 1000,
}),
provider.releaseControl("lease"),
provider.closeSession("x"),
provider.listSessions(),
];
for (const c of calls) expect(await rejectKind(c)).toBe("not_implemented");
for (const c of calls) expect(await rejectKind(c)).toBe("not_found");
expect(await rejectKind(provider.releaseControl("lease"))).toBe("not_found");
expect(await provider.listSessions()).toEqual([]);
});
});
@@ -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");
}
}
@@ -0,0 +1,293 @@
import { createHash } from "node:crypto";
import type { NavigationBudget } from "@umt/contracts";
import { buildFakeTorrent, createMockCamofox, MOCK_ROW_H, MOCK_ROW_Y0, type MockCamofox } from "@umt/testkit";
import { afterEach, describe, expect, it } from "vitest";
import { BrowserError, createCamofoxBrowserProvider, memoryDownloadSink, staticBrowserSecrets } from "./index.ts";
// C1-E:camofox 适配器的交互、标签页、下载捕获与控制权(对 testkit 交互式 mock 站点验证,未连真实实例)。
const PROVIDER = "11111111-1111-4111-8111-111111111111";
const SITE = "22222222-2222-4222-8222-222222222222";
const ACCOUNT = "33333333-3333-4333-8333-333333333333";
const ORIGIN = "https://audiences.example";
const PASSWORD = "s3cret-login-pw";
const PASSKEY = "fedcba9876543210fedcba9876543210";
const TORRENT = buildFakeTorrent({ name: "Oppenheimer.2023.2160p.UHD.BluRay" });
const budget = (over: Partial<NavigationBudget> = {}): NavigationBudget => ({
domainAllowlist: ["audiences.example"],
timeoutMs: 500,
maxRetries: 1,
maxBytes: 1_000_000,
...over,
});
let mocks: MockCamofox[] = [];
afterEach(async () => {
await Promise.all(mocks.map((m) => m.close()));
mocks = [];
});
async function setup(opts: { captcha?: boolean; accessKey?: string } = {}) {
const mock = await createMockCamofox({
...(opts.accessKey ? { accessKey: opts.accessKey } : {}),
site: {
origin: ORIGIN,
username: "fixture-user",
password: PASSWORD,
passkey: PASSKEY,
...(opts.captcha ? { captcha: true, captchaAnswer: "4321" } : {}),
releases: [{ id: "42", title: "Oppenheimer 2023 2160p UHD BluRay" }],
torrent: () => TORRENT.bytes,
},
});
mocks.push(mock);
const sink = memoryDownloadSink();
const provider = createCamofoxBrowserProvider({
providerId: PROVIDER,
baseUrl: mock.url,
secrets: staticBrowserSecrets({
...(opts.accessKey ? { [`provider:${PROVIDER}`]: opts.accessKey } : {}),
[`site:${SITE}:${ACCOUNT}:username`]: "fixture-user",
[`site:${SITE}:${ACCOUNT}:password`]: PASSWORD,
}),
timeoutMs: 500,
downloadSink: sink,
downloadPollMs: 20,
});
const { session, tab } = await provider.openSession({
providerId: PROVIDER,
siteId: SITE,
accountId: ACCOUNT,
budget: budget(),
initialUrl: `${ORIGIN}/torrents.php`,
});
return { mock, provider, sink, session, tab, ref: { sessionId: session.id } };
}
async function kind(p: Promise<unknown>): Promise<string> {
try {
await p;
} catch (e) {
expect(e).toBeInstanceOf(BrowserError);
return (e as BrowserError).kind;
}
throw new Error("预期抛错");
}
type Setup = Awaited<ReturnType<typeof setup>>;
async function login(x: Setup) {
await x.provider.snapshot(x.ref);
await x.provider.type(x.ref, { ref: "e2" }, { kind: "secret", siteId: SITE, accountId: ACCOUNT, field: "username" });
await x.provider.type(x.ref, { ref: "e3" }, { kind: "secret", siteId: SITE, accountId: ACCOUNT, field: "password" });
await x.provider.click(x.ref, { ref: "e6" });
}
describe("登录与凭证注入", () => {
it("登录失效落到登录页;凭证由服务端解析后下发,快照里的密码值被遮蔽", async () => {
const x = await setup();
const info = x.provider.sessionInfo(x.session.id)!;
expect(info.loginPage).toBe(true);
expect(info.session.currentUrl).toContain("/login.php");
await x.provider.snapshot(x.ref);
await x.provider.type(
x.ref,
{ ref: "e2" },
{ kind: "secret", siteId: SITE, accountId: ACCOUNT, field: "username" },
);
await x.provider.type(
x.ref,
{ ref: "e3" },
{ kind: "secret", siteId: SITE, accountId: ACCOUNT, field: "password" },
);
// 输入后页面上的密码框有值:快照进入模型前必须遮蔽
const snap = await x.provider.snapshot(x.ref);
expect(snap.text).not.toContain(PASSWORD);
expect(snap.redactions.some((r) => r.kind === "password_field")).toBe(true);
// 凭证确实到了浏览器
expect(x.mock.actions.find((a) => a.action === "type" && a.body.ref === "e3")?.body.text).toBe(PASSWORD);
await x.provider.click(x.ref, { ref: "e6" });
expect(x.provider.sessionInfo(x.session.id)!.loginPage).toBe(false);
expect(x.mock.loggedIn.has(x.session.profileKey)).toBe(true);
});
it("账号没有该凭证 → auth,错误信息不含任何凭证", async () => {
const x = await setup();
await x.provider.snapshot(x.ref);
const e = await x.provider
.type(x.ref, { ref: "e3" }, { kind: "secret", siteId: SITE, accountId: null, field: "password" })
.catch((err) => err as BrowserError);
expect(e).toMatchObject({ kind: "auth" });
expect(String((e as Error).message)).not.toContain(PASSWORD);
});
});
describe("元素引用与控制权", () => {
it("导航/点击后旧引用失效(stale_ref),重新快照后可用;选择器与坐标不受限", async () => {
const x = await setup();
expect(await kind(x.provider.click(x.ref, { ref: "e6" }))).toBe("stale_ref");
await x.provider.snapshot(x.ref);
expect(x.provider.sessionInfo(x.session.id)!.refsValid).toBe(true);
await x.provider.navigate(x.ref, `${ORIGIN}/`, budget());
expect(await kind(x.provider.click(x.ref, { ref: "e2" }))).toBe("stale_ref");
await x.provider.click(x.ref, { selector: "button[type=submit]" });
expect(x.mock.actions.at(-1)).toMatchObject({ action: "click", body: { selector: "button[type=submit]" } });
});
it("接管:人工期间自动化被拒、人工输入可用;交还后旧引用作废,需重新读取", async () => {
const x = await setup();
await x.provider.snapshot(x.ref);
const lease = await x.provider.acquireControl({
sessionId: x.session.id,
holderKind: "human",
holder: "admin",
reason: "验证码",
ttlMs: 60_000,
});
expect(lease.status).toBe("active");
expect(await kind(x.provider.click(x.ref, { ref: "e6" }))).toBe("control_conflict");
expect(
await kind(
x.provider.acquireControl({
sessionId: x.session.id,
holderKind: "human",
holder: "other",
reason: "x",
ttlMs: 60_000,
}),
),
).toBe("control_conflict");
// 人工按坐标点用户名框(第 2 行),输入落到焦点元素
const human = { ...x.ref, as: "human" as const };
await x.provider.click(human, { x: 100, y: MOCK_ROW_Y0 + MOCK_ROW_H * 1 + 5 });
await x.provider.type(human, null, { kind: "text", text: "fixture-user" });
await x.provider.click(human, { x: 100, y: MOCK_ROW_Y0 + MOCK_ROW_H * 2 + 5 });
await x.provider.type(human, null, { kind: "text", text: PASSWORD });
await x.provider.press(human, "Enter");
expect(x.mock.loggedIn.has(x.session.profileKey)).toBe(true);
await x.provider.releaseControl(lease.id, "done");
const info = x.provider.sessionInfo(x.session.id)!;
expect(info.session.controller).toBe("automation");
expect(info.refsValid).toBe(false);
expect(await kind(x.provider.click(x.ref, { ref: "e11" }))).toBe("stale_ref");
const snap = await x.provider.snapshot(x.ref);
expect(snap.text).toContain("种子列表");
expect(snap.text).not.toContain(PASSKEY);
});
it("人工身份在自动化控制期间操作 → control_conflict(先接管)", async () => {
const x = await setup();
expect(await kind(x.provider.scroll({ ...x.ref, as: "human" }, { direction: "down", amount: 300 }))).toBe(
"control_conflict",
);
await x.provider.scroll(x.ref, { direction: "down", amount: 300 });
await x.provider.snapshot(x.ref);
await x.provider.select(x.ref, { ref: "e2" }, "401");
expect(x.mock.actions.map((a) => a.action)).toEqual(expect.arrayContaining(["scroll", "select"]));
});
});
describe("标签页与历史", () => {
it("新建/关闭标签页;上游回收的标签页对账标 lost", async () => {
const x = await setup();
const t2 = await x.provider.openTab(x.session.id, { url: `${ORIGIN}/`, budget: budget() });
expect(x.provider.sessionInfo(x.session.id)!.currentTabId).toBe(t2.upstreamTabId);
expect(await kind(x.provider.openTab(x.session.id, { url: "https://evil.example/", budget: budget() }))).toBe(
"domain_blocked",
);
await x.provider.closeTab({ ...x.ref, tabId: t2.upstreamTabId });
expect(x.mock.tabs.has(t2.upstreamTabId)).toBe(false);
expect(x.provider.sessionInfo(x.session.id)!.currentTabId).toBe(x.tab.upstreamTabId);
x.mock.dropTab(x.tab.upstreamTabId);
const r = await x.provider.reconcileTabs(x.session.id);
expect(r.lost).toEqual([x.tab.upstreamTabId]);
expect(
x.provider.sessionInfo(x.session.id)!.tabs.find((t) => t.upstreamTabId === x.tab.upstreamTabId)?.status,
).toBe("lost");
expect(await kind(x.provider.snapshot({ ...x.ref, tabId: x.tab.upstreamTabId }))).toBe("not_found");
});
it("后退/前进/刷新", async () => {
const x = await setup();
await login(x);
await x.provider.navigate(x.ref, `${ORIGIN}/details.php?id=42`, budget());
const back = await x.provider.history(x.ref, "back", budget());
expect(back.url).toContain("/torrents.php");
const fwd = await x.provider.history(x.ref, "forward", budget());
expect(fwd.url).toContain("/details.php");
await x.provider.history(x.ref, "reload", budget());
expect(x.mock.callsTo(/\/refresh$/)).toHaveLength(1);
});
});
describe("提取与下载捕获", () => {
it("extract 按 x-ref 取字段,需先快照", async () => {
const x = await setup();
await login(x);
const schema = { type: "object", properties: { title: { type: "string", "x-ref": "e10" } } };
expect(await kind(x.provider.extract(x.ref, schema))).toBe("stale_ref");
await x.provider.snapshot(x.ref);
expect(await x.provider.extract(x.ref, schema)).toEqual({ title: "Oppenheimer 2023 2160p UHD BluRay" });
});
it("点击下载 → 捕获 .torrent:字节进暂存、sha256 一致、来源地址去掉 passkey;只取一次", async () => {
const x = await setup();
await login(x);
await x.provider.snapshot(x.ref);
await x.provider.click(x.ref, { ref: "e11" });
const a = await x.provider.waitForDownload(x.ref, { timeoutMs: 1_000, maxBytes: 5_000_000 });
expect(a.filename).toMatch(/\.torrent$/);
expect(a.sizeBytes).toBe(TORRENT.bytes.byteLength);
expect(a.sha256).toBe(createHash("sha256").update(TORRENT.bytes).digest("hex"));
expect(x.sink.blobs.get(a.bytes.ref)).toEqual(TORRENT.bytes);
expect(a.sourceUrl).toContain("id=42");
expect(a.sourceUrl).not.toContain(PASSKEY);
expect(JSON.stringify(a)).not.toContain(PASSKEY);
// 已消费:再等只会超时
expect(await kind(x.provider.waitForDownload(x.ref, { timeoutMs: 100, maxBytes: 5_000_000 }))).toBe("timeout");
});
it("下载超字节预算 → budget_exceeded", async () => {
const x = await setup();
await login(x);
await x.provider.snapshot(x.ref);
await x.provider.click(x.ref, { ref: "e11" });
expect(await kind(x.provider.waitForDownload(x.ref, { timeoutMs: 500, maxBytes: 10 }))).toBe("budget_exceeded");
});
});
describe("异常路径", () => {
it("交互时上游 401 → auth;超时 → timeout;不重试", async () => {
const x = await setup({ accessKey: "k-1" });
await x.provider.snapshot(x.ref);
x.mock.fault(/\/click$/, { status: 401 });
expect(await kind(x.provider.click(x.ref, { selector: "a" }))).toBe("auth");
x.mock.fault(/\/type$/, { delayMs: 800 });
expect(await kind(x.provider.type(x.ref, { selector: "input" }, { kind: "text", text: "x" }))).toBe("timeout");
expect(x.mock.callsTo(/\/click$/)).toHaveLength(1);
});
it("关闭会话:DELETE /sessions/{userId},上游标签页清空,之后动作报 not_found", async () => {
const x = await setup();
await x.provider.closeSession(x.session.id, "done");
expect(x.mock.callsTo(/^\/sessions\//)[0]?.method).toBe("DELETE");
expect(x.mock.tabs.size).toBe(0);
expect(await x.provider.listSessions()).toEqual([]);
expect(await kind(x.provider.snapshot(x.ref))).toBe("not_found");
});
it("恢复会话(服务重启):引用失效,可继续读取", async () => {
const x = await setup();
const info = x.provider.sessionInfo(x.session.id)!;
const fresh = createCamofoxBrowserProvider({
providerId: PROVIDER,
baseUrl: x.mock.url,
secrets: staticBrowserSecrets({}),
timeoutMs: 500,
});
fresh.restoreSession({ session: info.session, tabs: info.tabs, currentTabId: info.currentTabId, budget: budget() });
expect(fresh.sessionInfo(x.session.id)!.refsValid).toBe(false);
expect(await kind(fresh.click(x.ref, { ref: "e6" }))).toBe("stale_ref");
expect((await fresh.snapshot(x.ref)).text).toContain("登录");
});
});
+15
View File
@@ -42,5 +42,20 @@ export function createNoneBrowserProvider(): BrowserProvider {
waitForDownload: unavailable,
acquireControl: unavailable,
releaseControl: unavailable,
openTab: unavailable,
closeTab: unavailable,
history: unavailable,
select: unavailable,
scroll: unavailable,
press: unavailable,
setController() {
throw new BrowserError("unavailable", "未配置浏览器服务");
},
restoreSession() {
throw new BrowserError("unavailable", "未配置浏览器服务");
},
reconcileTabs: unavailable,
sessionInfo: () => null,
drain: async () => {},
};
}
+53 -4
View File
@@ -35,12 +35,51 @@ export interface BrowserProvider {
screenshot(ref: TabRef, opts?: { maxBytes?: number }): Promise<BrowserScreenshot>;
click(ref: TabRef, target: ElementTarget): Promise<void>;
/** text 为明文或凭证引用;凭证引用由服务端解析后直接下发,不进入模型与日志 */
type(ref: TabRef, target: ElementTarget, text: TypeInput): Promise<void>;
type(ref: TabRef, target: ElementTarget | null, text: TypeInput): Promise<void>;
extract(ref: TabRef, schema: Record<string, unknown>): Promise<Record<string, unknown>>;
waitForDownload(ref: TabRef, opts: { timeoutMs: number; maxBytes: number }): Promise<DownloadArtifact>;
acquireControl(req: AcquireControlRequest): Promise<BrowserControlLease>;
releaseControl(leaseId: string, reason?: string): Promise<void>;
// ── C1-E:标签页、历史导航、其余交互与状态同步 ──
openTab(sessionId: string, opts: { url?: string; budget: NavigationBudget; as?: ActorKind }): Promise<BrowserTab>;
closeTab(ref: TabRef): Promise<void>;
/** 后退/前进/刷新(camofox /back /forward /refresh) */
history(ref: TabRef, action: "back" | "forward" | "reload", budget: NavigationBudget): Promise<NavigationResult>;
select(ref: TabRef, target: ElementTarget, value: string): Promise<void>;
scroll(ref: TabRef, opts: { direction: "up" | "down" | "left" | "right"; amount: number }): Promise<void>;
press(ref: TabRef, key: string): Promise<void>;
/** 服务层把控制权变更同步给适配器;任何变更都让旧元素引用失效 */
setController(sessionId: string, controller: BrowserSession["controller"]): void;
/** 服务重启后按数据库记录恢复内存状态(旧元素引用一律失效) */
restoreSession(state: RestoreSessionState): void;
/** 对账:上游已不存在的标签页标 lost */
reconcileTabs(sessionId: string): Promise<{ alive: string[]; lost: string[] }>;
/** 会话内存状态摘要(不含未脱敏地址) */
sessionInfo(sessionId: string): BrowserSessionInfo | null;
/** 等待进行中的操作收尾(接管前调用) */
drain(sessionId: string): Promise<void>;
}
/** 动作发起方:自动化(Agent/任务)或持有接管租约的人 */
export type ActorKind = "automation" | "human";
export interface RestoreSessionState {
session: BrowserSession;
tabs: BrowserTab[];
currentTabId: string | null;
budget: NavigationBudget;
}
export interface BrowserSessionInfo {
session: BrowserSession;
tabs: BrowserTab[];
currentTabId: string | null;
/** 最近一次快照之后没有导航/交接:eN 引用可用 */
refsValid: boolean;
/** 当前页面是登录页 */
loginPage: boolean;
}
export interface OpenSessionRequest {
@@ -64,6 +103,8 @@ export interface OpenSessionResult {
export interface TabRef {
sessionId: string;
tabId?: string;
/** 动作发起方,缺省 automation;与会话 controller 不符时报 control_conflict */
as?: ActorKind;
}
export interface NavigationResult {
@@ -95,11 +136,19 @@ export interface BrowserScreenshot {
capturedAt: string;
}
export type ElementTarget = { ref: string } | { selector: string };
/** 元素定位:快照引用 / CSS 选择器 / 视口像素坐标(原站视图点击;camofox 坐标点击待核验) */
export type ElementTarget = { ref: string } | { selector: string } | { x: number; y: number };
export type TypeInput =
| { kind: "text"; text: string }
| { kind: "secret"; siteId: string; accountId: string | null; field: "username" | "password" | "totp" };
| { kind: "text"; text: string; submit?: boolean; clear?: boolean }
| {
kind: "secret";
siteId: string;
accountId: string | null;
field: "username" | "password" | "totp";
submit?: boolean;
clear?: boolean;
};
export interface AcquireControlRequest {
sessionId: string;