diff --git a/README.md b/README.md index 7112ef4..07246b9 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ Conductor). ## Highlights - **Shared agent sessions** — one isolated browser session per Herdr workspace. +- **Attach to any CDP browser** — observe a Playwright, Puppeteer, or Browser Use + run (or any Chrome started with `--remote-debugging-port`) without owning it. - **Live push streaming** — frames, URL/title changes, console messages, and page errors arrive over WebSocket, with transparent polling fallback. - **Failed network requests** — 4xx/5xx and no-response xhr/fetch/document @@ -37,7 +39,7 @@ Conductor). | Component | Requirement | Notes | | --- | --- | --- | | Herdr | `>= 0.7.0` | Tested with Herdr 0.7.4 | -| Node.js | `>= 20` | Node 22+ enables live WebSocket streaming | +| Node.js | `>= 20` | Node 22+ enables live WebSocket streaming and CDP attach mode | | agent-browser | Required | Tested with agent-browser 0.33.x; failed-request reporting needs the `network requests` command | | chafa | Optional | ANSI rendering and streamed JPEGs in Kitty mode | | carbonyl | Optional | Only required for the separate interactive Browse action | @@ -137,6 +139,7 @@ Use these controls to drive the shared session directly: | Input | Action | | --- | --- | | `u` | Open the address prompt; `https://` is assumed when omitted | +| `a` | Attach to a CDP endpoint (`http://host:port` or `ws://…`) | | Click the screenshot | Send real Chrome mouse move/down/up events at that page coordinate | | `i` | Type into the currently focused page element | | `b` / `f` | Navigate backward / forward | @@ -213,6 +216,57 @@ identical failure paints once and stays collapsed until it has been quiet for sessions the feed turns itself off with a one-time note once the daemon's request log outgrows the pane's read buffer. +## Attach to any CDP browser + +The pane can observe a browser it does not own. Point it at a Chrome DevTools +Protocol endpoint and it renders that browser's page, streams its console and +network failures, and forwards your clicks and keystrokes — while your +automation client keeps driving. + +```sh +# the browser your automation already runs, with a debugging port +chrome --remote-debugging-port=9222 + +# tell the pane where to look (either source works) +export HERDR_BROWSER_CDP_URL=http://127.0.0.1:9222 +printf 'http://127.0.0.1:9222\n' > "$(herdr plugin config-dir structupath.browser)/cdp-url" +``` + +Press `a` in the pane to attach at runtime. `u` still means "navigate" — the +keys are separate because `localhost:9222` is a valid destination as well as a +valid endpoint. + +Launcher recipes: Playwright `chromium.launch({args:['--remote-debugging-port=9222']})`, +Puppeteer the same `args`, Browser Use its `chrome_remote_debugging_port` option. +A default launch often uses a pipe transport with no TCP port — the port has to +be requested explicitly. + +### What attach mode guarantees + +- **It never owns anything.** No target is created or closed, no viewport or + device emulation is set (your automation client owns those — a pane that + overrode them would fight the client it is meant to observe), and quitting + the pane closes only its own screencast and socket. +- **It opens no port.** The pane dials out to the endpoint you name. There is + no gateway, proxy, or listening socket to secure. +- **Endpoint tokens stay secret.** A DevTools URL's path is a capability token; + the pane displays and logs `host:port` only. +- **One honest footprint:** the console feed calls `Runtime.enable`, which is + observable by the page and is avoided by stealth automation stacks. Set + `consoleTier` to `log-only` to skip it — network failures and violations + still surface through the Log domain. + +### What it shows + +Failed requests appear with Chrome's own error text — `net::ERR_CONNECTION_REFUSED` +rather than a bare status — alongside console output, uncaught exceptions, and +failures from embedded iframes and workers. One blind spot by design: a request +that hangs without ever failing produces no CDP event, so attach mode cannot +report it the way the agent-browser polling feed's timeout heuristic does. + +Attach mode needs Node 22 or newer (for the built-in WebSocket client); the pane +says so plainly on older Node and keeps working in agent-browser mode. + ## Session model By default, each Herdr workspace uses: diff --git a/bin/cdp.mjs b/bin/cdp.mjs new file mode 100644 index 0000000..8b71b12 --- /dev/null +++ b/bin/cdp.mjs @@ -0,0 +1,548 @@ +// Zero-dependency Chrome DevTools Protocol client for attach mode. +// The pane attaches to a browser someone else owns (Playwright, Puppeteer, +// Browser Use, plain --remote-debugging-port Chrome) — so this layer never +// creates or closes targets and exposes no listening port of its own. +import http from "node:http"; +import net from "node:net"; + +// Attach mode needs a WebSocket client; Node grew a global one in 22. +// On Node 20 the pane keeps working in agent-browser mode — callers gate on +// this instead of crashing at connect time. +export function cdpSupported() { + return typeof WebSocket === "function"; +} + +// DevTools ws URLs are capability tokens (a GUID path grants full browser +// control). Anything user-visible — header, banners, logs — gets host:port +// only, never the path. +export function redactWsUrl(url) { + try { + const u = new URL(url); + return u.host; + } catch { + return "invalid endpoint"; + } +} + +// GET a /json/* endpoint over node:http. WHATWG fetch cannot send a custom +// Host header (undici silently drops it), and Chrome 111+ rejects /json/* +// requests whose Host is a DNS name — so we dial the resolved address and +// send an IP-literal Host explicitly. +function getJson(host, port, path, timeout = 5_000) { + return new Promise((resolve, reject) => { + const hostHeader = net.isIPv6(host) ? `[${host}]:${port}` : `${host}:${port}`; + const req = http.request( + { host, port, path, method: "GET", headers: { Host: hostHeader }, timeout }, + (res) => { + let body = ""; + res.setEncoding("utf8"); + res.on("data", (c) => { + body += c; + if (body.length > 4 * 1024 * 1024) req.destroy(new Error("oversized /json response")); + }); + res.on("end", () => { + if (res.statusCode !== 200) + return reject(new Error(`endpoint answered ${res.statusCode} for ${path}`)); + try { + resolve(JSON.parse(body)); + } catch { + reject(new Error(`endpoint returned non-JSON for ${path}`)); + } + }); + }, + ); + req.on("timeout", () => req.destroy(new Error("endpoint timed out"))); + req.on("error", reject); + req.end(); + }); +} + +// Resolve a user-supplied endpoint — http(s)://host:port or a browser-level +// ws:// URL — to { wsUrl, host, port, browser (product string), pages }. +// A pasted page-level URL (/devtools/page/) is refused with a pointer at +// the browser endpoint: Target.attachToTarget only works browser-level, and +// this is the most common paste mistake from /json/list output. +export async function discoverEndpoint(input, { lookup } = {}) { + let u; + try { + u = new URL(String(input).trim()); + } catch { + throw new Error("not an endpoint URL — use http://host:port or ws://…"); + } + if (/\/devtools\/page\//.test(u.pathname)) + throw new Error( + "that is a page-level DevTools URL — use the browser endpoint (http://host:port or /devtools/browser/…)", + ); + if (u.protocol === "ws:" || u.protocol === "wss:") { + // Raw ws endpoint: no HTTP discovery surface; identity fields best-effort. + return { + wsUrl: u.href, + host: u.hostname, + port: u.port, + browser: null, + guid: u.pathname.split("/").pop() || null, + pages: [], + rediscoverable: false, + }; + } + if (u.protocol !== "http:" && u.protocol !== "https:") + throw new Error("not an endpoint URL — use http://host:port or ws://…"); + let host = u.hostname.replace(/^\[|\]$/g, ""); + if (!net.isIP(host)) { + const resolve = lookup ?? (await import("node:dns/promises")).lookup; + host = (await resolve(host)).address; + } + const port = u.port || "9222"; + const version = await getJson(host, port, "/json/version"); + const wsUrl = version.webSocketDebuggerUrl; + if (!wsUrl) throw new Error("endpoint has no webSocketDebuggerUrl — not a DevTools endpoint"); + let pages = []; + try { + const list = await getJson(host, port, "/json/list"); + if (Array.isArray(list)) pages = list.filter((t) => t.type === "page"); + } catch { + /* list is best-effort; attach can enumerate targets itself */ + } + return { + wsUrl, + host, + port, + browser: version.Browser ?? null, + guid: new URL(wsUrl).pathname.split("/").pop() || null, + pages, + rediscoverable: true, + }; +} + +// A CDP connection over the browser-level socket with flat sessions: +// requests carry an optional sessionId, responses correlate by id, events +// route to subscribers. Never throws out of the message handler — a torn +// frame from a dying browser must not become an uncaughtException. +export function makeCdpSession(wsUrl, { wsFactory } = {}) { + const factory = wsFactory ?? ((url) => new WebSocket(url)); + const ws = factory(wsUrl); + let msgId = 0; + let dead = false; + const pending = new Map(); // id -> {resolve, reject, timer} + const handlers = new Set(); // fn({method, params, sessionId}) + const closers = new Set(); + const failAll = (why) => { + if (dead) return; + dead = true; + for (const { reject, timer } of pending.values()) { + clearTimeout(timer); + reject(new Error(why)); + } + pending.clear(); + for (const fn of closers) { + try { + fn(why); + } catch { + /* subscriber's problem */ + } + } + }; + const opened = new Promise((resolve, reject) => { + const t = setTimeout(() => { + try { + ws.close(); + } catch { + /* fine */ + } + reject(new Error("endpoint connect timed out")); + }, 5_000); + ws.onopen = () => { + clearTimeout(t); + resolve(); + }; + ws.onerror = () => { + clearTimeout(t); + reject(new Error("endpoint refused the connection")); + failAll("connection failed"); + }; + }); + ws.onclose = () => failAll("connection closed"); + ws.onmessage = (ev) => { + let m; + try { + m = JSON.parse(ev.data); + } catch { + return; + } + if (m.id !== undefined && pending.has(m.id)) { + const { resolve, reject, timer } = pending.get(m.id); + pending.delete(m.id); + clearTimeout(timer); + if (m.error) reject(new Error(m.error.message || "CDP error")); + else resolve(m.result); + return; + } + if (m.method) { + for (const fn of handlers) { + try { + fn(m); + } catch { + /* one bad subscriber must not drop events for the rest */ + } + } + } + }; + return { + opened, + send(method, params = {}, sessionId, timeout = 10_000) { + if (dead) return Promise.reject(new Error("connection closed")); + return new Promise((resolve, reject) => { + const id = ++msgId; + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`${method} timed out`)); + }, timeout); + pending.set(id, { resolve, reject, timer }); + try { + ws.send(JSON.stringify(sessionId ? { id, method, params, sessionId } : { id, method, params })); + } catch (err) { + pending.delete(id); + clearTimeout(timer); + reject(err); + } + }); + }, + onEvent(fn) { + handlers.add(fn); + return () => handlers.delete(fn); + }, + onClose(fn) { + closers.add(fn); + }, + // Liveness probe: half-open sockets can sit silent for minutes; a + // bounded getVersion answers "is anyone there" without side effects. + async ping(timeout = 4_000) { + try { + await this.send("Browser.getVersion", {}, undefined, timeout); + return true; + } catch { + return false; + } + }, + get dead() { + return dead; + }, + close() { + dead = true; + try { + ws.close(); + } catch { + /* already closed */ + } + }, + }; +} + +// --- Renderer-facing backend adapter --- + +// The attach-mode counterpart of makeBrowser. It deliberately has NO +// setViewport (the automation client owns emulation — competitors that +// override it fight their own clients), NO network (the polling failure +// feed is agent-browser-specific; attach mode feeds the console from CDP +// events), and NO snapshot/streamEnable/streamStatus (frames are pushed). +// The Renderer's existing typeof guards turn those absences into disabled +// features instead of crashes. It never calls Target.createTarget, +// Target.closeTarget, or Emulation.* — the pane observes, it does not own. +export function makeCdpBrowser(endpointInput, opts = {}) { + const quality = opts.quality ?? 60; + const maxDim = opts.maxDim ?? 1280; + const consoleTier = opts.consoleTier === "log-only" ? "log-only" : "runtime+log"; + let session = null; + let endpoint = null; + let pageSessionId = null; + let pinnedTargetId = null; + let gen = 0; // screencast generation: stale acks and frames are discarded + let lastMeta = null; // latest frame metadata (deviceWidth/Height for input scaling) + let handler = null; // onMessage subscriber (the Renderer) + let attachTimeMs = 0; + const emit = (m) => { + try { + handler?.(m); + } catch { + /* the Renderer guards its own paint path */ + } + }; + + const pageTargets = async () => { + const { targetInfos } = await session.send("Target.getTargets"); + return targetInfos.filter((t) => t.type === "page"); + }; + + const startScreencast = async () => { + const g = ++gen; + await session.send( + "Page.startScreencast", + { format: "jpeg", quality, maxWidth: maxDim, maxHeight: maxDim, everyNthFrame: 1 }, + pageSessionId, + ); + return g; + }; + + // Console/error/network feed. Log is always on: it carries network + // failures with Chrome's real error text (net::ERR_*), detail the + // agent-browser daemon drops entirely. Runtime is opt-out because + // Runtime.enable is page-observable — stealth automation stacks avoid it, + // and observing a run must not be able to change its outcome. + const enableFeed = async (sessionId) => { + await session.send("Log.enable", {}, sessionId); + if (consoleTier !== "log-only") + await session.send("Runtime.enable", {}, sessionId); + }; + + const pinTarget = async (targetId) => { + const { sessionId } = await session.send("Target.attachToTarget", { + targetId, + flatten: true, + }); + pinnedTargetId = targetId; + pageSessionId = sessionId; + await session.send("Page.enable", {}, sessionId); + await enableFeed(sessionId); + // Events-only auto-attach: OOPIFs and workers deliver their console and + // network failures on their own sessions, and a broken embedded frame + // with a silent console is exactly the case this feature exists for. + // Rendering and input stay pinned to the page target. + try { + await session.send( + "Target.setAutoAttach", + { autoAttach: true, waitForDebuggerOnStart: false, flatten: true }, + sessionId, + ); + } catch { + /* older engines: page-level feed only */ + } + await startScreencast(); + }; + + const onCdpEvent = (m) => { + if (m.method === "Page.screencastFrame" && m.sessionId === pageSessionId) { + lastMeta = m.params.metadata ?? null; + emit({ + type: "frame", + data: m.params.data, + metadata: lastMeta, + // Two distinct ids: params.sessionId is the INTEGER the ack must + // echo; m.sessionId is the flat-session routing string. Conflate + // them and Chrome ignores the ack — the stream freezes at quota. + ackId: m.params.sessionId, + gen, + }); + return; + } + if (m.method === "Target.targetInfoChanged") { + const t = m.params.targetInfo; + if (t.targetId === pinnedTargetId) + emit({ type: "url", url: t.url, title: t.title }); + return; + } + if (m.method === "Target.targetDestroyed") { + if (m.params.targetId !== pinnedTargetId) return; + pinnedTargetId = null; + pageSessionId = null; + // Re-pin only on destruction of OUR target — never follow creation. + pageTargets() + .then(async (pages) => { + if (!pages.length) return emit({ type: "target_gone" }); + await pinTarget(pages[0].targetId); + emit({ type: "url", url: pages[0].url, title: pages[0].title }); + }) + .catch(() => emit({ type: "target_gone" })); + return; + } + if (m.method === "Inspector.targetCrashed" && m.sessionId === pageSessionId) { + emit({ type: "page_error", text: "page crashed — waiting for reload" }); + return; + } + // A newly auto-attached OOPIF/worker session needs its own feed. + if (m.method === "Target.attachedToTarget") { + const sid = m.params.sessionId; + enableFeed(sid).catch(() => {}); + return; + } + if (m.method === "Runtime.consoleAPICalled") { + if (isReplay(m.params.timestamp)) return; + emit({ + type: "console", + level: m.params.type === "warning" ? "warn" : m.params.type, + text: consoleArgsText(m.params.args), + }); + return; + } + if (m.method === "Runtime.exceptionThrown") { + if (isReplay(m.params.timestamp)) return; + const d = m.params.exceptionDetails ?? {}; + emit({ + type: "page_error", + text: d.exception?.description ?? d.text ?? "uncaught exception", + }); + return; + } + if (m.method === "Log.entryAdded") { + const e = m.params.entry ?? {}; + if (isReplay(e.timestamp)) return; + emit({ + type: "log_entry", + source: e.source, + level: e.level, + text: e.text ?? "", + url: e.url ?? "", + }); + } + }; + + // Chrome flushes buffered console/log history when the domains are + // enabled — the same wall-of-history problem the network feed's silent + // baseline solves. CDP timestamps are ms since epoch. + const isReplay = (ts) => + typeof ts === "number" && attachTimeMs > 0 && ts < attachTimeMs; + + // Console args are page-controlled and can be huge; take the shallow text + // only. No Runtime.getProperties — that would both bloat the pane and + // deepen the observable footprint on the page. + const consoleArgsText = (args) => + (args ?? []) + .map((a) => { + if (a.unserializableValue !== undefined) return String(a.unserializableValue); + if (a.value !== undefined) return typeof a.value === "string" ? a.value : JSON.stringify(a.value); + return a.description ?? a.className ?? a.type ?? ""; + }) + .join(" ") + .slice(0, 2_000); + + return { + // Identity of what we're attached to; the Renderer compares guid across + // reattaches so a reused port can't silently swap browsers underneath. + async connect() { + endpoint = await discoverEndpoint(endpointInput, opts); + session = makeCdpSession(endpoint.wsUrl, opts); + await session.opened; + session.onEvent(onCdpEvent); + session.onClose(() => emit({ type: "endpoint_gone" })); + await session.send("Target.setDiscoverTargets", { discover: true }); + const pages = await pageTargets(); + if (!pages.length) throw new Error("endpoint has no page targets"); + attachTimeMs = Date.now(); + await pinTarget(pages[0].targetId); + return { + host: endpoint.host, + port: endpoint.port, + browser: endpoint.browser, + guid: endpoint.guid, + rediscoverable: endpoint.rediscoverable, + url: pages[0].url, + title: pages[0].title, + }; + }, + onMessage(fn) { + handler = fn; + }, + attachTime: () => attachTimeMs, + frameMetadata: () => lastMeta, + // Ack path for the Renderer's paint-settle hook. Generation-guarded so + // an ack from before a restart/re-pin can never reach a new screencast. + async ackFrame(ackId, frameGen) { + if (frameGen !== gen || !pageSessionId) return; + try { + await session.send("Page.screencastFrameAck", { sessionId: ackId }, pageSessionId); + } catch { + /* stream may be mid-restart; the watchdog covers a stall */ + } + }, + async restartScreencast() { + try { + await session.send("Page.stopScreencast", {}, pageSessionId); + } catch { + /* already stopped */ + } + await startScreencast(); + }, + async cycleTarget() { + const pages = await pageTargets(); + if (pages.length < 2) return false; + const i = pages.findIndex((t) => t.targetId === pinnedTargetId); + const next = pages[(i + 1) % pages.length]; + try { + await session.send("Page.stopScreencast", {}, pageSessionId); + } catch { + /* old session may be gone */ + } + await pinTarget(next.targetId); + emit({ type: "url", url: next.url, title: next.title }); + return true; + }, + async open(u) { + await session.send("Page.navigate", { url: u }, pageSessionId); + }, + async back() { + const h = await session.send("Page.getNavigationHistory", {}, pageSessionId); + if (h.currentIndex <= 0) return; + await session.send( + "Page.navigateToHistoryEntry", + { entryId: h.entries[h.currentIndex - 1].id }, + pageSessionId, + ); + }, + async forward() { + const h = await session.send("Page.getNavigationHistory", {}, pageSessionId); + if (h.currentIndex >= h.entries.length - 1) return; + await session.send( + "Page.navigateToHistoryEntry", + { entryId: h.entries[h.currentIndex + 1].id }, + pageSessionId, + ); + }, + async reload() { + await session.send("Page.reload", {}, pageSessionId); + }, + // x/y arrive in page CSS pixels — the Renderer scales pane cells -> + // frame pixels -> CSS via the per-frame metadata before calling. + async click(x, y) { + const base = { x, y, button: "left", clickCount: 1 }; + await session.send("Input.dispatchMouseEvent", { type: "mousePressed", ...base }, pageSessionId); + await session.send("Input.dispatchMouseEvent", { type: "mouseReleased", ...base }, pageSessionId); + }, + async scroll(dir, px) { + const m = lastMeta; + const cx = m ? Math.floor((m.deviceWidth ?? 800) / 2) : 400; + const cy = m ? Math.floor((m.deviceHeight ?? 600) / 2) : 300; + await session.send( + "Input.dispatchMouseEvent", + { type: "mouseWheel", x: cx, y: cy, deltaX: 0, deltaY: dir === "down" ? px : -px }, + pageSessionId, + ); + }, + async type(text) { + await session.send("Input.insertText", { text }, pageSessionId); + }, + async screenshot(file) { + const { data } = await session.send( + "Page.captureScreenshot", + { format: "png" }, + pageSessionId, + 15_000, + ); + const fs = await import("node:fs"); + fs.writeFileSync(file, Buffer.from(data, "base64")); + }, + async sessionExists() { + if (!session || session.dead || !pinnedTargetId) return false; + return session.ping(); + }, + close() { + const s = session; + if (!s || s.dead) return; + // Attach-mode cleanup: stop OUR screencast, close OUR socket. + // Never a Target.closeTarget, never an agent-browser subprocess. + const done = pageSessionId + ? s.send("Page.stopScreencast", {}, pageSessionId, 1_000).catch(() => {}) + : Promise.resolve(); + done.finally(() => s.close()); + }, + _session: () => session, // U4 console wiring + tests reach the raw session + }; +} diff --git a/bin/renderer.mjs b/bin/renderer.mjs index 47fa168..e1023de 100644 --- a/bin/renderer.mjs +++ b/bin/renderer.mjs @@ -8,6 +8,7 @@ import { createHash } from "node:crypto"; import { pathToFileURL } from "node:url"; import fs from "node:fs"; import path from "node:path"; +import { makeCdpBrowser, cdpSupported, redactWsUrl } from "./cdp.mjs"; const pExecFile = promisify(execFile); const ESC = "\x1b"; @@ -557,7 +558,19 @@ export class Renderer { `shot-${safeWsId(env.HERDR_WORKSPACE_ID)}.png`, ); this.bin = "agent-browser"; - this.browser = makeBrowser(this.session, this.bin); + // Backend arbitration: an explicitly configured CDP endpoint is the more + // deliberate act than an ambient agent-browser session, so it wins — and + // it wins deterministically at start, never by racing discovery. + this.cdpEndpoint = this.resolveCdpEndpoint(env); + this.mode = this.cdpEndpoint ? "attach" : "agent-browser"; + this.browser = + this.mode === "attach" + ? makeCdpBrowser(this.cdpEndpoint) + : makeBrowser(this.session, this.bin); + // Attach mode observes a browser someone else owns: ownership is never + // claimed, so the quit path can never close a stranger's session. + this.ownershipEnabled = this.mode !== "attach"; + this.backendName = this.mode === "attach" ? "browser endpoint" : "agent-browser"; const onPath = (cmd) => spawnSync("sh", ["-c", `command -v ${cmd}`], { timeout: 5000 }).status === 0; @@ -610,6 +623,14 @@ export class Renderer { this.networkPollErrors = 0; this.networkTimer = null; // live-mode cadence (see goLive/dropLive) this.networkIdleTicks = 0; + // Attach mode (see attachCdp): endpoint identity, frame liveness, and + // the per-frame metadata that scales pane clicks into page pixels. + this.cdpGuid = null; + this.cdpIdentity = null; + this.lastFrameMeta = null; + this.lastFrameAt = 0; + this.staleHandled = false; + this.loopbackWarned = false; this.kittyAnon = false; // chafa emitted anonymous kitty placements this.lastImageDims = null; this.lastViewportRequest = ""; @@ -726,6 +747,216 @@ export class Renderer { } } + // Endpoint sources, most deliberate first. Both are static (readable before + // the renderer starts), which is what lets scripts/open.sh reach the same + // verdict without a runtime marker file. + resolveCdpEndpoint(env) { + const raw = env.HERDR_BROWSER_CDP_URL || this.configValue("cdp-url"); + if (!raw) return null; + const value = String(raw).trim(); + return value || null; + } + + // Switch this pane to attach mode at runtime. Everything the old backend + // reconciled against is meaningless afterwards, so state resets and the + // console carries one discontinuity line. + async attachTo(value) { + const endpoint = String(value ?? "").trim(); + if (!/^(wss?|https?):\/\//i.test(endpoint)) { + this.banner = + "not an endpoint — use http://host:port or ws://… (u navigates, a attaches)"; + this.header(); + return; + } + if (this.mode === "agent-browser" && this.live) this.dropLive(); + this.stopNetworkTimer(); + this.cdpEndpoint = endpoint; + this.mode = "attach"; + this.ownershipEnabled = false; + this.backendName = "browser endpoint"; + this.selfCreated = false; // never inherit ownership across a switch + this.browser = makeCdpBrowser(endpoint); + this.attached = false; + this.cdpGuid = null; + this.resetBackendState(); + this.pushConsole([{ text: "— switched to attach mode —", type: "log" }], false); + this.streamCooldownUntil = 0; + await this.tick(); + } + + // Attach: connect, wire the event bridge, and take the R9 baseline. All + // failures land in a banner — a bad endpoint must never crash the pane. + async attachCdp() { + if (!cdpSupported()) { + this.banner = + "attach mode needs Node 22+ (global WebSocket) — pane is idle"; + this.header(); + return false; + } + try { + this.browser.onMessage((m) => this.onCdpMessage(m)); + const id = await this.browser.connect(); + // A reused port can front a different browser than last time; treat + // that as a discontinuity rather than silently continuing the feed. + if (this.cdpGuid && id.guid && id.guid !== this.cdpGuid) { + this.resetBackendState(); + this.pushConsole( + [{ text: "— reattached to a different browser —", type: "log" }], + false, + ); + } + this.cdpGuid = id.guid; + this.cdpIdentity = id; + this.attached = true; + this.startNavigateWatch(); + this.lastUrl = sanitizeText(id.url ?? ""); + this.lastTitle = sanitizeText(id.title ?? ""); + this.lastFrameAt = Date.now(); + // Endpoint URLs carry capability tokens in their path: host:port only. + this.banner = id.rediscoverable + ? "" + : `attached to ${redactWsUrl(this.cdpEndpoint)} (raw endpoint — no reconnect)`; + if (!this.loopbackWarned && !/^(127\.|\[?::1\]?$|localhost)/.test(String(id.host))) { + this.loopbackWarned = true; + this.banner = `attached to ${id.host}:${id.port} — remote endpoint, traffic is unencrypted`; + } + this.header(); + return true; + } catch (err) { + this.attached = false; + this.banner = `cannot attach to ${redactWsUrl(this.cdpEndpoint)}: ${sanitizeText(err?.message ?? "unknown error")}`; + this.header(); + return false; + } + } + + // Cmd+click hand-off from open.sh. Watched rather than polled: the attach + // tick backs off, and a click that navigates 30 s later reads as broken. + // The tick-time read stays as the fallback for platforms where fs.watch + // misses events (some network filesystems). + startNavigateWatch() { + if (this.mode !== "attach" || this.navigateWatcher) return; + this.navigateFile = path.join( + this.stateDir, + `navigate-${safeWsId(this.env.HERDR_WORKSPACE_ID)}`, + ); + const consume = () => { + let url; + try { + url = fs.readFileSync(this.navigateFile, "utf8").split("\n")[0].trim(); + fs.unlinkSync(this.navigateFile); + } catch { + return; // nothing pending + } + if (url) this.userAction(() => this.browser.open(url)); + }; + this.consumeNavigateFile = consume; + try { + this.navigateWatcher = fs.watch(this.stateDir, (_e, name) => { + if (name && name === path.basename(this.navigateFile)) consume(); + }); + this.navigateWatcher.unref?.(); + } catch { + /* watch unsupported: the tick-time read still picks it up */ + } + consume(); // a click may have landed before the pane started + } + + resetBackendState() { + this.consoleState = { count: 0, tail: [] }; + this.networkState = newNetworkState(); + this.lastHash = ""; + this.shotFormat = "png"; + } + + // Bridge: adapter messages arrive already shaped like stream messages, so + // frames/url/page_error reuse onStreamMessage. Frames additionally carry + // the integer ack id, acked once the paint enqueue settles. + onCdpMessage(m) { + if (m.type === "frame") { + this.lastFrameAt = Date.now(); + this.lastFrameMeta = m.metadata ?? null; + this.onStreamMessage({ type: "frame", data: m.data }); + // Ack after the paint queue drains. A skipped paint (blocked stdout, + // chafa cooldown) still acks — a frozen stream is worse than a + // dropped frame, and quality/max-dimension knobs bound the cost. + this.enqueue(() => {}).then(() => this.browser.ackFrame?.(m.ackId, m.gen)); + return; + } + if (m.type === "url") { + this.lastUrl = sanitizeText(m.url ?? ""); + if (m.title !== undefined) this.lastTitle = sanitizeText(m.title); + this.enqueue(() => { + this.header(); + }); + return; + } + if (m.type === "page_error" || m.type === "console") { + this.onStreamMessage(m); + return; + } + if (m.type === "log_entry") { + this.pushLogEntry(m); + return; + } + if (m.type === "target_gone") { + this.banner = "the observed page closed — waiting"; + this.header(); + return; + } + if (m.type === "endpoint_gone") { + this.attached = false; + this.banner = "browser endpoint closed — waiting"; + this.header(); + } + } + + // Log-domain entries. Network-source entries are the attach-mode + // equivalent of the polling failure feed and share its dedupe window, so a + // retry loop paints once in either mode; other sources (violation, + // security, deprecation) paint at their own level. + pushLogEntry(entry) { + const text = entry.url + ? `${entry.text} ${truncate(sanitizeText(entry.url), 200)}` + : entry.text; + if (entry.source === "network") { + const key = `log ${entry.url} ${entry.text}`; + const now = Date.now(); + const last = this.networkState.recent.get(key); + this.networkState.recent.set(key, now); + if (last !== undefined && now - last <= 60_000) return; + } + const hadConsole = this.consoleLines.length > 0; + this.pushConsole( + [{ text, type: entry.level === "warning" ? "warn" : entry.level }], + false, + ); + this.queueConsolePaint(hadConsole); + } + + // Pane cell -> frame pixel -> page CSS pixel. The frame is scaled by both + // maxWidth and the observed browser's DPR, and either can change under us + // (a human resizes the window mid-session), so the scale comes from the + // latest frame's metadata every time — never cached. + cdpPagePoint(framePt, frameDims) { + const meta = this.lastFrameMeta; + if (!meta || !frameDims?.w) return framePt; + const sx = (meta.deviceWidth ?? frameDims.w) / frameDims.w; + const sy = (meta.deviceHeight ?? frameDims.h) / frameDims.h; + return { x: Math.round(framePt.x * sx), y: Math.round(framePt.y * sy) }; + } + + // Hidden tabs and DevTools screencast contention both present as a frozen + // frame with no error. One restart attempt, last frame stays on screen. + checkFrameStaleness(now = Date.now()) { + if (this.mode !== "attach" || !this.attached || !this.lastFrameAt) return; + if (now - this.lastFrameAt < 10_000 || this.staleHandled) return; + this.staleHandled = true; + this.banner = "frame stale (tab hidden or contended)"; + this.header(); + this.browser.restartScreencast?.().catch(() => {}); + } + // Live mode has no snapshot tick and the push stream carries no network // events, so failures need their own low-cadence poll. pollDelay-style // backoff keeps an unwatched live pane near-free; the idle counter resets @@ -951,6 +1182,35 @@ export class Renderer { } async tick() { + // Attach mode is event-driven: the tick only (re)connects, watches + // liveness, and notices a stalled screencast. Frames and console + // entries arrive over the CDP session, not from polling. + if (this.mode === "attach") { + if (!this.attached) { + if (Date.now() < this.streamCooldownUntil) return; + this.streamCooldownUntil = Date.now() + 5_000; + await this.attachCdp(); + return; + } + if (Date.now() - this.lastLiveCheck > 15_000) { + this.lastLiveCheck = Date.now(); + if (!(await this.browser.sessionExists())) { + this.attached = false; + // Re-discovery, not a re-dial: the browser may have restarted + // and minted a fresh token, and a raw ws endpoint has none. + this.banner = this.cdpIdentity?.rediscoverable + ? "browser endpoint went away — retrying" + : `browser endpoint went away — restart the pane to reattach (${redactWsUrl(this.cdpEndpoint)})`; + if (!this.cdpIdentity?.rediscoverable) + this.streamCooldownUntil = Number.MAX_SAFE_INTEGER; + this.header(); + return; + } + } + this.checkFrameStaleness(); + this.consumeNavigateFile?.(); + return; + } // Stay truly passive: any get/console/screenshot call would auto-create // the session (and a headless Chrome) on the daemon. Until the session // exists — created by an agent, a link click, or a URL-bearing open — @@ -1181,6 +1441,12 @@ export class Renderer { case "u": this.openPrompt("URL: ", (v) => this.navigate(v)); break; + // Attach gets its own key: "localhost:9222" is already a valid + // navigation target, so overloading the URL prompt would force a + // heuristic that guesses wrong on exactly the common case. + case "a": + this.openPrompt("attach to endpoint: ", (v) => this.attachTo(v)); + break; case "i": this.openPrompt("type: ", (v) => this.browser.type(v)); break; @@ -1430,7 +1696,7 @@ export class Renderer { } catch { // Poll mode reports daemon failures via the tick failure counter; // live mode's tick never runs that path, so say it directly. - this.banner = "command failed — agent-browser not responding"; + this.banner = `command failed — ${this.backendName} not responding`; this.header(); } }).then(() => this.enqueue(() => this.tick())); @@ -1490,7 +1756,7 @@ export class Renderer { this.networkBaselinePending = false; // nothing to swallow: empty log } await this.browser.open(u); - if (!existed) this.selfCreated = true; + if (!existed && this.ownershipEnabled) this.selfCreated = true; this.attached = true; // the user is explicitly starting/driving the session } @@ -1513,7 +1779,10 @@ export class Renderer { pngH: dims.h, }); if (!pt) return; - await this.browser.click(pt.x, pt.y); + // Attach-mode frames are scaled by maxWidth and the observed browser's + // DPR, so frame pixels are not page pixels — rescale before dispatch. + const target = this.mode === "attach" ? this.cdpPagePoint(pt, dims) : pt; + await this.browser.click(target.x, target.y); } async redrawAll() { @@ -1534,7 +1803,16 @@ export class Renderer { /* already closed */ } this.live = null; - if (this.selfCreated) { + if (this.mode === "attach") { + // Stop our screencast and drop the socket. Never a target close, + // never an agent-browser subprocess — we did not create any of this. + try { + this.browser.close?.(); + } catch { + /* endpoint already gone */ + } + } + if (this.selfCreated && this.ownershipEnabled) { // The session exists only because the user navigated in this pane; // quitting the pane ends it (and its daemon) instead of leaking it. // Short timeout: a wedged daemon must not freeze the quit path — its diff --git a/docs/plans/2026-08-04-001-feat-cdp-attach-mode-plan.md b/docs/plans/2026-08-04-001-feat-cdp-attach-mode-plan.md new file mode 100644 index 0000000..6bbcceb --- /dev/null +++ b/docs/plans/2026-08-04-001-feat-cdp-attach-mode-plan.md @@ -0,0 +1,236 @@ +--- +title: "feat: CDP attach mode — observe any DevTools-protocol browser" +type: feat +date: 2026-08-04 +--- + +# feat: CDP attach mode — observe any DevTools-protocol browser + +## Summary + +The pane gains a second backend: attach to any Chrome DevTools Protocol endpoint — a Playwright-, Puppeteer-, or Browser Use-launched Chrome, or any browser started with `--remote-debugging-port`. Frames arrive by ack-paced JPEG screencast, the console region is fed natively from CDP events (including "Failed to load resource" lines with real error text), and the user can click, type, and scroll in the observed browser. agent-browser mode stays the default and untouched. Attach mode never creates, closes, or emulates anything, and the plugin still exposes no listening port. + +## Problem Frame + +The pane only observes agent-browser sessions, while most agent browser automation runs on Playwright/Puppeteer/CDP stacks. The competing `ogulcancelik/herdr-browser` plugin won attention by accepting any CDP client — but it does so by owning its own Chromium behind an unauthenticated loopback control gateway, with pixels-only observability. Attaching read-mostly to the automation stack the user already runs takes the universality without the control surface, and CDP's `Log`/`Runtime` domains carry failure detail (real `net::ERR_*` error text) that agent-browser's daemon structurally drops. + +--- + +## Requirements + +**Attach and render** + +- R1. Given a CDP endpoint (`ws://` URL or `http://host:port`), the pane discovers the browser target via `/json/version`, attaches a flat session to a page target, and renders live JPEG screencast frames through the existing frame path. +- R2. Screencast frames are acknowledged only after the paint enqueue settles, bounding Chrome's encode rate to the pane's paint throughput; a skipped or misdirected ack never freezes the pane (acks are unconditional per received frame). Note the paint path acks immediately while stdout is blocked or chafa is cooling down — freeze-prevention wins over backpressure there; frame cost is bounded by JPEG quality, max dimensions, and `everyNthFrame`, not by ack starvation. +- R3. On endpoint death or WebSocket drop, the pane re-discovers via `/json/version` (never re-dials a cached token URL) using the existing cooldown/waiting-banner idioms; a raw-`ws://`-only endpoint that drops shows a terminal banner instead of a retry loop. Reattach compares the browser's identity (`/json/version` GUID and product string) against the previous attach — a different browser on the same port resets console/frame state and pushes a "reattached to a different browser" discontinuity marker. +- R4. In kitty mode without chafa, attach mode degrades to `Page.captureScreenshot` PNG polling riding the existing `pollDelay` backoff. + +**Input** + +- R5. Click, scroll, and type map to `Input.dispatchMouseEvent` / `dispatchKeyEvent` / `insertText` on the attached target, with click coordinates scaled per frame from screencast metadata (`deviceWidth`/`deviceHeight` vs frame pixel size) — never from cached dimensions. +- R6. The pane pins one page target; a cycle key moves between page targets, and re-selection happens automatically only when the pinned target is destroyed — never on target creation. + +**Observability** + +- R7. The console region is fed by `Runtime.consoleAPICalled` and `Runtime.exceptionThrown` (console + errors) and `Log.entryAdded` (network failures with error text, violations); the CDP `Network` domain is not enabled, eliminating duplicate failure lines by construction. Events from out-of-process iframes and workers are captured by an events-only `Target.setAutoAttach` (`waitForDebuggerOnStart: false`, flattened) on the pinned page session — rendering and input stay pinned to the page target. +- R8. Log-source network failures render with the same `✖ ` prefix and pass through the same recent-window dedupe map as the polling feed, so a retry loop paints once in either mode. Log entries carry text + URL, not structured method/status — lines show Chrome's error text; no fake method/status parity is synthesized. +- R9. Attach never replays history: buffered `Log` entries AND replayed `Runtime.consoleAPICalled`/`exceptionThrown` events (Chrome flushes the console backlog on `Runtime.enable`) older than the attach timestamp are swallowed, mirroring the network-feed silent baseline. +- R9a. A `console: log-only` config tier skips `Runtime.enable` entirely — `Runtime.enable` has page-observable side effects (execution-context reporting, eager argument serialization) that stealth automation stacks avoid; observing such a run must be possible without perturbing it. Default tier is `runtime+log`. + +**Mode arbitration and passivity** + +- R10. Backend selection: an explicitly configured CDP endpoint wins over an agent-browser session, with a banner naming the other backend; the choice is deterministic at pane start. +- R11. Attach mode never calls `Target.createTarget`, `Target.closeTarget`, or `Emulation.setDeviceMetricsOverride`; `selfCreated` is hard-false; cleanup is `Page.stopScreencast` plus WebSocket close only. The automation client owns viewport and lifecycle. +- R12. Switching backends resets all reconciliation state (console cursors, network state, frame hash) and pushes one discontinuity marker line. +- R13. Cmd+click localhost links, which are agent-browser-specific today, either route to the attached target or refuse with a clear message — they never spawn an invisible agent-browser session while the pane is attached elsewhere. + +**Degradation and security** + +- R14. On Node < 22 (no global `WebSocket`), attach mode is unavailable with a banner saying exactly that; agent-browser mode keeps working on Node 20. +- R15. DevTools WebSocket URLs are capability tokens: only host:port is ever displayed or logged; the token path never reaches the header, banners, debug logs, or state files. +- R16. A non-loopback endpoint triggers a one-time warning banner (plaintext transport, full browser capability). +- R17. A frame-staleness watchdog detects a screencast that has stopped producing frames (hidden tab, DevTools contention), banners "frame stale (tab hidden or contended)", attempts exactly one screencast restart, and keeps the last frame on screen. + +--- + +## Key Technical Decisions + +- **Second duck-typed backend, not a mode flag threaded through the Renderer.** `makeCdpBrowser()` presents the same surface the Renderer already duck-types (`open`, `click`, `scroll`, `type`, `sessionExists`, …) and deliberately **omits** `setViewport` and `network` — the existing `typeof` guards then disable viewport fitting and the polling failure feed by construction. These omissions are the no-emulation and no-double-reporting contracts and get their own tests. (Verified: `fitViewport` and `pollNetwork` already guard on method presence.) +- **Zero-dep CDP client on native WebSocket, in a new `bin/cdp.mjs`.** Spike-verified on Chrome 150 / Node 24: `/json/version` discovery, `Target.attachToTarget {flatten:true}`, request/response correlation with `sessionId` routing. Node 22 floor for attach mode only (R14); no `ws` package. Injectable WebSocket factory for tests, mirroring the repo's stub-based test style. Discovery uses `node:http.request`, NOT global fetch — WHATWG fetch silently drops a custom Host header (verified on Node 24), and Chrome 111+ rejects DNS-name Hosts on `/json/*`, so the client sends an IP-literal Host explicitly. +- **Ack-paced screencast, acked on paint-settle.** The spike confirmed frames halt until `Page.screencastFrameAck` — pacing before encode, the one idea worth taking from the competitor. Their fixed 750 ms boost and PNG-only pipeline are not taken: JPEG with a quality knob (default ~60) and pane-scaled `maxWidth/maxHeight` (~1280 cap) bound decode cost and message size. Every received frame is acked once its paint enqueue settles (a skipped or stdout-blocked paint still acks — freeze-prevention wins; see R2's honest bound). **Two distinct identifiers:** the ack echoes the frame event's **integer** `sessionId` in its params while routing over the flat-session **string** `sessionId` — conflating them means Chrome ignores acks and the stream freezes after the in-flight quota. Pacer state (including the last integer id) resets on every (re)attach and screencast restart. +- **Pinned-target policy.** Pin the first `type === "page"` target; a cycle key walks page targets; auto-re-pin only on `Target.targetDestroyed` of the pinned target. Never follow `targetCreated` for rendering — following an automation client's ephemeral targets thrashes the pane. Event subscription is broader than rendering: an events-only `Target.setAutoAttach` on the page session picks up OOPIF/worker console and Log events (R7) without moving the picture. Hidden-tab throttling and DevTools screencast contention both look like a frozen frame: the R17 watchdog covers both. +- **Console feed partition: Runtime + Log domains, Network domain off.** `Log.entryAdded` carries network failures with `net::ERR_*` text (spike-verified) — error detail agent-browser's daemon drops entirely. Skipping the Network domain eliminates the Log/Network duplicate-line class instead of deduping it. Accepted tradeoff: Log emits nothing for a request that hangs without failing, so the polling feed's 15 s "no response" heuristic has no attach-mode equivalent — a documented blind spot, not a regression to hide (U6). Log network entries keep the `✖ ` prefix and flow through the same recent-window dedupe map as the polling feed (extracted from `networkState.recent` — a small refactor U4 owns), so both modes have identical noise behavior (R8). The `log-only` tier (R9a) exists because `Runtime.enable` is page-observable and can trip bot detection on stealth runs — the plan's passivity story is honest about that footprint. +- **Explicit endpoints only.** `HERDR_BROWSER_CDP_URL` env, a `cdp-url` config-dir file, and a dedicated attach prompt key — the `u` prompt stays purely navigation (bare `localhost:9222` already means "navigate there" and must not become ambiguous). No port scanning: attaching uninvited is off-brand and indistinguishable from probing. +- **CDP-wins arbitration with a banner.** Setting an endpoint is the more deliberate act than an ambient agent-browser session existing; deterministic beats clever (R10). +- **The competitor's mistakes, deliberately not repeated:** no listening port of any kind (their gateway is unauthenticated loopback); no `Emulation.setDeviceMetricsOverride` (their viewer fights its own automation clients over viewport); no fixed kitty image id collisions (our existing frame path already handles ids); reconnect is re-discovery, not a one-way degradation latch. + +--- + +## High-Level Technical Design + +```mermaid +flowchart TB + subgraph backends [Backend selection at pane start] + ENV[CDP endpoint configured?] -->|yes| CDPB[makeCdpBrowser - bin/cdp.mjs] + ENV -->|no| ABB[makeBrowser - agent-browser CLI] + end + subgraph attach [Attach pipeline] + DISC[GET /json/version + /json/list - IP-literal Host] --> ATT[Target.attachToTarget flatten] + ATT --> SC[Page.startScreencast jpeg q~60 max~1280] + SC --> FR[frame -> existing jpg paint path] + FR --> ACK[screencastFrameAck on paint-settle, keyed by sessionId] + ATT --> EV[Runtime.consoleAPICalled / exceptionThrown + Log.entryAdded] + EV --> BASE[attach-time baseline swallow] --> PC[pushConsole - shared dedupe + sanitize] + end + CDPB --> DISC + IN[click / scroll / type] -->|scale by frame metadata| INPUT[Input.dispatch* on pinned target] + WATCH[liveness: Browser.getVersion ping] -->|dead| REDISC[re-discover via /json/version - cooldown idiom] +``` + +Renderer changes stay thin: the attach pipeline presents itself to the Renderer as a live stream (frames and console events pushed, poll loop reduced to liveness), reusing `onStreamMessage`-shaped entry points, `pushConsole`, `queueConsolePaint`, and the cooldown/banner idioms. The heavy lift is `bin/cdp.mjs` plus a backend-shaped adapter, not Renderer surgery. + +--- + +## Implementation Units + +### U1. Zero-dep CDP client (`bin/cdp.mjs`) + +- **Goal:** A tested, dependency-free CDP session layer: HTTP discovery, flat-session attach, message correlation, event dispatch, liveness ping, clean close. +- **Requirements:** R1, R3, R14, R15 +- **Dependencies:** none +- **Files:** `bin/cdp.mjs`, `tests/cdp.test.mjs` +- **Approach:** Exported `discoverEndpoint(input)` (normalizes `http://host:port` vs `ws://…`, requests `/json/version` + `/json/list` over `node:http` with an explicit IP-literal Host header — global fetch cannot set Host; a pasted page-level `/devtools/page/` URL is detected and rejected with a message naming the browser-level endpoint) and `makeCdpSession(wsUrl, {wsFactory})` returning `{send(method, params, sessionId), on(event, fn), ping(), close()}`. `wsFactory` injection is the test seam — no network in unit tests. A `redactWsUrl(url)` helper returns host:port only (R15). Node-version gate: export `cdpSupported()` checking `typeof WebSocket === "function"`. +- **Patterns to follow:** `makeBrowser`'s closure-over-config shape; stub-injection tests like the existing bash-stub CLI tests; swallow-and-degrade error idiom. +- **Test scenarios:** + - `discoverEndpoint("http://127.0.0.1:9222")` against a stub HTTP server → browser ws URL and page target list; DNS-name input → stub asserts the received Host header is an IP literal. + - Page-level `ws://…/devtools/page/` input → rejected with a message pointing at the browser endpoint. + - Request/response correlation: two in-flight `send`s resolve to their own results; error responses reject. + - Events with `sessionId` route to the right subscriber; unknown ids are dropped without throwing. + - `ping()` timeout → session reports dead; `close()` is idempotent. + - `redactWsUrl` strips the token path from every URL shape; `cdpSupported()` false path. +- **Verification:** `npm test` green; no new dependencies in `package.json`. + +### U2. `makeCdpBrowser` backend adapter + +- **Goal:** The Renderer-facing backend surface over a CDP session, with the passivity contract encoded as deliberate interface omissions. +- **Requirements:** R5, R6, R11 +- **Dependencies:** U1 +- **Files:** `bin/cdp.mjs` (same file as U1's session layer), `tests/cdp.test.mjs` +- **Approach:** `makeCdpBrowser(endpoint)` exposing the full surface the Renderer's key handlers call unguarded: `open(url)` → `Page.navigate`, `back()`/`forward()` → `Page.getNavigationHistory` + `navigateToHistoryEntry`, `reload()` → `Page.reload`, `click(x, y)` / `scroll(dir, px)` / `type(text)` → Input domain (coordinates pre-scaled by the caller per R5), `sessionExists()` → ws liveness + pinned-target existence, `screenshot(path)` → `Page.captureScreenshot` (the kitty-without-chafa poll source, R4), `cycleTarget()`, plus `onMessage(handler)` — the event bridge: the adapter translates CDP events into the existing stream-message shapes (`{type:"frame"}`, `{type:"console"}`, `{type:"page_error"}`, `{type:"url"}` from `Target.targetInfoChanged`, which is also the header's url/title source in attach mode) so the Renderer wires it straight into its `onStreamMessage` path. Deliberately **no `setViewport`, no `network`, no `snapshot`, no `streamEnable`/`streamStatus`** — with a test asserting those keys are absent (the duck-type guards then disable viewport fitting, the polling feed, and the goLive path by construction). Target pinning per the KTD: first `page` target, re-pin on `targetDestroyed` only. +- **Patterns to follow:** duck-typed optional methods (`streamEnable` precedent); `selfCreated` never set by this backend. +- **Test scenarios:** + - Adapter surface: `setViewport` and `network` are `undefined` (the no-emulation / no-double-report contracts). + - `open` navigates the pinned target, never creates one (fake session asserts no `Target.createTarget` ever sent — the R11 passivity test). + - Pinned target destroyed → re-pins to a surviving page target; created targets are ignored. + - `type("héllo")` routes through `insertText`; Enter/arrow keys through `dispatchKeyEvent` pairs. + - `back()`/`forward()`/`reload()` issue the right Page-domain calls (the `b`/`f`/`r` keys call these unguarded — absence would banner a misleading agent-browser error). + - `onMessage` delivers a screencast frame as a `{type:"frame"}`-shaped message and `targetInfoChanged` as `{type:"url"}`. +- **Verification:** `npm test` green; grep confirms no `createTarget`/`closeTarget`/`setDeviceMetricsOverride` strings in the adapter. + +### U3. Attach pipeline in the Renderer + +- **Goal:** Frames paint, input works, reconnect behaves — attach mode is a first-class live backend. +- **Requirements:** R1, R2, R3, R4, R5, R10, R12 +- **Dependencies:** U2 +- **Files:** `bin/renderer.mjs`, `tests/renderer.test.mjs` +- **Approach:** Mode arbitration in the constructor/start path (CDP endpoint present → attach backend + banner naming the skipped agent-browser session). Screencast frames enter the existing `onStreamMessage`-shaped jpg path via the adapter's `onMessage` bridge; the ack echoes each frame's **integer** `sessionId` over the flat-session string route, fires when the paint enqueue settles, unconditionally per frame, and pacer state (including the last integer id) resets on every (re)attach and screencast restart (R2). Click coordinates scale by the latest frame's `metadata.deviceWidth / frameWidth` — held per frame, never cached (R5). Liveness ping replaces the 15 s `sessionExists` check; drop → re-discovery via cooldown idiom with the R3 browser-identity comparison, raw-`ws://` endpoints get a terminal banner. Kitty-without-chafa → `screenshot()` polling on `pollDelay` (R4). Backend switch resets `consoleState`/`networkState`/`lastHash`/`shotFormat` and pushes a discontinuity line (R12). **Backend-aware ownership paths:** `navigate()` skips the `selfCreated` assignment and `cleanup()` skips the `agent-browser close` branch whenever the active backend is CDP — attach-mode cleanup is `Page.stopScreencast` + ws close only (R11); the `userAction` failure banner becomes backend-supplied text. Frame-staleness watchdog per R17. +- **Patterns to follow:** `goLive`/`dropLive` transition hygiene; `streamCooldownUntil` cadence state; `userAction` banner parametrized per backend (the "agent-browser not responding" string becomes backend-supplied). +- **Test scenarios:** + - Fake CDP backend pushes a jpeg frame → painted via the jpg path; ack sent exactly once after paint settles; a frame arriving during a blocked paint still acks. + - Reconnect mints a new session → old pacer state discarded; a screencast restart mints a new integer ack id and the old integer id is never acked. + - Attach-mode quit → no agent-browser subprocess spawned (call-log assertion on cleanup), even after a navigate that found `sessionExists()` false. + - Reattach to the same port with a different browser GUID → state reset + "reattached to a different browser" marker. + - Retina-scaled frame (deviceWidth 2× frame width) → click at pane center dispatches at scaled page coordinates. + - Endpoint dies (ping timeout) → waiting banner + re-discovery attempt; raw `ws://` endpoint → terminal banner, no retry. + - Both backends configured → attach wins, banner names the agent-browser session. + - Backend switch → console/network state reset, one discontinuity marker, no replayed lines. + - Existing agent-browser mode tests pass unmodified (the default path is untouched). +- **Verification:** `npm test` green; manual attach to a locally launched `--remote-debugging-port` Chrome shows live frames and working clicks. + +### U4. Event-driven console and failure feed + +- **Goal:** Attach mode's console region is richer than agent-browser mode's, with identical noise behavior. +- **Requirements:** R7, R8, R9 +- **Dependencies:** U3 +- **Files:** `bin/renderer.mjs`, `bin/cdp.mjs`, `tests/renderer.test.mjs` +- **Approach:** Enable `Log` always; `Runtime` per the R9a tier (default on, `log-only` skips it). Events-only `Target.setAutoAttach` on the page session brings OOPIF/worker events in (R7). `consoleAPICalled` → existing console line shape (shallow arg text extraction, hard-capped, no `Runtime.getProperties` — size and passivity); `exceptionThrown` → `✖` line. `Log.entryAdded` with `source: "network"` → `✖` line with Chrome's error text + URL, through the shared recent-window dedupe map (extract the `recent` map from `networkState` so both feeds share it); other Log sources (violation, security) → prefixed lines. Attach-time baseline: swallow Log AND replayed Runtime events whose timestamp predates the attach (R9). +- **Patterns to follow:** `pushConsole`/`queueConsolePaint` for everything; `sanitizeText` + truncation at push time; the network-feed baseline idiom. +- **Test scenarios:** + - `consoleAPICalled` warn/log/error → prefixed lines; a 100-arg call → one capped line. + - `Log.entryAdded` network error → one `✖` line in the same shape as the polling feed's output for the same failure. + - The same failing fetch retried 5× (five Log entries, same URL/text) → one line (shared dedupe). + - Buffered Log entries with pre-attach timestamps → swallowed; post-attach → painted. + - Replayed `consoleAPICalled` backlog with pre-attach timestamps → swallowed (the `Runtime.enable` flush case); post-attach → painted. + - `log-only` tier → no `Runtime.enable` sent (call-log assertion), Log lines still paint. + - OOPIF-session Log entry (via auto-attached session) → painted like a page-session entry. + - `exceptionThrown` → `✖` line with sanitized text. +- **Verification:** `npm test` green; manual attach shows `net::ERR_CONNECTION_REFUSED` text for a refused fetch — detail agent-browser mode structurally cannot show. + +### U5. Entry affordances, link handler, and security hygiene + +- **Goal:** Deterministic, safe ways in and out of attach mode. +- **Requirements:** R10, R13, R14, R15, R16 +- **Dependencies:** U3 +- **Files:** `bin/renderer.mjs`, `scripts/open.sh`, `tests/renderer.test.mjs`, `tests/launchers.test.mjs` +- **Approach:** Endpoint sources in precedence order: `HERDR_BROWSER_CDP_URL` env > `cdp-url` config-dir file (documented). Dedicated attach prompt key (`a`) accepting `ws://…` or `http://host:port` — the `u` prompt's validation is untouched. Node < 22 with an endpoint configured → explanatory banner (R14). All display/log paths route endpoint URLs through `redactWsUrl` (R15); non-loopback host → one-time warning banner (R16). **Link handler (R13): `open.sh` decides attach mode from the same static sources the renderer uses** — the `cdp-url` config-dir file first (safe regardless of whether herdr propagates user env to actions) and `HERDR_BROWSER_CDP_URL` when present — falling back to the renderer's mode marker only for prompt-entered endpoints. A marker can't exist before the first renderer start, so static sources are the primary authority; this closes the cold-start race where the first Cmd+click would spawn exactly the invisible agent-browser session R13 forbids. In attach mode the URL lands in a state-dir handoff file the renderer picks up via `fs.watch` (tick-time read as fallback — `pollDelay` backoff would otherwise delay pickup up to 30 s). +- **Patterns to follow:** `configValue()` for config-dir reads; prompt state machine from the `u` prompt; state-dir file conventions (0600, workspace-scoped names). +- **Test scenarios:** + - Precedence: env beats config file; neither → agent-browser mode untouched. + - `a`-prompt: valid `http://127.0.0.1:9222` accepted; `https://example.com` refused with a banner (it's a navigation URL, not an endpoint). + - Header/banner text for an attached endpoint contains host:port and never the token path (assert on rendered strings). + - Non-loopback endpoint → exactly one warning banner. + - `open.sh` with attach-mode marker present → no `agent-browser` invocation (launcher test asserts the call log), URL lands in the handoff file. + - Cold start: `cdp-url` config file present, no marker, no pane → `open.sh` makes no `agent-browser` call (the invisible-session regression case). + - Handoff file written while the renderer idles → navigation fires promptly (watch path), not on the next backed-off tick. +- **Verification:** `npm test` + `shellcheck scripts/*.sh` green. + +### U6. Documentation and positioning + +- **Goal:** README and wiki present attach mode as the headline: observe any automation stack, with the observability and security story explicit. +- **Requirements:** R1, R7, R11 (as documented claims) +- **Dependencies:** U3, U4, U5 +- **Files:** `README.md` (wiki edits tracked as follow-up per the wiki-clone workflow) +- **Approach:** New "Attach to any CDP browser" section: endpoint setup for Playwright/Puppeteer/Browser Use/plain Chrome, the passivity guarantees (no target creation, no emulation, no listening ports, token redaction), Node 22 note, and the failure-feed comparison (error text vs status-only). Requirements table row for attach mode. Update the Highlights list. +- **Test scenarios:** Test expectation: none — documentation only. +- **Verification:** README claims match shipped behavior; every claim traceable to a test or spike result. + +--- + +## Scope Boundaries + +**In scope:** the six units above — attach, render, input, observability, affordances, docs. + +**Not in scope:** + +- Owning or launching any browser; any listening port, gateway, or proxy; any `Emulation` calls. These are the competitor's architecture and its liabilities. +- Tab-strip UI, hover forwarding, drag/selection, modifier keys, IME. The pane is an observer with basic steering, not a terminal browser. +- Auto-discovery/port scanning of CDP endpoints. +- Windows support (unchanged from the plugin's existing scope). + +**Deferred to follow-up work:** + +- Flight-recorder `dump` action (debrief artifact: screenshot + console tail + failures; HAR only applies to agent-browser mode) — planned separately after attach mode lands. +- Wiki comparison page vs `official.browser` (uses the teardown findings; factual tone). +- Attach-mode WebM recording parity (the recording path — `scripts/record.sh` + `bin/record.mjs` in the wave0 branch — is agent-browser-coupled today). +- Multi-target picker UI beyond the cycle key; an observe-only input toggle for watching live runs without interference risk. +- Endpoint-configuration recipes for launchers that default to pipe transport (Playwright/Puppeteer need `--remote-debugging-port` in launch args) — U6 carries the basic recipes; deeper integration guides are follow-up. + +--- + +## Risks & Dependencies + +- **CDP protocol drift.** The attach path uses only stable, years-old domains (`Target`, `Page`, `Runtime`, `Log`, `Input`) — spike-verified on Chrome 150. Risk is low; the liveness ping doubles as a version probe (`Browser.getVersion`) if gating ever becomes necessary. +- **Non-Chromium browsers.** Firefox's CDP subset lacks screencast; attach fails at `startScreencast`. Degrade with a named banner rather than a generic failure; WebDriver BiDi is out of scope until it has a rendering story. +- **Automation-client interference is inherent.** A client may navigate, resize, or close the pinned target mid-view; the design absorbs this (per-frame metadata scaling, `targetDestroyed` re-pin, staleness watchdog) but a sufficiently chaotic client will still make the pane flicker between states. Accepted: the pane mirrors reality. +- **Node 22 gate splits the feature matrix — and no CI exists on this base.** The Node-20-pinned workflow lives only on the unmerged wave0 branches; `main` ships no `.github` tree, so nothing automatically exercises either lane here. Until wave0's CI lands (then grow it a Node 22 lane), Node-20 compatibility of the gated code paths is verified manually; attach tests skip-gate on `cdpSupported()` like the existing e2e test. Separately, the Node 22 WebSocket claim is extrapolated down from the Node 24 spike — verify once on 22 during implementation. +- **Pane input can flake the automation run being observed.** A click or keystroke mid-run blurs the field Playwright is typing into or dismisses the element it's waiting on — interference runs both directions, and the docs say so. An observe-only input toggle is deferred follow-up work. +- **Screencast CPU on the observed browser.** Ack pacing plus pane-scaled max dimensions plus JPEG quality bound this, and an unwatched pane's pacer naturally slows to the paint rate. The competitor's full-pixel PNG pipeline is the cautionary tale, not the model. + +--- + +## Sources & Research + +- Live spike (`scratchpad/cdp-spike.mjs`, Chrome 150 + Node 24): discovery, flat-session attach, JPEG screencast + ack-gating confirmation, `Log.entryAdded` network-failure text, `Network.loadingFailed` errorText, native WebSocket compatibility. +- Competitor teardown (ogulcancelik/herdr-browser @ 2-commit dump): ack-pacer design worth adopting (`screencastAckPacer.ts`), unauthenticated gateway / lease-reaping / cell-probe input-corruption bug / PNG-only pipeline as anti-patterns to avoid; full findings in session research. +- `bin/renderer.mjs` duck-typing seams: `fitViewport` and `pollNetwork` method-presence guards; `goLive`/`dropLive`/cooldown idioms; `pushConsole` sanitize/cap pipeline. +- agent-browser 0.33.2 source (opensrc cache): the daemon drops `Network.loadingFailed` detail — the structural reason attach mode's failure feed is richer. diff --git a/scripts/open.sh b/scripts/open.sh index d59dcce..73597e0 100755 --- a/scripts/open.sh +++ b/scripts/open.sh @@ -12,7 +12,29 @@ require_herdr url="${1:-${HERDR_PLUGIN_CLICKED_URL:-}}" session="$(session_name)" -if [ -n "$url" ]; then +# Attach mode is decided from the same static sources the renderer reads, not +# from a runtime marker: on the first click after configuring an endpoint no +# pane has ever run, and taking the agent-browser path there would spawn +# exactly the invisible session attach mode promises never to create. +cdp_endpoint="" +if [ -n "${HERDR_BROWSER_CDP_URL:-}" ]; then + cdp_endpoint="$HERDR_BROWSER_CDP_URL" +elif [ -n "${HERDR_PLUGIN_CONFIG_DIR:-}" ] && [ -f "${HERDR_PLUGIN_CONFIG_DIR}/cdp-url" ]; then + cdp_endpoint="$(head -n1 "${HERDR_PLUGIN_CONFIG_DIR}/cdp-url" | tr -d '[:space:][:cntrl:]')" +fi + +if [ -n "$url" ] && [ -n "$cdp_endpoint" ]; then + if ! validate_url "$url"; then + echo "herdr-browser: refusing URL (must start with http:// or https://, no credentials): $url" >&2 + exit 2 + fi + # Attach mode: hand the URL to the pane, which navigates the attached + # target. The renderer watches this file, so pickup does not wait for a + # backed-off poll tick. + handoff="$(state_dir)/navigate-$(ws_id)" + umask 077 + printf '%s\n' "$url" > "$handoff" +elif [ -n "$url" ]; then if ! validate_url "$url"; then echo "herdr-browser: refusing URL (must start with http:// or https://, no credentials): $url" >&2 exit 2 diff --git a/tests/cdp.test.mjs b/tests/cdp.test.mjs new file mode 100644 index 0000000..e050892 --- /dev/null +++ b/tests/cdp.test.mjs @@ -0,0 +1,474 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { + cdpSupported, + redactWsUrl, + discoverEndpoint, + makeCdpSession, +} from "../bin/cdp.mjs"; + +// --- discovery --- + +const withStubEndpoint = async (fn, { host = "127.0.0.1" } = {}) => { + const seen = { hosts: [] }; + const server = http.createServer((req, res) => { + seen.hosts.push(req.headers.host); + if (req.url === "/json/version") { + res.end( + JSON.stringify({ + Browser: "Chrome/150.0.0.0", + webSocketDebuggerUrl: `ws://${host}:${server.address().port}/devtools/browser/abc-123`, + }), + ); + } else if (req.url === "/json/list") { + res.end( + JSON.stringify([ + { type: "page", id: "P1", url: "https://x/" }, + { type: "service_worker", id: "W1", url: "https://x/sw.js" }, + ]), + ); + } else { + res.statusCode = 404; + res.end("nope"); + } + }); + await new Promise((r) => server.listen(0, host, r)); + try { + await fn(server.address().port, seen); + } finally { + server.close(); + } +}; + +test("discoverEndpoint resolves /json/version and filters page targets", async () => { + await withStubEndpoint(async (port) => { + const ep = await discoverEndpoint(`http://127.0.0.1:${port}`); + assert.equal(ep.browser, "Chrome/150.0.0.0"); + assert.match(ep.wsUrl, /^ws:\/\/127\.0\.0\.1/); + assert.equal(ep.guid, "abc-123"); + assert.deepEqual( + ep.pages.map((p) => p.id), + ["P1"], + "non-page targets filtered", + ); + assert.equal(ep.rediscoverable, true); + }); +}); + +test("discoverEndpoint sends an IP-literal Host for DNS-name input", async () => { + await withStubEndpoint(async (port, seen) => { + const ep = await discoverEndpoint(`http://devbox.local:${port}`, { + lookup: async () => ({ address: "127.0.0.1" }), + }); + assert.ok(ep.wsUrl); + assert.ok( + seen.hosts.every((h) => h.startsWith("127.0.0.1:")), + `Host headers must be IP literals, saw ${seen.hosts}`, + ); + }); +}); + +test("discoverEndpoint refuses page-level URLs with a pointer to the browser endpoint", async () => { + await assert.rejects( + discoverEndpoint("ws://127.0.0.1:9222/devtools/page/DEADBEEF"), + /browser endpoint/, + ); +}); + +test("discoverEndpoint passes raw browser ws URLs through, marked non-rediscoverable", async () => { + const ep = await discoverEndpoint("ws://127.0.0.1:9222/devtools/browser/xyz"); + assert.equal(ep.rediscoverable, false); + assert.equal(ep.guid, "xyz"); + assert.equal(ep.browser, null); +}); + +test("discoverEndpoint rejects junk and non-endpoint schemes", async () => { + await assert.rejects(discoverEndpoint("not a url"), /endpoint URL/); + await assert.rejects(discoverEndpoint("file:///etc/passwd"), /endpoint URL/); +}); + +test("redactWsUrl strips capability token paths everywhere", () => { + assert.equal( + redactWsUrl("ws://127.0.0.1:9222/devtools/browser/secret-guid-here"), + "127.0.0.1:9222", + ); + assert.equal(redactWsUrl("total junk"), "invalid endpoint"); +}); + +test("cdpSupported reflects the global WebSocket gate", () => { + assert.equal(cdpSupported(), typeof WebSocket === "function"); +}); + +// --- session layer (fake ws seam, no network) --- + +const makeFakeWs = () => { + const sent = []; + const ws = { + sent, + onopen: null, + onclose: null, + onerror: null, + onmessage: null, + send: (s) => sent.push(JSON.parse(s)), + close() { + this.onclose?.(); + }, + // test drivers + open: () => ws.onopen?.(), + deliver: (obj) => ws.onmessage?.({ data: JSON.stringify(obj) }), + }; + return ws; +}; + +test("session correlates concurrent requests and routes errors", async () => { + const ws = makeFakeWs(); + const s = makeCdpSession("ws://x/devtools/browser/1", { wsFactory: () => ws }); + ws.open(); + await s.opened; + const a = s.send("Page.enable", {}, "S1"); + const b = s.send("Page.navigate", { url: "https://x/" }, "S1"); + assert.equal(ws.sent.length, 2); + assert.equal(ws.sent[1].sessionId, "S1"); + ws.deliver({ id: ws.sent[1].id, result: { frameId: "F" } }); + ws.deliver({ id: ws.sent[0].id, error: { message: "nope" } }); + assert.deepEqual(await b, { frameId: "F" }); + await assert.rejects(a, /nope/); +}); + +test("session events fan out and a throwing subscriber cannot break others", async () => { + const ws = makeFakeWs(); + const s = makeCdpSession("ws://x/devtools/browser/1", { wsFactory: () => ws }); + ws.open(); + await s.opened; + const got = []; + s.onEvent(() => { + throw new Error("bad subscriber"); + }); + s.onEvent((m) => got.push(m.method)); + ws.deliver({ method: "Page.screencastFrame", params: {}, sessionId: "S1" }); + assert.deepEqual(got, ["Page.screencastFrame"]); +}); + +test("close fails pending requests and fires onClose once", async () => { + const ws = makeFakeWs(); + const s = makeCdpSession("ws://x/devtools/browser/1", { wsFactory: () => ws }); + ws.open(); + await s.opened; + let closes = 0; + s.onClose(() => closes++); + const p = s.send("Browser.getVersion"); + ws.close(); + await assert.rejects(p, /connection closed/); + assert.equal(closes, 1); + assert.equal(s.dead, true); + await assert.rejects(s.send("Page.enable"), /connection closed/); +}); + +test("ping resolves false on timeout instead of throwing", async () => { + const ws = makeFakeWs(); + const s = makeCdpSession("ws://x/devtools/browser/1", { wsFactory: () => ws }); + ws.open(); + await s.opened; + assert.equal(await s.ping(50), false, "no reply -> dead"); + const alive = s.ping(1000); + ws.deliver({ id: ws.sent.at(-1).id, result: { product: "Chrome" } }); + assert.equal(await alive, true); +}); + +test("malformed frames are dropped without throwing", async () => { + const ws = makeFakeWs(); + const s = makeCdpSession("ws://x/devtools/browser/1", { wsFactory: () => ws }); + ws.open(); + await s.opened; + ws.onmessage({ data: "%%% not json %%%" }); + const p = s.send("Page.enable"); + ws.deliver({ id: ws.sent.at(-1).id, result: {} }); + await p; // session still functional +}); + +// --- makeCdpBrowser adapter (scripted fake CDP endpoint, no network) --- + +import { makeCdpBrowser } from "../bin/cdp.mjs"; + +const makeFakeCdp = ({ pages, results } = {}) => { + const state = { + sent: [], + pages: pages ?? [ + { targetId: "T1", type: "page", url: "https://x/", title: "X" }, + ], + results: results ?? {}, + ws: null, + }; + const ws = { + onopen: null, + onclose: null, + onerror: null, + onmessage: null, + send(s) { + const msg = JSON.parse(s); + state.sent.push(msg); + const custom = state.results[msg.method]; + const result = + typeof custom === "function" + ? custom(msg) + : custom !== undefined + ? custom + : msg.method === "Target.getTargets" + ? { targetInfos: state.pages } + : msg.method === "Target.attachToTarget" + ? { sessionId: `sess-${msg.params.targetId}` } + : {}; + if (result !== null) + queueMicrotask(() => + ws.onmessage?.({ data: JSON.stringify({ id: msg.id, result }) }), + ); + }, + close() { + this.onclose?.(); + }, + }; + state.ws = ws; + state.deliver = (obj) => ws.onmessage?.({ data: JSON.stringify(obj) }); + state.calls = (method) => state.sent.filter((m) => m.method === method); + return state; +}; + +const attachBrowser = async (fake) => { + const b = makeCdpBrowser("ws://127.0.0.1:1/devtools/browser/test", { + wsFactory: () => { + queueMicrotask(() => fake.ws.onopen?.()); + return fake.ws; + }, + }); + const got = []; + b.onMessage((m) => got.push(m)); + const id = await b.connect(); + return { b, got, id }; +}; + +test("adapter surface: forbidden methods are absent, required ones present", async () => { + const fake = makeFakeCdp(); + const { b } = await attachBrowser(fake); + for (const missing of ["setViewport", "network", "snapshot", "streamEnable", "streamStatus"]) + assert.equal(b[missing], undefined, `${missing} must not exist — duck-type guards depend on it`); + for (const required of ["open", "back", "forward", "reload", "click", "scroll", "type", "sessionExists", "screenshot", "cycleTarget"]) + assert.equal(typeof b[required], "function", `${required} missing — a key handler calls it unguarded`); +}); + +test("adapter pins the first page target and starts a jpeg screencast", async () => { + const fake = makeFakeCdp({ + pages: [ + { targetId: "T1", type: "page", url: "https://one/", title: "One" }, + { targetId: "T2", type: "page", url: "https://two/", title: "Two" }, + ], + }); + const { id } = await attachBrowser(fake); + assert.equal(id.url, "https://one/"); + const att = fake.calls("Target.attachToTarget"); + assert.equal(att.length, 1); + assert.deepEqual(att[0].params, { targetId: "T1", flatten: true }); + const sc = fake.calls("Page.startScreencast")[0]; + assert.equal(sc.params.format, "jpeg"); + assert.equal(sc.sessionId, "sess-T1"); +}); + +test("adapter never creates or closes targets across its whole lifecycle", async () => { + const fake = makeFakeCdp({ + pages: [ + { targetId: "T1", type: "page", url: "https://one/", title: "One" }, + { targetId: "T2", type: "page", url: "https://two/", title: "Two" }, + ], + }); + const { b } = await attachBrowser(fake); + await b.open("https://elsewhere/"); + await b.reload(); + await b.cycleTarget(); + fake.deliver({ method: "Target.targetDestroyed", params: { targetId: "T2" } }); + await new Promise((r) => setTimeout(r, 10)); + b.close(); + await new Promise((r) => setTimeout(r, 10)); + assert.equal(fake.calls("Target.createTarget").length, 0); + assert.equal(fake.calls("Target.closeTarget").length, 0); + assert.ok(!fake.sent.some((m) => m.method.startsWith("Emulation.")), "no emulation ever"); +}); + +test("adapter frame events carry the integer ack id; stale-generation acks are dropped", async () => { + const fake = makeFakeCdp(); + const { b, got } = await attachBrowser(fake); + fake.deliver({ + method: "Page.screencastFrame", + sessionId: "sess-T1", + params: { data: "AAAA", sessionId: 7, metadata: { deviceWidth: 1600, deviceHeight: 900 } }, + }); + const frame = got.find((m) => m.type === "frame"); + assert.equal(frame.ackId, 7, "integer ack id from params, not the routing string"); + await b.ackFrame(frame.ackId, frame.gen); + const acks = fake.calls("Page.screencastFrameAck"); + assert.equal(acks.length, 1); + assert.deepEqual(acks[0].params, { sessionId: 7 }); + assert.equal(acks[0].sessionId, "sess-T1", "routed over the flat-session string"); + await b.restartScreencast(); + await b.ackFrame(frame.ackId, frame.gen); // old generation + assert.equal(fake.calls("Page.screencastFrameAck").length, 1, "stale ack dropped"); +}); + +test("adapter emits url for the pinned target only; re-pins on destruction only", async () => { + const fake = makeFakeCdp({ + pages: [ + { targetId: "T1", type: "page", url: "https://one/", title: "One" }, + { targetId: "T2", type: "page", url: "https://two/", title: "Two" }, + ], + }); + const { got } = await attachBrowser(fake); + fake.deliver({ + method: "Target.targetInfoChanged", + params: { targetInfo: { targetId: "T2", url: "https://noise/", title: "n" } }, + }); + fake.deliver({ + method: "Target.targetInfoChanged", + params: { targetInfo: { targetId: "T1", url: "https://one/next", title: "One+" } }, + }); + assert.deepEqual( + got.filter((m) => m.type === "url").map((m) => m.url), + ["https://one/next"], + "unpinned targets never move the header", + ); + fake.deliver({ + method: "Target.targetCreated", + params: { targetInfo: { targetId: "T9", type: "page", url: "https://pop/" } }, + }); + await new Promise((r) => setTimeout(r, 10)); + assert.equal(fake.calls("Target.attachToTarget").length, 1, "creation never re-pins"); + fake.deliver({ method: "Target.targetDestroyed", params: { targetId: "T1" } }); + await new Promise((r) => setTimeout(r, 10)); + assert.equal(fake.calls("Target.attachToTarget").length, 2, "destruction re-pins"); +}); + +test("adapter destroyed pin with no survivors emits target_gone", async () => { + const fake = makeFakeCdp(); + const { got } = await attachBrowser(fake); + fake.pages.length = 0; + fake.deliver({ method: "Target.targetDestroyed", params: { targetId: "T1" } }); + await new Promise((r) => setTimeout(r, 10)); + assert.ok(got.some((m) => m.type === "target_gone")); +}); + +test("adapter navigation: back is a no-op at history start, forward/reload work", async () => { + const history = { + currentIndex: 1, + entries: [{ id: 10 }, { id: 11 }, { id: 12 }], + }; + const fake = makeFakeCdp({ results: { "Page.getNavigationHistory": () => history } }); + const { b } = await attachBrowser(fake); + await b.back(); + assert.deepEqual(fake.calls("Page.navigateToHistoryEntry")[0].params, { entryId: 10 }); + await b.forward(); + assert.deepEqual(fake.calls("Page.navigateToHistoryEntry")[1].params, { entryId: 12 }); + history.currentIndex = 0; + await b.back(); + assert.equal(fake.calls("Page.navigateToHistoryEntry").length, 2, "no-op at boundary"); + await b.reload(); + assert.equal(fake.calls("Page.reload").length, 1); +}); + +test("adapter input: click is press+release, type is insertText, scroll is mouseWheel", async () => { + const fake = makeFakeCdp(); + const { b } = await attachBrowser(fake); + await b.click(120, 240); + const mouse = fake.calls("Input.dispatchMouseEvent"); + assert.deepEqual( + mouse.map((m) => m.params.type), + ["mousePressed", "mouseReleased"], + ); + assert.equal(mouse[0].params.x, 120); + await b.type("héllo"); + assert.equal(fake.calls("Input.insertText")[0].params.text, "héllo"); + await b.scroll("down", 300); + const wheel = fake.calls("Input.dispatchMouseEvent").at(-1); + assert.equal(wheel.params.type, "mouseWheel"); + assert.equal(wheel.params.deltaY, 300); +}); + +test("adapter feed: log-only tier never sends Runtime.enable", async () => { + const fake = makeFakeCdp(); + const b = makeCdpBrowser("ws://127.0.0.1:1/devtools/browser/test", { + consoleTier: "log-only", + wsFactory: () => { + queueMicrotask(() => fake.ws.onopen?.()); + return fake.ws; + }, + }); + b.onMessage(() => {}); + await b.connect(); + assert.equal(fake.calls("Log.enable").length, 1, "Log always on"); + assert.equal( + fake.calls("Runtime.enable").length, + 0, + "Runtime.enable is page-observable — the opt-out must really opt out", + ); + assert.equal(fake.calls("Target.setAutoAttach").length, 1, "OOPIF feed still armed"); +}); + +test("adapter feed: default tier enables Runtime and swallows pre-attach replay", async () => { + const fake = makeFakeCdp(); + const { b, got } = await attachBrowser(fake); + assert.equal(fake.calls("Runtime.enable").length, 1); + const before = b.attachTime() - 5_000; + const after = b.attachTime() + 5_000; + fake.deliver({ + method: "Runtime.consoleAPICalled", + sessionId: "sess-T1", + params: { type: "log", timestamp: before, args: [{ value: "ancient history" }] }, + }); + fake.deliver({ + method: "Log.entryAdded", + sessionId: "sess-T1", + params: { entry: { source: "network", level: "error", timestamp: before, text: "old failure" } }, + }); + assert.equal(got.filter((m) => m.type === "console" || m.type === "log_entry").length, 0, + "buffered backlog swallowed on both domains"); + fake.deliver({ + method: "Runtime.consoleAPICalled", + sessionId: "sess-T1", + params: { type: "warning", timestamp: after, args: [{ value: "live" }, { value: 42 }] }, + }); + const line = got.find((m) => m.type === "console"); + assert.equal(line.level, "warn"); + assert.equal(line.text, "live 42"); +}); + +test("adapter feed: exceptions and network log entries surface with their detail", async () => { + const fake = makeFakeCdp(); + const { b, got } = await attachBrowser(fake); + const t = b.attachTime() + 1_000; + fake.deliver({ + method: "Runtime.exceptionThrown", + sessionId: "sess-T1", + params: { timestamp: t, exceptionDetails: { exception: { description: "TypeError: x is not a function" } } }, + }); + assert.equal(got.at(-1).type, "page_error"); + assert.match(got.at(-1).text, /TypeError/); + fake.deliver({ + method: "Log.entryAdded", + sessionId: "sess-T1", + params: { entry: { source: "network", level: "error", timestamp: t, text: "Failed to load resource: net::ERR_CONNECTION_REFUSED", url: "http://127.0.0.1:1/x" } }, + }); + const ne = got.at(-1); + assert.equal(ne.type, "log_entry"); + assert.match(ne.text, /ERR_CONNECTION_REFUSED/, "real error text, not just a status"); +}); + +test("adapter feed: auto-attached OOPIF sessions get their own feed enabled", async () => { + const fake = makeFakeCdp(); + await attachBrowser(fake); + fake.deliver({ + method: "Target.attachedToTarget", + params: { sessionId: "sess-IFRAME", targetInfo: { targetId: "IF1", type: "iframe" } }, + }); + await new Promise((r) => setTimeout(r, 10)); + assert.ok( + fake.calls("Log.enable").some((m) => m.sessionId === "sess-IFRAME"), + "embedded frame failures must not be silent", + ); +}); diff --git a/tests/renderer.test.mjs b/tests/renderer.test.mjs index d337595..4bbac87 100644 --- a/tests/renderer.test.mjs +++ b/tests/renderer.test.mjs @@ -1921,3 +1921,268 @@ test("live timer: never starts for browsers without network()", () => { r.startNetworkTimer(5); assert.equal(r.networkTimer, null); }); + +// --- Wave 4: CDP attach mode --- + +const attachRenderer = (over = {}) => { + const r = mkRenderer({ HERDR_BROWSER_CDP_URL: "http://127.0.0.1:9222", ...over }); + quiet(r); + r.redrawAll = async () => {}; + r.fitViewport = async () => false; + return r; +}; +const fakeCdpBackend = (over = {}) => { + const calls = []; + let handler = null; + return { + calls, + emit: (m) => handler?.(m), + onMessage: (fn) => { + handler = fn; + }, + connect: async () => { + calls.push("connect"); + return { + host: "127.0.0.1", + port: "9222", + guid: "guid-1", + browser: "Chrome/150", + url: "https://x/", + title: "X", + rediscoverable: true, + ...(over.identity ?? {}), + }; + }, + sessionExists: async () => over.alive !== false, + ackFrame: async (ackId, gen) => calls.push(`ack:${ackId}:${gen}`), + restartScreencast: async () => calls.push("restart"), + click: async (x, y) => calls.push(`click:${x},${y}`), + close: () => calls.push("close"), + }; +}; + +test("attach mode: CDP endpoint wins over agent-browser and disables owning paths", () => { + const r = attachRenderer(); + assert.equal(r.mode, "attach"); + assert.equal(r.ownershipEnabled, false); + assert.equal(r.backendName, "browser endpoint"); + // The duck-type omissions are the contract: no viewport fitting, no + // polling failure feed, no goLive. + assert.equal(typeof r.browser.setViewport, "undefined"); + assert.equal(typeof r.browser.network, "undefined"); + assert.equal(typeof r.browser.streamEnable, "undefined"); + const plain = mkRenderer(); + assert.equal(plain.mode, "agent-browser"); + assert.equal(plain.ownershipEnabled, true); +}); + +test("attach mode: tick connects, then only watches liveness", async () => { + const r = attachRenderer(); + r.browser = fakeCdpBackend(); + await r.tick(); + assert.equal(r.attached, true); + assert.equal(r.lastUrl, "https://x/"); + r.lastLiveCheck = Date.now(); // fresh check + await r.tick(); + assert.deepEqual(r.browser.calls, ["connect"], "no polling while attached"); +}); + +test("attach mode: frames paint and ack with the integer id after the paint settles", async () => { + const r = attachRenderer(); + r.browser = fakeCdpBackend(); + let renders = 0; + r.renderImage = async () => { + renders++; + }; + await r.tick(); + r.browser.emit({ + type: "frame", + data: jpeg(1280, 720).toString("base64"), + metadata: { deviceWidth: 1280, deviceHeight: 720 }, + ackId: 42, + gen: 1, + }); + await flush(); + assert.equal(renders, 1, "frame painted"); + assert.ok(r.browser.calls.includes("ack:42:1"), "acked after paint"); +}); + +test("attach mode: clicks scale from frame pixels to page pixels per frame", async () => { + const r = attachRenderer(); + r.browser = fakeCdpBackend(); + await r.tick(); + // Frame is 800px wide but the page is 1600 CSS px (retina / maxWidth scale). + r.lastFrameMeta = { deviceWidth: 1600, deviceHeight: 900 }; + const scaled = r.cdpPagePoint({ x: 100, y: 50 }, { w: 800, h: 450 }); + assert.deepEqual(scaled, { x: 200, y: 100 }); + // Metadata changing mid-session (window resized) is picked up immediately. + r.lastFrameMeta = { deviceWidth: 800, deviceHeight: 450 }; + assert.deepEqual(r.cdpPagePoint({ x: 100, y: 50 }, { w: 800, h: 450 }), { + x: 100, + y: 50, + }); +}); + +test("attach mode: dead endpoint detaches; raw ws endpoints do not retry", async () => { + const r = attachRenderer(); + r.browser = fakeCdpBackend({ alive: false }); + await r.tick(); + r.lastLiveCheck = 0; + await r.tick(); + assert.equal(r.attached, false); + assert.match(r.banner, /went away — retrying/); + + const raw = attachRenderer(); + raw.browser = fakeCdpBackend({ alive: false, identity: { rediscoverable: false } }); + await raw.tick(); + raw.lastLiveCheck = 0; + await raw.tick(); + assert.match(raw.banner, /restart the pane/); + assert.equal(raw.streamCooldownUntil, Number.MAX_SAFE_INTEGER, "no retry loop"); +}); + +test("attach mode: reattach to a different browser resets state with a marker", async () => { + const r = attachRenderer(); + r.browser = fakeCdpBackend(); + await r.tick(); + r.consoleLines.push(" stale line from the old browser"); + r.attached = false; + r.browser = fakeCdpBackend({ identity: { guid: "guid-2" } }); + r.browser.onMessage((m) => r.onCdpMessage(m)); + r.streamCooldownUntil = 0; + await r.tick(); + assert.ok( + r.consoleLines.some((l) => /reattached to a different browser/.test(l)), + "discontinuity marker pushed", + ); + assert.equal(r.lastHash, "", "frame state reset"); +}); + +test("attach mode: stale frames banner once and trigger one restart", async () => { + const r = attachRenderer(); + r.browser = fakeCdpBackend(); + await r.tick(); + r.lastFrameAt = Date.now() - 30_000; + r.checkFrameStaleness(); + r.checkFrameStaleness(); + assert.match(r.banner, /frame stale/); + assert.equal( + r.browser.calls.filter((c) => c === "restart").length, + 1, + "exactly one restart attempt", + ); +}); + +test("attach mode: cleanup closes the socket and spawns no agent-browser", async () => { + const r = attachRenderer(); + r.browser = fakeCdpBackend(); + await r.tick(); + // Even if a navigate had wrongly flagged ownership, attach mode must not + // shell out to close somebody else's session. + r.selfCreated = true; + r.cleanup(); + assert.ok(r.browser.calls.includes("close")); + assert.equal(r.ownershipEnabled, false); +}); + +test("attach mode: Node without global WebSocket banners instead of crashing", async () => { + const saved = global.WebSocket; + delete global.WebSocket; + try { + const r = attachRenderer(); + const ok = await r.attachCdp(); + assert.equal(ok, false); + assert.match(r.banner, /Node 22\+/); + } finally { + if (saved === undefined) delete global.WebSocket; + else global.WebSocket = saved; + } +}); + +test("attach mode: endpoint tokens never reach the banner", async () => { + const r = attachRenderer({ + HERDR_BROWSER_CDP_URL: "ws://127.0.0.1:9222/devtools/browser/SECRET-TOKEN", + }); + r.browser = fakeCdpBackend(); + r.browser.connect = async () => { + throw new Error("refused"); + }; + r.browser.onMessage(() => {}); + await r.attachCdp(); + assert.match(r.banner, /127\.0\.0\.1:9222/); + assert.ok(!r.banner.includes("SECRET-TOKEN"), "capability token redacted"); +}); + +test("attach mode: network log entries dedupe on the shared window", async () => { + const r = attachRenderer(); + r.browser = fakeCdpBackend(); + await r.tick(); + const entry = { + type: "log_entry", + source: "network", + level: "error", + text: "Failed to load resource: net::ERR_CONNECTION_REFUSED", + url: "http://127.0.0.1:1/beacon", + }; + for (let i = 0; i < 5; i++) r.onCdpMessage(entry); + await flush(); + assert.equal(r.consoleLines.length, 1, "retry loop paints once"); + assert.match(r.consoleLines[0], /^✖ Failed to load resource: net::ERR_CONNECTION_REFUSED/); + r.onCdpMessage({ ...entry, source: "violation", level: "warn", text: "slow handler", url: "" }); + await flush(); + assert.equal(r.consoleLines.length, 2, "non-network sources are not deduped away"); + assert.match(r.consoleLines[1], /^⚠ slow handler/); +}); + +test("attach mode: config-dir cdp-url is a valid endpoint source", () => { + const cfg = fs.mkdtempSync(path.join(os.tmpdir(), "hb-cfg-cdp-")); + fs.writeFileSync(path.join(cfg, "cdp-url"), "http://127.0.0.1:9333\n"); + const r = mkRenderer({ HERDR_PLUGIN_CONFIG_DIR: cfg }); + assert.equal(r.mode, "attach"); + assert.equal(r.cdpEndpoint, "http://127.0.0.1:9333"); + // Env wins over the file. + const r2 = mkRenderer({ + HERDR_PLUGIN_CONFIG_DIR: cfg, + HERDR_BROWSER_CDP_URL: "http://127.0.0.1:9444", + }); + assert.equal(r2.cdpEndpoint, "http://127.0.0.1:9444"); +}); + +test("attach prompt refuses navigation-shaped input and keeps u for URLs", async () => { + const r = quiet(mkRenderer()); + await r.attachTo("localhost:9222"); + assert.match(r.banner, /not an endpoint/); + assert.equal(r.mode, "agent-browser", "bad input must not switch modes"); +}); + +test("attach switch resets reconciliation state and drops ownership", async () => { + const r = quiet(mkRenderer()); + r.selfCreated = true; + r.consoleState = { count: 9, tail: ["x"] }; + r.lastHash = "deadbeef"; + r.browser = { ...r.browser, sessionExists: async () => false }; + await r.attachTo("http://127.0.0.1:9222"); + assert.equal(r.mode, "attach"); + assert.equal(r.ownershipEnabled, false); + assert.equal(r.selfCreated, false, "ownership never survives a backend switch"); + assert.deepEqual(r.consoleState, { count: 0, tail: [] }); + assert.equal(r.lastHash, ""); + assert.ok(r.consoleLines.some((l) => /switched to attach mode/.test(l))); +}); + +test("attach mode: a Cmd+click handoff file navigates the attached target", async () => { + const r = attachRenderer(); + r.browser = fakeCdpBackend(); + r.browser.open = async (u) => r.browser.calls.push(`open:${u}`); + await r.tick(); + fs.writeFileSync( + path.join(r.stateDir, `navigate-${safeWsId(r.env.HERDR_WORKSPACE_ID)}`), + "http://localhost:3000/dash\n", + ); + r.consumeNavigateFile(); + await flush(); + assert.ok( + r.browser.calls.includes("open:http://localhost:3000/dash"), + "click navigates the attached target, not an agent-browser session", + ); +});