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:
rootandClaude Opus 5.5 committed 2026-09-29 00:05:21 +08:00
1 parent b4c9b1ead2
commit 40dd0e69e4
29 files changed
+1661 -73

No files matched your search

@@ -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 });
});
});