feat(browser): W4-C BrowserProvider contract and camofox-browser adapter skeleton

- contracts/browser.ts: provider kind, per-capability {supported, verified} flags,
  session/tab/control-lease rows, NavigationBudget, redacted BrowserSnapshot,
  DownloadArtifact with blob/secret refs, BrowserErrorKind
- adapters/browser: BrowserProvider interface, camofox client (health, capabilities
  static + /openapi.json probe, openSession/navigate/snapshot/screenshot; rest
  not_implemented for M2), none provider, redactSnapshot, profile serial lock,
  domain allowlist budget; Bearer access key via BrowserSecretResolver
- testkit/browser: mock camofox server (auth, fixtures with secrets, faults)
- db 0023: browser_provider/session/tab/control_lease with one-active-lease and
  one-live-session-per-profile partial unique indexes; types regenerated
- docs/design/browser-provider.md: contract, camofox OpenAPI 1.17.0 mapping,
  unverified items and M2 prototype checklist; Playwright is not a product browser

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 21:39:29 +08:00
1 parent 269a2269a1
commit effaa32b70
23 files changed
+2290

No files matched your search

+223
View File
@@ -0,0 +1,223 @@
import { z } from "zod";
import { EntityBase, Id, JsonObject, NonNegInt, Timestamp } from "./common.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<typeof BrowserProviderKind>;
/**
* 单项能力声明。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<typeof BrowserCapabilityFlag>;
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<typeof BrowserCapabilities>;
/** 会话控制权:自动化 / 人工接管 / 等待处理(验证码、2FA 等)/ 已关闭(REQ §4.7.3) */
export const BrowserController = z.enum(["automation", "human", "waiting", "closed"]);
export type BrowserController = z.infer<typeof BrowserController>;
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<typeof BrowserProviderRow>;
/** 浏览器会话:按站点/账号绑定;同一 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<typeof BrowserSession>;
export const BrowserTabStatus = z.enum(["open", "closed", "lost"]);
export type BrowserTabStatus = z.infer<typeof BrowserTabStatus>;
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<typeof BrowserTab>;
export const BrowserLeaseHolderKind = z.enum(["automation", "human"]);
export type BrowserLeaseHolderKind = z.infer<typeof BrowserLeaseHolderKind>;
export const BrowserLeaseStatus = z.enum(["active", "released", "expired", "revoked"]);
export type BrowserLeaseStatus = z.infer<typeof BrowserLeaseStatus>;
/** 会话控制权租约:每个会话同时至多一个 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<typeof BrowserControlLease>;
/** 导航预算(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<typeof NavigationBudget>;
export const SnapshotRedactionKind = z.enum(["password_field", "cookie", "credential_url", "secret_value"]);
export type SnapshotRedactionKind = z.infer<typeof SnapshotRedactionKind>;
/**
* 可交给模型的页面快照:只含经 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<typeof BrowserSnapshot>;
/** 二进制内容的引用:blob 暂存路径或 secret(下载产物可能含 passkey,不内联进合同/日志) */
export const BlobRef = z.object({
kind: z.enum(["blob", "secret"]),
ref: z.string().min(1),
});
export type BlobRef = z.infer<typeof BlobRef>;
/** 浏览器捕获的下载产物:交后端受控暂存与核验后再走原有提交链路(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),
capturedAt: Timestamp,
});
export type DownloadArtifact = z.infer<typeof DownloadArtifact>;
export const BrowserHealth = z.object({
status: z.enum(["ok", "degraded", "down", "disabled"]),
kind: BrowserProviderKind,
detail: JsonObject,
checkedAt: Timestamp,
});
export type BrowserHealth = z.infer<typeof BrowserHealth>;
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",
]);
export type BrowserErrorKind = z.infer<typeof BrowserErrorKind>;
+1
View File
@@ -3,6 +3,7 @@ export * from "./agent-events.ts";
export * from "./api.ts";
export * from "./assets.ts";
export * from "./auth.ts";
export * from "./browser.ts";
export * from "./capabilities-io.ts";
export * from "./capability.ts";
export * from "./common.ts";