feat(browser): ship camofox v1.17.0 in compose, verified on a real instance
- compose: camofox service on by default (internal only, volume umt-camofox, healthcheck, CAMOFOX_REPLICAS=0 to skip); app depends on it with service_started/required=false and auto-registers camofox-env from BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY / BROWSER_ENABLED - docker/camofox/Dockerfile: builds upstream tag v1.17.0 (389c996); fixes the upstream Dockerfile (missing postinstall.js before npm ci, sh -c not forwarding SIGTERM so logins were never persisted on restart) - adapter fixes from the real container: coordinate click via evaluate + selector, focus typing with mode=keyboard, password values redacted from ARIA snapshots/extract, downloads field names, 410 after browser restart treated as lost tab; navigate reopens a tab in the same session - verified-capability table keyed by upstream version (1.17.0 all ten); other versions stay unverified; probe reports auth failures - settings: browser providers block (create/edit/delete/probe, capability table); API POST/PATCH/DELETE /browser/providers, POST .../probe - scripts/browser-verify: umtbr stack + mock NexusPHP run of REQ 4.7.4 items - docs: runbook browser §1/§7 (real-instance record), deploy §2.1 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b4c9b1ead2
commit
40dd0e69e4
29 files changed
+1661
-73
No files matched your search
@@ -2,13 +2,13 @@
|
||||
|
||||
`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`。
|
||||
- `camofox/`:camofox-browser 适配器。按 jo-inc/camofox-browser v1.17.0 的 `openapi.json`、README 与 server.js 实现,2026-09-28 对真实容器逐项核验(`docs/runbook/browser.md` §7;核验表 `camofox/capabilities.ts` 的 `CAMOFOX_VERIFIED_RELEASES`),`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) 拒绝。
|
||||
|
||||
## 已实现(C1-E,仅对 mock 与上游文档验证)
|
||||
## 已实现(C1-E;BR-1 对 v1.17.0 真机核验并修正实测差异)
|
||||
|
||||
- 会话:`openSession`(`POST /tabs`)、`closeSession`(`DELETE /sessions/{userId}`)、`listSessions`(内存镜像;服务层以数据库为准)、`restoreSession`(重启后按 DB 恢复,旧引用失效)。
|
||||
- 标签页:`openTab`、`closeTab`(`DELETE /tabs/{id}`)、`reconcileTabs`(`GET /tabs?userId`,上游已回收的标 `lost`)。
|
||||
|
||||
@@ -10,7 +10,10 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
BrowserError,
|
||||
type BrowserLogger,
|
||||
CAMOFOX_ENDPOINTS,
|
||||
CAMOFOX_VERIFIED_RELEASES,
|
||||
type CamofoxConfig,
|
||||
camofoxCapabilitiesFromOpenApi,
|
||||
camofoxStaticCapabilities,
|
||||
classifyHttpStatus,
|
||||
createCamofoxBrowserProvider,
|
||||
@@ -89,7 +92,7 @@ describe("能力声明", () => {
|
||||
expect(c.navigate).toMatchObject({ supported: true, verified: false, source: "static" });
|
||||
expect(c.snapshot).toMatchObject({ supported: true, verified: true });
|
||||
expect(c.interact).toMatchObject({ supported: true, verified: true });
|
||||
expect(c.interact.note).toContain("待核验");
|
||||
expect(c.interact.note).toContain("evaluate");
|
||||
expect(c.takeover).toMatchObject({ supported: true, verified: false });
|
||||
expect(c.upstreamVersion).toBeNull();
|
||||
});
|
||||
@@ -97,13 +100,31 @@ describe("能力声明", () => {
|
||||
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.upstreamVersion).toBe("1.17.0-mock");
|
||||
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("真机核验表:探测到已核验的上游版本才置 verified;配置可逐项撤销;其它版本不继承", () => {
|
||||
const paths: Record<string, Record<string, unknown>> = {};
|
||||
for (const e of Object.values(CAMOFOX_ENDPOINTS).flat()) {
|
||||
const [m, p] = e.split(" ");
|
||||
paths[p!] = { ...paths[p!], [m!]: {} };
|
||||
}
|
||||
const doc = (version: string) => ({ info: { version }, paths });
|
||||
const ok = camofoxCapabilitiesFromOpenApi(doc("1.17.0"));
|
||||
expect(Object.keys(CAMOFOX_VERIFIED_RELEASES)).toContain("1.17.0");
|
||||
for (const k of CAMOFOX_VERIFIED_RELEASES["1.17.0"]!.keys)
|
||||
expect(ok[k]).toMatchObject({ supported: true, verified: true });
|
||||
expect(camofoxCapabilitiesFromOpenApi(doc("1.17.0"), { takeover: false }).takeover.verified).toBe(false);
|
||||
const other = camofoxCapabilitiesFromOpenApi(doc("1.18.0"));
|
||||
expect(other.snapshot.verified).toBe(false);
|
||||
// 静态声明不知道上游版本:一律未核验
|
||||
expect(camofoxStaticCapabilities().snapshot.verified).toBe(false);
|
||||
});
|
||||
|
||||
it("none 实现:能力全关,动作报 unavailable", async () => {
|
||||
const none = createNoneBrowserProvider();
|
||||
const c = await none.capabilities();
|
||||
@@ -300,6 +321,7 @@ describe("错误分类", () => {
|
||||
expect(classifyHttpStatus(401)).toBe("auth");
|
||||
expect(classifyHttpStatus(403)).toBe("auth");
|
||||
expect(classifyHttpStatus(404)).toBe("not_found");
|
||||
expect(classifyHttpStatus(410)).toBe("not_found");
|
||||
expect(classifyHttpStatus(409)).toBe("bad_request");
|
||||
expect(classifyHttpStatus(429)).toBe("rate_limited");
|
||||
expect(classifyHttpStatus(500)).toBe("upstream");
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { NavigationBudget } from "@umt/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BrowserError, createCamofoxBrowserProvider, memoryDownloadSink, staticBrowserSecrets } from "./index.ts";
|
||||
|
||||
// BR-1:对照 camofox-browser v1.17.0 真实容器(2026-09-28)记下的请求/响应形状,锁住适配器的实测差异修正。
|
||||
// 响应体取自真机核验输出(docs/runbook/browser.md §7),只把值换成测试常量。
|
||||
|
||||
const PROVIDER = "11111111-1111-4111-8111-111111111111";
|
||||
const SITE = "22222222-2222-4222-8222-222222222222";
|
||||
const ORIGIN = "http://mocknexus:8080";
|
||||
const PASSWORD = "mock-password";
|
||||
const budget: NavigationBudget = { domainAllowlist: ["mocknexus"], timeoutMs: 500, maxRetries: 0, maxBytes: 1_000_000 };
|
||||
|
||||
type Call = { method: string; path: string; query: Record<string, string>; body: Record<string, unknown> | null };
|
||||
type Reply = { status?: number; json?: unknown; bytes?: Uint8Array; type?: string };
|
||||
|
||||
function realUpstream(handler: (c: Call) => Reply | undefined) {
|
||||
const calls: Call[] = [];
|
||||
const fetchImpl = (async (input: URL | string, init?: RequestInit) => {
|
||||
const u = new URL(String(input));
|
||||
const call: Call = {
|
||||
method: init?.method ?? "GET",
|
||||
path: u.pathname,
|
||||
query: Object.fromEntries(u.searchParams),
|
||||
body: init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : null,
|
||||
};
|
||||
calls.push(call);
|
||||
const r =
|
||||
handler(call) ??
|
||||
(call.path === "/tabs" && call.method === "POST"
|
||||
? { json: { tabId: "t-1", url: `${ORIGIN}/login.php`, httpStatus: 200, navigationOk: true } }
|
||||
: { json: { ok: true } });
|
||||
if (r.bytes)
|
||||
return new Response(r.bytes as Uint8Array<ArrayBuffer>, {
|
||||
status: r.status ?? 200,
|
||||
headers: { "content-type": r.type ?? "image/png" },
|
||||
});
|
||||
return new Response(JSON.stringify(r.json ?? {}), {
|
||||
status: r.status ?? 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
return { calls, fetchImpl };
|
||||
}
|
||||
|
||||
async function open(handler: (c: Call) => Reply | undefined) {
|
||||
const up = realUpstream(handler);
|
||||
const sink = memoryDownloadSink();
|
||||
const provider = createCamofoxBrowserProvider({
|
||||
providerId: PROVIDER,
|
||||
baseUrl: "http://camofox:9377",
|
||||
secrets: staticBrowserSecrets({ [`provider:${PROVIDER}`]: "k" }),
|
||||
fetch: up.fetchImpl,
|
||||
downloadSink: sink,
|
||||
downloadPollMs: 5,
|
||||
});
|
||||
const { session } = await provider.openSession({
|
||||
providerId: PROVIDER,
|
||||
siteId: SITE,
|
||||
accountId: null,
|
||||
budget,
|
||||
initialUrl: `${ORIGIN}/login.php`,
|
||||
});
|
||||
return { ...up, provider, sink, ref: { sessionId: session.id } };
|
||||
}
|
||||
|
||||
const ARIA_LOGIN = `- paragraph: 你需要启用cookies才能登录\n- textbox [e1]: mockuser\n- textbox [e2]: ${PASSWORD}\n- button "登录" [e3]`;
|
||||
|
||||
describe("camofox 1.17.0 实测形状", () => {
|
||||
it("ARIA 快照里密码框与普通文本框同形:经 evaluate 取密码框当前值后按字面量遮蔽", async () => {
|
||||
const x = await open((c) => {
|
||||
if (c.path.endsWith("/snapshot"))
|
||||
return { json: { url: `${ORIGIN}/login.php`, snapshot: ARIA_LOGIN, refsCount: 3 } };
|
||||
if (c.path.endsWith("/evaluate")) return { json: { ok: true, result: [PASSWORD] } };
|
||||
});
|
||||
const s = await x.provider.snapshot(x.ref);
|
||||
expect(s.text).not.toContain(PASSWORD);
|
||||
expect(s.text).toContain("- textbox [e1]: mockuser");
|
||||
expect(s.redactions).toEqual([{ kind: "secret_value", count: 1 }]);
|
||||
});
|
||||
|
||||
it("evaluate 不可用时遮蔽全部输入框的值(宁可多遮)", async () => {
|
||||
const x = await open((c) => {
|
||||
if (c.path.endsWith("/snapshot")) return { json: { url: `${ORIGIN}/login.php`, snapshot: ARIA_LOGIN } };
|
||||
if (c.path.endsWith("/evaluate")) return { status: 500, json: { error: "x" } };
|
||||
});
|
||||
const s = await x.provider.snapshot(x.ref);
|
||||
expect(s.text).not.toContain(PASSWORD);
|
||||
expect(s.text).not.toContain("mockuser");
|
||||
expect(s.text).toContain('- button "登录" [e3]');
|
||||
});
|
||||
|
||||
it("截图直接返回 image/png 二进制", async () => {
|
||||
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]);
|
||||
const x = await open((c) => (c.path.endsWith("/screenshot") ? { bytes: png } : undefined));
|
||||
const shot = await x.provider.screenshot(x.ref);
|
||||
expect(shot.mimeType).toBe("image/png");
|
||||
expect([...shot.data]).toEqual([...png]);
|
||||
});
|
||||
|
||||
it("坐标点击:click 不接受 {x, y},先 evaluate 标记该点元素,再按选择器点击", async () => {
|
||||
const x = await open((c) => (c.path.endsWith("/evaluate") ? { json: { ok: true, result: true } } : undefined));
|
||||
await x.provider.acquireControl({
|
||||
sessionId: x.ref.sessionId,
|
||||
holderKind: "human",
|
||||
holder: "u",
|
||||
reason: "t",
|
||||
ttlMs: 60_000,
|
||||
});
|
||||
await x.provider.click({ ...x.ref, as: "human" }, { x: 98.4, y: 71 });
|
||||
const ev = x.calls.find((c) => c.path.endsWith("/evaluate"))!;
|
||||
expect(String(ev.body?.expression)).toContain("elementFromPoint(98, 71)");
|
||||
const click = x.calls.find((c) => c.path.endsWith("/click"))!;
|
||||
expect(click.body).toMatchObject({
|
||||
userId: expect.any(String),
|
||||
selector: expect.stringMatching(/^\[data-umt-pt="/),
|
||||
});
|
||||
expect(click.body).not.toHaveProperty("x");
|
||||
});
|
||||
|
||||
it("坐标处没有元素 → bad_request,不发 click", async () => {
|
||||
const x = await open((c) => (c.path.endsWith("/evaluate") ? { json: { ok: true, result: false } } : undefined));
|
||||
await x.provider.acquireControl({
|
||||
sessionId: x.ref.sessionId,
|
||||
holderKind: "human",
|
||||
holder: "u",
|
||||
reason: "t",
|
||||
ttlMs: 60_000,
|
||||
});
|
||||
await expect(x.provider.click({ ...x.ref, as: "human" }, { x: 5000, y: 5000 })).rejects.toMatchObject({
|
||||
kind: "bad_request",
|
||||
});
|
||||
expect(x.calls.some((c) => c.path.endsWith("/click"))).toBe(false);
|
||||
});
|
||||
|
||||
it("无目标输入 = 当前焦点:mode=keyboard(fill 模式必须带 ref/selector)", async () => {
|
||||
const x = await open(() => undefined);
|
||||
await x.provider.type(x.ref, null, { kind: "text", text: "abc" });
|
||||
await x.provider.type(x.ref, { selector: "#u" }, { kind: "text", text: "abc" });
|
||||
const types = x.calls.filter((c) => c.path.endsWith("/type"));
|
||||
expect(types[0]!.body).toMatchObject({ mode: "keyboard", text: "abc" });
|
||||
expect(types[1]!.body).not.toHaveProperty("mode");
|
||||
});
|
||||
|
||||
it("downloads 字段:suggestedFilename / bytes / dataBase64;failure 与 dataSkipped 分别报错", async () => {
|
||||
const bytes = new TextEncoder().encode("d8:announce0:e");
|
||||
const b64 = btoa(String.fromCharCode(...bytes));
|
||||
const list = [
|
||||
{
|
||||
id: "d1",
|
||||
url: `${ORIGIN}/download.php?id=1&passkey=abcdef0123456789abcdef0123456789`,
|
||||
suggestedFilename: "[Mock].x.torrent",
|
||||
mimeType: "application/x-bittorrent",
|
||||
bytes: bytes.byteLength,
|
||||
createdAt: 1,
|
||||
failure: null,
|
||||
dataBase64: b64,
|
||||
},
|
||||
];
|
||||
const x = await open((c) =>
|
||||
c.path.endsWith("/downloads") ? { json: { tabId: "t-1", downloads: list } } : undefined,
|
||||
);
|
||||
const a = await x.provider.waitForDownload(x.ref, { timeoutMs: 200, maxBytes: 1024 });
|
||||
expect(a).toMatchObject({ filename: "[Mock].x.torrent", sizeBytes: bytes.byteLength });
|
||||
expect(a.sourceUrl).not.toContain("abcdef0123456789");
|
||||
|
||||
const y = await open((c) =>
|
||||
c.path.endsWith("/downloads")
|
||||
? { json: { downloads: [{ ...list[0], id: "d2", dataBase64: undefined, dataSkipped: "max_bytes_exceeded" }] } }
|
||||
: undefined,
|
||||
);
|
||||
await expect(y.provider.waitForDownload(y.ref, { timeoutMs: 200, maxBytes: 4 })).rejects.toMatchObject({
|
||||
kind: "budget_exceeded",
|
||||
});
|
||||
const z = await open((c) =>
|
||||
c.path.endsWith("/downloads")
|
||||
? { json: { downloads: [{ ...list[0], id: "d3", dataBase64: undefined, failure: "canceled" }] } }
|
||||
: undefined,
|
||||
);
|
||||
await expect(z.provider.waitForDownload(z.ref, { timeoutMs: 200, maxBytes: 1024 })).rejects.toMatchObject({
|
||||
kind: "upstream",
|
||||
});
|
||||
});
|
||||
|
||||
it("浏览器重启后旧标签页返回 410 browser_restarted:按 not_found 处理", async () => {
|
||||
const x = await open((c) =>
|
||||
c.path.endsWith("/snapshot")
|
||||
? { status: 410, json: { error: "Tab no longer exists (browser was restarted).", code: "browser_restarted" } }
|
||||
: undefined,
|
||||
);
|
||||
const err = await x.provider.snapshot(x.ref).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(BrowserError);
|
||||
expect(err).toMatchObject({ kind: "not_found", httpStatus: 410 });
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
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)。
|
||||
* camofox-browser 能力声明。依据:jo-inc/camofox-browser v1.17.0(commit 389c996)的 openapi.json、README 与 server.js;
|
||||
* 用户实际部署可能是其他 fork/版本,运行时以 /openapi.json 探测结果为准(REQ §4.7.4)。
|
||||
*
|
||||
* supported = 本适配器当前能对该上游提供的能力(上游有端点但适配器尚未实现的记 false 并注明 M2);
|
||||
* verified 只能由真实实例原型验证结果置 true(配置注入),探测永远不会把它设为 true。
|
||||
* supported = 本适配器当前能对该上游提供的能力(上游缺端点的记 false 并注明);
|
||||
* verified 只来自人工真机核验:CAMOFOX_VERIFIED_RELEASES(按上游版本登记的核验记录,只在探测到的 info.version
|
||||
* 完全一致时生效)与 browser_provider.config.verified(逐项覆盖,可写 false 撤销)。探测本身永远不会把它设为 true。
|
||||
*/
|
||||
|
||||
/** 每项能力依赖的上游端点(OpenAPI paths 键,小写方法) */
|
||||
@@ -15,11 +16,22 @@ export const CAMOFOX_ENDPOINTS: Record<BrowserCapabilityKey, readonly string[]>
|
||||
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"],
|
||||
// 坐标点击经 evaluate(elementFromPoint)转成选择器:1.17.0 的 click 不接受 {x, y}
|
||||
interact: [
|
||||
"post /tabs/{tabId}/click",
|
||||
"post /tabs/{tabId}/type",
|
||||
"post /tabs/{tabId}/select",
|
||||
"post /tabs/{tabId}/evaluate",
|
||||
],
|
||||
extract: ["post /tabs/{tabId}/extract"],
|
||||
downloadCapture: ["get /tabs/{tabId}/downloads"],
|
||||
// 原站视图接管 = 同一会话的定时截图 + 点击/输入映射(不另起浏览器);VNC 插件未接入
|
||||
takeover: ["get /tabs/{tabId}/screenshot", "post /tabs/{tabId}/click", "post /tabs/{tabId}/type"],
|
||||
takeover: [
|
||||
"get /tabs/{tabId}/screenshot",
|
||||
"post /tabs/{tabId}/click",
|
||||
"post /tabs/{tabId}/type",
|
||||
"post /tabs/{tabId}/evaluate",
|
||||
],
|
||||
// 持久化插件默认开启,无需端点;storage_state 端点属插件,不在 OpenAPI 中
|
||||
persistentProfile: [],
|
||||
};
|
||||
@@ -40,15 +52,47 @@ const ADAPTER_IMPLEMENTED: ReadonlySet<BrowserCapabilityKey> = new Set([
|
||||
|
||||
const STATIC_NOTES: Partial<Record<BrowserCapabilityKey, string>> = {
|
||||
sessions: "上游无显式建会话端点:userId 即会话(BrowserContext),首个 POST /tabs 隐式创建;会话以数据库为准",
|
||||
tabs: "新建/关闭/对账(GET /tabs?userId,上游回收的标 lost)",
|
||||
snapshot: "可访问性树文本 + eN 元素引用,支持 offset 分页",
|
||||
interact: "click/type/select + scroll/press(后两者不在 1.17.0 OpenAPI,待核验);坐标点击待核验",
|
||||
tabs: "新建/关闭/对账(GET /tabs?userId);重启或空闲回收后旧标签页 404/410,对账标 lost 后在同一会话开新标签页",
|
||||
snapshot: "ARIA 快照文本 + [eN] 元素引用,支持 offset 分页;密码框值经 evaluate 取值后遮蔽",
|
||||
interact: "click/type/select/scroll/press/后退/前进/刷新;坐标点击经 evaluate 定位元素,焦点输入用 mode=keyboard",
|
||||
extract: "POST /extract 需先 snapshot 建 ref 表(x-ref)",
|
||||
downloadCapture: "GET /downloads?includeData&consume&maxBytes(README;字段形状待核验)",
|
||||
takeover: "原站视图:同一会话定时截图 + 输入映射;坐标点击与无目标输入待核验,VNC 未接入",
|
||||
persistentProfile: "上游持久化插件默认保存 cookies+localStorage(~/.camofox/profiles),重启恢复待验证",
|
||||
downloadCapture: "GET /downloads?includeData&consume&maxBytes → dataBase64;点击下载链接后捕获",
|
||||
takeover: "原站视图:同一会话同一标签页的截图 + 坐标点击/焦点输入(API 层核验);VNC 插件未接入",
|
||||
persistentProfile:
|
||||
"cookies+localStorage 按 profile 落盘:会话空闲回收、关闭与 SIGTERM 停止时写入;SIGKILL/OOM 丢上次落盘后的新登录",
|
||||
};
|
||||
|
||||
/**
|
||||
* 真机核验记录(docs/runbook/browser.md「真机核验」)。只登记核验通过的能力;新版本必须重新核验后再加一行。
|
||||
*/
|
||||
export const CAMOFOX_VERIFIED_RELEASES: Readonly<
|
||||
Record<string, { verifiedAt: string; source: string; keys: readonly BrowserCapabilityKey[] }>
|
||||
> = {
|
||||
"1.17.0": {
|
||||
verifiedAt: "2026-09-28",
|
||||
source: "jo-inc/camofox-browser v1.17.0 (389c996),docker/camofox/Dockerfile 构建",
|
||||
keys: [
|
||||
"sessions",
|
||||
"tabs",
|
||||
"navigate",
|
||||
"snapshot",
|
||||
"screenshot",
|
||||
"interact",
|
||||
"extract",
|
||||
"downloadCapture",
|
||||
"takeover",
|
||||
"persistentProfile",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** 某上游版本的已核验能力 + 配置覆盖(配置里显式 false 可撤销) */
|
||||
export function verifiedFor(version: string | null, config: VerifiedMap = {}): VerifiedMap {
|
||||
const rec = version ? CAMOFOX_VERIFIED_RELEASES[version] : undefined;
|
||||
const base: VerifiedMap = Object.fromEntries((rec?.keys ?? []).map((k) => [k, true]));
|
||||
return { ...base, ...config };
|
||||
}
|
||||
|
||||
export type VerifiedMap = Partial<Record<BrowserCapabilityKey, boolean>>;
|
||||
|
||||
const KEYS = Object.keys(CAMOFOX_ENDPOINTS) as BrowserCapabilityKey[];
|
||||
@@ -84,16 +128,18 @@ export function camofoxCapabilitiesFromOpenApi(
|
||||
// 路径参数名不同的 fork 也视为同一端点
|
||||
const norm = (s: string) => s.replace(/\{[^}]+\}/g, "{}");
|
||||
const haveNorm = new Set([...have].map(norm));
|
||||
const version = typeof d.info?.version === "string" ? d.info.version : null;
|
||||
const effective = verifiedFor(version, verified);
|
||||
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)];
|
||||
if (missing.length > 0) return [k, flag(k, false, "probed", effective, `上游缺少端点:${missing.join(", ")}`)];
|
||||
return [k, flag(k, ADAPTER_IMPLEMENTED.has(k), CAMOFOX_ENDPOINTS[k].length > 0 ? "probed" : "static", effective)];
|
||||
}),
|
||||
) as Record<BrowserCapabilityKey, BrowserCapabilityFlag>;
|
||||
return {
|
||||
...flags,
|
||||
upstreamVersion: typeof d.info?.version === "string" ? d.info.version : null,
|
||||
upstreamVersion: version,
|
||||
probedAt: now.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -127,6 +127,11 @@ 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);
|
||||
|
||||
/** ARIA 快照里带值的输入框:`- textbox [e2]: 值` / `- textbox "标签" [e2]: 值` */
|
||||
const TEXTBOX_WITH_VALUE = /-\s*(?:textbox|searchbox)\b[^\n]*\[e\d+\][^\n]*?:\s*\S/;
|
||||
/** 兜底:遮蔽全部输入框的值(只替换冒号后的值部分) */
|
||||
const TEXTBOX_VALUE_ANY = /(?<=-\s*(?:textbox|searchbox)\b[^\n]*\[e\d+\][^\n]*?:\s*)\S[^\n]*/g;
|
||||
|
||||
const LOGIN_PATH = /(?:^|\/)(?:login|takelogin|signin|sign_in|auth)(?:\.php)?(?:\/|$|\?)/i;
|
||||
|
||||
export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
@@ -636,7 +641,8 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
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 ?? [] });
|
||||
const pw = await this.passwordRedactions(s, tab, text);
|
||||
const red = redactSnapshot({ text, url: rawUrl, structure }, { extra: [...(opts.extraRedactions ?? []), ...pw] });
|
||||
this.touch(s, rawUrl || s.rawUrl);
|
||||
const at = s.session.lastActivityAt;
|
||||
tab.lastSnapshotAt = at;
|
||||
@@ -660,6 +666,34 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 1.17.0 实测:快照是 Playwright ARIA 快照(`- textbox [e2]: 值`),密码框与普通文本框同为 textbox 且带明文值,
|
||||
* 按标签名的规则抓不到。快照里出现带值的文本框时,经 evaluate 取页面上 type=password 输入框的当前值,按字面量遮蔽;
|
||||
* evaluate 失败时退而遮蔽全部文本框的值(宁可多遮)。
|
||||
*/
|
||||
private async passwordRedactions(s: SessionState, tab: BrowserTab, text: string | null): Promise<RegExp[]> {
|
||||
if (text !== null && !TEXTBOX_WITH_VALUE.test(text)) return [];
|
||||
const esc = (v: string) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
try {
|
||||
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/evaluate`;
|
||||
const res = await this.request("POST", path, {
|
||||
body: {
|
||||
userId: s.session.profileKey,
|
||||
expression:
|
||||
"Array.from(document.querySelectorAll('input[type=password]')).map((e) => e.value).filter((v) => v)",
|
||||
},
|
||||
timeoutMs: s.budget.timeoutMs,
|
||||
});
|
||||
const out = await this.json(res, path);
|
||||
const values = Array.isArray(out.result) ? out.result.filter((v): v is string => typeof v === "string") : [];
|
||||
// 过短的值按字面量遮蔽会误伤正文:改为遮蔽全部输入框的值
|
||||
if (values.some((v) => v.length < 4)) return [TEXTBOX_VALUE_ANY, ...values.map((v) => new RegExp(esc(v), "g"))];
|
||||
return values.map((v) => new RegExp(esc(v), "g"));
|
||||
} catch {
|
||||
return [TEXTBOX_VALUE_ANY];
|
||||
}
|
||||
}
|
||||
|
||||
async screenshot(ref: TabRef, opts: { maxBytes?: number } = {}): Promise<BrowserScreenshot> {
|
||||
const s = this.state(ref.sessionId);
|
||||
return this.withProfile(s, async () => {
|
||||
@@ -742,7 +776,9 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
|
||||
/** 视口坐标 → 唯一属性选择器(POST /tabs/{id}/evaluate + elementFromPoint);坐标处无元素报 bad_request */
|
||||
private async pointSelector(s: SessionState, tab: BrowserTab, x: number, y: number): Promise<string> {
|
||||
const nonce = this.newId().replace(/[^a-z0-9]/gi, "").slice(0, 16);
|
||||
const nonce = this.newId()
|
||||
.replace(/[^a-z0-9]/gi, "")
|
||||
.slice(0, 16);
|
||||
const expression = `(() => { const e = document.elementFromPoint(${x}, ${y}); if (!e) return false;
|
||||
for (const o of document.querySelectorAll("[data-umt-pt]")) o.removeAttribute("data-umt-pt");
|
||||
e.setAttribute("data-umt-pt", "${nonce}"); return true; })()`;
|
||||
@@ -864,7 +900,9 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
});
|
||||
const body = await this.json(res, path);
|
||||
const data = (body.data && typeof body.data === "object" && !Array.isArray(body.data) ? body.data : body) as Json;
|
||||
const red = redactSnapshot({ text: "", url: "", structure: data });
|
||||
// x-ref 可能指向密码框:同快照一样按页面上密码框的当前值遮蔽
|
||||
const pw = Object.keys(data).length ? await this.passwordRedactions(s, tab, null) : [];
|
||||
const red = redactSnapshot({ text: "", url: "", structure: data }, { extra: pw });
|
||||
this.touch(s);
|
||||
return (red.structure ?? {}) as Record<string, unknown>;
|
||||
});
|
||||
|
||||
@@ -26,7 +26,8 @@ export const notImplemented = (method: string): BrowserError =>
|
||||
/** 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";
|
||||
// 410:camofox 1.17.0 对"浏览器重启前存在过的标签页"返回 410 browser_restarted(实测),与 404 同样按标签页已不存在处理
|
||||
if (status === 404 || status === 410) return "not_found";
|
||||
if (status === 408) return "timeout";
|
||||
if (status === 429) return "rate_limited";
|
||||
if (status === 503) return "unavailable";
|
||||
|
||||
Reference in new issue
Block a user