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

+26
View File
@@ -0,0 +1,26 @@
# browser
`BrowserProvider` 合同(`provider.ts`)及其实现:业务侧访问浏览器服务只走这套接口。产品浏览器能力对接用户已有的 camofox-browser;**Playwright 只用于前端截图测试,不是产品浏览器能力**。设计说明见 `docs/design/browser-provider.md`。
- `camofox/`:camofox-browser 适配器。按 jo-inc/camofox-browser master `openapi.json`(1.17.0)与 README 实现,`Authorization: Bearer <CAMOFOX_ACCESS_KEY>`,凭据每次请求经 `BrowserSecretResolver` 解出,不缓存、不入日志。camofox `userId` = 本项目 `profileKey`(站点/账号隔离),`sessionKey` = `browser_session.id`。
- `none.ts`:默认实现(未配置浏览器),能力全关,动作报 `unavailable`。
- `redact.ts`:`redactSnapshot` / `redactBrowserUrl`,快照与地址进入模型前遮蔽密码框值、Cookie 值、含 passkey/token 的 URL。
- `profile-lock.ts`:同一 profile 串行锁(进程内内存实现;多进程部署换持久化实现,接口不变)。
- `budget.ts`:导航域名白名单(含子域),非 http(s) 拒绝。
## v0.1 已实现
`health`(`/health`,免认证,503 → degraded)、`capabilities`(静态声明;`probe: true` 读上游 `/openapi.json` 推导,缺端点即降级)、`openSession`(`POST /tabs`)、`navigate`(`POST /tabs/{id}/navigate`,预算内重试 5xx/超时/网络错误)、`snapshot`(`GET /tabs/{id}/snapshot?format=text`,字节预算截断 + 脱敏)、`screenshot`(`GET /tabs/{id}/screenshot`,JSON base64 或二进制图片;登录页/含凭据地址标 `modelSafe: false`)。
## M2(当前抛 `BrowserError('not_implemented')`)
| 方法 | 上游候选端点 | 待办 |
| --- | --- | --- |
| `closeSession` | `DELETE /sessions/{userId}` | 与 browser_session 持久化、租约释放一起落地 |
| `listSessions` | `GET /tabs?userId=` | 上游只列标签页,会话清单以 DB 为准,需对账 |
| `click` / `type` | `POST /tabs/{id}/click` / `type` / `select` | 接凭证代理(`TypeInput.kind = 'secret'`);README 示例 `pressEnter` 与 OpenAPI `submit` 字段名不一致,需核验 |
| `extract` | `POST /tabs/{id}/extract`(JSON Schema + `x-ref`) | 需先 snapshot 建 ref 表 |
| `waitForDownload` | `GET /tabs/{id}/downloads?includeData&consume&maxBytes` | 产物落 blob 暂存 + sha256,交后端核验 |
| `acquireControl` / `releaseControl` | 无(上游无接管 API) | 服务层以 `browser_control_lease` 持久化;画面/输入通道待 VNC 原型 |
仅对 mock 与上游文档验证,未连接真实实例。
@@ -0,0 +1,348 @@
import type { NavigationBudget } from "@umt/contracts";
import {
CAMOFOX_FIXTURE_COOKIE,
CAMOFOX_FIXTURE_PASSKEY,
CAMOFOX_FIXTURE_PASSWORD,
createMockCamofox,
type MockCamofox,
} from "@umt/testkit";
import { afterEach, describe, expect, it } from "vitest";
import {
BrowserError,
type BrowserLogger,
type CamofoxConfig,
camofoxStaticCapabilities,
classifyHttpStatus,
createCamofoxBrowserProvider,
createNoneBrowserProvider,
hostAllowed,
InMemoryProfileLock,
profileKeyFor,
redactBrowserUrl,
redactSnapshot,
staticBrowserSecrets,
} from "./index.ts";
const ACCESS_KEY = "camofox-access-key-fixture";
const PROVIDER = "11111111-1111-4111-8111-111111111111";
const SITE = "22222222-2222-4222-8222-222222222222";
const ACCOUNT = "33333333-3333-4333-8333-333333333333";
const budget = (over: Partial<NavigationBudget> = {}): NavigationBudget => ({
domainAllowlist: ["audiences.example"],
timeoutMs: 500,
maxRetries: 1,
maxBytes: 1_000_000,
...over,
});
let mocks: MockCamofox[] = [];
afterEach(async () => {
await Promise.all(mocks.map((m) => m.close()));
mocks = [];
});
async function setup(
opts: { accessKey?: string; key?: string | null; omitPaths?: string[] } = {},
extra: Partial<CamofoxConfig> = {},
) {
const mock = await createMockCamofox({
...(opts.accessKey ? { accessKey: opts.accessKey } : {}),
...(opts.omitPaths ? { omitPaths: opts.omitPaths } : {}),
});
mocks.push(mock);
const logs: Parameters<BrowserLogger>[0][] = [];
const key = opts.key === undefined ? opts.accessKey : opts.key;
const provider = createCamofoxBrowserProvider({
providerId: PROVIDER,
baseUrl: `${mock.url}/`,
secrets: staticBrowserSecrets(key ? { [`provider:${PROVIDER}`]: key } : {}),
timeoutMs: 500,
logger: (e) => logs.push(e),
...extra,
});
return { mock, provider, logs };
}
const open = (p: Awaited<ReturnType<typeof setup>>["provider"], url?: string) =>
p.openSession({
providerId: PROVIDER,
siteId: SITE,
accountId: ACCOUNT,
budget: budget(),
...(url ? { initialUrl: url } : {}),
});
async function rejectKind(p: Promise<unknown>): Promise<string> {
try {
await p;
} catch (e) {
expect(e).toBeInstanceOf(BrowserError);
return (e as BrowserError).kind;
}
throw new Error("预期抛错");
}
describe("能力声明", () => {
it("静态声明:已实现能力 supported、M2 能力 false 并注明,默认全部未验证", () => {
const c = camofoxStaticCapabilities({ snapshot: true, interact: true });
expect(c.navigate).toMatchObject({ supported: true, verified: false, source: "static" });
expect(c.snapshot).toMatchObject({ supported: true, verified: true });
// 未实现的能力即使配置了 verified 也不会被标为已验证
expect(c.interact).toMatchObject({ supported: false, verified: false });
expect(c.interact.note).toContain("M2");
expect(c.takeover.supported).toBe(false);
expect(c.upstreamVersion).toBeNull();
});
it("探测 /openapi.json:读取上游版本,缺端点的能力降级", async () => {
const { provider } = await setup({ omitPaths: ["get /tabs/{tabId}/snapshot"] });
const c = await provider.capabilities({ probe: true });
expect(c.upstreamVersion).toBe("1.17.0");
expect(c.probedAt).not.toBeNull();
expect(c.navigate).toMatchObject({ supported: true, source: "probed", verified: false });
expect(c.snapshot).toMatchObject({ supported: false, source: "probed" });
expect(c.snapshot.note).toContain("/tabs/{tabId}/snapshot");
});
it("none 实现:能力全关,动作报 unavailable", async () => {
const none = createNoneBrowserProvider();
const c = await none.capabilities();
expect(Object.values(c).filter((v) => v && typeof v === "object" && "supported" in v && v.supported)).toEqual([]);
expect((await none.health()).status).toBe("disabled");
expect(await rejectKind(none.navigate({ sessionId: "x" }, "https://a.example", budget()))).toBe("unavailable");
});
});
describe("认证与健康", () => {
it("每个请求带 Bearer 访问凭据;凭据不进日志与错误信息", async () => {
const { mock, provider, logs } = await setup({ accessKey: ACCESS_KEY });
await open(provider, "https://audiences.example/torrents.php");
const call = mock.callsTo("/tabs")[0]!;
expect(call.headers.authorization).toBe(`Bearer ${ACCESS_KEY}`);
expect(JSON.stringify(logs)).not.toContain(ACCESS_KEY);
expect(logs.every((l) => !l.path?.includes("?"))).toBe(true);
});
it("凭据缺失/错误 → auth,不重试", async () => {
const { mock, provider } = await setup({ accessKey: ACCESS_KEY, key: "wrong-key" });
try {
await open(provider);
throw new Error("预期抛错");
} catch (e) {
expect(e).toMatchObject({ kind: "auth", httpStatus: 401 });
expect(String((e as Error).message)).not.toContain("wrong-key");
}
expect(mock.callsTo("/tabs")).toHaveLength(1);
});
it("health:/health 免认证;503 → degraded;连不上 → down", async () => {
const { mock, provider } = await setup({ accessKey: ACCESS_KEY, key: null });
const ok = await provider.health();
expect(ok.status).toBe("ok");
expect(ok.detail).toMatchObject({ engine: "camoufox", browserConnected: true });
mock.fault("/health", { status: 503, body: { ok: false, recovering: true } });
expect((await provider.health()).status).toBe("degraded");
await mock.close();
mocks = [];
expect((await provider.health()).status).toBe("down");
});
});
describe("会话、导航与快照", () => {
it("openSession 以 profileKey 作 camofox userId,会话 id 作 sessionKey", async () => {
const { mock, provider } = await setup();
const r = await open(provider, "https://audiences.example/torrents.php");
expect(r.session.profileKey).toBe(profileKeyFor(SITE, ACCOUNT));
expect(r.session.controller).toBe("automation");
expect(r.tab.upstreamTabId).toBe("tab-1");
expect(r.navigation).toMatchObject({ httpStatus: 200, ok: true });
expect([...mock.tabs.values()][0]).toMatchObject({ userId: r.session.profileKey, sessionKey: r.session.id });
// 同一 profile 不能并存两个未关闭会话
expect(await rejectKind(open(provider))).toBe("control_conflict");
});
it("快照进入模型前脱敏:密码值、Cookie 值、passkey URL 均被遮蔽", async () => {
const { provider } = await setup();
const { session } = await open(provider, "https://audiences.example/torrents.php");
await provider.navigate(
{ sessionId: session.id },
`https://audiences.example/details.php?id=42&passkey=${CAMOFOX_FIXTURE_PASSKEY}`,
budget(),
);
const snap = await provider.snapshot({ sessionId: session.id });
const dump = JSON.stringify(snap);
for (const secret of [CAMOFOX_FIXTURE_PASSWORD, CAMOFOX_FIXTURE_COOKIE, CAMOFOX_FIXTURE_PASSKEY])
expect(dump).not.toContain(secret);
expect(snap.text).toContain("奥本海默");
expect(snap.text).toContain("[button e6] 登录");
expect(snap.url).toBe("https://audiences.example/details.php?id=42&passkey=[REDACTED]");
const kinds = Object.fromEntries(snap.redactions.map((r) => [r.kind, r.count]));
expect(kinds.password_field).toBeGreaterThanOrEqual(2);
expect(kinds.cookie).toBeGreaterThanOrEqual(1);
expect(kinds.credential_url).toBeGreaterThanOrEqual(2);
expect(snap.refsCount).toBe(7);
});
it("redactSnapshot:HTML 密码框、URL userinfo、路径 passkey、YAML 形式;普通文本不动", () => {
const r = redactSnapshot({
text: [
'<input name="pw" type="password" value="s3cret-html">',
"https://user:[email protected]/announce/passkey/abcdef0123456789",
'- textbox "Password": yaml-secret',
"做种 128 · 大小 58.2 GB",
].join("\n"),
url: "https://audiences.example/rss.php?rsskey=deadbeefdeadbeef&cat=401",
});
expect(r.text).not.toMatch(/s3cret-html|pw123|abcdef0123456789|yaml-secret/);
expect(r.text).toContain("做种 128 · 大小 58.2 GB");
expect(r.url).toBe("https://audiences.example/rss.php?rsskey=[REDACTED]&cat=401");
expect(redactBrowserUrl("https://a.example/x?id=1")).toBe("https://a.example/x?id=1");
// 幂等:已脱敏的文本再过一遍不变
expect(redactSnapshot({ text: r.text, url: r.url }).text).toBe(r.text);
});
it("导航预算:白名单外直接拒绝不发请求;重定向出白名单报 domain_blocked", async () => {
const { mock, provider } = await setup();
const { session } = await open(provider);
expect(await rejectKind(provider.navigate({ sessionId: session.id }, "https://evil.example/", budget()))).toBe(
"domain_blocked",
);
expect(await rejectKind(provider.navigate({ sessionId: session.id }, "file:///etc/passwd", budget()))).toBe(
"domain_blocked",
);
expect(mock.callsTo(/navigate$/)).toHaveLength(0);
const redirect = `https://audiences.example/jump?redirect=${encodeURIComponent("https://other.example/login")}`;
expect(await rejectKind(provider.navigate({ sessionId: session.id }, redirect, budget()))).toBe("domain_blocked");
expect(hostAllowed("cdn.audiences.example", ["audiences.example"])).toBe(true);
expect(hostAllowed("audiences.example.evil", ["audiences.example"])).toBe(false);
expect(hostAllowed("a.example", [])).toBe(false);
});
it("截图:解码 1x1 PNG;登录页不可进模型;超字节预算报 budget_exceeded", async () => {
const { provider } = await setup();
const { session } = await open(provider, "https://audiences.example/torrents.php");
const shot = await provider.screenshot({ sessionId: session.id });
expect(shot.mimeType).toBe("image/png");
expect([...shot.data.slice(1, 4)].map((c) => String.fromCharCode(c)).join("")).toBe("PNG");
expect(shot.modelSafe).toBe(true);
await provider.navigate({ sessionId: session.id }, "https://audiences.example/login.php", budget());
expect((await provider.screenshot({ sessionId: session.id })).modelSafe).toBe(false);
expect(await rejectKind(provider.screenshot({ sessionId: session.id }, { maxBytes: 10 }))).toBe("budget_exceeded");
});
it("人工接管期间拒绝自动化导航", async () => {
const { provider } = await setup();
const { session } = await open(provider);
provider.setController(session.id, "human");
expect(await rejectKind(provider.navigate({ sessionId: session.id }, "https://audiences.example/", budget()))).toBe(
"control_conflict",
);
});
});
describe("串行锁", () => {
it("同一 profile 严格串行、不同 profile 并行;等待超时报 lock_timeout", async () => {
const lock = new InMemoryProfileLock();
const order: string[] = [];
const task = (key: string, name: string, ms: number) =>
lock.run(key, async () => {
order.push(`${name}:start`);
await new Promise((r) => setTimeout(r, ms));
order.push(`${name}:end`);
});
await Promise.all([task("p1", "a", 20), task("p1", "b", 1), task("p2", "c", 1)]);
expect(order.indexOf("a:end")).toBeLessThan(order.indexOf("b:start"));
expect(order.indexOf("c:start")).toBeLessThan(order.indexOf("a:end"));
expect(lock.size()).toBe(0);
const slow = lock.run("p1", () => new Promise((r) => setTimeout(r, 60)));
try {
await lock.run("p1", async () => "never", { waitMs: 10 });
throw new Error("预期超时");
} catch (e) {
expect(e).toMatchObject({ kind: "lock_timeout" });
}
await slow;
expect(lock.size()).toBe(0);
});
it("适配器对同一会话的并发请求逐个发往上游", async () => {
const { mock, provider } = await setup();
const { session } = await open(provider, "https://audiences.example/torrents.php");
mock.fault(/snapshot$/, { delayMs: 40 });
const started = Date.now();
await Promise.all([provider.snapshot({ sessionId: session.id }), provider.screenshot({ sessionId: session.id })]);
const shotCall = mock.callsTo(/screenshot$/)[0]!;
expect(shotCall.status).toBe(200);
expect(Date.now() - started).toBeGreaterThanOrEqual(40);
});
});
describe("M2 未实现分支", () => {
it("click/type/extract/waitForDownload/接管/关闭会话均抛 not_implemented", async () => {
const { provider } = await setup();
const ref = { sessionId: "x" };
const calls: Promise<unknown>[] = [
provider.click(ref, { ref: "e1" }),
provider.type(ref, { ref: "e1" }, { kind: "text", text: "x" }),
provider.extract(ref, {}),
provider.waitForDownload(ref, { timeoutMs: 10, maxBytes: 10 }),
provider.acquireControl({
sessionId: "x",
holderKind: "human",
holder: "admin",
reason: "登录验证",
ttlMs: 1000,
}),
provider.releaseControl("lease"),
provider.closeSession("x"),
provider.listSessions(),
];
for (const c of calls) expect(await rejectKind(c)).toBe("not_implemented");
});
});
describe("错误分类", () => {
it("HTTP 状态映射", () => {
expect(classifyHttpStatus(401)).toBe("auth");
expect(classifyHttpStatus(403)).toBe("auth");
expect(classifyHttpStatus(404)).toBe("not_found");
expect(classifyHttpStatus(409)).toBe("bad_request");
expect(classifyHttpStatus(429)).toBe("rate_limited");
expect(classifyHttpStatus(500)).toBe("upstream");
expect(classifyHttpStatus(503)).toBe("unavailable");
expect(new BrowserError("upstream").retryable).toBe(true);
expect(new BrowserError("auth").retryable).toBe(false);
});
it("上游 5xx 在 maxRetries 内重试;超时分类为 timeout;429 不重试", async () => {
const { mock, provider } = await setup();
const { session } = await open(provider);
const ref = { sessionId: session.id };
mock.fault(/navigate$/, { status: 502, times: 1 });
const r = await provider.navigate(ref, "https://audiences.example/torrents.php", budget({ maxRetries: 1 }));
expect(r.attempts).toBe(2);
mock.fault(/navigate$/, { delayMs: 200, times: 5 });
expect(
await rejectKind(provider.navigate(ref, "https://audiences.example/", budget({ timeoutMs: 50, maxRetries: 0 }))),
).toBe("timeout");
mock.clearFaults();
mock.fault(/navigate$/, { status: 429, times: 5 });
const before = mock.callsTo(/navigate$/).length;
expect(await rejectKind(provider.navigate(ref, "https://audiences.example/", budget({ maxRetries: 3 })))).toBe(
"rate_limited",
);
expect(mock.callsTo(/navigate$/).length - before).toBe(1);
});
it("未知会话 → not_found;上游返回非 JSON → protocol", async () => {
const { mock, provider } = await setup();
expect(await rejectKind(provider.snapshot({ sessionId: "nope" }))).toBe("not_found");
const { session } = await open(provider);
mock.fault(/snapshot$/, { status: 200, body: undefined });
expect(await rejectKind(provider.snapshot({ sessionId: session.id }))).toBe("protocol");
});
});
+27
View File
@@ -0,0 +1,27 @@
import type { NavigationBudget } from "@umt/contracts";
import { BrowserError } from "./errors.ts";
import { redactBrowserUrl } from "./redact.ts";
/** 主机是否在白名单内:精确匹配或为白名单项的子域。空白名单 = 全部拒绝。 */
export function hostAllowed(host: string, allowlist: readonly string[]): boolean {
const h = host.toLowerCase().replace(/\.$/, "");
return allowlist.some((raw) => {
const d = raw.toLowerCase().replace(/^\*\./, "").replace(/\.$/, "");
return d !== "" && (h === d || h.endsWith(`.${d}`));
});
}
/** 导航前检查:只允许 http(s) 且主机在白名单内;违规抛 domain_blocked(信息里只含脱敏 URL) */
export function assertNavigable(url: string, budget: NavigationBudget): URL {
let u: URL;
try {
u = new URL(url);
} catch {
throw new BrowserError("bad_request", "非法 URL");
}
if (u.protocol !== "http:" && u.protocol !== "https:")
throw new BrowserError("domain_blocked", `不允许的协议 ${u.protocol}`);
if (!hostAllowed(u.hostname, budget.domainAllowlist))
throw new BrowserError("domain_blocked", `域名不在白名单:${redactBrowserUrl(u.origin)}`);
return u;
}
@@ -0,0 +1,95 @@
import type { BrowserCapabilities, BrowserCapabilityFlag, BrowserCapabilityKey } from "@umt/contracts";
/**
* camofox-browser 能力声明。依据:jo-inc/camofox-browser master 的 openapi.json(info.version 1.17.0)与 README,
* 2026-09-27 取得;用户实际部署可能是其他 fork/版本,运行时以 /openapi.json 探测结果为准(REQ §4.7.4)。
*
* supported = 本适配器当前能对该上游提供的能力(上游有端点但适配器尚未实现的记 false 并注明 M2);
* verified 只能由真实实例原型验证结果置 true(配置注入),探测永远不会把它设为 true。
*/
/** 每项能力依赖的上游端点(OpenAPI paths 键,小写方法) */
export const CAMOFOX_ENDPOINTS: Record<BrowserCapabilityKey, readonly string[]> = {
sessions: ["post /tabs", "delete /sessions/{userId}"],
tabs: ["post /tabs", "get /tabs", "delete /tabs/{tabId}"],
navigate: ["post /tabs/{tabId}/navigate"],
snapshot: ["get /tabs/{tabId}/snapshot"],
screenshot: ["get /tabs/{tabId}/screenshot"],
interact: ["post /tabs/{tabId}/click", "post /tabs/{tabId}/type", "post /tabs/{tabId}/select"],
extract: ["post /tabs/{tabId}/extract"],
downloadCapture: ["get /tabs/{tabId}/downloads"],
// 上游 OpenAPI 无接管端点;VNC 插件(ENABLE_VNC,noVNC 独立端口)是整块虚拟显示,需原型验证
takeover: [],
// 持久化插件默认开启,无需端点;storage_state 端点属插件,不在 OpenAPI 中
persistentProfile: [],
};
/** 适配器已实现的能力;其余上游虽有端点,适配器在 M2 实现 */
const ADAPTER_IMPLEMENTED: ReadonlySet<BrowserCapabilityKey> = new Set([
"sessions",
"tabs",
"navigate",
"snapshot",
"screenshot",
"persistentProfile",
]);
const STATIC_NOTES: Partial<Record<BrowserCapabilityKey, string>> = {
sessions: "上游无显式建会话端点:userId 即会话(BrowserContext),首个 POST /tabs 隐式创建;关闭会话适配器 M2",
tabs: "仅建首个标签页;列举/关闭适配器 M2",
snapshot: "可访问性树文本 + eN 元素引用,支持 offset 分页",
interact: "上游有 click/type/select;适配器 M2",
extract: "上游 POST /extract 需先 snapshot 建 ref 表(x-ref);适配器 M2",
downloadCapture: "上游 GET /downloads 支持 includeData/consume/maxBytes;适配器 M2",
takeover: "上游无接管 API;VNC 插件画面/输入与同会话连续操作待原型验证",
persistentProfile: "上游持久化插件默认保存 cookies+localStorage(~/.camofox/profiles),重启恢复待验证",
};
export type VerifiedMap = Partial<Record<BrowserCapabilityKey, boolean>>;
const KEYS = Object.keys(CAMOFOX_ENDPOINTS) as BrowserCapabilityKey[];
function flag(
key: BrowserCapabilityKey,
supported: boolean,
source: "static" | "probed",
verified: VerifiedMap,
note?: string,
): BrowserCapabilityFlag {
const n = note ?? STATIC_NOTES[key];
return { supported, verified: supported && verified[key] === true, source, ...(n ? { note: n } : {}) };
}
export function camofoxStaticCapabilities(verified: VerifiedMap = {}): BrowserCapabilities {
const flags = Object.fromEntries(
KEYS.map((k) => [k, flag(k, ADAPTER_IMPLEMENTED.has(k), "static", verified)]),
) as Record<BrowserCapabilityKey, BrowserCapabilityFlag>;
return { ...flags, upstreamVersion: null, probedAt: null };
}
/** 从上游 openapi.json 文档推导能力:缺端点的能力降为 false 并注明 */
export function camofoxCapabilitiesFromOpenApi(
doc: unknown,
verified: VerifiedMap = {},
now = new Date(),
): BrowserCapabilities {
const d = (doc ?? {}) as { info?: { version?: unknown }; paths?: Record<string, Record<string, unknown>> };
const have = new Set<string>();
for (const [p, ops] of Object.entries(d.paths ?? {}))
for (const m of Object.keys(ops ?? {})) have.add(`${m.toLowerCase()} ${p}`);
// 路径参数名不同的 fork 也视为同一端点
const norm = (s: string) => s.replace(/\{[^}]+\}/g, "{}");
const haveNorm = new Set([...have].map(norm));
const flags = Object.fromEntries(
KEYS.map((k) => {
const missing = CAMOFOX_ENDPOINTS[k].filter((e) => !haveNorm.has(norm(e)));
if (missing.length > 0) return [k, flag(k, false, "probed", verified, `上游缺少端点:${missing.join(", ")}`)];
return [k, flag(k, ADAPTER_IMPLEMENTED.has(k), CAMOFOX_ENDPOINTS[k].length > 0 ? "probed" : "static", verified)];
}),
) as Record<BrowserCapabilityKey, BrowserCapabilityFlag>;
return {
...flags,
upstreamVersion: typeof d.info?.version === "string" ? d.info.version : null,
probedAt: now.toISOString(),
};
}
@@ -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);
}
@@ -0,0 +1,2 @@
export * from "./capabilities.ts";
export * from "./client.ts";
+36
View File
@@ -0,0 +1,36 @@
import type { BrowserErrorKind } from "@umt/contracts";
/**
* 浏览器适配层统一错误。message 只含方法、路径与状态,不含访问凭据、Cookie、页面正文。
* retryable:同一预算内是否值得重试(超时/网络/上游 5xx/不可用);认证、预算、控制权冲突一律不重试。
*/
export class BrowserError extends Error {
readonly kind: BrowserErrorKind;
readonly httpStatus?: number;
constructor(kind: BrowserErrorKind, message?: string, opts: { httpStatus?: number; cause?: unknown } = {}) {
super(message ?? kind, opts.cause === undefined ? undefined : { cause: opts.cause });
this.name = "BrowserError";
this.kind = kind;
if (opts.httpStatus !== undefined) this.httpStatus = opts.httpStatus;
}
get retryable(): boolean {
return (
this.kind === "timeout" || this.kind === "network" || this.kind === "upstream" || this.kind === "unavailable"
);
}
}
export const notImplemented = (method: string): BrowserError =>
new BrowserError("not_implemented", `${method} 未实现(M2 原型验证后启用)`);
/** HTTP 状态 → 错误种类(camofox OpenAPI 1.17.0 的响应码:400/401/403/404/409/422/429/500/503) */
export function classifyHttpStatus(status: number): BrowserErrorKind {
if (status === 401 || status === 403) return "auth";
if (status === 404) return "not_found";
if (status === 408) return "timeout";
if (status === 429) return "rate_limited";
if (status === 503) return "unavailable";
if (status >= 500) return "upstream";
if (status >= 400) return "bad_request";
return "protocol";
}
+7
View File
@@ -0,0 +1,7 @@
export * from "./budget.ts";
export * from "./camofox/index.ts";
export * from "./errors.ts";
export * from "./none.ts";
export * from "./profile-lock.ts";
export * from "./provider.ts";
export * from "./redact.ts";
+46
View File
@@ -0,0 +1,46 @@
import type { BrowserCapabilities, BrowserCapabilityFlag, BrowserHealth } from "@umt/contracts";
import { BROWSER_CAPABILITY_KEYS } from "@umt/contracts";
import { BrowserError } from "./errors.ts";
import type { BrowserProvider } from "./provider.ts";
const off: BrowserCapabilityFlag = { supported: false, verified: false, source: "static", note: "未配置浏览器服务" };
export function noneCapabilities(): BrowserCapabilities {
const flags = Object.fromEntries(BROWSER_CAPABILITY_KEYS.map((k) => [k, { ...off }])) as Record<
(typeof BROWSER_CAPABILITY_KEYS)[number],
BrowserCapabilityFlag
>;
return { ...flags, upstreamVersion: null, probedAt: null };
}
const unavailable = () => Promise.reject(new BrowserError("unavailable", "未配置浏览器服务"));
/**
* 默认实现:未配置浏览器服务。所有动作返回 unavailable,能力全部 false;
* 业务侧据此在界面标注"浏览器不可用",不回退到其他浏览器。
*/
export function createNoneBrowserProvider(): BrowserProvider {
return {
kind: "none",
async health(): Promise<BrowserHealth> {
return { status: "disabled", kind: "none", detail: {}, checkedAt: new Date().toISOString() };
},
async capabilities() {
return noneCapabilities();
},
openSession: unavailable,
closeSession: unavailable,
async listSessions() {
return [];
},
navigate: unavailable,
snapshot: unavailable,
screenshot: unavailable,
click: unavailable,
type: unavailable,
extract: unavailable,
waitForDownload: unavailable,
acquireControl: unavailable,
releaseControl: unavailable,
};
}
@@ -0,0 +1,66 @@
import { BrowserError } from "./errors.ts";
/**
* 同一 profile(站点/账号)的浏览器操作串行(REQ §4.4)。
* 内存实现只在单进程内有效;多进程/多实例部署需换成持久化实现(PG advisory lock 或
* browser_control_lease 表),接口保持不变。
*/
export interface ProfileLock {
/** 在持有 key 锁期间执行 fn;等待超过 waitMs 抛 BrowserError('lock_timeout') */
run<T>(key: string, fn: () => Promise<T>, opts?: { waitMs?: number }): Promise<T>;
/** 当前持有或排队中的 key 数量(诊断用) */
size(): number;
}
interface Waiter {
resolve: () => void;
reject: (e: unknown) => void;
timer?: ReturnType<typeof setTimeout>;
}
export class InMemoryProfileLock implements ProfileLock {
private readonly held = new Map<string, Waiter[]>();
constructor(private readonly defaultWaitMs = 30_000) {}
size(): number {
return this.held.size;
}
async run<T>(key: string, fn: () => Promise<T>, opts: { waitMs?: number } = {}): Promise<T> {
await this.acquire(key, opts.waitMs ?? this.defaultWaitMs);
try {
return await fn();
} finally {
this.release(key);
}
}
private acquire(key: string, waitMs: number): Promise<void> {
const queue = this.held.get(key);
if (!queue) {
this.held.set(key, []);
return Promise.resolve();
}
return new Promise<void>((resolve, reject) => {
const w: Waiter = { resolve, reject };
w.timer = setTimeout(() => {
const i = queue.indexOf(w);
if (i >= 0) queue.splice(i, 1);
reject(new BrowserError("lock_timeout", `profile 锁等待超时(${waitMs}ms)`));
}, waitMs);
queue.push(w);
});
}
private release(key: string): void {
const queue = this.held.get(key);
if (!queue) return;
const next = queue.shift();
if (!next) {
this.held.delete(key);
return;
}
if (next.timer) clearTimeout(next.timer);
next.resolve();
}
}
+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;
+181
View File
@@ -0,0 +1,181 @@
import type { SnapshotRedactionKind } from "@umt/contracts";
import { REDACTED } from "../rss/redact.ts";
/**
* 快照/截图进入模型前的脱敏钩子(REQ §4.4、§4.7.3)。
* 覆盖:密码框的值、Cookie 值、URL 中的 passkey/token 等凭据参数。
* camofox 可访问性快照的精确文本格式("[role eN] name ...")未在真实实例核验,
* 这里同时处理 a11y 文本、HTML 片段与 structure JSON,宁可多遮不可漏。
*/
/** URL 查询参数/路径段里的凭据键(PT 站 passkey、RSS key、下载签名等) */
const CREDENTIAL_PARAM =
/([?&;](?:passkey|authkey|torrent_pass|rsskey|downhash|credential|token|access_token|api_key|apikey|secret|sign|signature|key)=)([^&#\s"'<>)\]]+)/gi;
/** 路径形式的 passkey:/passkey/<hex> 或 /rss/<32+ hex> */
const CREDENTIAL_PATH = /(\/(?:passkey|rsskey|torrent_pass)\/)([A-Za-z0-9]{8,})/gi;
/** 常见站点登录/会话 Cookie 名 */
const COOKIE_PAIR =
/\b(c_secure_[a-z_]+|PHPSESSID|cf_clearance|__cf_bm|sessionid|session_id|laravel_session|XSRF-TOKEN|auth_token|remember_[a-z0-9_]+|tp|uid|pass)=([^;\s"'<>]+)/gi;
/** "Cookie: ..." / "set-cookie: ..." 整行 */
const COOKIE_HEADER = /\b((?:set-)?cookie\s*[:=]\s*)([^\n]+)/gi;
/** HTML:type=password 的 input 上的 value */
const HTML_PASSWORD_INPUT = /<input\b[^>]*\btype\s*=\s*["']?password["']?[^>]*>/gi;
const HTML_VALUE_ATTR = /(\bvalue\s*=\s*)("[^"]*"|'[^']*'|[^\s>]+)/i;
/**
* a11y 文本中名称像密码/凭据的元素,遮蔽其后的取值。
* 形如 `[textbox e3] Password: hunter2`、`[textbox e3 password] 密码 "hunter2"`、`- textbox "Password": hunter2`。
*/
const A11Y_SECRET_FIELD =
/(\[[a-z]+ e\d+[^\]]*\][^\n[]*?(?:password|passwd|密码|passkey|2fa|totp|验证码|otp)[^\n[:"]*?(?::\s*|\s+))("[^"\n]*"|[^\s[\n][^[\n]*?)(?=\s{2,}|\s*\[|\n|$)/gi;
const A11Y_TYPED_PASSWORD =
/(\[[a-z]+ e\d+[^\]]*\bpassword\b[^\]]*\](?:[^\n[:"]*:)?\s*)("[^"\n]*"|[^\s[\n][^[\n]*?)(?=\s{2,}|\s*\[|\n|$)/gi;
/(\[[a-z]+ e\d+[^\]]*\bpassword\b[^\]]*\][^\n[]*?(?::\s*|\s+))("[^"\n]*"|[^\s[\n][^[\n]*?)(?=\s{2,}|\s*\[|\n|$)/gi;
const YAML_SECRET_FIELD = /(-\s*textbox\s+"[^"\n]*(?:password|passwd|密码|passkey)[^"\n]*"\s*:\s*)(\S[^\n]*)/gi;
export interface RedactionReport {
kind: SnapshotRedactionKind;
count: number;
}
class Counter {
private readonly m = new Map<SnapshotRedactionKind, number>();
add(kind: SnapshotRedactionKind, n = 1) {
this.m.set(kind, (this.m.get(kind) ?? 0) + n);
}
list(): RedactionReport[] {
return [...this.m].filter(([, n]) => n > 0).map(([kind, count]) => ({ kind, count }));
}
}
function replaceCounting(s: string, re: RegExp, fn: (...m: string[]) => string, onHit: () => void): string {
return s.replace(re, (...args: unknown[]) => {
const groups = args.filter((a): a is string => typeof a === "string");
const out = fn(...groups);
if (out !== groups[0]) onHit();
return out;
});
}
const USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)([^/\s@:]+(?::[^/\s@]*)?)@/gi;
function redactUrlsIn(s: string, c: Counter): string {
let out = replaceCounting(
s,
USERINFO,
(_a, scheme) => scheme,
() => c.add("credential_url"),
);
out = replaceCounting(
out,
CREDENTIAL_PARAM,
(all, k, v) => (v.startsWith("[REDACTED") ? all : `${k}${REDACTED}`),
() => c.add("credential_url"),
);
out = replaceCounting(
out,
CREDENTIAL_PATH,
(_all, k) => `${k}${REDACTED}`,
() => c.add("credential_url"),
);
return out;
}
/** 单独脱敏一个 URL(日志、导航结果、会话 currentUrl 都用它) */
export function redactBrowserUrl(url: string): string {
return redactUrlsIn(url, new Counter());
}
/** URL 是否带凭据(用于判定截图能否进入模型) */
export function urlHasCredential(url: string): boolean {
return redactBrowserUrl(url) !== url;
}
function redactText(s: string, c: Counter, extra: RegExp[]): string {
let out = redactUrlsIn(s, c);
out = replaceCounting(
out,
COOKIE_HEADER,
(_a, k) => `${k}${REDACTED}`,
() => c.add("cookie"),
);
out = replaceCounting(
out,
COOKIE_PAIR,
(a, k, v) => (v === REDACTED ? a : `${k}=${REDACTED}`),
() => c.add("cookie"),
);
out = out.replace(HTML_PASSWORD_INPUT, (tag) => {
if (!HTML_VALUE_ATTR.test(tag)) return tag;
c.add("password_field");
return tag.replace(HTML_VALUE_ATTR, (_m, k) => `${k}"${REDACTED}"`);
});
for (const re of [A11Y_TYPED_PASSWORD, A11Y_SECRET_FIELD, YAML_SECRET_FIELD]) {
out = replaceCounting(
out,
re,
(a, k, v) => (v === REDACTED || v === `"${REDACTED}"` ? a : `${k}${REDACTED}`),
() => c.add("password_field"),
);
}
for (const re of extra) {
out = replaceCounting(
out,
re,
() => REDACTED,
() => c.add("secret_value"),
);
}
return out;
}
const SECRET_KEY = /password|passwd|passkey|cookie|token|secret|authkey|credential/i;
function redactJson(v: unknown, c: Counter, extra: RegExp[], parentIsPassword = false): unknown {
if (typeof v === "string") return redactText(v, c, extra);
if (Array.isArray(v)) return v.map((x) => redactJson(x, c, extra, parentIsPassword));
if (v && typeof v === "object") {
const o = v as Record<string, unknown>;
const isPassword =
parentIsPassword ||
(typeof o.type === "string" && o.type.toLowerCase() === "password") ||
(typeof o.inputType === "string" && o.inputType.toLowerCase() === "password");
const out: Record<string, unknown> = {};
for (const [k, val] of Object.entries(o)) {
if ((isPassword && k === "value") || (SECRET_KEY.test(k) && typeof val === "string" && val !== "")) {
c.add(k.toLowerCase().includes("cookie") ? "cookie" : "password_field");
out[k] = REDACTED;
} else out[k] = redactJson(val, c, extra, false);
}
return out;
}
return v;
}
export interface SnapshotInput {
text: string;
url: string;
structure?: Record<string, unknown> | null;
}
export interface RedactedSnapshot {
text: string;
url: string;
structure: Record<string, unknown> | null;
redactions: RedactionReport[];
}
/**
* 快照脱敏:隐藏密码框值、Cookie 值、含 passkey 等凭据的 URL。
* 输出可直接交给模型;调用方仍不得把未经此函数的上游快照写进 agent 上下文或日志。
*/
export function redactSnapshot(input: SnapshotInput, opts: { extra?: RegExp[] } = {}): RedactedSnapshot {
const c = new Counter();
const extra = (opts.extra ?? []).map((r) => new RegExp(r.source, r.flags.includes("g") ? r.flags : `${r.flags}g`));
const text = redactText(input.text, c, extra);
const url = redactUrlsIn(input.url, c);
const structure = input.structure ? (redactJson(input.structure, c, extra) as Record<string, unknown>) : null;
return { text, url, structure, redactions: c.list() };
}