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,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;
|
||||
Reference in new issue
Block a user