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

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