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:
1 parent
269a2269a1
commit
effaa32b70
23 files changed
+2290
No files matched your search
@@ -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>;
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user