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

@@ -0,0 +1,509 @@
/**
* camofox-browser 适配器(BrowserProvider 首个实现)。
* 对照:jo-inc/camofox-browser master openapi.json(info.version 1.17.0)与 README,仅读文档,未连接真实实例。
*
* 上游模型与本项目合同的映射:
* - camofox userId = 一个隔离 BrowserContext(cookies/storage),本项目用 profileKey(站点/账号)充当;
* - camofox sessionKey = 标签组,本项目用 browser_session.id;
* - 上游没有"建会话"端点,首个 POST /tabs 隐式创建;会话由本适配器在内存登记,持久化由服务层写 browser_session。
* 认证:README/OpenAPI 为 `Authorization: Bearer <CAMOFOX_ACCESS_KEY>`(设置后除 /health 外全部路由要求);
* 凭据每次请求经 BrowserSecretResolver 解出,不缓存、不入日志、不进错误信息。
*
* v0.1 实现:health、capabilities(静态 + /openapi.json 探测)、openSession、navigate、snapshot、screenshot;
* 其余方法抛 BrowserError('not_implemented'),见 README 的 M2 清单。
*/
import type {
BrowserCapabilities,
BrowserControlLease,
BrowserHealth,
BrowserSession,
BrowserSnapshot,
BrowserTab,
DownloadArtifact,
NavigationBudget,
} from "@umt/contracts";
import { assertNavigable, hostAllowed } from "../budget.ts";
import { BrowserError, classifyHttpStatus, notImplemented } from "../errors.ts";
import { InMemoryProfileLock, type ProfileLock } from "../profile-lock.ts";
import {
type AcquireControlRequest,
type BrowserLogger,
type BrowserProvider,
type BrowserScreenshot,
type BrowserSecretResolver,
type ElementTarget,
type NavigationResult,
type OpenSessionRequest,
type OpenSessionResult,
profileKeyFor,
type SnapshotOptions,
type TabRef,
type TypeInput,
} from "../provider.ts";
import { redactBrowserUrl, redactSnapshot, urlHasCredential } from "../redact.ts";
import { camofoxCapabilitiesFromOpenApi, camofoxStaticCapabilities, type VerifiedMap } from "./capabilities.ts";
export interface CamofoxConfig {
/** browser_provider.id */
providerId: string;
/** 例 http://camofox:9377 */
baseUrl: string;
secrets: BrowserSecretResolver;
/** 单次请求默认超时;导航用 budget.timeoutMs */
timeoutMs?: number;
/** 同一 profile 串行锁;缺省进程内内存锁 */
lock?: ProfileLock;
lockWaitMs?: number;
/** 已对真实实例验证通过的能力(来自原型验证记录);缺省全部未验证 */
verified?: VerifiedMap;
/** 截图/快照默认字节上限(未传 budget 时) */
defaultMaxBytes?: number;
fetch?: typeof fetch;
logger?: BrowserLogger;
now?: () => Date;
newId?: () => string;
}
interface SessionState {
session: BrowserSession;
tabs: Map<string, BrowserTab>;
currentTabId: string;
/** 未脱敏地址只留在内存,用于判断截图能否进入模型;不对外返回 */
rawUrl: string | null;
budget: NavigationBudget;
}
type Json = Record<string, unknown>;
const str = (v: unknown) => (typeof v === "string" ? v : undefined);
const int = (v: unknown) => (typeof v === "number" && Number.isInteger(v) ? v : undefined);
const LOGIN_PATH = /(?:^|\/)(?:login|takelogin|signin|sign_in|auth)(?:\.php)?(?:\/|$|\?)/i;
export class CamofoxBrowserProvider implements BrowserProvider {
readonly kind = "camofox" as const;
private readonly base: string;
private readonly fetchImpl: typeof fetch;
private readonly lock: ProfileLock;
private readonly sessions = new Map<string, SessionState>();
private readonly now: () => Date;
private readonly newId: () => string;
constructor(private readonly cfg: CamofoxConfig) {
this.base = cfg.baseUrl.replace(/\/+$/, "");
this.fetchImpl = cfg.fetch ?? fetch;
this.lock = cfg.lock ?? new InMemoryProfileLock(cfg.lockWaitMs);
this.now = cfg.now ?? (() => new Date());
this.newId = cfg.newId ?? (() => crypto.randomUUID());
}
// ---------- HTTP ----------
private async authHeader(): Promise<Record<string, string>> {
const key = await this.cfg.secrets.resolve({ kind: "provider_access_key", providerId: this.cfg.providerId });
return key ? { authorization: `Bearer ${key}` } : {};
}
private async request(
method: "GET" | "POST" | "DELETE",
path: string,
opts: { query?: Record<string, string | undefined>; body?: Json; timeoutMs?: number; auth?: boolean } = {},
): Promise<Response> {
const u = new URL(this.base + path);
for (const [k, v] of Object.entries(opts.query ?? {})) if (v !== undefined) u.searchParams.set(k, v);
const headers: Record<string, string> = {
accept: "application/json",
...(opts.auth === false ? {} : await this.authHeader()),
};
if (opts.body) headers["content-type"] = "application/json";
const timeoutMs = opts.timeoutMs ?? this.cfg.timeoutMs ?? 30_000;
const started = Date.now();
let res: Response;
try {
res = await this.fetchImpl(u, {
method,
headers,
...(opts.body ? { body: JSON.stringify(opts.body) } : {}),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (e) {
const name = (e as { name?: string })?.name;
const kind = name === "TimeoutError" || name === "AbortError" ? "timeout" : "network";
this.cfg.logger?.({ level: "warn", msg: `camofox ${kind}`, method, path, ms: Date.now() - started });
throw new BrowserError(
kind,
`camofox ${method} ${path} ${kind === "timeout" ? `超时(${timeoutMs}ms)` : "网络错误"}`,
);
}
this.cfg.logger?.({ level: "debug", msg: "camofox", method, path, status: res.status, ms: Date.now() - started });
if (!res.ok) {
await res.body?.cancel();
throw new BrowserError(classifyHttpStatus(res.status), `camofox ${method} ${path} → HTTP ${res.status}`, {
httpStatus: res.status,
});
}
return res;
}
private async json(res: Response, path: string): Promise<Json> {
try {
const v = (await res.json()) as unknown;
if (v && typeof v === "object" && !Array.isArray(v)) return v as Json;
} catch {
/* fallthrough */
}
throw new BrowserError("protocol", `camofox ${path} 返回非 JSON 对象`);
}
// ---------- 状态 ----------
private state(sessionId: string): SessionState {
const s = this.sessions.get(sessionId);
if (!s) throw new BrowserError("not_found", "会话不存在或已关闭");
return s;
}
private tabOf(s: SessionState, ref: TabRef): BrowserTab {
const t = s.tabs.get(ref.tabId ?? s.currentTabId);
if (t?.status !== "open") throw new BrowserError("not_found", "标签页不存在或已关闭");
return t;
}
/** 接管/等待/关闭中的会话拒绝自动化动作(REQ §4.7.3) */
private assertAutomation(s: SessionState): void {
if (s.session.controller !== "automation")
throw new BrowserError("control_conflict", `会话当前由 ${s.session.controller} 控制,自动化动作已停止`);
}
private touch(s: SessionState, rawUrl?: string | null): void {
const at = this.now().toISOString();
if (rawUrl !== undefined) {
s.rawUrl = rawUrl;
s.session.currentUrl = rawUrl === null ? null : redactBrowserUrl(rawUrl);
}
s.session.lastActivityAt = at;
s.session.updatedAt = at;
}
private withProfile<T>(s: SessionState | string, fn: () => Promise<T>): Promise<T> {
const key = typeof s === "string" ? s : s.session.profileKey;
return this.lock.run(key, fn, this.cfg.lockWaitMs === undefined ? {} : { waitMs: this.cfg.lockWaitMs });
}
/** 服务层把会话控制权变更(接管/交还/等待)同步给适配器;租约本身由服务层持久化 */
setController(sessionId: string, controller: BrowserSession["controller"]): void {
const s = this.state(sessionId);
s.session.controller = controller;
this.touch(s);
}
// ---------- 健康与能力 ----------
async health(): Promise<BrowserHealth> {
const checkedAt = this.now().toISOString();
const u = `${this.base}/health`;
try {
// /health 在 CAMOFOX_ACCESS_KEY 下也免认证(README);503 表示恢复中
const res = await this.fetchImpl(u, { signal: AbortSignal.timeout(this.cfg.timeoutMs ?? 10_000) });
const body = (await res.json().catch(() => ({}))) as Json;
const detail: Json = {
httpStatus: res.status,
engine: str(body.engine) ?? null,
browserConnected: body.browserConnected ?? null,
browserRunning: body.browserRunning ?? null,
activeTabs: int(body.activeTabs) ?? null,
activeSessions: int(body.activeSessions) ?? null,
consecutiveFailures: int(body.consecutiveFailures) ?? null,
recovering: body.recovering ?? null,
};
if (res.status === 503) return { status: "degraded", kind: "camofox", detail, checkedAt };
if (!res.ok || body.ok === false) return { status: "down", kind: "camofox", detail, checkedAt };
return { status: "ok", kind: "camofox", detail, checkedAt };
} catch (e) {
const name = (e as { name?: string })?.name;
return {
status: "down",
kind: "camofox",
detail: { error: name === "TimeoutError" ? "timeout" : "network" },
checkedAt,
};
}
}
/** probe=true 时读取上游 /openapi.json 推导;失败回退静态声明(note 标注探测失败) */
async capabilities(opts: { probe?: boolean } = {}): Promise<BrowserCapabilities> {
const verified = this.cfg.verified ?? {};
if (!opts.probe) return camofoxStaticCapabilities(verified);
try {
const res = await this.request("GET", "/openapi.json");
return camofoxCapabilitiesFromOpenApi(await this.json(res, "/openapi.json"), verified, this.now());
} catch (e) {
if (e instanceof BrowserError && e.kind === "auth") throw e;
return camofoxStaticCapabilities(verified);
}
}
// ---------- 会话 ----------
async openSession(req: OpenSessionRequest): Promise<OpenSessionResult> {
const profileKey = req.profileKey ?? profileKeyFor(req.siteId, req.accountId);
if (req.initialUrl) assertNavigable(req.initialUrl, req.budget);
return this.withProfile(profileKey, async () => {
for (const s of this.sessions.values())
if (s.session.profileKey === profileKey && s.session.controller !== "closed")
throw new BrowserError("control_conflict", "该站点/账号已有未关闭的浏览器会话");
const sessionId = this.newId();
const res = await this.request("POST", "/tabs", {
body: { userId: profileKey, sessionKey: sessionId, ...(req.initialUrl ? { url: req.initialUrl } : {}) },
timeoutMs: req.budget.timeoutMs,
});
const body = await this.json(res, "/tabs");
const tabId = str(body.tabId);
if (!tabId) throw new BrowserError("protocol", "camofox POST /tabs 未返回 tabId");
const rawUrl = str(body.url) ?? req.initialUrl ?? null;
const at = this.now().toISOString();
const session: BrowserSession = {
id: sessionId,
providerId: this.cfg.providerId,
siteId: req.siteId,
accountId: req.accountId,
profileKey,
controller: "automation",
currentUrl: rawUrl === null ? null : redactBrowserUrl(rawUrl),
lastActivityAt: at,
upstreamRef: { userId: profileKey, sessionKey: sessionId },
recoveryState: {},
closedAt: null,
closeReason: null,
createdAt: at,
updatedAt: at,
};
const tab: BrowserTab = {
id: this.newId(),
sessionId,
upstreamTabId: tabId,
url: session.currentUrl,
title: null,
status: "open",
lastSnapshotAt: null,
closedAt: null,
createdAt: at,
updatedAt: at,
};
const st: SessionState = {
session,
tabs: new Map([[tabId, tab]]),
currentTabId: tabId,
rawUrl,
budget: req.budget,
};
this.sessions.set(sessionId, st);
let navigation: NavigationResult | null = null;
if (req.initialUrl) {
navigation = {
url: session.currentUrl ?? "",
httpStatus: int(body.httpStatus) ?? null,
ok: body.navigationOk !== false,
attempts: 1,
};
this.assertLanded(rawUrl, req.budget);
}
return { session: { ...session }, tab: { ...tab }, navigation };
});
}
/** 重定向落到白名单外:报 domain_blocked(页面已打开,调用方应停止后续动作并转人工) */
private assertLanded(rawUrl: string | null, budget: NavigationBudget): void {
if (!rawUrl) return;
let host: string;
try {
host = new URL(rawUrl).hostname;
} catch {
return;
}
if (!hostAllowed(host, budget.domainAllowlist))
throw new BrowserError("domain_blocked", `导航被重定向到白名单外:${redactBrowserUrl(new URL(rawUrl).origin)}`);
}
async closeSession(_sessionId: string, _reason?: string): Promise<void> {
throw notImplemented("closeSession");
}
async listSessions(): Promise<BrowserSession[]> {
throw notImplemented("listSessions");
}
// ---------- 页面 ----------
async navigate(ref: TabRef, url: string, budget: NavigationBudget): Promise<NavigationResult> {
const s = this.state(ref.sessionId);
this.assertAutomation(s);
assertNavigable(url, budget);
return this.withProfile(s, async () => {
this.assertAutomation(s);
const tab = this.tabOf(s, ref);
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/navigate`;
let attempts = 0;
for (;;) {
attempts++;
try {
const res = await this.request("POST", path, {
body: { userId: s.session.profileKey, url },
timeoutMs: budget.timeoutMs,
});
const body = await this.json(res, path);
const rawUrl = str(body.url) ?? url;
this.touch(s, rawUrl);
tab.url = s.session.currentUrl;
tab.updatedAt = s.session.updatedAt;
s.budget = budget;
this.assertLanded(rawUrl, budget);
return {
url: redactBrowserUrl(rawUrl),
httpStatus: int(body.httpStatus) ?? null,
ok: body.ok !== false && body.navigationOk !== false,
attempts,
};
} catch (e) {
if (e instanceof BrowserError && e.retryable && attempts <= budget.maxRetries) continue;
throw e;
}
}
});
}
async snapshot(ref: TabRef, opts: SnapshotOptions = {}): Promise<BrowserSnapshot> {
const s = this.state(ref.sessionId);
return this.withProfile(s, async () => {
const tab = this.tabOf(s, ref);
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/snapshot`;
const res = await this.request("GET", path, {
query: {
userId: s.session.profileKey,
format: "text",
...(opts.offset !== undefined ? { offset: String(opts.offset) } : {}),
},
timeoutMs: s.budget.timeoutMs,
});
const body = await this.json(res, path);
let text = str(body.snapshot) ?? "";
let truncated = body.truncated === true || body.hasMore === true;
const maxBytes = opts.maxBytes ?? s.budget.maxBytes;
const enc = new TextEncoder();
if (enc.encode(text).byteLength > maxBytes) {
// 按字节预算截断;在字符边界上截,避免半个 UTF-8 字符
let lo = 0;
let hi = text.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (enc.encode(text.slice(0, mid)).byteLength <= maxBytes) lo = mid;
else hi = mid - 1;
}
text = text.slice(0, lo);
truncated = true;
}
const rawUrl = str(body.url) ?? s.rawUrl ?? "";
const structure =
body.structure && typeof body.structure === "object" && !Array.isArray(body.structure)
? (body.structure as Json)
: null;
const red = redactSnapshot({ text, url: rawUrl, structure }, { extra: opts.extraRedactions ?? [] });
this.touch(s, rawUrl || s.rawUrl);
const at = s.session.lastActivityAt;
tab.lastSnapshotAt = at;
tab.url = s.session.currentUrl;
const refsCount = int(body.refsCount) ?? new Set(text.match(/\be\d+\b/g) ?? []).size;
return {
sessionId: s.session.id,
tabId: tab.upstreamTabId,
url: red.url,
format: "a11y_text",
text: red.text,
structure: red.structure,
refsCount,
truncated,
nextOffset: truncated ? (int(body.nextOffset) ?? null) : null,
redactions: red.redactions,
capturedAt: at,
};
});
}
async screenshot(ref: TabRef, opts: { maxBytes?: number } = {}): Promise<BrowserScreenshot> {
const s = this.state(ref.sessionId);
return this.withProfile(s, async () => {
const tab = this.tabOf(s, ref);
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/screenshot`;
const res = await this.request("GET", path, {
query: { userId: s.session.profileKey },
timeoutMs: s.budget.timeoutMs,
});
const maxBytes = opts.maxBytes ?? s.budget.maxBytes ?? this.cfg.defaultMaxBytes ?? 8 * 1024 * 1024;
const declared = Number(res.headers.get("content-length") ?? "NaN");
if (Number.isFinite(declared) && declared > maxBytes * 1.4) {
await res.body?.cancel();
throw new BrowserError("budget_exceeded", `截图超出字节预算(${maxBytes})`);
}
const ctype = res.headers.get("content-type") ?? "";
let data: Uint8Array;
let mimeType: string;
if (ctype.startsWith("image/")) {
// 部分版本/fork 直接返回二进制图片(待核验)
data = new Uint8Array(await res.arrayBuffer());
mimeType = ctype.split(";")[0]!.trim();
} else {
// OpenAPI 1.17.0:{ screenshot: { data: base64, mimeType } }
const body = await this.json(res, path);
const shot = (body.screenshot && typeof body.screenshot === "object" ? body.screenshot : body) as Json;
const b64 = str(shot.data);
if (!b64) throw new BrowserError("protocol", "camofox 截图响应缺少 data");
try {
data = Uint8Array.from(atob(b64.replace(/^data:[^,]*,/, "")), (c) => c.charCodeAt(0));
} catch {
throw new BrowserError("protocol", "camofox 截图不是合法 base64");
}
mimeType = str(shot.mimeType) ?? "image/png";
}
if (data.byteLength > maxBytes) throw new BrowserError("budget_exceeded", `截图超出字节预算(${maxBytes})`);
this.touch(s);
const raw = s.rawUrl;
let loginPage = false;
try {
loginPage = raw !== null && LOGIN_PATH.test(new URL(raw).pathname);
} catch {
/* ignore */
}
return {
mimeType,
data,
url: raw === null ? null : redactBrowserUrl(raw),
modelSafe: raw !== null && !urlHasCredential(raw) && !loginPage,
capturedAt: s.session.lastActivityAt,
};
});
}
// ---------- M2 ----------
async click(_ref: TabRef, _target: ElementTarget): Promise<void> {
throw notImplemented("click");
}
async type(_ref: TabRef, _target: ElementTarget, _text: TypeInput): Promise<void> {
throw notImplemented("type");
}
async extract(_ref: TabRef, _schema: Record<string, unknown>): Promise<Record<string, unknown>> {
throw notImplemented("extract");
}
async waitForDownload(_ref: TabRef, _opts: { timeoutMs: number; maxBytes: number }): Promise<DownloadArtifact> {
throw notImplemented("waitForDownload");
}
async acquireControl(_req: AcquireControlRequest): Promise<BrowserControlLease> {
throw notImplemented("acquireControl");
}
async releaseControl(_leaseId: string, _reason?: string): Promise<void> {
throw notImplemented("releaseControl");
}
}
export function createCamofoxBrowserProvider(cfg: CamofoxConfig): CamofoxBrowserProvider {
return new CamofoxBrowserProvider(cfg);
}