From c17d4b90bd037cbffde59da00498b15b6d6ab4e1 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 20:17:46 +0800 Subject: [PATCH] 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 --- packages/adapters/src/browser/README.md | 23 +- packages/adapters/src/browser/browser.test.ts | 26 +- .../src/browser/camofox/capabilities.ts | 22 +- .../adapters/src/browser/camofox/client.ts | 532 ++++++++++++++++-- .../adapters/src/browser/interact.test.ts | 293 ++++++++++ packages/adapters/src/browser/none.ts | 15 + packages/adapters/src/browser/provider.ts | 57 +- packages/contracts/src/api.ts | 162 ++++++ packages/contracts/src/browser.ts | 219 +++++++ packages/contracts/src/capabilities-io.ts | 100 ++++ packages/contracts/src/capability.ts | 50 ++ packages/testkit/src/browser/mock-camofox.ts | 434 +++++++++++++- 12 files changed, 1832 insertions(+), 101 deletions(-) create mode 100644 packages/adapters/src/browser/interact.test.ts diff --git a/packages/adapters/src/browser/README.md b/packages/adapters/src/browser/README.md index bd143962..ecee5e20 100644 --- a/packages/adapters/src/browser/README.md +++ b/packages/adapters/src/browser/README.md @@ -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 节。 diff --git a/packages/adapters/src/browser/browser.test.ts b/packages/adapters/src/browser/browser.test.ts index b05aee90..19d9253f 100644 --- a/packages/adapters/src/browser/browser.test.ts +++ b/packages/adapters/src/browser/browser.test.ts @@ -84,14 +84,13 @@ async function rejectKind(p: Promise): Promise { } 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[] = [ @@ -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([]); }); }); diff --git a/packages/adapters/src/browser/camofox/capabilities.ts b/packages/adapters/src/browser/camofox/capabilities.ts index ed6cb187..61cd1a6f 100644 --- a/packages/adapters/src/browser/camofox/capabilities.ts +++ b/packages/adapters/src/browser/camofox/capabilities.ts @@ -18,30 +18,34 @@ export const CAMOFOX_ENDPOINTS: Record 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 = new Set([ "sessions", "tabs", "navigate", "snapshot", "screenshot", + "interact", + "extract", + "downloadCapture", + "takeover", "persistentProfile", ]); const STATIC_NOTES: Partial> = { - 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),重启恢复待验证", }; diff --git a/packages/adapters/src/browser/camofox/client.ts b/packages/adapters/src/browser/camofox/client.ts index 65b6b17a..825133ad 100644 --- a/packages/adapters/src/browser/camofox/client.ts +++ b/packages/adapters/src/browser/camofox/client.ts @@ -9,8 +9,10 @@ * 认证:README/OpenAPI 为 `Authorization: Bearer `(设置后除 /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; +} + +/** 下载产物的受控暂存:返回引用,字节不进合同/日志 */ +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 } { + const blobs = new Map(); + const sink = (async (bytes, meta) => { + const ref = `mem:${meta.id}`; + blobs.set(ref, bytes); + return { kind: "blob" as const, ref }; + }) as DownloadSink & { blobs: Map }; + 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 { + const d = await crypto.subtle.digest("SHA-256", bytes as Uint8Array); + return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join(""); } type Json = Record; @@ -85,6 +134,8 @@ export class CamofoxBrowserProvider implements BrowserProvider { private readonly fetchImpl: typeof fetch; private readonly lock: ProfileLock; private readonly sessions = new Map(); + private readonly leases = new Map(); + 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 { + const s = this.state(sessionId); + await this.withProfile(s, async () => {}); + } + // ---------- 健康与能力 ---------- async health(): Promise { @@ -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 { - throw notImplemented("closeSession"); + /** DELETE /sessions/{userId}:关闭该 profile 的全部上游标签页;上游 404(已被回收)视为已关闭 */ + async closeSession(sessionId: string, reason = "closed"): Promise { + 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 { - throw notImplemented("listSessions"); + return [...this.sessions.values()].map((s) => ({ ...s.session })); + } + + // ---------- 标签页 ---------- + + async openTab( + sessionId: string, + opts: { url?: string; budget: NavigationBudget; as?: ActorKind }, + ): Promise { + 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 { + 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 { 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 { - throw notImplemented("click"); + /** 交互动作的公共路径:控制者校验 → 串行锁 → POST /tabs/{id}/ */ + 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 { - throw notImplemented("type"); + + /** 点击可能跳转或改动页面:之后旧引用作废 */ + async click(ref: TabRef, target: ElementTarget): Promise { + await this.act( + ref, + "click", + (s) => this.assertTarget(s, target), + (s) => { + s.refsValid = false; + }, + ); } - async extract(_ref: TabRef, _schema: Record): Promise> { - throw notImplemented("extract"); + + /** 凭证型输入在服务端解析后直接下发;明文不进日志、错误信息与返回值 */ + async type(ref: TabRef, target: ElementTarget | null, input: TypeInput): Promise { + 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 { - throw notImplemented("waitForDownload"); + + async select(ref: TabRef, target: ElementTarget, value: string): Promise { + await this.act(ref, "select", (s) => ({ ...this.assertTarget(s, target), value })); } - async acquireControl(_req: AcquireControlRequest): Promise { - throw notImplemented("acquireControl"); + + async scroll(ref: TabRef, opts: { direction: "up" | "down" | "left" | "right"; amount: number }): Promise { + await this.act(ref, "scroll", () => ({ direction: opts.direction, amount: opts.amount })); } - async releaseControl(_leaseId: string, _reason?: string): Promise { - throw notImplemented("releaseControl"); + + /** 单个按键(Enter / Tab / Backspace …);Enter 可能提交表单,之后旧引用作废 */ + async press(ref: TabRef, key: string): Promise { + await this.act( + ref, + "press", + () => ({ key }), + (s) => { + s.refsValid = false; + }, + ); + } + + async history( + ref: TabRef, + action: "back" | "forward" | "reload", + budget: NavigationBudget, + ): Promise { + 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): Promise> { + 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; + }); + } + + /** + * 轮询 GET /tabs/{id}/downloads?includeData&consume&maxBytes,拿到第一个未见过的完成下载: + * 字节交 downloadSink 暂存、算 sha256,只返回引用。超时报 timeout,超限报 budget_exceeded。 + */ + async waitForDownload(ref: TabRef, opts: { timeoutMs: number; maxBytes: number }): Promise { + 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 { + 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 { + 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"); } } diff --git a/packages/adapters/src/browser/interact.test.ts b/packages/adapters/src/browser/interact.test.ts new file mode 100644 index 00000000..5839c2a7 --- /dev/null +++ b/packages/adapters/src/browser/interact.test.ts @@ -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 => ({ + 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): Promise { + try { + await p; + } catch (e) { + expect(e).toBeInstanceOf(BrowserError); + return (e as BrowserError).kind; + } + throw new Error("预期抛错"); +} + +type Setup = Awaited>; +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("登录"); + }); +}); diff --git a/packages/adapters/src/browser/none.ts b/packages/adapters/src/browser/none.ts index 7ca4b3b7..73016d3a 100644 --- a/packages/adapters/src/browser/none.ts +++ b/packages/adapters/src/browser/none.ts @@ -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 () => {}, }; } diff --git a/packages/adapters/src/browser/provider.ts b/packages/adapters/src/browser/provider.ts index 2dced4e7..d7f7ef66 100644 --- a/packages/adapters/src/browser/provider.ts +++ b/packages/adapters/src/browser/provider.ts @@ -35,12 +35,51 @@ export interface BrowserProvider { screenshot(ref: TabRef, opts?: { maxBytes?: number }): Promise; click(ref: TabRef, target: ElementTarget): Promise; /** text 为明文或凭证引用;凭证引用由服务端解析后直接下发,不进入模型与日志 */ - type(ref: TabRef, target: ElementTarget, text: TypeInput): Promise; + type(ref: TabRef, target: ElementTarget | null, text: TypeInput): Promise; extract(ref: TabRef, schema: Record): Promise>; waitForDownload(ref: TabRef, opts: { timeoutMs: number; maxBytes: number }): Promise; acquireControl(req: AcquireControlRequest): Promise; releaseControl(leaseId: string, reason?: string): Promise; + + // ── C1-E:标签页、历史导航、其余交互与状态同步 ── + openTab(sessionId: string, opts: { url?: string; budget: NavigationBudget; as?: ActorKind }): Promise; + closeTab(ref: TabRef): Promise; + /** 后退/前进/刷新(camofox /back /forward /refresh) */ + history(ref: TabRef, action: "back" | "forward" | "reload", budget: NavigationBudget): Promise; + select(ref: TabRef, target: ElementTarget, value: string): Promise; + scroll(ref: TabRef, opts: { direction: "up" | "down" | "left" | "right"; amount: number }): Promise; + press(ref: TabRef, key: string): Promise; + /** 服务层把控制权变更同步给适配器;任何变更都让旧元素引用失效 */ + 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; +} + +/** 动作发起方:自动化(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; diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index dd01363a..bb33174d 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -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({ diff --git a/packages/contracts/src/browser.ts b/packages/contracts/src/browser.ts index b6bd699e..81322779 100644 --- a/packages/contracts/src/browser.ts +++ b/packages/contracts/src/browser.ts @@ -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; @@ -219,5 +222,221 @@ export const BrowserErrorKind = z.enum([ "budget_exceeded", "control_conflict", "lock_timeout", + /** 元素引用来自导航/交接之前的快照,已失效:先重新读取页面 */ + "stale_ref", ]); export type BrowserErrorKind = z.infer; + +// ── 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; + +export const BrowserProvidersQuery = z.object({ + /** true = 现场探测 /health 与 /openapi.json 并写回 */ + probe: QueryBool, +}); +export type BrowserProvidersQuery = z.infer; + +/** 会话视图:会话 + 标签页 + 当前有效租约 + 界面可用能力 */ +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; + +export const BrowserSessionOpen = z.object({ + siteId: Id, + accountId: Id.nullable().optional(), + /** 缺省取第一个启用的浏览器服务 */ + providerId: Id.optional(), + /** 缺省打开站点首页;必须在站点域名内 */ + url: z.url().optional(), +}); +export type BrowserSessionOpen = z.infer; + +export const BrowserSessionsQuery = z.object({ + siteId: Id.optional(), + includeClosed: QueryBool, +}); +export type BrowserSessionsQuery = z.infer; + +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; + +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; + +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; + +/** 元素定位:快照引用 / 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; + +export const BrowserClick = z.object({ tabId: z.string().min(1).optional(), target: BrowserTarget }); +export type BrowserClick = z.infer; + +/** 输入:按键(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; + +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; + +export const BrowserSelect = z.object({ + tabId: z.string().min(1).optional(), + target: BrowserTarget, + value: z.string().max(500), +}); +export type BrowserSelect = z.infer; + +export const BrowserActionResult = z.object({ ok: z.literal(true), session: BrowserSessionView }); +export type BrowserActionResult = z.infer; + +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; + +/** 暂存核验结果:种子解析出的 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; + +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; + +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; + +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; + +export const BrowserControlResult = z.object({ + lease: BrowserControlLease.nullable(), + session: BrowserSessionView, +}); +export type BrowserControlResult = z.infer; diff --git a/packages/contracts/src/capabilities-io.ts b/packages/contracts/src/capabilities-io.ts index 91b17486..09611b76 100644 --- a/packages/contracts/src/capabilities-io.ts +++ b/packages/contracts/src/capabilities-io.ts @@ -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; type CapabilityIo = typeof CAPABILITY_IO; @@ -404,3 +496,11 @@ export type SiteLoginRefreshOutput = z.infer; export type SiteUserDataInput = z.infer; export type SiteUserDataOutput = z.infer; export type SubagentBudget = z.infer; +export type BrowserSnapshotInput = z.infer; +export type BrowserSnapshotOutput = z.infer; +export type BrowserNavigateInput = z.infer; +export type BrowserNavigateOutput = z.infer; +export type BrowserInteractInput = z.infer; +export type BrowserInteractOutput = z.infer; +export type BrowserDownloadInput = z.infer; +export type BrowserDownloadOutput = z.infer; diff --git a/packages/contracts/src/capability.ts b/packages/contracts/src/capability.ts index deaeb2c1..fa2a48ed 100644 --- a/packages/contracts/src/capability.ts +++ b/packages/contracts/src/capability.ts @@ -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; @@ -348,6 +352,52 @@ export const CAPABILITY_CATALOG: Record 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( diff --git a/packages/testkit/src/browser/mock-camofox.ts b/packages/testkit/src/browser/mock-camofox.ts index 6acfae84..f0675021 100644 --- a/packages/testkit/src/browser/mock-camofox.ts +++ b/packages/testkit/src/browser/mock-camofox.ts @@ -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 `,否则 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; + focus: string | null; + downloads: MockCamofoxDownload[]; + error: string | null; } -export type MockCamofox = MockHttp & { tabs: Map }; +export interface MockCamofoxDownload { + id: string; + filename: string; + url: string; + mimeType: string; + bytes: Uint8Array; +} + +/** 交互动作记录(断言凭证确实下发到了浏览器、坐标换算结果等) */ +export interface MockCamofoxAction { + action: string; + tabId: string; + body: Record; +} + +export type MockCamofox = MockHttp & { + tabs: Map; + actions: MockCamofoxAction[]; + /** 已登录的 userId(profile) */ + loggedIn: Set; + /** 模拟站点登录过期(全部 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 = { + "/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(); +/** 纯色 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 { const tabs = new Map(); + const actions: MockCamofoxAction[] = []; + const loggedIn = new Set(); + 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, _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= 落到另一地址 - 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; + const lines = site ? pageLines(t) : []; + const text = snapshotOf(t).split("\n"); + const data: Record = {}; + 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); + }, + }); }