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;
+162
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import * as agent from "./agent.ts";
import * as assets from "./assets.ts";
import * as auth from "./auth.ts";
import * as browser from "./browser.ts";
import { CapabilityDescriptor, KnownCapabilityId } from "./capability.ts";
import { type ApiScope, CursorQuery, cursorPage, HealthStatus, Id, IdParam } from "./common.ts";
import * as content from "./content.ts";
@@ -2008,6 +2009,167 @@ export const Api = {
}),
},
// ── 浏览器(C1-E):站点浏览器会话、原站视图、人工接管;写动作需人工持有控制权(接管) ──
browser: {
providers: ep({
method: "get",
path: "/browser/providers",
scope: "admin",
summary: "浏览器服务列表(地址、凭证引用、启用、能力 supported/verified);probe=true 现场探测并写回",
query: browser.BrowserProvidersQuery,
response: z.object({ items: z.array(browser.BrowserProviderView) }),
}),
sessions: ep({
method: "get",
path: "/browser/sessions",
scope: "admin",
summary: "浏览器会话列表(以数据库为准;默认只列未关闭的)",
query: browser.BrowserSessionsQuery,
response: z.object({ items: z.array(browser.BrowserSessionView) }),
}),
open: ep({
method: "post",
path: "/browser/sessions",
scope: "admin",
summary: "打开站点浏览器会话(同一站点/账号已有未关闭会话时直接返回它)",
body: browser.BrowserSessionOpen,
response: browser.BrowserSessionView,
}),
get: ep({
method: "get",
path: "/browser/sessions/{id}",
scope: "admin",
summary: "浏览器会话详情(控制者、标签页、租约、是否需重新读取页面)",
params: browser.BrowserSessionParams,
response: browser.BrowserSessionView,
}),
close: ep({
method: "delete",
path: "/browser/sessions/{id}",
scope: "admin",
summary: "关闭浏览器会话(关闭上游标签页,撤销租约)",
params: browser.BrowserSessionParams,
response: browser.BrowserSessionView,
}),
openTab: ep({
method: "post",
path: "/browser/sessions/{id}/tabs",
scope: "admin",
summary: "新建标签页并设为当前(需持有控制权)",
params: browser.BrowserSessionParams,
body: browser.BrowserTabOpen,
response: browser.BrowserSessionView,
}),
closeTab: ep({
method: "delete",
path: "/browser/sessions/{id}/tabs/{tabId}",
scope: "admin",
summary: "关闭标签页(需持有控制权)",
params: browser.BrowserTabParams,
response: browser.BrowserSessionView,
}),
navigate: ep({
method: "post",
path: "/browser/sessions/{id}/navigate",
scope: "admin",
summary: "地址栏导航或后退/前进/刷新(需持有控制权;限站点域名)",
params: browser.BrowserSessionParams,
body: browser.BrowserNavigate,
response: browser.BrowserNavigation,
}),
snapshot: ep({
method: "get",
path: "/browser/sessions/{id}/snapshot",
scope: "admin",
summary: "结构化页面快照(凭证已遮蔽)",
params: browser.BrowserSessionParams,
query: browser.BrowserSnapshotQuery,
response: browser.BrowserSnapshot,
}),
screenshot: ep({
method: "get",
path: "/browser/sessions/{id}/screenshot",
scope: "admin",
summary: "当前画面截图(base64,只给原站视图,不进模型)",
params: browser.BrowserSessionParams,
query: browser.BrowserScreenshotQuery,
response: browser.BrowserScreenshotView,
}),
click: ep({
method: "post",
path: "/browser/sessions/{id}/click",
scope: "admin",
summary: "点击(快照引用 / 选择器 / 截图坐标;需持有控制权)",
params: browser.BrowserSessionParams,
body: browser.BrowserClick,
response: browser.BrowserActionResult,
}),
type: ep({
method: "post",
path: "/browser/sessions/{id}/type",
scope: "admin",
summary: "输入文本 / 站点账号凭证 / 按键(需持有控制权;凭证由服务端注入)",
params: browser.BrowserSessionParams,
body: browser.BrowserType,
response: browser.BrowserActionResult,
}),
scroll: ep({
method: "post",
path: "/browser/sessions/{id}/scroll",
scope: "admin",
summary: "滚动页面(需持有控制权)",
params: browser.BrowserSessionParams,
body: browser.BrowserScroll,
response: browser.BrowserActionResult,
}),
select: ep({
method: "post",
path: "/browser/sessions/{id}/select",
scope: "admin",
summary: "下拉框选择(需持有控制权)",
params: browser.BrowserSessionParams,
body: browser.BrowserSelect,
response: browser.BrowserActionResult,
}),
extract: ep({
method: "post",
path: "/browser/sessions/{id}/extract",
scope: "admin",
summary: "按 JSON Schema(x-ref)提取页面字段",
params: browser.BrowserSessionParams,
body: browser.BrowserExtract,
response: browser.BrowserExtractResult,
}),
download: ep({
method: "post",
path: "/browser/sessions/{id}/download",
scope: "organize.execute",
summary:
"捕获浏览器下载 → 后端暂存并核验 infohash、匹配本地资源;给出 submit 时经 POST /acquisition-plans 同一流程提交",
params: browser.BrowserSessionParams,
body: browser.BrowserDownload,
response: browser.BrowserDownloadResult,
}),
acquire: ep({
method: "post",
path: "/browser/sessions/{id}/control/acquire",
scope: "admin",
summary: "人工接管:先停止该会话新的自动化输入并等进行中的操作收尾,再发放人工租约",
params: browser.BrowserSessionParams,
body: browser.BrowserControlAcquire,
response: browser.BrowserControlResult,
}),
release: ep({
method: "post",
path: "/browser/sessions/{id}/control/release",
scope: "admin",
summary: "交还控制权:Agent 恢复前必须重新读取页面,旧元素引用失效",
params: browser.BrowserSessionParams,
body: browser.BrowserControlRelease,
response: browser.BrowserControlResult,
}),
},
// ── 定时任务看板(W7-C):统一注册表 + Agent 巡检;全部只接受控制台会话 ──
schedules: {
list: ep({
+219
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { EntityBase, Id, JsonObject, NonNegInt, Timestamp } from "./common.ts";
import { ManualAcquisitionResult } from "./execution.ts";
import { InstanceHealth } from "./instances.ts";
// 浏览器能力合同(REQ §4.4、§4.7.2–4.7.4、§12 浏览器实体)。
@@ -192,6 +193,8 @@ export const DownloadArtifact = z.object({
bytes: BlobRef,
/** 十六进制 sha256 */
sha256: z.string().min(1),
/** 已脱敏的下载来源地址(上游提供时) */
sourceUrl: z.string().nullable().optional(),
capturedAt: Timestamp,
});
export type DownloadArtifact = z.infer<typeof DownloadArtifact>;
@@ -219,5 +222,221 @@ export const BrowserErrorKind = z.enum([
"budget_exceeded",
"control_conflict",
"lock_timeout",
/** 元素引用来自导航/交接之前的快照,已失效:先重新读取页面 */
"stale_ref",
]);
export type BrowserErrorKind = z.infer<typeof BrowserErrorKind>;
// ── C1-E:浏览器 API 与 Agent 能力的请求/响应(docs/runbook/browser.md) ──
/** 查询串布尔 */
const QueryBool = z.enum(["true", "false"]).optional();
/** 浏览器服务(GET /browser/providers);凭证只给引用是否存在,不给明文 */
export const BrowserProviderView = z.object({
id: Id,
name: z.string(),
kind: BrowserProviderKind,
baseUrl: z.string().nullable(),
/** 访问凭据的 secret 引用(明文永不出 API) */
secretRef: Id.nullable(),
enabled: z.boolean(),
/** 最近一次声明/探测到的能力;verified 只由人工核验写入 config.verified */
capabilities: BrowserCapabilities.nullable(),
verifiedAt: Timestamp.nullable(),
healthStatus: InstanceHealth,
lastHealthAt: Timestamp.nullable(),
/** probe=true 时的即时健康结果 */
health: BrowserHealth.nullable(),
});
export type BrowserProviderView = z.infer<typeof BrowserProviderView>;
export const BrowserProvidersQuery = z.object({
/** true = 现场探测 /health 与 /openapi.json 并写回 */
probe: QueryBool,
});
export type BrowserProvidersQuery = z.infer<typeof BrowserProvidersQuery>;
/** 会话视图:会话 + 标签页 + 当前有效租约 + 界面可用能力 */
export const BrowserSessionView = BrowserSession.extend({
tabs: z.array(BrowserTab),
currentTabId: z.string().nullable(),
lease: BrowserControlLease.nullable(),
/** 控制权交还后到重新读取页面之前为 true:旧元素引用一律失效 */
needsSnapshot: z.boolean(),
/** 等待处理/接管原因(login_required / captcha / human_takeover / lease_expired …) */
waitingReason: z.string().nullable(),
});
export type BrowserSessionView = z.infer<typeof BrowserSessionView>;
export const BrowserSessionOpen = z.object({
siteId: Id,
accountId: Id.nullable().optional(),
/** 缺省取第一个启用的浏览器服务 */
providerId: Id.optional(),
/** 缺省打开站点首页;必须在站点域名内 */
url: z.url().optional(),
});
export type BrowserSessionOpen = z.infer<typeof BrowserSessionOpen>;
export const BrowserSessionsQuery = z.object({
siteId: Id.optional(),
includeClosed: QueryBool,
});
export type BrowserSessionsQuery = z.infer<typeof BrowserSessionsQuery>;
export const BrowserSessionParams = z.object({ id: Id });
export const BrowserTabParams = z.object({ id: Id, tabId: z.string().min(1).max(200) });
export const BrowserTabOpen = z.object({ url: z.url().optional() });
/** 导航:地址栏(url)或导航按钮(action),二选一(url 优先) */
export const BrowserNavigate = z.object({
tabId: z.string().min(1).optional(),
url: z.url().max(4000).optional(),
action: z.enum(["back", "forward", "reload"]).optional(),
});
export type BrowserNavigate = z.infer<typeof BrowserNavigate>;
export const BrowserNavigation = z.object({
/** 已脱敏的最终地址 */
url: z.string(),
httpStatus: z.number().int().nullable(),
ok: z.boolean(),
attempts: z.number().int().min(0),
/** 落在登录页(登录失效) */
loginRequired: z.boolean(),
session: BrowserSessionView,
});
export type BrowserNavigation = z.infer<typeof BrowserNavigation>;
export const BrowserSnapshotQuery = z.object({
tabId: z.string().min(1).optional(),
offset: z.coerce.number().int().min(0).optional(),
});
export const BrowserScreenshotQuery = z.object({ tabId: z.string().min(1).optional() });
/** 截图:base64 直接给原站视图;不落盘、不进模型(modelSafe 标注) */
export const BrowserScreenshotView = z.object({
mimeType: z.string(),
dataBase64: z.string(),
/** 图片像素尺寸(PNG/JPEG 头解析;解析不出为 null) */
width: z.number().int().nullable(),
height: z.number().int().nullable(),
url: z.string().nullable(),
modelSafe: z.boolean(),
capturedAt: Timestamp,
controller: BrowserController,
});
export type BrowserScreenshotView = z.infer<typeof BrowserScreenshotView>;
/** 元素定位:快照引用 / CSS 选择器 / 截图像素坐标(原站视图点击) */
export const BrowserTarget = z.union([
/** 快照元素引用,形如 e12 */
z.object({ ref: z.string().min(1).max(40) }),
z.object({ selector: z.string().min(1).max(500) }),
z.object({ x: z.number().min(0), y: z.number().min(0) }),
]);
export type BrowserTarget = z.infer<typeof BrowserTarget>;
export const BrowserClick = z.object({ tabId: z.string().min(1).optional(), target: BrowserTarget });
export type BrowserClick = z.infer<typeof BrowserClick>;
/** 输入:按键(key)> 站点账号凭证字段(credential,服务端解析后直接下发)> 明文(text);target 缺省 = 当前焦点 */
export const BrowserType = z.object({
tabId: z.string().min(1).optional(),
target: BrowserTarget.optional(),
text: z.string().max(4000).optional(),
credential: z.enum(["username", "password", "totp"]).optional(),
key: z.string().min(1).max(40).optional(),
submit: z.boolean().optional(),
clear: z.boolean().optional(),
});
export type BrowserType = z.infer<typeof BrowserType>;
export const BrowserScroll = z.object({
tabId: z.string().min(1).optional(),
direction: z.enum(["up", "down", "left", "right"]),
amount: z.number().int().min(1).max(10_000).default(600),
});
export type BrowserScroll = z.infer<typeof BrowserScroll>;
export const BrowserSelect = z.object({
tabId: z.string().min(1).optional(),
target: BrowserTarget,
value: z.string().max(500),
});
export type BrowserSelect = z.infer<typeof BrowserSelect>;
export const BrowserActionResult = z.object({ ok: z.literal(true), session: BrowserSessionView });
export type BrowserActionResult = z.infer<typeof BrowserActionResult>;
export const BrowserExtract = z.object({
tabId: z.string().min(1).optional(),
/** JSON Schema;属性用 x-ref 指向快照元素(camofox /extract) */
schema: JsonObject,
});
export const BrowserExtractResult = z.object({ data: JsonObject });
/** 捕获下载:可先点击 target 触发;submit 给出时经 POST /acquisition-plans 同一流程提交 */
export const BrowserDownload = z.object({
tabId: z.string().min(1).optional(),
target: BrowserTarget.optional(),
timeoutMs: z.number().int().min(500).max(120_000).default(15_000),
submit: z
.object({ slotId: Id.optional(), entityId: Id.optional(), note: z.string().max(2000).optional() })
.optional(),
});
export type BrowserDownload = z.infer<typeof BrowserDownload>;
/** 暂存核验结果:种子解析出的 infohash、与本地资源的匹配 */
export const BrowserDownloadVerification = z.object({
valid: z.boolean(),
infohashV1: z.string().nullable(),
infohashV2: z.string().nullable(),
name: z.string().nullable(),
totalSize: NonNegInt.nullable(),
private: z.boolean().nullable(),
error: z.string().nullable(),
});
export type BrowserDownloadVerification = z.infer<typeof BrowserDownloadVerification>;
export const BrowserDownloadResult = z.object({
artifact: DownloadArtifact,
verification: BrowserDownloadVerification,
/** 按 infohash / 站点资源 ID 匹配到的本地资源;null = 不在本地索引 */
sourceReleaseId: Id.nullable(),
/** 同一会话已捕获过同一文件(sha256 相同):不重复提交 */
duplicate: z.boolean(),
/** submit 给出且匹配到资源时的提交结果(与 POST /acquisition-plans 相同) */
submission: ManualAcquisitionResult.nullable(),
note: z.string().nullable(),
});
export type BrowserDownloadResult = z.infer<typeof BrowserDownloadResult>;
export const BrowserControlAcquire = z.object({
reason: z.string().min(1).max(200).default("人工接管"),
/** 租约时长;到期未交还 → 会话转"等待处理",不自动恢复自动化 */
ttlMs: z
.number()
.int()
.min(60_000)
.max(4 * 3_600_000)
.default(30 * 60_000),
});
export type BrowserControlAcquire = z.infer<typeof BrowserControlAcquire>;
export const BrowserControlRelease = z.object({
leaseId: Id.optional(),
reason: z.string().min(1).max(200).default("人工交还"),
/** 交还给自动化(缺省)或留在等待处理 */
to: z.enum(["automation", "waiting"]).default("automation"),
});
export type BrowserControlRelease = z.infer<typeof BrowserControlRelease>;
export const BrowserControlResult = z.object({
lease: BrowserControlLease.nullable(),
session: BrowserSessionView,
});
export type BrowserControlResult = z.infer<typeof BrowserControlResult>;
+100
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { MediaAsset } from "./assets.ts";
import { BrowserController, BrowserDownloadVerification, BrowserSnapshot } from "./browser.ts";
import { CAPABILITY_CATALOG, type CapabilitySpec, type KnownCapabilityId } from "./capability.ts";
import { Id, JsonObject, NonNegInt } from "./common.ts";
import { MediaEntity, MediaKind } from "./content.ts";
@@ -318,6 +319,93 @@ export const SiteUserDataOutput = z.object({
history: z.array(SiteUserDataPoint),
});
// C1-E 浏览器能力:会话按站点/账号隔离;元素引用只来自最近一次 browser.snapshot,
// 控制权交还或导航后旧引用失效;凭证用 credential 字段由服务端注入,不进入模型。
const BrowserAgentTarget = z.union([
z.object({ ref: z.string().min(1).max(40) }),
z.object({ selector: z.string().min(1).max(500) }),
]);
export const BrowserSnapshotInput = z
.object({
/** 会话 ID;缺省用 siteId 对应的未关闭会话 */
sessionId: Id.optional(),
siteId: Id.optional(),
tabId: z.string().min(1).optional(),
offset: z.number().int().min(0).optional(),
})
.refine((v) => !!v.sessionId || !!v.siteId, { message: "sessionId 与 siteId 至少给一个" });
export const BrowserSnapshotOutput = z.object({
snapshot: BrowserSnapshot,
controller: BrowserController,
/** 会话在等待处理(验证码 / 二次认证 / 登录失效)时的原因;此时自动化动作被拒,需人工在原站视图处理 */
waitingReason: z.string().nullable(),
loginRequired: z.boolean(),
});
export const BrowserNavigateInput = z
.object({
sessionId: Id.optional(),
/** 无会话时按站点(和账号)打开一个 */
siteId: Id.optional(),
accountId: Id.optional(),
tabId: z.string().min(1).optional(),
/** 必须在站点域名内 */
url: z.url().max(4000),
})
.refine((v) => !!v.sessionId || !!v.siteId, { message: "sessionId 与 siteId 至少给一个" });
export const BrowserNavigateOutput = z.object({
sessionId: Id,
url: z.string(),
httpStatus: z.number().int().nullable(),
ok: z.boolean(),
attempts: z.number().int(),
loginRequired: z.boolean(),
controller: BrowserController,
});
export const BrowserInteractInput = z.object({
sessionId: Id,
tabId: z.string().min(1).optional(),
/** request_human:转"等待处理",由人在原站视图接管(验证码、二次认证等) */
action: z.enum(["click", "type", "select", "scroll", "press", "request_human"]),
target: BrowserAgentTarget.optional(),
text: z.string().max(4000).optional(),
/** 站点账号凭证字段:服务端解析后直接下发到输入框 */
credential: z.enum(["username", "password", "totp"]).optional(),
key: z.string().min(1).max(40).optional(),
value: z.string().max(500).optional(),
direction: z.enum(["up", "down", "left", "right"]).optional(),
amount: z.number().int().min(1).max(10_000).optional(),
submit: z.boolean().optional(),
reason: z.string().max(200).optional(),
});
export const BrowserInteractOutput = z.object({
ok: z.literal(true),
controller: BrowserController,
/** true = 页面可能已变化,下一步先 browser.snapshot 再用新引用 */
needsSnapshot: z.boolean(),
});
export const BrowserDownloadInput = z.object({
sessionId: Id,
tabId: z.string().min(1).optional(),
/** 触发下载的元素(快照引用);缺省只等待已触发的下载 */
target: BrowserAgentTarget.optional(),
timeoutMs: z.number().int().min(500).max(120_000).default(15_000),
});
export const BrowserDownloadOutput = z.object({
artifactId: Id,
filename: z.string(),
sizeBytes: NonNegInt,
sha256: z.string(),
verification: BrowserDownloadVerification,
/** 匹配到的本地资源;下一步用 download.plan / download.submit 走同一提交流程 */
sourceReleaseId: Id.nullable(),
duplicate: z.boolean(),
note: z.string().nullable(),
});
/** 能力 ID → 输入/输出 schema(server 注册处理器、agent 生成工具定义时共用) */
export const CAPABILITY_IO = {
"catalog.lookup": { input: CatalogLookupInput, output: CatalogLookupOutput },
@@ -341,6 +429,10 @@ export const CAPABILITY_IO = {
"site.health_check": { input: SiteHealthCheckInput, output: SiteHealthCheckOutput },
"site.refresh_login": { input: SiteLoginRefreshInput, output: SiteLoginRefreshOutput },
"site.user_data": { input: SiteUserDataInput, output: SiteUserDataOutput },
"browser.snapshot": { input: BrowserSnapshotInput, output: BrowserSnapshotOutput },
"browser.navigate": { input: BrowserNavigateInput, output: BrowserNavigateOutput },
"browser.interact": { input: BrowserInteractInput, output: BrowserInteractOutput },
"browser.download": { input: BrowserDownloadInput, output: BrowserDownloadOutput },
} as const satisfies Record<KnownCapabilityId, { input: z.ZodType; output: z.ZodType }>;
type CapabilityIo = typeof CAPABILITY_IO;
@@ -404,3 +496,11 @@ export type SiteLoginRefreshOutput = z.infer<typeof SiteLoginRefreshOutput>;
export type SiteUserDataInput = z.infer<typeof SiteUserDataInput>;
export type SiteUserDataOutput = z.infer<typeof SiteUserDataOutput>;
export type SubagentBudget = z.infer<typeof SubagentBudget>;
export type BrowserSnapshotInput = z.infer<typeof BrowserSnapshotInput>;
export type BrowserSnapshotOutput = z.infer<typeof BrowserSnapshotOutput>;
export type BrowserNavigateInput = z.infer<typeof BrowserNavigateInput>;
export type BrowserNavigateOutput = z.infer<typeof BrowserNavigateOutput>;
export type BrowserInteractInput = z.infer<typeof BrowserInteractInput>;
export type BrowserInteractOutput = z.infer<typeof BrowserInteractOutput>;
export type BrowserDownloadInput = z.infer<typeof BrowserDownloadInput>;
export type BrowserDownloadOutput = z.infer<typeof BrowserDownloadOutput>;
+50
View File
@@ -72,6 +72,10 @@ export const CAPABILITY_IDS = [
"site.health_check",
"site.refresh_login",
"site.user_data",
"browser.snapshot",
"browser.navigate",
"browser.interact",
"browser.download",
] as const;
export const KnownCapabilityId = z.enum(CAPABILITY_IDS);
export type KnownCapabilityId = z.infer<typeof KnownCapabilityId>;
@@ -348,6 +352,52 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
true,
false,
),
// C1-E 浏览器能力(REQ §4.4、§4.7.3):经 BrowserProvider 访问用户已有的 camofox-browser;
// 导航限站点域名,凭证由服务端注入,人工接管期间自动化动作被拒;下载产物只进后端暂存核验
"browser.snapshot": d(
"browser.snapshot",
"读取原站页面",
"读取站点浏览器会话当前页面的结构化快照(可访问性文本 + eN 元素引用,凭证已遮蔽);控制权交还或导航后必须重新读取",
"read",
"BrowserSnapshot",
"read",
"low",
true,
false,
),
"browser.navigate": d(
"browser.navigate",
"打开原站页面",
"在站点浏览器会话里打开站点域名内的地址(没有会话时按站点打开);受站点限流、超时与重试预算约束",
"execute",
"BrowserNavigate",
"admin",
"low",
false,
false,
),
"browser.interact": d(
"browser.interact",
"操作原站页面",
"在站点浏览器会话里点击 / 输入 / 选择 / 滚动 / 按键;登录字段用 credential 由服务端注入凭证;验证码或二次认证用 request_human 转人工",
"execute",
"BrowserInteract",
"admin",
"medium",
false,
false,
),
"browser.download": d(
"browser.download",
"捕获原站下载",
"捕获浏览器会话里的种子下载,交后端暂存并核验 infohash、匹配本地资源;提交下载仍用 download.plan / download.submit",
"execute",
"BrowserDownload",
"organize.execute",
"medium",
false,
false,
),
};
function d(
+410 -24
View File
@@ -1,17 +1,45 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import { deflateSync } from "node:zlib";
import type { MockCall, MockFault, MockHttp, MockReply } from "../tmdb/mock-http.ts";
/**
* mock camofox-browser(形状按 jo-inc/camofox-browser openapi.json 1.17.0,取值虚构,未对真实实例核验)。
* 路由:GET /health、GET /openapi.json、POST /tabs、GET /tabs、POST /tabs/{id}/navigate、
* GET /tabs/{id}/snapshot(固定可访问性快照 + 表单 structure,含密码值/Cookie/passkey 供脱敏测试)、
* GET /tabs/{id}/screenshot(1x1 PNG,base64)、DELETE /sessions/{userId}。
* 路由:GET /health、GET /openapi.json、POST /tabs、GET /tabs、DELETE /tabs/{id}、POST /tabs/{id}/navigate、
* POST /tabs/{id}/{back,forward,refresh}、GET /tabs/{id}/snapshot、GET /tabs/{id}/screenshot(base64 PNG)、
* POST /tabs/{id}/{click,type,select,scroll,press,extract}、GET /tabs/{id}/downloads、DELETE /sessions/{userId}。
* 不给 site:快照为固定文本(含密码值/Cookie/passkey 供脱敏测试)。
* 给 site:模拟一个 NexusPHP 风格站点——未登录访问受保护页重定向登录页,登录页可带验证码(需人工),
* 点击/输入改变页面,点"下载"把 .torrent 放进该标签页的下载列表;登录状态按 userId(profile)保存。
* 坐标点击:页面元素按快照行排布,第 i 行(从 0 起)占 y ∈ [MOCK_ROW_Y0 + i·MOCK_ROW_H, +MOCK_ROW_H)。
* accessKey 设置时模拟 CAMOFOX_ACCESS_KEY:除 /health 外要求 `Authorization: Bearer <key>`,否则 401。
* 故障注入(401/超时/断连)用 MockHttp.fault。
*/
/** 交互式 mock 站点 */
export interface MockCamofoxSite {
/** 例 https://audiences.example */
origin: string;
username: string;
password: string;
loginPath?: string;
/** 未登录访问会被重定向到登录页的路径 */
protectedPaths?: RegExp;
/** 登录页带验证码:只有填对 captchaAnswer 才能登录(模拟需本人操作) */
captcha?: boolean;
captchaAnswer?: string;
releases?: { id: string; title: string }[];
/** 下载内容;缺省返回固定字节 */
torrent?: (id: string) => Uint8Array;
passkey?: string;
}
export const MOCK_VIEWPORT = { width: 1280, height: 800 } as const;
export const MOCK_ROW_Y0 = 20;
export const MOCK_ROW_H = 40;
export interface MockCamofoxOptions {
accessKey?: string;
site?: MockCamofoxSite;
/** 覆盖快照文本;{url} 替换为当前地址 */
snapshotText?: string;
/** 让 openapi.json 缺少某些端点(模拟旧版本/fork) */
@@ -25,9 +53,40 @@ export interface MockCamofoxTab {
userId: string;
sessionKey: string;
url: string;
history: string[];
cursor: number;
/** 元素引用 → 已输入的值 */
fields: Map<string, string>;
focus: string | null;
downloads: MockCamofoxDownload[];
error: string | null;
}
export type MockCamofox = MockHttp & { tabs: Map<string, MockCamofoxTab> };
export interface MockCamofoxDownload {
id: string;
filename: string;
url: string;
mimeType: string;
bytes: Uint8Array;
}
/** 交互动作记录(断言凭证确实下发到了浏览器、坐标换算结果等) */
export interface MockCamofoxAction {
action: string;
tabId: string;
body: Record<string, unknown>;
}
export type MockCamofox = MockHttp & {
tabs: Map<string, MockCamofoxTab>;
actions: MockCamofoxAction[];
/** 已登录的 userId(profile) */
loggedIn: Set<string>;
/** 模拟站点登录过期(全部 profile 或指定 profile) */
expireLogin(userId?: string): void;
/** 模拟上游回收标签页(空闲超时 / 重启) */
dropTab(tabId: string): void;
};
/** 1x1 透明 PNG */
export const PNG_1X1_BASE64 =
@@ -48,6 +107,11 @@ export const CAMOFOX_FIXTURE_SNAPSHOT = [
].join("\n");
const OPENAPI_PATHS: Record<string, string[]> = {
"/tabs/{tabId}/scroll": ["post"],
"/tabs/{tabId}/press": ["post"],
"/tabs/{tabId}/back": ["post"],
"/tabs/{tabId}/forward": ["post"],
"/tabs/{tabId}/refresh": ["post"],
"/health": ["get"],
"/tabs": ["post", "get"],
"/tabs/{tabId}": ["delete"],
@@ -145,15 +209,214 @@ async function startJsonMock(
};
}
// ---------- 截图:按页面种类填色的 PNG(1280x800) ----------
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[n] = c >>> 0;
}
return t;
})();
function crc32(buf: Uint8Array): number {
let c = 0xffffffff;
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff]! ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function pngChunk(type: string, data: Uint8Array): Buffer {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.byteLength);
const td = Buffer.concat([Buffer.from(type, "ascii"), Buffer.from(data)]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(td));
return Buffer.concat([len, td, crc]);
}
const pngCache = new Map<string, string>();
/** 纯色 PNG(base64),可选若干横条表示元素行 */
export function solidPngBase64(w: number, h: number, rgb: [number, number, number], rows: number[] = []): string {
const key = `${w}x${h}:${rgb.join(",")}:${rows.join(",")}`;
const hit = pngCache.get(key);
if (hit) return hit;
const raw = Buffer.alloc((w * 3 + 1) * h);
for (let y = 0; y < h; y++) {
const off = y * (w * 3 + 1);
const inRow = rows.some((r) => y >= MOCK_ROW_Y0 + r * MOCK_ROW_H + 8 && y < MOCK_ROW_Y0 + (r + 1) * MOCK_ROW_H - 8);
for (let x = 0; x < w; x++) {
const bar = inRow && x >= 40 && x < 640;
raw[off + 1 + x * 3] = bar ? 90 : rgb[0];
raw[off + 2 + x * 3] = bar ? 110 : rgb[1];
raw[off + 3 + x * 3] = bar ? 140 : rgb[2];
}
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(w, 0);
ihdr.writeUInt32BE(h, 4);
ihdr[8] = 8;
ihdr[9] = 2;
const png = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
pngChunk("IHDR", ihdr),
pngChunk("IDAT", deflateSync(raw)),
pngChunk("IEND", new Uint8Array()),
]).toString("base64");
pngCache.set(key, png);
return png;
}
// ---------- 交互式站点 ----------
interface Line {
ref: string;
role: string;
text: string;
/** 点击行为 */
onClick?: () => void;
}
export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<MockCamofox> {
const tabs = new Map<string, MockCamofoxTab>();
const actions: MockCamofoxAction[] = [];
const loggedIn = new Set<string>();
const site = opts.site;
const loginPath = site?.loginPath ?? "/login.php";
const protectedPaths = site?.protectedPaths ?? /^\/(?:torrents|details|download|index)\.php/;
let seq = 0;
let dl = 0;
const err = (status: number, error: string): MockReply => ({ status, json: { error } });
const tabFor = (id: string, userId: string | undefined) => {
const t = tabs.get(decodeURIComponent(id));
return t && t.userId === userId ? t : undefined;
};
/** 站点路由:未登录访问受保护页 → 登录页(带 returnto) */
const resolve = (t: MockCamofoxTab, url: string): string => {
if (!site) return new URL(url).searchParams.get("redirect") ?? url;
const u = new URL(url);
if (u.origin !== new URL(site.origin).origin) return url;
if (!loggedIn.has(t.userId) && protectedPaths.test(u.pathname))
return `${site.origin}${loginPath}?returnto=${encodeURIComponent(u.pathname + u.search)}`;
return url;
};
const go = (t: MockCamofoxTab, url: string, push = true) => {
const target = resolve(t, url);
if (push) {
t.history = t.history.slice(0, t.cursor + 1);
t.history.push(target);
t.cursor = t.history.length - 1;
} else t.history[t.cursor] = target;
t.url = target;
t.fields.clear();
t.focus = null;
t.error = null;
return target;
};
const tryLogin = (t: MockCamofoxTab) => {
if (!site) return;
const ok =
t.fields.get("e2") === site.username &&
t.fields.get("e3") === site.password &&
(!site.captcha || t.fields.get("e5") === (site.captchaAnswer ?? "4321"));
if (!ok) {
t.error =
site.captcha && t.fields.get("e5") !== (site.captchaAnswer ?? "4321") ? "验证码错误" : "用户名或密码错误";
return;
}
loggedIn.add(t.userId);
const back = new URL(t.url).searchParams.get("returnto") ?? "/torrents.php";
go(t, `${site.origin}${back}`);
};
const pageLines = (t: MockCamofoxTab): Line[] => {
if (!site) return [];
const u = new URL(t.url);
const passkey = site.passkey ?? CAMOFOX_FIXTURE_PASSKEY;
const releases = site.releases ?? [{ id: "42", title: "Oppenheimer 2023 2160p UHD BluRay" }];
const field = (ref: string) => t.fields.get(ref) ?? "";
if (u.pathname === loginPath) {
const lines: Line[] = [
{ ref: "e1", role: "heading", text: "登录" },
{ ref: "e2", role: "textbox", text: `用户名: ${field("e2")}` },
{ ref: "e3", role: "textbox password", text: `密码: ${field("e3")}` },
];
if (site.captcha)
lines.push(
{ ref: "e4", role: "img", text: "验证码图片" },
{ ref: "e5", role: "textbox", text: `验证码: ${field("e5")}` },
);
lines.push({ ref: "e6", role: "button", text: "登录", onClick: () => tryLogin(t) });
if (t.error) lines.push({ ref: "e7", role: "alert", text: t.error });
return lines;
}
if (u.pathname === "/torrents.php") {
const lines: Line[] = [{ ref: "e1", role: "heading", text: "种子列表" }];
releases.forEach((r, i) => {
const dlUrl = `${site.origin}/download.php?id=${r.id}&passkey=${passkey}`;
lines.push(
{
ref: `e${10 + i * 2}`,
role: "link",
text: r.title,
onClick: () => void go(t, `${site.origin}/details.php?id=${r.id}`),
},
{
ref: `e${11 + i * 2}`,
role: "link",
text: `下载 ${dlUrl}`,
onClick: () => {
const bytes = site.torrent?.(r.id) ?? new TextEncoder().encode(`fake-torrent-${r.id}`);
t.downloads.push({
id: `dl-${++dl}`,
filename: `[UHD].${r.title.replace(/\s+/g, ".")}.torrent`,
url: dlUrl,
mimeType: "application/x-bittorrent",
bytes,
});
},
},
);
});
return lines;
}
return [
{ ref: "e1", role: "heading", text: "首页" },
{
ref: "e2",
role: "link",
text: `种子 ${site.origin}/torrents.php`,
onClick: () => void go(t, `${site.origin}/torrents.php`),
},
];
};
const snapshotOf = (t: MockCamofoxTab) =>
site
? pageLines(t)
.map(
(l) =>
`[${l.role.split(" ")[0]} ${l.ref}${l.role.includes(" ") ? ` ${l.role.split(" ").slice(1).join(" ")}` : ""}] ${l.text}`,
)
.join("\n")
: (opts.snapshotText ?? CAMOFOX_FIXTURE_SNAPSHOT).replaceAll("{url}", t.url);
const findLine = (t: MockCamofoxTab, b: Body): Line | undefined => {
const lines = pageLines(t);
if (typeof b.ref === "string") return lines.find((l) => l.ref === b.ref);
if (typeof b.y === "number") return lines[Math.floor((b.y - MOCK_ROW_Y0) / MOCK_ROW_H)];
if (typeof b.selector === "string") return lines.find((l) => l.role.startsWith("button"));
return undefined;
};
const action =
(name: string, fn: (t: MockCamofoxTab, b: Body) => MockReply | undefined) =>
(m: RegExpMatchArray, _q: Record<string, string>, _req: IncomingMessage, b: Body): MockReply => {
const t = tabFor(m[1]!, b.userId as string | undefined);
if (!t) return err(404, "Tab not found");
actions.push({ action: name, tabId: t.tabId, body: { ...b } });
return fn(t, b) ?? { json: { ok: true, tabId: t.tabId, url: t.url } };
};
const mock = await startJsonMock(
[
[
@@ -201,22 +464,53 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
if (typeof b.userId !== "string" || typeof b.sessionKey !== "string")
return err(400, "userId and sessionKey required");
const tabId = `tab-${++seq}`;
const url = typeof b.url === "string" ? b.url : "about:blank";
tabs.set(tabId, { tabId, userId: b.userId, sessionKey: b.sessionKey, url });
const t: MockCamofoxTab = {
tabId,
userId: b.userId,
sessionKey: b.sessionKey,
url: "about:blank",
history: [],
cursor: -1,
fields: new Map(),
focus: null,
downloads: [],
error: null,
};
tabs.set(tabId, t);
const url = typeof b.url === "string" ? go(t, b.url) : go(t, "about:blank");
return { json: { tabId, url, httpStatus: b.url ? 200 : null, navigationOk: true } };
},
],
[
/^\/tabs\/([^/]+)\/navigate$/,
(m, _q, _req, b) => {
const t = tabFor(m[1]!, b.userId as string | undefined);
/^\/tabs\/([^/]+)$/,
(m, q, req, b) => {
if (req.method !== "DELETE") return err(405, "method");
const t = tabFor(m[1]!, (q.userId ?? b.userId) as string | undefined);
if (!t) return err(404, "Tab not found");
tabs.delete(t.tabId);
return { json: { ok: true } };
},
],
[
/^\/tabs\/([^/]+)\/navigate$/,
action("navigate", (t, b) => {
if (typeof b.url !== "string") return err(400, "url required");
// 模拟站点重定向:?redirect=<url> 落到另一地址
const target = new URL(b.url).searchParams.get("redirect") ?? b.url;
t.url = target;
const target = go(t, b.url);
const httpStatus = target.includes("/missing") ? 404 : 200;
return { json: { ok: true, tabId: t.tabId, url: target, httpStatus, navigationOk: httpStatus < 400 } };
}),
],
[
/^\/tabs\/([^/]+)\/(back|forward|refresh)$/,
(m, q, req, b) => {
const dir = m[2]!;
return action(dir, (t) => {
if (dir === "back" && t.cursor > 0) t.cursor--;
if (dir === "forward" && t.cursor < t.history.length - 1) t.cursor++;
const target = t.history[t.cursor] ?? t.url;
go(t, target, false);
return { json: { ok: true, tabId: t.tabId, url: t.url, httpStatus: 200, navigationOk: true } };
})(m, q, req, b);
},
],
[
@@ -224,23 +518,25 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
(m, q) => {
const t = tabFor(m[1]!, q.userId);
if (!t) return err(404, "Tab not found");
const snapshot = (opts.snapshotText ?? CAMOFOX_FIXTURE_SNAPSHOT).replaceAll("{url}", t.url);
const snapshot = snapshotOf(t);
const offset = Number(q.offset ?? 0);
return {
json: {
url: t.url,
snapshot: snapshot.slice(offset),
structure: {
forms: [
{
action: "takelogin.php",
fields: [
{ name: "username", type: "text", value: "fixture-user" },
{ name: "password", type: "password", value: CAMOFOX_FIXTURE_PASSWORD },
structure: site
? null
: {
forms: [
{
action: "takelogin.php",
fields: [
{ name: "username", type: "text", value: "fixture-user" },
{ name: "password", type: "password", value: CAMOFOX_FIXTURE_PASSWORD },
],
},
],
},
],
},
refsCount: new Set(snapshot.match(/\[[a-z]+ (e\d+)/g) ?? []).size,
truncated: false,
totalChars: snapshot.length,
@@ -254,7 +550,86 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
(m, q) => {
const t = tabFor(m[1]!, q.userId);
if (!t) return err(404, "Tab not found");
return { json: { screenshot: { data: PNG_1X1_BASE64, mimeType: "image/png" } } };
if (!site) return { json: { screenshot: { data: PNG_1X1_BASE64, mimeType: "image/png" } } };
const login = new URL(t.url).pathname === loginPath;
const rows = pageLines(t).map((_l, i) => i);
const data = solidPngBase64(
MOCK_VIEWPORT.width,
MOCK_VIEWPORT.height,
login ? [236, 232, 224] : [248, 248, 250],
rows,
);
return { json: { screenshot: { data, mimeType: "image/png" } } };
},
],
[
/^\/tabs\/([^/]+)\/click$/,
action("click", (t, b) => {
const line = findLine(t, b);
if (!line) return typeof b.ref === "string" ? err(422, "Unknown ref") : undefined;
if (line.role.startsWith("textbox")) t.focus = line.ref;
line.onClick?.();
return undefined;
}),
],
[
/^\/tabs\/([^/]+)\/type$/,
action("type", (t, b) => {
if (typeof b.text !== "string") return err(400, "text required");
const ref = typeof b.ref === "string" ? b.ref : (findLine(t, b)?.ref ?? t.focus);
if (!ref) return err(422, "No focused element");
t.fields.set(ref, b.clear === false ? (t.fields.get(ref) ?? "") + b.text : b.text);
t.focus = ref;
if (b.submit === true) tryLogin(t);
return undefined;
}),
],
[/^\/tabs\/([^/]+)\/select$/, action("select", () => undefined)],
[/^\/tabs\/([^/]+)\/scroll$/, action("scroll", () => undefined)],
[
/^\/tabs\/([^/]+)\/press$/,
action("press", (t, b) => {
if (b.key === "Enter" && site && new URL(t.url).pathname === loginPath) tryLogin(t);
if (b.key === "Backspace" && t.focus) t.fields.set(t.focus, (t.fields.get(t.focus) ?? "").slice(0, -1));
return undefined;
}),
],
[
/^\/tabs\/([^/]+)\/extract$/,
action("extract", (t, b) => {
const props = ((b.schema as Body | undefined)?.properties ?? {}) as Record<string, Body>;
const lines = site ? pageLines(t) : [];
const text = snapshotOf(t).split("\n");
const data: Record<string, unknown> = {};
for (const [k, p] of Object.entries(props)) {
const ref = p["x-ref"];
const hit =
lines.find((l) => l.ref === ref)?.text ??
text.find((l) => l.includes(` ${ref}]`) || l.includes(` ${ref} `))?.replace(/^\[[^\]]*\]\s*/, "");
data[k] = hit ?? null;
}
return { json: { ok: true, data } };
}),
],
[
/^\/tabs\/([^/]+)\/downloads$/,
(m, q) => {
const t = tabFor(m[1]!, q.userId);
if (!t) return err(404, "Tab not found");
const max = q.maxBytes ? Number(q.maxBytes) : Number.POSITIVE_INFINITY;
const list = t.downloads.map((d) => ({
id: d.id,
filename: d.filename,
url: d.url,
mimeType: d.mimeType,
size: d.bytes.byteLength,
status: "completed",
...(q.includeData === "true" && d.bytes.byteLength <= max
? { data: Buffer.from(d.bytes).toString("base64") }
: { truncated: d.bytes.byteLength > max }),
}));
if (q.consume === "true") t.downloads = [];
return { json: { downloads: list } };
},
],
[
@@ -280,5 +655,16 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
},
},
);
return Object.assign(mock, { tabs });
return Object.assign(mock, {
tabs,
actions,
loggedIn,
expireLogin(userId?: string) {
if (userId) loggedIn.delete(userId);
else loggedIn.clear();
},
dropTab(tabId: string) {
tabs.delete(tabId);
},
});
}