diff --git a/package.json b/package.json index 3badae520..26282b92e 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "build": "tsc -b && tsc -p tsconfig.server.json && vite build", "typecheck": "tsc -b && tsc -p tsconfig.server.json", "test": "vitest run", + "bench:observation": "node --experimental-strip-types scripts/bench-observation.ts", "test:watch": "vitest", "check:electron": "node --check electron/main.mjs && node --check electron/preload.cjs && node --check electron/capabilities.cjs && node --check electron/cua-connection.cjs && node --check electron/cua.mjs && node --check electron/speech.mjs", "preview": "vite preview", diff --git a/scripts/bench-observation.ts b/scripts/bench-observation.ts new file mode 100644 index 000000000..7afc28990 --- /dev/null +++ b/scripts/bench-observation.ts @@ -0,0 +1,23 @@ +import { ObservationCoordinator } from "../server/computer-observation.ts"; + +// Stable fixture: every requested observation still captures, because pages +// can update asynchronously. Only byte-identical frames are withheld from the +// model; deterministic actions therefore always receive fresh evidence. +const frames = ["initial", "initial", "after-click", "after-click", "after-submit", "after-submit", "after-submit"]; +const baselineSent = frames.length; +const coordinator = new ObservationCoordinator(); +for (const [index, frame] of frames.entries()) { + // index 1 deliberately models an action whose pixels do not change: it + // must still capture once, while duplicate suppression avoids a model send. + if (index === 1 || index === 2 || index === 4) coordinator.noteAction(); + coordinator.observeFrame(frame, null); +} +const metrics = coordinator.metrics; +const reduction = ((1 - metrics.screenshotsSentToModel / baselineSent) * 100).toFixed(1); +const suppressed = metrics.screenshotsCaptured - metrics.screenshotsSentToModel; +if (suppressed < 1 || metrics.screenshotsCaptured !== frames.length) { + throw new Error("fixture failed to capture every observation or suppress a duplicate"); +} +console.log("Computer observation benchmark (offline deterministic fixture)"); +console.log(`Screenshot observations sent to model: ${baselineSent} → ${metrics.screenshotsSentToModel} (${reduction}% reduction)`); +console.log(`Actions: ${metrics.computerActions}; screenshots captured: ${metrics.screenshotsCaptured}; duplicate captures suppressed: ${suppressed}`); diff --git a/server/computer-observation.test.ts b/server/computer-observation.test.ts new file mode 100644 index 000000000..b00bbb79a --- /dev/null +++ b/server/computer-observation.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeBrowserUrl, + normalizeCrop, + ObservationCoordinator, + parseBrowserTargets, + safeBrowserUrl, +} from "./computer-observation.ts"; + +describe("computer observation coordinator", () => { + it("captures after actions but sends vision only for changed pixels", () => { + const coordinator = new ObservationCoordinator(); + expect(coordinator.observeFrame("frame-a", null)).toMatchObject({ changed: true }); + coordinator.noteAction(); + expect(coordinator.observeFrame("frame-a", null)).toMatchObject({ changed: false }); + coordinator.noteAction(); + expect(coordinator.observeFrame("frame-b", { x: 20, y: 20, width: 100, height: 100 })).toMatchObject({ changed: true }); + coordinator.noteRetry(); + coordinator.noteStructuredObservation(); + coordinator.noteVerification(true); + coordinator.noteVerification(false); + expect(coordinator.metrics).toEqual({ + screenshotsCaptured: 3, + screenshotsSentToModel: 2, + fullScreenObservations: 1, + croppedObservations: 1, + structuredBrowserObservations: 1, + computerActions: 2, + retries: 1, + verificationSuccesses: 1, + verificationFailures: 1, + }); + }); + + it("accepts bounded crop regions and rejects width or height overflow", () => { + expect(normalizeCrop({ x: 10, y: 20, width: 200, height: 100 }, 1280, 720)).toEqual({ + x: 10, + y: 20, + width: 200, + height: 100, + }); + expect(normalizeCrop({ x: -1, y: 0, width: 200, height: 100 }, 1280, 720)).toBeNull(); + expect(normalizeCrop({ x: 0, y: 0, width: 31, height: 100 }, 1280, 720)).toBeNull(); + expect(normalizeCrop({ x: 0, y: 0, width: 100, height: 31 }, 1280, 720)).toBeNull(); + expect(normalizeCrop({ x: 0, y: 0, width: "wide", height: 100 }, 1280, 720)).toBeNull(); + expect(normalizeCrop({ x: 1200, y: 0, width: 200, height: 100 }, 1280, 720)).toBeNull(); + expect(normalizeCrop({ x: 0, y: 700, width: 100, height: 50 }, 1280, 720)).toBeNull(); + }); + + it("redacts exposed URLs but preserves full navigation state internally", () => { + const raw = "https://user:password@example.com/a?token=secret#fragment"; + expect(safeBrowserUrl(raw)).toBe("https://example.com/a"); + expect(normalizeBrowserUrl(raw)).toBe("https://example.com/a?token=secret#fragment"); + expect(safeBrowserUrl(`https://example.com/${"a".repeat(2_048)}`)).toBeNull(); + expect(parseBrowserTargets(JSON.stringify([ + { id: "one", type: "page", title: " Example page ", url: raw }, + { id: "two", type: "service_worker", url: "https://example.com/worker" }, + ]))).toEqual([ + { + id: "one", + title: "Example page", + url: "https://example.com/a", + comparisonUrl: "https://example.com/a?token=secret#fragment", + }, + ]); + }); +}); diff --git a/server/computer-observation.ts b/server/computer-observation.ts new file mode 100644 index 000000000..c72c2d0f8 --- /dev/null +++ b/server/computer-observation.ts @@ -0,0 +1,159 @@ +import { createHash } from "node:crypto"; + +/** Provider-neutral policy for deciding when a computer observation needs vision. */ +export interface ObservationMetrics { + screenshotsCaptured: number; + screenshotsSentToModel: number; + fullScreenObservations: number; + croppedObservations: number; + structuredBrowserObservations: number; + computerActions: number; + retries: number; + verificationSuccesses: number; + verificationFailures: number; +} + +export interface CropRegion { + x: number; + y: number; + width: number; + height: number; +} + +export interface BrowserTarget { + id: string; + title: string; + /** Safe for a model or log: credentials, query, and fragment removed. */ + url: string; + /** Internal-only comparison value. Never include this in tool output. */ + comparisonUrl: string; +} + +export const emptyObservationMetrics = (): ObservationMetrics => ({ + screenshotsCaptured: 0, + screenshotsSentToModel: 0, + fullScreenObservations: 0, + croppedObservations: 0, + structuredBrowserObservations: 0, + computerActions: 0, + retries: 0, + verificationSuccesses: 0, + verificationFailures: 0, +}); + +export function normalizeCrop(raw: unknown, maxWidth: number, maxHeight: number): CropRegion | null { + if (!raw || typeof raw !== "object") return null; + const value = raw as Record; + const x = Math.round(Number(value.x)); + const y = Math.round(Number(value.y)); + const width = Math.round(Number(value.width)); + const height = Math.round(Number(value.height)); + if ( + ![x, y, width, height, maxWidth, maxHeight].every(Number.isFinite) || + maxWidth <= 0 || + maxHeight <= 0 || + x < 0 || + y < 0 || + width < 32 || + height < 32 + ) { + return null; + } + if (x + width > maxWidth || y + height > maxHeight) return null; + return { x, y, width, height }; +} + +/** Canonical value for internal navigation checks. Credentials are never + * needed for equality and are removed here; query and fragment remain so + * two distinct application states cannot verify as the same destination. */ +export function normalizeBrowserUrl(value: unknown): string | null { + if (typeof value !== "string" || !value || value.length > 8_192) return null; + try { + const url = new URL(value); + if (!/^https?:$/.test(url.protocol)) return null; + url.username = ""; + url.password = ""; + return url.toString(); + } catch { + return null; + } +} + +/** Removes credentials, query, and fragment before browser state reaches a model or log. */ +export function safeBrowserUrl(value: unknown): string | null { + const normalized = normalizeBrowserUrl(value); + if (!normalized) return null; + const url = new URL(normalized); + url.search = ""; + url.hash = ""; + const safe = url.toString(); + return safe.length <= 2_048 ? safe : null; +} + +/** Parses Chrome's /json/list response into a small, safe structured observation. */ +export function parseBrowserTargets(raw: string): BrowserTarget[] { + if (raw.length > 1_000_000) return []; + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.slice(0, 20).flatMap((item) => { + if (!item || typeof item !== "object") return []; + const value = item as Record; + const comparisonUrl = normalizeBrowserUrl(value.url); + const url = safeBrowserUrl(value.url); + if (value.type !== "page" || !url || !comparisonUrl || typeof value.id !== "string") return []; + const title = typeof value.title === "string" ? value.title.replace(/\s+/g, " ").trim().slice(0, 200) : ""; + return [{ id: value.id.slice(0, 100), title, url, comparisonUrl }]; + }); + } catch { + return []; + } +} + +/** + * Keeps observations cheap without claiming the screen is immutable. Every + * requested observation still captures fresh pixels (pages can change without + * an input action), while byte-identical frames are not sent to the model twice. + */ +export class ObservationCoordinator { + metrics = emptyObservationMetrics(); + private lastObservation: string | null = null; + + noteAction(count = 1) { + this.metrics.computerActions += Math.max(0, Math.trunc(count)); + } + + noteRetry() { + this.metrics.retries += 1; + } + + /** canonicalFrame must describe the full screenshot, even when the image + * returned to the model is cropped. A box-provided full-frame hash works. */ + observeFrame(canonicalFrame: string | null, crop: CropRegion | null) { + this.metrics.screenshotsCaptured += 1; + const hash = canonicalFrame + ? createHash("sha256").update(canonicalFrame).digest("hex") + : null; + const view = crop ? `${crop.x},${crop.y},${crop.width},${crop.height}` : "full"; + const signature = hash ? `${hash}:${view}` : null; + // If the box cannot provide a full-frame hash, fail open and send the + // valid image. Suppressing a possibly-new crop would be worse. + const changed = signature === null || signature !== this.lastObservation; + if (signature) this.lastObservation = signature; + if (changed) { + this.metrics.screenshotsSentToModel += 1; + if (crop) this.metrics.croppedObservations += 1; + else this.metrics.fullScreenObservations += 1; + } + return { changed, hash }; + } + + noteStructuredObservation() { + this.metrics.structuredBrowserObservations += 1; + } + + noteVerification(ok: boolean) { + if (ok) this.metrics.verificationSuccesses += 1; + else this.metrics.verificationFailures += 1; + } +} diff --git a/server/computer-proxy.test.ts b/server/computer-proxy.test.ts index 202017f60..f4ea9a576 100644 --- a/server/computer-proxy.test.ts +++ b/server/computer-proxy.test.ts @@ -34,6 +34,8 @@ describe("computer proxy (fake box)", () => { const commands: string[] = []; let fileReads = 0; let hash = "aaaa1111"; + let browserUrl = "https://example.com/"; + let cropFails = false; const rpc = (msg: unknown) => proxy.stdin!.write(JSON.stringify(msg) + "\n"); const results = new Map(); @@ -57,9 +59,15 @@ describe("computer proxy (fake box)", () => { commands.push(command); // a real box echoes what the capture block printed const size = Buffer.from(JPEG, "base64").length; - const stdout = /GEOM/.test(command) - ? `GEOM 1920 1080\nHASH ${hash}\nSIZE ${size}\nB64 ${JPEG}\nACT ok\n` - : "ACT ok\n"; + const stdout = command.includes("127.0.0.1:9222/json/list") + ? JSON.stringify([ + { id: "page-1", type: "page", title: " Example ", url: browserUrl }, + ]) + : cropFails && /convert "\$f" -crop/.test(command) + ? `GEOM 1920 1080\nHASH ${hash}\nCROP_FAILED\n` + : /GEOM/.test(command) + ? `GEOM 1920 1080\nHASH ${hash}\nSIZE ${size}\nB64 ${JPEG}\nACT ok\n` + : "ACT ok\n"; res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ exitCode: 0, stdout, stderr: "" })); }); @@ -110,13 +118,16 @@ describe("computer proxy (fake box)", () => { box?.close(); }); - it("exposes a batch tool and advertises that actions return the screen", async () => { + it("exposes action, structured-state, crop, and metrics tools", async () => { rpc({ jsonrpc: "2.0", id: 2, method: "tools/list" }); const res = await waitFor(2); const names = res.result.tools.map((t: any) => t.name); expect(names).toContain("computer_batch"); + expect(names).toEqual(expect.arrayContaining(["browser_state", "wait_for_navigation", "observation_metrics"])); const click = res.result.tools.find((t: any) => t.name === "click"); expect(click.description).toMatch(/return the resulting screen/i); + const screenshot = res.result.tools.find((t: any) => t.name === "screenshot"); + expect(screenshot.inputSchema.properties.region).toBeTruthy(); }); it("clicks and returns the frame in ONE round trip, scaled box-side", async () => { @@ -212,4 +223,170 @@ describe("computer proxy (fake box)", () => { expect(commands.at(-1)).not.toMatch(/scrot/); expect(res.result.content).toHaveLength(1); }); + + it("never exposes browser credentials, queries, or fragments", async () => { + browserUrl = "https://user:password@example.com/path?token=secret#private"; + rpc({ jsonrpc: "2.0", id: 8, method: "tools/call", params: { name: "browser_state", arguments: {} } }); + const res = await waitFor(8); + const output = res.result.content[0].text; + expect(output).toContain("https://example.com/path"); + expect(output).not.toMatch(/user|password|token|secret|private/); + }); + + it("does not verify a different query or an invalid expected URL", async () => { + browserUrl = "https://example.com/path?step=2#done"; + rpc({ jsonrpc: "2.0", id: 90, method: "tools/call", params: { name: "observation_metrics", arguments: {} } }); + const metricsBefore = JSON.parse((await waitFor(90)).result.content[0].text); + const beforeInvalid = commands.length; + rpc({ + jsonrpc: "2.0", + id: 9, + method: "tools/call", + params: { name: "wait_for_navigation", arguments: { url: "not a URL" } }, + }); + const invalid = await waitFor(9); + expect(invalid.result.isError).toBe(true); + expect(commands.length).toBe(beforeInvalid); + + rpc({ + jsonrpc: "2.0", + id: 10, + method: "tools/call", + params: { + name: "wait_for_navigation", + arguments: { url: "https://example.com/path?step=1#done" }, + }, + }); + const mismatch = await waitFor(10); + expect(mismatch.result.isError).toBe(true); + expect(mismatch.result.content[0].text).toMatch(/not verified/i); + + rpc({ + jsonrpc: "2.0", + id: 11, + method: "tools/call", + params: { + name: "wait_for_navigation", + arguments: { url: "https://example.com/path?step=2#done" }, + }, + }); + const exact = await waitFor(11); + expect(exact.result.isError).not.toBe(true); + expect(exact.result.content[0].text).toMatch(/verified/i); + expect(exact.result.content[0].text).not.toContain("step=2"); + + rpc({ jsonrpc: "2.0", id: 91, method: "tools/call", params: { name: "observation_metrics", arguments: {} } }); + const metricsAfter = JSON.parse((await waitFor(91)).result.content[0].text); + expect(metricsAfter.structuredBrowserObservations).toBe(metricsBefore.structuredBrowserObservations); + }); + + it("rejects out-of-height crops and fails closed when conversion fails", async () => { + rpc({ + jsonrpc: "2.0", + id: 120, + method: "tools/call", + params: { name: "screenshot", arguments: {} }, + }); + await waitFor(120); + const beforeBounds = commands.length; + rpc({ + jsonrpc: "2.0", + id: 12, + method: "tools/call", + params: { + name: "screenshot", + arguments: { region: { x: 0, y: 700, width: 100, height: 50 } }, + }, + }); + const bounds = await waitFor(12); + expect(bounds.result.isError).toBe(true); + expect(bounds.result.content[0].text).toMatch(/1280×720/); + expect(commands.length).toBe(beforeBounds); + + cropFails = true; + rpc({ + jsonrpc: "2.0", + id: 13, + method: "tools/call", + params: { + name: "screenshot", + arguments: { region: { x: 10, y: 20, width: 100, height: 80 } }, + }, + }); + const failed = await waitFor(13); + expect(failed.result.isError).toBe(true); + expect(failed.result.content).toHaveLength(1); + expect(failed.result.content[0].text).toMatch(/crop failed/i); + + cropFails = false; + }); + + it("uses a private Chrome profile, strips URL credentials, and reports redirects", async () => { + browserUrl = "https://example.com/landed?private=value#done"; + const before = commands.length; + rpc({ + jsonrpc: "2.0", + id: 130, + method: "tools/call", + params: { + name: "open_url", + arguments: { + url: "https://user:password@example.com/requested?token=secret#fragment", + observe: false, + }, + }, + }); + const result = await waitFor(130); + const issued = commands.slice(before); + expect(issued).toHaveLength(2); + expect(issued[0]).toContain('mkdir -p "$HOME/.openmausbot/chrome-profile"'); + expect(issued[0]).toContain('chmod 700 "$HOME/.openmausbot/chrome-profile"'); + expect(issued[0]).toContain('--user-data-dir="$HOME/.openmausbot/chrome-profile"'); + expect(issued[0]).not.toContain("user:password@"); + expect(issued[0]).toContain("'https://example.com/requested?token=secret#fragment'"); + expect(result.result.content[0].text).toContain("https://example.com/landed"); + expect(result.result.content[0].text).not.toMatch(/private|value|token|secret|fragment/); + }); + + it("hashes the full frame while treating distinct crops as distinct observations", async () => { + hash = "dddd4444"; + rpc({ + jsonrpc: "2.0", + id: 14, + method: "tools/call", + params: { + name: "screenshot", + arguments: { region: { x: 10, y: 20, width: 100, height: 80 } }, + }, + }); + const first = await waitFor(14); + expect(first.result.content.some((item: any) => item.type === "image")).toBe(true); + const command = commands.at(-1)!; + expect(command.indexOf('echo "HASH')).toBeLessThan(command.indexOf('-crop 100x80+10+20')); + + rpc({ + jsonrpc: "2.0", + id: 15, + method: "tools/call", + params: { + name: "screenshot", + arguments: { region: { x: 20, y: 20, width: 100, height: 80 } }, + }, + }); + const second = await waitFor(15); + expect(second.result.content.some((item: any) => item.type === "image")).toBe(true); + + rpc({ + jsonrpc: "2.0", + id: 16, + method: "tools/call", + params: { + name: "screenshot", + arguments: { region: { x: 20, y: 20, width: 100, height: 80 } }, + }, + }); + const repeated = await waitFor(16); + expect(repeated.result.content).toHaveLength(1); + expect(repeated.result.content[0].text).toMatch(/identical/i); + }); }); diff --git a/server/computer-proxy.ts b/server/computer-proxy.ts index 7fae99264..0370cac42 100644 --- a/server/computer-proxy.ts +++ b/server/computer-proxy.ts @@ -26,6 +26,16 @@ // type, Enter) in one round trip with one frame at the end. // // stdout is the MCP channel — never console.log here. +import { + normalizeBrowserUrl, + normalizeCrop, + ObservationCoordinator, + parseBrowserTargets, + safeBrowserUrl, + type BrowserTarget, + type CropRegion, +} from "./computer-observation.ts"; + const BOX_API = process.env.OGB_BOX_API ?? "https://ascii.dev/api/box/v1"; const boxId = process.env.OGB_BOX_ID ?? ""; const token = process.env.OGB_BOX_TOKEN ?? ""; @@ -39,6 +49,11 @@ const SHOT_PATH = "/tmp/ogb-shot.jpg"; const SETTLE_MS = 350; /** Gap between batched actions so focus changes land before typing. */ const ACTION_GAP_MS = 120; +const CHROME_PROFILE = "$HOME/.openmausbot/chrome-profile"; +const CHROME_DEBUG_FLAGS = + `--user-data-dir="${CHROME_PROFILE}" --no-first-run --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222`; +const CHROME_PROFILE_SETUP = + `mkdir -p "${CHROME_PROFILE}" && chmod 700 "${CHROME_PROFILE}"`; /** Frames larger than this come back over the files API instead of * inline stdout (keeps us clear of the command endpoint's stdout cap). */ const INLINE_MAX_BYTES = 400_000; @@ -93,6 +108,46 @@ async function runOnBox(command: string, timeoutMs = 60_000, allowWake = true): }; } +const observations = new ObservationCoordinator(); + +function metricsText(): string { + return JSON.stringify(observations.metrics); +} + +async function browserTargets(countObservation = true): Promise { + // DevTools stays loopback-only inside the box. Only redacted fields are + // ever formatted into tool output; comparisonUrl remains internal. + const out = await runOnBox("curl -sf --max-time 2 http://127.0.0.1:9222/json/list", 5_000); + const targets = out.ok ? parseBrowserTargets(out.stdout) : []; + if (countObservation && targets.length) observations.noteStructuredObservation(); + return targets; +} + +async function waitForNavigation( + value: string, + attempts = 3, +): Promise<{ ok: boolean; targets: BrowserTarget[] }> { + const expected = normalizeBrowserUrl(value); + if (!expected) { + observations.noteVerification(false); + return { ok: false, targets: [] }; + } + let targets: BrowserTarget[] = []; + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (attempt > 0) { + observations.noteRetry(); + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + targets = await browserTargets(false); + if (targets.some((target) => target.comparisonUrl === expected)) { + observations.noteVerification(true); + return { ok: true, targets }; + } + } + observations.noteVerification(false); + return { ok: false, targets }; +} + const ENV = 'export DISPLAY=${DISPLAY:-:0}'; /** Resolve the real display size into $W/$H for box-side click scaling. */ const GEOMETRY = [ @@ -112,8 +167,19 @@ function scaled(varName: string, value: number): string { return `if [ "$W" -gt ${SHOT_WIDTH} ] 2>/dev/null; then ${varName}=$(( ${v} * W / ${SHOT_WIDTH} )); else ${varName}=${v}; fi`; } -/** act → settle → capture → hash → (inline base64 if small). One hop. */ -function captureBlock(settleMs = SETTLE_MS): string { +/** act → settle → capture → canonical hash → optional crop → inline bytes. + * The hash is taken before cropping, so change detection always describes + * the full screen. A requested crop fails closed when conversion fails. */ +function captureBlock(settleMs = SETTLE_MS, crop: CropRegion | null = null): string { + const downscale = crop + ? `if [ "$W" -gt ${SHOT_WIDTH} ] 2>/dev/null; then if ! command -v convert >/dev/null 2>&1 || ! convert "$f" -thumbnail ${SHOT_WIDTH}x -quality ${JPEG_QUALITY} "$f" 2>/dev/null; then echo CROP_FAILED; exit 0; fi; fi` + : `if [ "$W" -gt ${SHOT_WIDTH} ] 2>/dev/null && command -v convert >/dev/null 2>&1; then convert "$f" -thumbnail ${SHOT_WIDTH}x -quality ${JPEG_QUALITY} "$f" 2>/dev/null || true; fi`; + const cropSteps = crop + ? [ + `if ! command -v convert >/dev/null 2>&1 || ! convert "$f" -crop ${crop.width}x${crop.height}+${crop.x}+${crop.y} +repage "$f" 2>/dev/null; then echo CROP_FAILED; exit 0; fi`, + `if [ ! -s "$f" ]; then echo CROP_FAILED; exit 0; fi`, + ] + : []; return [ settleMs > 0 ? `sleep ${(settleMs / 1000).toFixed(2)}` : "true", `f=${SHOT_PATH}`, @@ -121,10 +187,11 @@ function captureBlock(settleMs = SETTLE_MS): string { `scrot -o -q ${JPEG_QUALITY} "$f" 2>/dev/null || import -window root -quality ${JPEG_QUALITY} "$f" 2>/dev/null || ffmpeg -y -f x11grab -i "$DISPLAY" -frames:v 1 -q:v 6 "$f" >/dev/null 2>&1`, // only re-encode when the display is bigger than the model's space — // ImageMagick startup is the most expensive step in the old pipeline - `if [ "$W" -gt ${SHOT_WIDTH} ] 2>/dev/null && command -v convert >/dev/null 2>&1; then convert "$f" -thumbnail ${SHOT_WIDTH}x -quality ${JPEG_QUALITY} "$f" 2>/dev/null || true; fi`, + downscale, `if [ ! -s "$f" ]; then echo SHOT_FAILED; exit 0; fi`, 'echo "GEOM $W $H"', 'echo "HASH $(md5sum "$f" 2>/dev/null | cut -d\' \' -f1)"', + ...cropSteps, 's=$(stat -c%s "$f" 2>/dev/null || echo 0)', // SIZE is what makes the inline path safe: the frame is only trusted // when the bytes we decoded match the bytes the box says it wrote @@ -196,10 +263,33 @@ interface Frame { } let inlineWorks = true; // flipped off for the proxy's life on first garbage -let lastFrameHash: string | null = null; +let lastDisplayGeometry: Frame["geometry"] = null; + +function geometryFrom(stdout: string): Frame["geometry"] { + const match = stdout.match(/^GEOM\s+(\d+)\s+(\d+)$/m); + if (!match) return null; + const width = Number(match[1]); + const height = Number(match[2]); + return width > 0 && height > 0 ? { width, height } : null; +} + +async function observationBounds(): Promise<{ width: number; height: number } | null> { + let geometry = lastDisplayGeometry; + if (!geometry) { + const out = await runOnBox([ENV, GEOMETRY, 'echo "GEOM $W $H"'].join("; "), 15_000); + geometry = geometryFrom(out.stdout); + if (geometry) lastDisplayGeometry = geometry; + } + if (!geometry) return null; + const scale = geometry.width > SHOT_WIDTH ? SHOT_WIDTH / geometry.width : 1; + return { + width: Math.round(geometry.width * scale), + height: Math.round(geometry.height * scale), + }; +} async function frameFrom(out: RunOut): Promise { - if (/SHOT_FAILED/.test(out.stdout)) return null; + if (/SHOT_FAILED|CROP_FAILED/.test(out.stdout)) return null; let hash: string | null = null; let geometry: Frame["geometry"] = null; let inline = ""; @@ -212,6 +302,7 @@ async function frameFrom(out: RunOut): Promise { if (Number.isFinite(w) && w > 0) geometry = { width: w, height: Number.isFinite(h) ? h : 0 }; } else if (line.startsWith("B64 ")) inline = line.slice(4).trim(); } + if (geometry?.height) lastDisplayGeometry = geometry; if (inline && inlineWorks) { const bytes = Buffer.from(inline, "base64"); if (wholeImage(bytes, size || undefined)) return { data: inline, mime: "image/jpeg", hash, geometry }; @@ -233,20 +324,25 @@ const text = (id: unknown, t: string, isError = false): void => /** An action result: the text plus the frame the action produced. When * the pixels are byte-identical to the frame the model just saw, the * image is dropped — it already has it, and it costs ~1.2k tokens. */ -function observed(id: unknown, note: string, frame: Frame | null) { +function observed( + id: unknown, + note: string, + frame: Frame | null, + crop: CropRegion | null = null, + followsAction = true, +) { if (!frame) { return text(id, `${note}\n(couldn't capture the screen — call screenshot to retry)`); } - const unchanged = frame.hash != null && frame.hash === lastFrameHash; - lastFrameHash = frame.hash ?? lastFrameHash; - if (unchanged) { + const observation = observations.observeFrame(frame.hash ?? (crop ? null : frame.data), crop); + if (!observation.changed) { // deliberately does NOT suggest repeating the action: the action may // well have landed, and re-clicking a button that already submitted // is the expensive kind of wrong - return text( - id, - `${note}\n(the screen is identical to the frame you already have, so no new image is attached. Don't repeat the action — if you expected a change, it may still be rendering: call screenshot again in a moment, or re-check your coordinates against that frame.)`, - ); + const guidance = followsAction + ? " Don't repeat the action — it may already have succeeded. If you expected a change, call screenshot again after it has had time to render." + : " No new image is attached."; + return text(id, `${note}\n(the screen is identical to the frame you already have.${guidance})`); } send({ jsonrpc: "2.0", @@ -273,7 +369,43 @@ const TOOLS = [ { name: "screenshot", description: - "See the bot's cloud computer screen (returns an image). The desktop runs Chrome and a full Linux GUI. You usually do NOT need this after acting — click, type_text, press_key, scroll and open_url already return the resulting screen.", + "See the bot's cloud computer screen when visual state is needed. First prefer browser_state for Chrome title/URL checks. The frame is captured fresh; byte-identical pixels are not resent.", + inputSchema: { + type: "object", + properties: { + region: { + type: "object", + description: "Optional crop in the coordinates of the last screenshot.", + properties: { + x: { type: "number" }, + y: { type: "number" }, + width: { type: "number" }, + height: { type: "number" }, + }, + required: ["x", "y", "width", "height"], + }, + }, + }, + }, + { + name: "browser_state", + description: + "Read structured Chrome page titles and safe URLs. Credentials, query strings, and fragments are removed before output.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "wait_for_navigation", + description: + "Verify that Chrome reached one exact http(s) URL, including its query and fragment, with at most three bounded checks.", + inputSchema: { + type: "object", + properties: { url: { type: "string" } }, + required: ["url"], + }, + }, + { + name: "observation_metrics", + description: "Return this turn's observation, action, retry, and verification counters.", inputSchema: { type: "object", properties: {} }, }, { @@ -374,7 +506,8 @@ const TOOLS = [ }, { name: "open_url", - description: "Open a URL in the computer's own Chrome and return the resulting screen.", + description: + "Open a URL in the computer's own Chrome, verify the exact destination when DevTools is available, and return the resulting screen.", inputSchema: { type: "object", properties: { url: { type: "string" }, ...OBSERVE_PROPS }, @@ -438,6 +571,7 @@ async function actAndObserve( if (parts.length) parts.push(`sleep ${(ACTION_GAP_MS / 1000).toFixed(2)}`); parts.push(shell); } + observations.noteAction(actions.filter((action) => action?.action !== "wait").length); const observe = wantsFrame(args); // The actions run in a guarded group so a failing xdotool is REPORTED // rather than silently swallowed by the capture that follows it — but @@ -464,19 +598,55 @@ async function actAndObserve( async function call(id: unknown, name: string, args: any) { if (name === "screenshot") { - const out = await runOnBox([ENV, GEOMETRY, captureBlock(0)].join("; "), 60_000); + let crop: CropRegion | null = null; + if (args.region !== undefined) { + const bounds = await observationBounds(); + if (!bounds) return text(id, "crop unavailable: could not determine the screenshot dimensions", true); + crop = normalizeCrop(args.region, bounds.width, bounds.height); + if (!crop) { + return text( + id, + `region must be at least 32×32 and stay within the ${bounds.width}×${bounds.height} screenshot`, + true, + ); + } + } + const out = await runOnBox([ENV, GEOMETRY, captureBlock(0, crop)].join("; "), 60_000); + if (/CROP_FAILED/.test(out.stdout)) { + return text(id, `crop failed: ${out.stderr.slice(0, 200) || "ImageMagick could not create the requested region"}`, true); + } const frame = await frameFrom(out); if (!frame) { return text(id, `screenshot failed: ${out.stderr.slice(0, 200) || "capture produced no frame"}`, true); } - // an explicit look always returns pixels, even if nothing moved - lastFrameHash = frame.hash ?? lastFrameHash; - return send({ - jsonrpc: "2.0", + return observed(id, crop ? "cropped screen captured" : "screen captured", frame, crop, false); + } + if (name === "browser_state") { + const targets = await browserTargets(); + return text( id, - result: { content: [{ type: "image", data: frame.data, mimeType: frame.mime }] }, - }); + targets.length + ? `Structured browser state:\n${targets.map((target) => `- ${target.title || "Untitled"}: ${target.url}`).join("\n")}` + : "Structured browser state unavailable. Use screenshot only if visual state is necessary.", + ); + } + if (name === "wait_for_navigation") { + const url = String(args.url ?? ""); + const publicUrl = safeBrowserUrl(url); + if (!normalizeBrowserUrl(url) || !publicUrl) { + observations.noteVerification(false); + return text(id, "wait_for_navigation needs a valid http(s) URL", true); + } + const result = await waitForNavigation(url); + return text( + id, + result.ok + ? `navigation verified: ${publicUrl}` + : `navigation not verified after 3 checks. Current structured state: ${result.targets.map((target) => target.url).join(", ") || "unavailable"}. Use screenshot only if needed.`, + !result.ok, + ); } + if (name === "observation_metrics") return text(id, metricsText()); if (name === "click") { const x = Math.round(Number(args.x)); const y = Math.round(Number(args.y)); @@ -519,6 +689,7 @@ async function call(id: unknown, name: string, args: any) { } if (name === "computer_exec") { const command = String(args.command ?? "").slice(0, 4000); + observations.noteAction(); const out = await runOnBox(command, 120_000); const note = `exit ${out.exitCode}\n${out.stdout.slice(-6000)}${out.stderr ? `\n[stderr]\n${out.stderr.slice(-2000)}` : ""}`; if (args.observe !== true) return text(id, note); @@ -527,21 +698,30 @@ async function call(id: unknown, name: string, args: any) { } if (name === "open_url") { const url = String(args.url ?? ""); - if (!/^https?:\/\//.test(url)) return text(id, "only http(s) URLs", true); - const q = shellQuote(url.replace(/'/g, "%27")); + const normalized = normalizeBrowserUrl(url); + const publicUrl = safeBrowserUrl(url); + if (!normalized || !publicUrl) return text(id, "only valid http(s) URLs", true); + const q = shellQuote(normalized); const observe = wantsFrame(args); // launch, then poll for a browser window instead of a blind sleep — // a fast page returns in a fraction of the old fixed 3s const command = [ ENV, GEOMETRY, - `(google-chrome ${q} || chromium ${q} || chromium-browser ${q} || xdg-open ${q}) >/dev/null 2>&1 &`, + CHROME_PROFILE_SETUP, + `(google-chrome ${CHROME_DEBUG_FLAGS} ${q} || chromium ${CHROME_DEBUG_FLAGS} ${q} || chromium-browser ${CHROME_DEBUG_FLAGS} ${q} || xdg-open ${q}) >/dev/null 2>&1 &`, 'for i in 1 2 3 4 5 6 7 8 9 10 11 12; do xdotool search --onlyvisible --class "chrom" >/dev/null 2>&1 && break; sleep 0.25; done', observe ? captureBlock(600) : "true", ].join("; "); + observations.noteAction(); const out = await runOnBox(command, 60_000); - if (!observe) return text(id, `opened ${url}`); - return observed(id, `opened ${url}`, await frameFrom(out)); + const verification = await waitForNavigation(normalized, 1); + const current = verification.targets.map((target) => target.url).join(", ") || "unavailable"; + const note = verification.ok + ? `opened and navigation verified: ${publicUrl}` + : `opened ${publicUrl}, but the exact destination was not verified. Current structured state: ${current}`; + if (!observe) return text(id, note); + return observed(id, note, await frameFrom(out)); } return text(id, `unknown tool ${name}`, true); }