feat(browser): vision automation loop with screenshots, coordinate actions and bounded challenge solving

- browser.screenshot returns the page as an image content block (≤1280px wide, JPEG quality
  configurable) for vision-capable upstreams; non-vision upstreams get a redacted text snapshot.
  Login pages / password fields are marked sensitive and withheld unless a
  browser.sensitive_screenshot grant exists and allowSensitive is passed.
- browser.click_at / type_at / scroll / wait act in screenshot pixels; the server converts via
  viewport/DPR, requires automation control and an allowlisted host, and writes a thumbnail
  evidence file (DATA_DIR/tmp/evidence) referenced from agent_run_event.
- browser.solve_challenge runs a bounded child agent_run (vision caps only, maxSteps ≤ 12,
  budget), judges success from page facts, stops immediately on 2FA / SMS / device confirmation,
  and otherwise moves the session to waiting with evidence and a takeover link.
- PiAgentRuntime passes modelImage outputs as pi-ai image content when model.input has image;
  the ledger never stores image data; coordinate actions use per-invocation idempotency keys.
- Tests: image codec, runtime image content, E14 e2e (login solved, 2FA stop, step limit,
  sensitive withheld). Real camofox 1.17.0 check on example.com recorded in the runbook.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 01:27:48 +08:00
1 parent 0765264a47
commit c437c6bff6
34 files changed
+2194 -35

No files matched your search

@@ -40,6 +40,7 @@ import {
type NavigationResult,
type OpenSessionRequest,
type OpenSessionResult,
type PageMetrics,
profileKeyFor,
type RestoreSessionState,
type SnapshotOptions,
@@ -132,6 +133,11 @@ const TEXTBOX_WITH_VALUE = /-\s*(?:textbox|searchbox)\b[^\n]*\[e\d+\][^\n]*?:\s*
/** 兜底:遮蔽全部输入框的值(只替换冒号后的值部分) */
const TEXTBOX_VALUE_ANY = /(?<=-\s*(?:textbox|searchbox)\b[^\n]*\[e\d+\][^\n]*?:\s*)\S[^\n]*/g;
/** 页面度量表达式(mock 按 umt-metrics 标记识别) */
const PAGE_METRICS_EXPR = `/* umt-metrics */ (() => ({ w: innerWidth, h: innerHeight, dpr: devicePixelRatio,
pw: !!document.querySelector("input[type=password]"),
text: String((document.body && document.body.innerText) || "").slice(0, 4000) }))()`;
const LOGIN_PATH = /(?:^|\/)(?:login|takelogin|signin|sign_in|auth)(?:\.php)?(?:\/|$|\?)/i;
export class CamofoxBrowserProvider implements BrowserProvider {
@@ -743,6 +749,36 @@ export class CamofoxBrowserProvider implements BrowserProvider {
});
}
/** 视口 / DPR / 密码框 / 文字样本:一次 evaluate;失败(旧版本、页面禁止脚本)返回 null */
async pageMetrics(ref: TabRef): Promise<PageMetrics | null> {
const s = this.state(ref.sessionId);
return this.withProfile(s, async () => {
const tab = this.tabOf(s, ref);
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/evaluate`;
try {
const res = await this.request("POST", path, {
body: { userId: s.session.profileKey, expression: PAGE_METRICS_EXPR },
timeoutMs: s.budget.timeoutMs,
});
const out = await this.json(res, path);
const r = (out.result && typeof out.result === "object" ? out.result : null) as Json | null;
const w = typeof r?.w === "number" ? r.w : NaN;
const h = typeof r?.h === "number" ? r.h : NaN;
if (!(w > 0) || !(h > 0)) return null;
return {
viewportWidth: w,
viewportHeight: h,
devicePixelRatio: typeof r?.dpr === "number" && r.dpr > 0 ? r.dpr : 1,
passwordField: r?.pw === true,
textSample: typeof r?.text === "string" ? r.text.slice(0, 4000) : "",
};
} catch (e) {
if (e instanceof BrowserError && e.kind === "not_found") throw e;
return null;
}
});
}
// ---------- 交互 ----------
/** 交互动作的公共路径:控制者校验 → 串行锁 → POST /tabs/{id}/<action> */