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

+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>;