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

+147
View File
@@ -0,0 +1,147 @@
import type {
BrowserCapabilities,
BrowserControlLease,
BrowserHealth,
BrowserLeaseHolderKind,
BrowserProviderKind,
BrowserSession,
BrowserSnapshot,
BrowserTab,
DownloadArtifact,
NavigationBudget,
} from "@umt/contracts";
/**
* BrowserProvider:业务侧访问浏览器服务的唯一合同(REQ §4.7.2)。
* 这是本项目接口;上游(camofox-browser 等)的同名能力由各实现映射,不假定上游具备同名端点。
* 约束:
* - 同一 profileKey(站点/账号)的操作串行(ProfileLock);
* - 导航受 NavigationBudget 约束(域名白名单、超时、重试、字节上限);
* - 进入模型的快照/截图先经脱敏钩子;凭证经 BrowserSecretResolver 注入,不落日志、不进模型;
* - controller 不是 automation 的会话拒绝自动化输入(接管期间,REQ §4.7.3);
* - 浏览器没有媒体库写权限,下载产物只交后端暂存核验。
*/
export interface BrowserProvider {
readonly kind: BrowserProviderKind;
health(): Promise<BrowserHealth>;
capabilities(opts?: { probe?: boolean }): Promise<BrowserCapabilities>;
openSession(req: OpenSessionRequest): Promise<OpenSessionResult>;
closeSession(sessionId: string, reason?: string): Promise<void>;
listSessions(): Promise<BrowserSession[]>;
navigate(ref: TabRef, url: string, budget: NavigationBudget): Promise<NavigationResult>;
snapshot(ref: TabRef, opts?: SnapshotOptions): Promise<BrowserSnapshot>;
screenshot(ref: TabRef, opts?: { maxBytes?: number }): Promise<BrowserScreenshot>;
click(ref: TabRef, target: ElementTarget): Promise<void>;
/** text 为明文或凭证引用;凭证引用由服务端解析后直接下发,不进入模型与日志 */
type(ref: TabRef, target: ElementTarget, text: TypeInput): Promise<void>;
extract(ref: TabRef, schema: Record<string, unknown>): Promise<Record<string, unknown>>;
waitForDownload(ref: TabRef, opts: { timeoutMs: number; maxBytes: number }): Promise<DownloadArtifact>;
acquireControl(req: AcquireControlRequest): Promise<BrowserControlLease>;
releaseControl(leaseId: string, reason?: string): Promise<void>;
}
export interface OpenSessionRequest {
providerId: string;
siteId: string;
accountId: string | null;
/** 缺省按 siteId/accountId 生成(profileKeyFor) */
profileKey?: string;
/** 可选初始地址,同样受 budget 约束 */
initialUrl?: string;
budget: NavigationBudget;
}
export interface OpenSessionResult {
session: BrowserSession;
tab: BrowserTab;
navigation: NavigationResult | null;
}
/** 会话内标签页定位;tabId 缺省 = 会话当前标签页 */
export interface TabRef {
sessionId: string;
tabId?: string;
}
export interface NavigationResult {
/** 已脱敏的最终地址 */
url: string;
httpStatus: number | null;
/** 上游报告的文档状态 < 400 */
ok: boolean;
attempts: number;
}
export interface SnapshotOptions {
offset?: number;
maxBytes?: number;
/** 追加脱敏规则(站点特定字段) */
extraRedactions?: RegExp[];
}
export interface BrowserScreenshot {
mimeType: string;
data: Uint8Array;
/** 已脱敏的页面地址 */
url: string | null;
/**
* 是否可直接进入模型。像素级遮蔽未实现(M2),当前按页面地址/已知密码框保守判断:
* 含凭据的地址或登录页一律 false,调用方只能给人看、不喂模型。
*/
modelSafe: boolean;
capturedAt: string;
}
export type ElementTarget = { ref: string } | { selector: string };
export type TypeInput =
| { kind: "text"; text: string }
| { kind: "secret"; siteId: string; accountId: string | null; field: "username" | "password" | "totp" };
export interface AcquireControlRequest {
sessionId: string;
holderKind: BrowserLeaseHolderKind;
holder: string;
reason: string;
ttlMs: number;
}
/** 凭证注入:服务端从 secret store 按需解出明文;适配器不持久化、不打印、不放进错误信息 */
export type BrowserSecretRequest =
| { kind: "provider_access_key"; providerId: string }
| { kind: "site_login"; siteId: string; accountId: string | null; field: "username" | "password" | "totp" };
export interface BrowserSecretResolver {
resolve(req: BrowserSecretRequest): Promise<string | null>;
}
/** 测试/脚本用固定凭证解析器;键为 `provider:<id>` 或 `site:<siteId>:<accountId|->:<field>` */
export function staticBrowserSecrets(table: Record<string, string>): BrowserSecretResolver {
return {
async resolve(req) {
const key =
req.kind === "provider_access_key"
? `provider:${req.providerId}`
: `site:${req.siteId}:${req.accountId ?? "-"}:${req.field}`;
return table[key] ?? null;
},
};
}
/** 站点/账号维度的 profile 键;同时用作 camofox userId(登录状态隔离单元) */
export function profileKeyFor(siteId: string, accountId: string | null): string {
return `umt-site-${siteId}-acct-${accountId ?? "anon"}`;
}
/** 只记录方法/路径/状态;实现不得把请求头、正文或快照写进日志 */
export type BrowserLogger = (entry: {
level: "debug" | "warn";
msg: string;
method?: string;
path?: string;
status?: number;
ms?: number;
}) => void;