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> */
@@ -0,0 +1,57 @@
import { MOCK_ROW_H, MOCK_ROW_Y0, solidPngBase64 } from "@umt/testkit";
import { decode as decodeJpeg } from "jpeg-js";
import { describe, expect, it } from "vitest";
import { decodeImage, decodePng, downscale, encodePng, imageDimensions, prepareImage, toBase64 } from "./image.ts";
// VIS-1:截图解码 / 缩放 / 编码(交给模型的截图 ≤1280 宽,证据缩略图更小)。
const b64 = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
describe("截图图片处理", () => {
it("PNG 解码(RGB,mock 截图)与尺寸读取", () => {
const png = b64(solidPngBase64(200, 120, [10, 20, 30], [0]));
expect(imageDimensions(png)).toEqual({ width: 200, height: 120 });
const img = decodePng(png);
expect(img.width).toBe(200);
expect(Array.from(img.data.subarray(0, 4))).toEqual([10, 20, 30, 255]);
// 第 0 行元素横条:y ∈ [MOCK_ROW_Y0+8, MOCK_ROW_Y0+MOCK_ROW_H-8),x ∈ [40, 640)
const y = MOCK_ROW_Y0 + Math.floor(MOCK_ROW_H / 2);
const o = (y * 200 + 50) * 4;
expect(Array.from(img.data.subarray(o, o + 3))).toEqual([90, 110, 140]);
});
it("RGBA 编码 → 解码往返;过滤器 1–4 的行由 zlib 往返覆盖", () => {
const w = 37;
const h = 11;
const data = new Uint8Array(w * h * 4);
for (let i = 0; i < data.length; i++) data[i] = (i * 7 + (i >> 5)) & 0xff;
const back = decodeImage(encodePng({ width: w, height: h, data }));
expect(back.width).toBe(w);
expect(back.height).toBe(h);
expect(Buffer.from(back.data).equals(Buffer.from(data))).toBe(true);
});
it("缩放:1600 宽 → 1280 宽等比,面积平均;窄图不放大", () => {
const big = decodePng(b64(solidPngBase64(1600, 1000, [200, 100, 50])));
const small = downscale(big, 1280);
expect([small.width, small.height]).toEqual([1280, 800]);
expect(Array.from(small.data.subarray(0, 4))).toEqual([200, 100, 50, 255]);
expect(downscale(small, 2000)).toBe(small);
});
it("prepareImage:缩到 maxWidth 并编码 JPEG(质量可配);已够小的同格式原样返回", () => {
const png = b64(solidPngBase64(1600, 1000, [240, 240, 240], [0, 1, 2]));
const jpg = prepareImage(png, { maxWidth: 1280, format: "jpeg", quality: 60 });
expect(jpg).toMatchObject({ mimeType: "image/jpeg", width: 1280, height: 800 });
const dec = decodeJpeg(jpg.data, { useTArray: true });
expect([dec.width, dec.height]).toEqual([1280, 800]);
expect(imageDimensions(jpg.data)).toEqual({ width: 1280, height: 800 });
const hi = prepareImage(png, { maxWidth: 1280, format: "jpeg", quality: 95 });
expect(hi.data.length).toBeGreaterThan(jpg.data.length);
const tiny = b64(solidPngBase64(300, 200, [1, 2, 3]));
expect(prepareImage(tiny, { format: "png" }).data).toBe(tiny);
const thumb = prepareImage(png, { maxWidth: 480 });
expect(thumb.width).toBe(480);
expect(toBase64(tiny)).toBe(solidPngBase64(300, 200, [1, 2, 3]));
});
});
+275
View File
@@ -0,0 +1,275 @@
/**
* 截图的解码、缩放与编码(VIS-1):交给视觉模型的截图缩放到不超过 maxWidth(缺省 1280)像素宽,
* 证据缩略图更小。PNG 自己解码 / 编码(node:zlib),JPEG 用 jpeg-js(纯 JS,无原生依赖,deno compile 可嵌入)。
* 只支持浏览器截图实际会出现的格式:8 位 PNG(灰度 / RGB / 调色板 / 带 alpha,非隔行)与基线 JPEG。
*/
import { deflateSync, inflateSync } from "node:zlib";
import { decode as decodeJpeg, encode as encodeJpeg } from "jpeg-js";
export interface RgbaImage {
width: number;
height: number;
/** RGBA,每像素 4 字节 */
data: Uint8Array;
}
export interface EncodedImage {
mimeType: "image/png" | "image/jpeg";
data: Uint8Array;
width: number;
height: number;
}
export interface PrepareImageOptions {
/** 最大宽度(像素);原图更窄时不放大 */
maxWidth?: number;
/** 输出格式;缺省 jpeg */
format?: "png" | "jpeg";
/** JPEG 质量 1–100,缺省 70 */
quality?: number;
}
/** PNG IHDR / JPEG SOF 里的像素尺寸;不认识的格式返回 null */
export function imageDimensions(data: Uint8Array): { width: number; height: number } | null {
if (data.length > 24 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) {
const v = new DataView(data.buffer, data.byteOffset, data.byteLength);
return { width: v.getUint32(16), height: v.getUint32(20) };
}
if (data[0] === 0xff && data[1] === 0xd8) {
let i = 2;
while (i + 9 < data.length) {
if (data[i] !== 0xff) break;
const marker = data[i + 1]!;
const len = (data[i + 2]! << 8) | data[i + 3]!;
if (marker >= 0xc0 && marker <= 0xc3)
return { width: (data[i + 7]! << 8) | data[i + 8]!, height: (data[i + 5]! << 8) | data[i + 6]! };
i += 2 + len;
}
}
return null;
}
export function decodeImage(data: Uint8Array): RgbaImage {
if (data[0] === 0x89 && data[1] === 0x50) return decodePng(data);
if (data[0] === 0xff && data[1] === 0xd8) {
const img = decodeJpeg(data, { useTArray: true, formatAsRGBA: true, maxMemoryUsageInMB: 256 });
return { width: img.width, height: img.height, data: img.data };
}
throw new Error("不支持的图片格式");
}
/** 解码 8 位、非隔行 PNG(颜色类型 0/2/3/4/6) */
export function decodePng(bytes: Uint8Array): RgbaImage {
const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
let pos = 8;
let width = 0;
let height = 0;
let depth = 0;
let color = 0;
let interlace = 0;
let palette: Uint8Array | null = null;
let trns: Uint8Array | null = null;
const idat: Uint8Array[] = [];
while (pos + 8 <= bytes.length) {
const len = v.getUint32(pos);
const type = String.fromCharCode(bytes[pos + 4]!, bytes[pos + 5]!, bytes[pos + 6]!, bytes[pos + 7]!);
const body = bytes.subarray(pos + 8, pos + 8 + len);
if (type === "IHDR") {
width = v.getUint32(pos + 8);
height = v.getUint32(pos + 12);
depth = bytes[pos + 16]!;
color = bytes[pos + 17]!;
interlace = bytes[pos + 20]!;
} else if (type === "PLTE") palette = body;
else if (type === "tRNS") trns = body;
else if (type === "IDAT") idat.push(body);
else if (type === "IEND") break;
pos += 12 + len;
}
if (depth !== 8 || interlace !== 0) throw new Error(`不支持的 PNG(位深 ${depth},隔行 ${interlace})`);
const channels = ({ 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 } as Record<number, number>)[color];
if (!channels) throw new Error(`不支持的 PNG 颜色类型 ${color}`);
if (width <= 0 || height <= 0 || width * height > 40_000_000) throw new Error("PNG 尺寸异常");
const raw = new Uint8Array(inflateSync(concat(idat)));
const stride = width * channels;
const out = new Uint8Array(width * height * 4);
let prev = new Uint8Array(stride);
let cur = new Uint8Array(stride);
for (let y = 0; y < height; y++) {
const off = y * (stride + 1);
const filter = raw[off]!;
for (let i = 0; i < stride; i++) {
const x = raw[off + 1 + i]!;
const a = i >= channels ? cur[i - channels]! : 0;
const b = prev[i]!;
const c = i >= channels ? prev[i - channels]! : 0;
let val: number;
switch (filter) {
case 0:
val = x;
break;
case 1:
val = x + a;
break;
case 2:
val = x + b;
break;
case 3:
val = x + ((a + b) >> 1);
break;
case 4: {
const p = a + b - c;
const pa = Math.abs(p - a);
const pb = Math.abs(p - b);
const pc = Math.abs(p - c);
val = x + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c);
break;
}
default:
throw new Error(`PNG 过滤类型 ${filter} 非法`);
}
cur[i] = val & 0xff;
}
for (let px = 0; px < width; px++) {
const o = (y * width + px) * 4;
const s = px * channels;
if (color === 6) {
out[o] = cur[s]!;
out[o + 1] = cur[s + 1]!;
out[o + 2] = cur[s + 2]!;
out[o + 3] = cur[s + 3]!;
} else if (color === 2) {
out[o] = cur[s]!;
out[o + 1] = cur[s + 1]!;
out[o + 2] = cur[s + 2]!;
out[o + 3] = 255;
} else if (color === 0 || color === 4) {
out[o] = out[o + 1] = out[o + 2] = cur[s]!;
out[o + 3] = color === 4 ? cur[s + 1]! : 255;
} else {
const idx = cur[s]!;
out[o] = palette?.[idx * 3] ?? 0;
out[o + 1] = palette?.[idx * 3 + 1] ?? 0;
out[o + 2] = palette?.[idx * 3 + 2] ?? 0;
out[o + 3] = trns?.[idx] ?? 255;
}
}
[prev, cur] = [cur, prev];
}
return { width, height, data: out };
}
/** 编码为 8 位 RGBA PNG(每行 filter 0,deflate 压缩) */
export function encodePng(img: RgbaImage): Uint8Array {
const stride = img.width * 4;
const raw = new Uint8Array((stride + 1) * img.height);
for (let y = 0; y < img.height; y++) raw.set(img.data.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
const ihdr = new Uint8Array(13);
const hv = new DataView(ihdr.buffer);
hv.setUint32(0, img.width);
hv.setUint32(4, img.height);
ihdr[8] = 8;
ihdr[9] = 6;
return concat([
new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", ihdr),
chunk("IDAT", new Uint8Array(deflateSync(raw))),
chunk("IEND", new Uint8Array()),
]);
}
/** 按面积平均缩小到 maxWidth(等比);不放大 */
export function downscale(img: RgbaImage, maxWidth: number): RgbaImage {
if (img.width <= maxWidth) return img;
const w = Math.max(1, Math.round(maxWidth));
const h = Math.max(1, Math.round((img.height * w) / img.width));
const sx = img.width / w;
const sy = img.height / h;
const out = new Uint8Array(w * h * 4);
for (let y = 0; y < h; y++) {
const y0 = Math.floor(y * sy);
const y1 = Math.max(y0 + 1, Math.min(img.height, Math.floor((y + 1) * sy)));
for (let x = 0; x < w; x++) {
const x0 = Math.floor(x * sx);
const x1 = Math.max(x0 + 1, Math.min(img.width, Math.floor((x + 1) * sx)));
let r = 0;
let g = 0;
let b = 0;
let a = 0;
for (let yy = y0; yy < y1; yy++) {
let i = (yy * img.width + x0) * 4;
for (let xx = x0; xx < x1; xx++, i += 4) {
r += img.data[i]!;
g += img.data[i + 1]!;
b += img.data[i + 2]!;
a += img.data[i + 3]!;
}
}
const n = (y1 - y0) * (x1 - x0);
const o = (y * w + x) * 4;
out[o] = r / n;
out[o + 1] = g / n;
out[o + 2] = b / n;
out[o + 3] = a / n;
}
}
return { width: w, height: h, data: out };
}
/** 截图 → 交给模型 / 证据用的图片:缩放到 maxWidth 以内,按 format 编码 */
export function prepareImage(data: Uint8Array, opts: PrepareImageOptions = {}): EncodedImage {
const maxWidth = opts.maxWidth ?? 1280;
const format = opts.format ?? "jpeg";
const dim = imageDimensions(data);
const isPng = data[0] === 0x89 && data[1] === 0x50;
// 已经够小且格式一致:原样返回,不重新编码
if (dim && dim.width <= maxWidth && ((format === "png" && isPng) || (format === "jpeg" && !isPng)))
return { mimeType: format === "png" ? "image/png" : "image/jpeg", data, ...dim };
const img = downscale(decodeImage(data), maxWidth);
if (format === "png") return { mimeType: "image/png", data: encodePng(img), width: img.width, height: img.height };
const quality = Math.min(100, Math.max(1, Math.round(opts.quality ?? 70)));
const jpeg = encodeJpeg({ width: img.width, height: img.height, data: img.data }, quality);
return { mimeType: "image/jpeg", data: new Uint8Array(jpeg.data), width: img.width, height: img.height };
}
export function toBase64(data: Uint8Array): string {
let s = "";
for (let i = 0; i < data.length; i += 0x8000) s += String.fromCharCode(...data.subarray(i, i + 0x8000));
return btoa(s);
}
function concat(parts: Uint8Array[]): Uint8Array {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let o = 0;
for (const p of parts) {
out.set(p, o);
o += p.length;
}
return out;
}
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[n] = c >>> 0;
}
return t;
})();
function crc32(buf: Uint8Array): number {
let c = 0xffffffff;
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff]! ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type: string, data: Uint8Array): Uint8Array {
const out = new Uint8Array(12 + data.length);
const v = new DataView(out.buffer);
v.setUint32(0, data.length);
for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
out.set(data, 8);
v.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
return out;
}
+1
View File
@@ -1,6 +1,7 @@
export * from "./budget.ts";
export * from "./camofox/index.ts";
export * from "./errors.ts";
export * from "./image.ts";
export * from "./none.ts";
export * from "./profile-lock.ts";
export * from "./provider.ts";
+1
View File
@@ -57,5 +57,6 @@ export function createNoneBrowserProvider(): BrowserProvider {
reconcileTabs: unavailable,
sessionInfo: () => null,
drain: async () => {},
pageMetrics: async () => null,
};
}
+15
View File
@@ -60,6 +60,21 @@ export interface BrowserProvider {
sessionInfo(sessionId: string): BrowserSessionInfo | null;
/** 等待进行中的操作收尾(接管前调用) */
drain(sessionId: string): Promise<void>;
/**
* 页面度量(VIS-1 坐标换算与敏感判定):视口 CSS 尺寸、DPR、是否有密码框、页面文字样本(只在服务端判定用,不外传)。
* 只读,不要求自动化控制权;上游不支持 evaluate 时返回 null。
*/
pageMetrics(ref: TabRef): Promise<PageMetrics | null>;
}
export interface PageMetrics {
viewportWidth: number;
viewportHeight: number;
devicePixelRatio: number;
/** 页面上有 input[type=password] */
passwordField: boolean;
/** document.body.innerText 前 4000 字(判定二次验证等,不进模型、不落日志) */
textSample: string;
}
/** 动作发起方:自动化(Agent/任务)或持有接管租约的人 */