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 浏览器实体)。 // 这是本项目自有接口,不等于上游同名接口:上游对接由 @umt/adapters 的 BrowserProvider 实现 // (首个实现为用户已有的 camofox-browser;Playwright 只用于前端截图测试,不是产品浏览器能力)。 // v0.1 只落合同与骨架,会话/接管/下载捕获在 M2 原型验证后启用(docs/design/browser-provider.md)。 /** 浏览器服务实现种类;none = 未配置(默认),所有浏览器动作返回不可用 */ export const BrowserProviderKind = z.enum(["camofox", "none"]); export type BrowserProviderKind = z.infer; /** * 单项能力声明。supported = 实现/上游声明具备;verified = 已对用户真实实例做过原型验证(REQ §4.7.4)。 * 界面只把 supported && verified 的能力当作可用,未验证的明确标注"未验证",不得用其他浏览器替代后宣称可用。 */ export const BrowserCapabilityFlag = z.object({ supported: z.boolean(), verified: z.boolean(), /** static = 适配器按文档静态声明;probed = 从服务端 OpenAPI/健康端点探测到 */ source: z.enum(["static", "probed"]), note: z.string().optional(), }); export type BrowserCapabilityFlag = z.infer; export const BROWSER_CAPABILITY_KEYS = [ "sessions", "tabs", "navigate", "snapshot", "screenshot", "interact", "extract", "downloadCapture", "takeover", "persistentProfile", ] as const; export type BrowserCapabilityKey = (typeof BROWSER_CAPABILITY_KEYS)[number]; export const BrowserCapabilities = z.object({ /** 会话(站点/账号隔离的浏览器上下文) */ sessions: BrowserCapabilityFlag, /** 标签页列举/新建/关闭 */ tabs: BrowserCapabilityFlag, navigate: BrowserCapabilityFlag, /** 结构化快照(可访问性树 + 元素引用),非原始 HTML */ snapshot: BrowserCapabilityFlag, screenshot: BrowserCapabilityFlag, /** click / type / select */ interact: BrowserCapabilityFlag, /** 结构化提取(按 schema 取字段) */ extract: BrowserCapabilityFlag, /** 捕获浏览器下载产物(.torrent 等)交后端暂存核验 */ downloadCapture: BrowserCapabilityFlag, /** 人工接管:同一会话的画面与输入通道 */ takeover: BrowserCapabilityFlag, /** 登录状态随 profile 持久化,重启后恢复 */ persistentProfile: BrowserCapabilityFlag, /** 上游版本(来自 OpenAPI info.version 等);未取得为 null */ upstreamVersion: z.string().nullable(), probedAt: Timestamp.nullable(), }); export type BrowserCapabilities = z.infer; /** 会话控制权:自动化 / 人工接管 / 等待处理(验证码、2FA 等)/ 已关闭(REQ §4.7.3) */ export const BrowserController = z.enum(["automation", "human", "waiting", "closed"]); export type BrowserController = z.infer; export const BrowserProviderRow = EntityBase.extend({ name: z.string().min(1), kind: BrowserProviderKind, baseUrl: z.string().nullable(), /** 访问凭据(如 camofox CAMOFOX_ACCESS_KEY)只以 secret 引用保存 */ secretRef: Id.nullable(), enabled: z.boolean(), /** 最近一次声明/探测到的 BrowserCapabilities 快照 */ capabilities: JsonObject, /** 最近一次对真实实例完成原型验证的时间;null = 从未验证 */ verifiedAt: Timestamp.nullable(), healthStatus: InstanceHealth, lastHealthAt: Timestamp.nullable(), config: JsonObject, }); export type BrowserProviderRow = z.infer; /** 浏览器会话:按站点/账号绑定;同一 profileKey 同时至多一个未关闭会话 */ export const BrowserSession = EntityBase.extend({ providerId: Id, siteId: Id, accountId: Id.nullable(), /** 站点/账号维度的 profile 键,决定上游登录状态隔离与串行锁 */ profileKey: z.string().min(1), controller: BrowserController, currentUrl: z.string().nullable(), lastActivityAt: Timestamp, /** 上游会话标识(camofox: userId/sessionKey);不含凭据 */ upstreamRef: JsonObject, /** 接管/断线/重启时的可恢复状态;恢复后必须重新读取页面,不重放旧元素引用 */ recoveryState: JsonObject, closedAt: Timestamp.nullable(), closeReason: z.string().nullable(), }); export type BrowserSession = z.infer; export const BrowserTabStatus = z.enum(["open", "closed", "lost"]); export type BrowserTabStatus = z.infer; export const BrowserTab = EntityBase.extend({ sessionId: Id, /** 上游标签页 ID(camofox tabId) */ upstreamTabId: z.string().min(1), url: z.string().nullable(), title: z.string().nullable(), status: BrowserTabStatus, lastSnapshotAt: Timestamp.nullable(), closedAt: Timestamp.nullable(), }); export type BrowserTab = z.infer; export const BrowserLeaseHolderKind = z.enum(["automation", "human"]); export type BrowserLeaseHolderKind = z.infer; export const BrowserLeaseStatus = z.enum(["active", "released", "expired", "revoked"]); export type BrowserLeaseStatus = z.infer; /** 会话控制权租约:每个会话同时至多一个 active(DB 部分唯一索引保证) */ export const BrowserControlLease = EntityBase.extend({ sessionId: Id, holderKind: BrowserLeaseHolderKind, /** 持有者标识:agent_run id / admin 用户名等 */ holder: z.string().min(1), reason: z.string().min(1), status: BrowserLeaseStatus, acquiredAt: Timestamp, expiresAt: Timestamp, releasedAt: Timestamp.nullable(), releaseReason: z.string().nullable(), }); export type BrowserControlLease = z.infer; /** 导航预算(REQ §4.4):域名范围、超时、重试与资源上限;空白名单 = 拒绝全部导航 */ export const NavigationBudget = z.object({ /** 允许的主机名;"example.org" 同时放行其子域 */ domainAllowlist: z.array(z.string().min(1)), timeoutMs: z.number().int().min(1), maxRetries: z.number().int().min(0).max(10), /** 单次响应(快照文本/截图)字节上限 */ maxBytes: z.number().int().min(1), }); export type NavigationBudget = z.infer; export const SnapshotRedactionKind = z.enum(["password_field", "cookie", "credential_url", "secret_value"]); export type SnapshotRedactionKind = z.infer; /** * 可交给模型的页面快照:只含经 redactSnapshot 脱敏后的结构化文本与元素引用, * 密码框值、Cookie 值、含 passkey 等凭据的 URL 已遮蔽;不含原始 HTML/脚本。 */ export const BrowserSnapshot = z.object({ sessionId: Id, tabId: z.string().min(1), /** 已脱敏的当前地址 */ url: z.string(), format: z.enum(["a11y_text"]), /** 已脱敏的可访问性快照文本,元素以 eN 引用 */ text: z.string(), /** 表单/表格摘要(已脱敏) */ structure: JsonObject.nullable(), refsCount: NonNegInt, truncated: z.boolean(), nextOffset: NonNegInt.nullable(), redactions: z.array(z.object({ kind: SnapshotRedactionKind, count: NonNegInt })), capturedAt: Timestamp, }); export type BrowserSnapshot = z.infer; /** 二进制内容的引用:blob 暂存路径或 secret(下载产物可能含 passkey,不内联进合同/日志) */ export const BlobRef = z.object({ kind: z.enum(["blob", "secret"]), ref: z.string().min(1), }); export type BlobRef = z.infer; /** 浏览器捕获的下载产物:交后端受控暂存与核验后再走原有提交链路(REQ §4.7.1) */ export const DownloadArtifact = z.object({ id: Id, sessionId: Id, filename: z.string().min(1), mimeType: z.string().nullable(), sizeBytes: NonNegInt, bytes: BlobRef, /** 十六进制 sha256 */ sha256: z.string().min(1), /** 已脱敏的下载来源地址(上游提供时) */ sourceUrl: z.string().nullable().optional(), capturedAt: Timestamp, }); export type DownloadArtifact = z.infer; export const BrowserHealth = z.object({ status: z.enum(["ok", "degraded", "down", "disabled"]), kind: BrowserProviderKind, detail: JsonObject, checkedAt: Timestamp, }); export type BrowserHealth = z.infer; export const BrowserErrorKind = z.enum([ "not_implemented", "unavailable", "auth", "timeout", "network", "not_found", "rate_limited", "bad_request", "upstream", "protocol", "domain_blocked", "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(), /** env = 由 BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY 登记(地址与凭据只能改部署配置);null = 设置页登记 */ managedBy: z.enum(["env"]).nullable(), /** 是否配置了访问凭据(secret 或环境变量) */ hasAccessKey: z.boolean(), 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; const HttpUrl = z .string() .url() .refine((u) => /^https?:\/\//i.test(u), "只支持 http(s) 地址"); /** 设置页登记浏览器服务(camofox);accessKey 明文只在请求里出现一次,落库为 secret */ export const BrowserProviderCreate = z.object({ name: z.string().min(1).max(64), baseUrl: HttpUrl, accessKey: z.string().min(1).max(512).optional(), enabled: z.boolean().optional(), }); export type BrowserProviderCreate = z.infer; /** 改地址 / 凭据 / 启用;accessKey = null 清除凭据 */ export const BrowserProviderUpdate = z.object({ baseUrl: HttpUrl.optional(), accessKey: z.string().min(1).max(512).nullable().optional(), enabled: z.boolean().optional(), }); export type BrowserProviderUpdate = z.infer; export const BrowserProviderParams = z.object({ id: Id }); 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;