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
@@ -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 节。
|
||||
@@ -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),重启恢复待验证",
|
||||
};
|
||||
|
||||
|
||||
@@ -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("登录");
|
||||
});
|
||||
});
|
||||
@@ -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 () => {},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user