返回 CodeWhale
browser-cdp.mjs
根目录 / crates / tui / plugins / computer-use / src / browser-cdp.mjs
1 // Browser control over the Chrome DevTools Protocol (CDP).
2 //
3 // A self-owned Chromium-family browser: launch (or reuse) an instance with its
4 // own --user-data-dir under the state dir and a loopback-only debugging port,
5 // then speak CDP over the browser WebSocket. The person's own browser profile
6 // is never attached to, never typed into, and never closed. No screen
7 // coordinates and no accessibility are involved: page elements are addressed
8 // by CSS selector through the DOM domain, and coordinate clicks are page
9 // viewport pixels — a different space from screen points, named differently so
10 // the two can never be confused.
11 //
12 // One tab per computer session; the last session out closes the shared
13 // browser.
14 //
15 // Attach mode (CODEWHALE_CU_BROWSER_ATTACH=/run/cw/cdp.sock, a Codewhale
16 // Computer): nothing is launched. The plugin connects to the CDP bridge of the
17 // one Chromium a person also sees on the shared display — NUL-delimited JSON
18 // over a Unix socket, the --remote-debugging-pipe framing — so the agent's
19 // navigations appear in that person's window and the tabs they open appear in
20 // the agent's targets. That browser is never closed and no tab is closed:
21 // stop only detaches. Node needs a global WebSocket (22+, or 21 with the default-on
22 // flag); older runtimes refuse with `unsupported_runtime` instead of
23 // half-working.
24 import fs from "node:fs";
25 import os from "node:os";
26 import path from "node:path";
27 import crypto from "node:crypto";
28 import net from "node:net";
29 import { spawn } from "node:child_process";
30 import { ExecError, currentSignal } from "./exec.mjs";
31 import { stateDir } from "./registry.mjs";
32 import { recordingsDir as defaultRecordingsDir } from "./recordings.mjs";
33
34 const APPLICATIONS = ["Google Chrome", "Chromium", "Brave Browser", "Microsoft Edge"];
35 const LINUX_BINARIES = ["google-chrome", "chromium", "chromium-browser", "brave-browser", "microsoft-edge"];
36
37 const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
38 const badArgs = (message) => Object.assign(new ExecError(message), { code: "bad_args" });
39
40 /** Only http(s) and about:blank can be navigated to; everything else is refused. */
41 export function checkBrowserUrl(url) {
42 if (typeof url !== "string" || !url.trim()) throw badArgs("browser navigate/start need a url (http:// or https:// or about:blank)");
43 const trimmed = url.trim();
44 if (/^about:blank$/i.test(trimmed)) return trimmed;
45 let parsed;
46 try { parsed = new URL(trimmed); } catch { throw badArgs(`"${trimmed}" is not a URL`); }
47 if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
48 // A person asking to "open" a local file wants to see it, not to have
49 // this self-owned browser read the disk: point at the route that shows it.
50 const hint = parsed.protocol === "file:"
51 ? "; to show a workspace file to the person use open_in_app when the Codewhale app offers it, or serve it over http://127.0.0.1"
52 : "";
53 throw badArgs(`only http(s):// and about:blank URLs can be opened (got "${parsed.protocol}//")${hint}`);
54 }
55 return parsed.href;
56 }
57
58 /** Locate a Chromium-family browser app or binary; CODEWHALE_CU_BROWSER_APP overrides. */
59 export function findBrowserApp(platform = process.platform, env = process.env, exists = fs.existsSync) {
60 if (env.CODEWHALE_CU_BROWSER_APP) return env.CODEWHALE_CU_BROWSER_APP;
61 if (platform === "darwin") {
62 for (const name of APPLICATIONS) {
63 for (const root of ["/Applications", path.join(os.homedir(), "Applications")]) {
64 const candidate = path.posix.join(root, `${name}.app`);
65 if (exists(candidate)) return candidate;
66 }
67 }
68 return null;
69 }
70 if (platform === "win32") {
71 const installs = [
72 ["Google", "Chrome", "Application", "chrome.exe"],
73 ["Chromium", "Application", "chrome.exe"],
74 ["BraveSoftware", "Brave-Browser", "Application", "brave.exe"],
75 ["Microsoft", "Edge", "Application", "msedge.exe"],
76 ];
77 for (const relative of installs) {
78 for (const root of [env.ProgramFiles, env["ProgramFiles(x86)"], env.LOCALAPPDATA]) {
79 if (!root) continue;
80 const candidate = path.win32.join(root, ...relative);
81 if (exists(candidate)) return candidate;
82 }
83 }
84 return null;
85 }
86 for (const bin of LINUX_BINARIES) {
87 for (const dir of (env.PATH ?? "").split(":")) {
88 if (dir && exists(path.join(dir, bin))) return path.join(dir, bin);
89 }
90 }
91 return null;
92 }
93
94 /** Launch detached so the browser is its own process, never a child we must reap. */
95 function defaultLaunch({ app, profileDir, url, platform = process.platform }) {
96 const flags = ["--remote-debugging-port=0", `--user-data-dir=${profileDir}`, "--no-first-run", "--no-default-browser-check"];
97 const target = url || "about:blank";
98 let cmd, args;
99 // -n (new instance) matters: without it, `open --args` is ignored whenever
100 // the person already has Chrome running — the args never reach a new process.
101 if (platform === "darwin") { cmd = "open"; args = ["-g", "-n", "-a", app, "--args", ...flags, target]; }
102 else { cmd = app; args = [...flags, target]; }
103 const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
104 child.on("error", () => {});
105 child.unref();
106 }
107
108 /** The CDP bridge socket to attach to, or null for launch mode. */
109 export function attachSocket(env = process.env) {
110 const value = env.CODEWHALE_CU_BROWSER_ATTACH;
111 return typeof value === "string" && value.trim() ? value.trim() : null;
112 }
113
114 /**
115 * Connect to a NUL-framed CDP Unix socket and expose the small WebSocket-like
116 * surface makeChannel uses (addEventListener message/close/error, send, close).
117 * The bridge admits one client; a second gets `{"error":"cdp_busy"}` and EOF,
118 * surfaced as `closeReason`.
119 */
120 export function connectPipeSocket(socketPath, { timeoutMs = 8_000, createConnection = net.createConnection } = {}) {
121 return new Promise((resolve, reject) => {
122 const listeners = { message: [], close: [], error: [] };
123 const emit = (type, event) => { for (const entry of [...listeners[type]]) { if (entry.once) listeners[type] = listeners[type].filter((e) => e !== entry); entry.fn(event); } };
124 let inbuf = Buffer.alloc(0);
125 let opened = false;
126 let closed = false;
127 const ws = {
128 closeReason: null,
129 addEventListener(type, fn, opts) { listeners[type]?.push({ fn, once: !!opts?.once }); },
130 send(text) { if (!closed) sock.write(`${text}\0`); },
131 close() { if (closed) return; closed = true; sock.destroy(); emit("close", {}); },
132 };
133 const sock = createConnection(socketPath);
134 const timer = setTimeout(() => {
135 sock.destroy();
136 reject(Object.assign(new ExecError(`the CDP bridge at ${socketPath} did not accept within ${timeoutMs}ms`), { code: "browser_unavailable" }));
137 }, timeoutMs);
138 sock.on("connect", () => { opened = true; clearTimeout(timer); resolve(ws); });
139 sock.on("data", (chunk) => {
140 inbuf = Buffer.concat([inbuf, chunk]);
141 let i;
142 while ((i = inbuf.indexOf(0)) >= 0) {
143 const text = inbuf.subarray(0, i).toString("utf8");
144 inbuf = inbuf.subarray(i + 1);
145 if (text.startsWith("{\"error\"")) {
146 try { ws.closeReason = JSON.parse(text).error ?? ws.closeReason; } catch {}
147 continue;
148 }
149 emit("message", { data: text });
150 }
151 });
152 sock.on("error", (error) => {
153 if (!opened) {
154 clearTimeout(timer);
155 const denied = error?.code === "EACCES";
156 reject(Object.assign(new ExecError(denied
157 ? `permission denied on the CDP bridge ${socketPath} — only the Engine's user may attach to the shared browser`
158 : `cannot reach the CDP bridge at ${socketPath} (${error?.code ?? error?.message}) — is the chrome service running?`), { code: "browser_unavailable" }));
159 return;
160 }
161 emit("error", error);
162 });
163 sock.on("close", () => { if (!closed) { closed = true; emit("close", {}); } });
164 });
165 }
166
167 /**
168 * Open the CDP WebSocket. Injectable: tests supply a fake ws-like object so
169 * the command sequence is verifiable without a browser.
170 */
171 function defaultConnect(url, { timeoutMs = 8_000 } = {}) {
172 return new Promise((resolve, reject) => {
173 if (typeof WebSocket === "undefined") {
174 reject(Object.assign(new ExecError("browser actions need a Node runtime with a global WebSocket (22+); this runtime does not have one"), { code: "unsupported_runtime" }));
175 return;
176 }
177 let ws;
178 try { ws = new WebSocket(url); } catch (error) {
179 reject(Object.assign(new ExecError(`cannot open a CDP socket at ${url}: ${error.message}`), { code: "browser_unavailable" }));
180 return;
181 }
182 const timer = setTimeout(() => {
183 try { ws.close(); } catch {}
184 reject(Object.assign(new ExecError(`the CDP socket at ${url} did not open within ${timeoutMs}ms`), { code: "browser_unavailable" }));
185 }, timeoutMs);
186 ws.addEventListener("open", () => { clearTimeout(timer); resolve(ws); }, { once: true });
187 ws.addEventListener("error", () => {
188 clearTimeout(timer);
189 reject(Object.assign(new ExecError(`the CDP socket at ${url} refused the connection`), { code: "browser_unavailable" }));
190 }, { once: true });
191 });
192 }
193
194 /** Per-command CDP reply deadline; teardown uses its own shorter bound. */
195 const commandTimeoutMs = () => Number(process.env.CODEWHALE_CU_BROWSER_COMMAND_TIMEOUT_MS) || 30_000;
196 // Teardown ignores the (possibly already cancelled) request signal and gives
197 // an unanswered peer at most three seconds per command.
198 const teardown = () => ({ timeoutMs: Math.min(3_000, commandTimeoutMs()), signal: null });
199
200 /** id-matched JSON-RPC over the WebSocket, plus CDP event fan-out. */
201 function makeChannel(ws) {
202 let nextId = 0;
203 const pending = new Map();
204 const listeners = new Map();
205 let closed = false;
206 const failAll = (reason) => {
207 closed = true;
208 for (const entry of [...pending.values()]) entry.reject(Object.assign(new ExecError(reason), { code: "browser_not_running" }));
209 pending.clear();
210 };
211 ws.addEventListener("message", (event) => {
212 let msg;
213 try { msg = JSON.parse(typeof event.data === "string" ? event.data : String(event.data)); } catch { return; }
214 if (msg.id != null && pending.has(msg.id)) {
215 const entry = pending.get(msg.id);
216 pending.delete(msg.id);
217 if (msg.error) entry.reject(Object.assign(new ExecError(`CDP ${msg.method ?? ""} failed: ${msg.error.message}`), { code: "cdp_error", cdp: msg.error }));
218 else entry.resolve(msg.result ?? {});
219 return;
220 }
221 if (msg.method) for (const fn of listeners.get(msg.method) ?? []) fn(msg);
222 });
223 ws.addEventListener("close", () => failAll("the browser closed the CDP connection"));
224 ws.addEventListener("error", () => {});
225 return {
226 get alive() { return !closed; },
227 /**
228 * One CDP command. Bounded: a peer that keeps the socket open but never
229 * answers must not hang the request (or a stop/teardown awaiting it)
230 * forever. After the command is written its effect is unknown, so a
231 * timeout or cancel carries requestDispatched; a late reply is dropped.
232 */
233 send(method, params = {}, sessionId, { timeoutMs = commandTimeoutMs(), signal = currentSignal() } = {}) {
234 if (closed) return Promise.reject(Object.assign(new ExecError("the browser is no longer reachable (CDP connection closed)"), { code: "browser_not_running" }));
235 if (signal?.aborted) return Promise.reject(Object.assign(new ExecError("computer request cancelled"), { code: "cancelled" }));
236 return new Promise((resolve, reject) => {
237 const id = ++nextId;
238 let timer = null;
239 const finish = () => { clearTimeout(timer); signal?.removeEventListener("abort", onAbort); pending.delete(id); };
240 const onAbort = () => { finish(); reject(Object.assign(new ExecError("computer request cancelled"), { code: "cancelled", requestDispatched: true })); };
241 pending.set(id, {
242 resolve: (value) => { finish(); resolve(value); },
243 reject: (error) => { finish(); reject(error); },
244 });
245 timer = setTimeout(() => {
246 finish();
247 reject(Object.assign(new ExecError(`CDP ${method} got no reply within ${timeoutMs}ms`), { code: "timeout", requestDispatched: true }));
248 }, timeoutMs);
249 signal?.addEventListener("abort", onAbort, { once: true });
250 ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
251 });
252 },
253 waitFor(method, { sessionId, timeoutMs = 15_000 } = {}) {
254 return new Promise((resolve, reject) => {
255 const signal = currentSignal();
256 const done = () => {
257 clearTimeout(timer);
258 signal?.removeEventListener("abort", onAbort);
259 const list = listeners.get(method) ?? [];
260 listeners.set(method, list.filter((fn) => fn !== handler));
261 };
262 const onAbort = () => { done(); reject(Object.assign(new ExecError("computer request cancelled"), { code: "cancelled" })); };
263 const handler = (msg) => {
264 if (sessionId && msg.sessionId !== sessionId) return;
265 done();
266 resolve(msg.params ?? {});
267 };
268 const timer = setTimeout(() => {
269 done();
270 reject(Object.assign(new ExecError(`timed out waiting for ${method}`), { code: "timeout" }));
271 }, timeoutMs);
272 if (signal?.aborted) { onAbort(); return; }
273 signal?.addEventListener("abort", onAbort, { once: true });
274 listeners.set(method, [...(listeners.get(method) ?? []), handler]);
275 });
276 },
277 close() { closed = true; try { ws.close(); } catch {} },
278 };
279 }
280
281 /**
282 * The browser bridge. One instance per backend (per computer session).
283 * Exposes browser_start / browser_status / browser_navigate / browser_click /
284 * browser_type / browser_screenshot / browser_stop plus close() for the
285 * session teardown hook.
286 */
287 export function createBrowser({
288 connect = defaultConnect,
289 launch = defaultLaunch,
290 findApp = findBrowserApp,
291 recordingsDir = defaultRecordingsDir,
292 platform = process.platform,
293 attach = attachSocket(),
294 connectAttach = connectPipeSocket,
295 } = {}) {
296 const state = { channel: null, port: null, profileDir: null, app: null, targetId: null, sessionId: null, pageEnabled: false, domEnabled: false, attached: false, product: null, closeReason: null };
297
298 const profileDir = () => path.join(stateDir(), "browser", "profile");
299 const loadTimeout = () => Number(process.env.CODEWHALE_CU_BROWSER_LOAD_TIMEOUT_MS) || 15_000;
300 const requireRunning = () => {
301 if (!state.channel) throw Object.assign(new ExecError("no browser for this session yet — run browser {action:\"start\"} first"), { code: "browser_not_running" });
302 };
303 async function targetInfo(targetId) {
304 const { targetInfo: info } = await state.channel.send("Target.getTargetInfo", { targetId });
305 return { url: info?.url ?? "", title: info?.title ?? "" };
306 }
307 async function listTabs(options) {
308 const { targetInfos } = await state.channel.send("Target.getTargets", {}, undefined, options);
309 return (targetInfos ?? []).filter((t) => t.type === "page");
310 }
311 async function ensureDom() {
312 if (!state.domEnabled) { await state.channel.send("DOM.enable", {}, state.sessionId); state.domEnabled = true; }
313 }
314 async function resolveNode(selector) {
315 if (typeof selector !== "string" || !selector.trim()) throw badArgs("selector must be a non-empty CSS selector");
316 await ensureDom();
317 const { root } = await state.channel.send("DOM.getDocument", { depth: 0 }, state.sessionId);
318 const { nodeId } = await state.channel.send("DOM.querySelector", { nodeId: root.nodeId, selector }, state.sessionId);
319 if (!nodeId) throw Object.assign(new ExecError(`no element on this page matches ${JSON.stringify(selector)}`), { code: "selector_not_found" });
320 return nodeId;
321 }
322 async function viewport() {
323 const metrics = await state.channel.send("Page.getLayoutMetrics", {}, state.sessionId);
324 const vp = metrics.cssLayoutViewport ?? {};
325 return { w: vp.clientWidth ?? 0, h: vp.clientHeight ?? 0, scale: metrics.cssVisualViewport?.scale ?? 1, metrics };
326 }
327 async function mousePoint(x, y) {
328 for (const [type, extra] of [["mouseMoved", {}], ["mousePressed", { button: "left", clickCount: 1, buttons: 1 }], ["mouseReleased", { button: "left", clickCount: 1, buttons: 0 }]]) {
329 await state.channel.send("Input.dispatchMouseEvent", { type, x, y, ...extra }, state.sessionId);
330 }
331 }
332 async function waitLoad(timeoutMs) {
333 await state.channel.send("Page.enable", {}, state.sessionId).catch(() => {});
334 state.pageEnabled = true;
335 return state.channel.waitFor("Page.loadEventFired", { sessionId: state.sessionId, timeoutMs });
336 }
337 async function bindTab(url, reused) {
338 // A fresh launch already came with one about:blank tab — adopt it instead
339 // of stacking a second tab that nothing will ever close. A single
340 // leftover blank tab (a dead session's) is adopted too; visible tabs are
341 // never stolen (an instance with real tabs gets a new tab of this
342 // session's own).
343 const tabs = await listTabs();
344 let targetId = null;
345 if (tabs.length === 1 && (!reused || tabs[0].url === "about:blank")) targetId = tabs[0].targetId;
346 if (!targetId) ({ targetId } = await state.channel.send("Target.createTarget", { url: "about:blank" }));
347 const { sessionId } = await state.channel.send("Target.attachToTarget", { targetId, flatten: true });
348 state.targetId = targetId;
349 state.sessionId = sessionId;
350 state.pageEnabled = false;
351 state.domEnabled = false;
352 let verified = true;
353 if (url && url !== "about:blank") {
354 const load = waitLoad(loadTimeout());
355 await state.channel.send("Page.navigate", { url }, sessionId);
356 verified = await load.then(() => true).catch(() => false);
357 }
358 return { verified };
359 }
360
361 /**
362 * Attach mode: bind to a requested tab, or adopt a lone blank tab, or open
363 * a new foreground tab in the person's window — and bring it to the front so
364 * what the agent does is visible. A person's open tab is only taken when
365 * named explicitly with `tab`.
366 */
367 async function bindSharedTab(url, tab) {
368 const tabs = await listTabs();
369 let targetId = null;
370 if (tab != null) {
371 if (!tabs.some((t) => t.targetId === tab)) throw Object.assign(new ExecError(`no tab ${JSON.stringify(tab)} in the shared browser — browser {action:"status"} lists them`), { code: "bad_target" });
372 targetId = tab;
373 } else if (tabs.length === 1 && /^(about:blank|chrome:\/\/newtab\/?|chrome:\/\/new-tab-page\/?)$/.test(tabs[0].url ?? "")) {
374 targetId = tabs[0].targetId;
375 } else {
376 ({ targetId } = await state.channel.send("Target.createTarget", { url: "about:blank", background: false }));
377 }
378 const { sessionId } = await state.channel.send("Target.attachToTarget", { targetId, flatten: true });
379 await state.channel.send("Target.activateTarget", { targetId }).catch(() => {});
380 state.targetId = targetId;
381 state.sessionId = sessionId;
382 state.pageEnabled = false;
383 state.domEnabled = false;
384 let verified = true;
385 if (url && url !== "about:blank") {
386 const load = waitLoad(loadTimeout());
387 await state.channel.send("Page.navigate", { url }, sessionId);
388 verified = await load.then(() => true).catch(() => false);
389 }
390 return { verified, adopted: tab != null || targetId !== null && tabs.some((t) => t.targetId === targetId) };
391 }
392
393 async function startAttached(target, tab) {
394 const ws = await connectAttach(attach);
395 state.channel = makeChannel(ws);
396 state.attached = true;
397 state.app = `attached:${attach}`;
398 try {
399 const version = await state.channel.send("Browser.getVersion", {});
400 state.product = version.product ?? null;
401 const { verified, adopted } = await bindSharedTab(target, tab);
402 const info = await targetInfo(state.targetId);
403 return {
404 running: true, attached: true, launched: false, shared: true, browser: state.product, socket: attach,
405 tab: { id: state.targetId, url: info.url, title: info.title }, adopted_tab: adopted, verified,
406 note: "attached to the computer's shared browser: the person watching sees this tab, and tabs they open appear in browser status. Stop only detaches.",
407 };
408 } catch (error) {
409 const reason = ws.closeReason;
410 state.channel?.close();
411 state.channel = null; state.attached = false; state.targetId = null; state.sessionId = null;
412 if (reason === "cdp_busy") throw Object.assign(new ExecError(`the shared browser's CDP bridge (${attach}) already has a client — only one controller may attach at a time`), { code: "browser_busy" });
413 throw error;
414 }
415 }
416
417 const api = {
418 async start({ url, tab } = {}) {
419 const target = url ? checkBrowserUrl(url) : "about:blank";
420 if (state.channel) {
421 if (state.attached && tab != null && tab !== state.targetId) {
422 await state.channel.send("Target.detachFromTarget", { sessionId: state.sessionId }).catch(() => {});
423 const { verified } = await bindSharedTab(target, tab);
424 return { ...(await this.status()), switched_tab: true, verified };
425 }
426 if (url) await this.navigate({ url: target });
427 return { ...(await this.status()), already_running: true };
428 }
429 if (tab != null && !attach) throw badArgs("tab selects a tab of the shared browser and needs attach mode (CODEWHALE_CU_BROWSER_ATTACH)");
430 if (attach) return startAttached(target, tab);
431 if (typeof WebSocket === "undefined") throw Object.assign(new ExecError("browser actions need a Node runtime with a global WebSocket (22+); this runtime does not have one"), { code: "unsupported_runtime" });
432 const app = findApp();
433 if (!app) throw Object.assign(new ExecError(`no Chromium-family browser found (looked for ${APPLICATIONS.join(", ")}); set CODEWHALE_CU_BROWSER_APP to the app path`), { code: "browser_not_installed" });
434 const dir = profileDir();
435 fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
436
437 // Reuse a live instance for this profile (a previous session may not have
438 // stopped it); a stale port file must never shadow a fresh launch. The
439 // port file carries the browser WebSocket path, so no HTTP probe is
440 // needed — a socket that opens is an instance that is alive.
441 const portFile = path.join(dir, "DevToolsActivePort");
442 const readPortFile = () => {
443 try {
444 const [port, wsPath] = fs.readFileSync(portFile, "utf8").split("\n");
445 return /^\d+$/.test((port ?? "").trim()) && (wsPath ?? "").trim()
446 ? { port: Number(port.trim()), wsPath: wsPath.trim() }
447 : null;
448 } catch { return null; }
449 };
450 const attempt = async (file) => {
451 if (!file) return null;
452 try { return await connect(`ws://127.0.0.1:${file.port}${file.wsPath}`); } catch { return null; }
453 };
454 let ws = null, reused = false;
455 const existing = readPortFile();
456 if (existing) {
457 ws = await attempt(existing);
458 if (ws) { reused = true; state.port = existing.port; }
459 else { try { fs.rmSync(portFile, { force: true }); } catch {} }
460 }
461 if (!ws) {
462 await launch({ app, profileDir: dir, url: "about:blank", platform });
463 const deadline = Date.now() + 20_000;
464 for (;;) {
465 if (currentSignal()?.aborted) throw Object.assign(new ExecError("computer request cancelled"), { code: "cancelled" });
466 const file = readPortFile();
467 if (file) {
468 ws = await attempt(file);
469 if (ws) { state.port = file.port; break; }
470 }
471 if (Date.now() > deadline) throw Object.assign(new ExecError(`the browser started but its debugging endpoint never came up (profile ${dir}); is it running with a usable profile?`), { code: "browser_unavailable" });
472 await sleep(300);
473 }
474 }
475 state.channel = makeChannel(ws);
476 state.profileDir = dir;
477 state.app = app;
478 const { verified } = await bindTab(target, reused);
479 const info = await targetInfo(state.targetId);
480 return {
481 running: true, launched: !reused, reused, browser: app, profile: dir,
482 tab: { id: state.targetId, url: info.url, title: info.title }, verified,
483 note: "self-owned profile under the state dir; the user's own browser was not touched",
484 };
485 },
486
487 async status() {
488 if (!state.channel) {
489 if (attach) return { running: false, attached: false, socket: attach, note: "not attached yet — browser {action:\"start\"} attaches to the computer's shared browser" };
490 return { running: false, browser: state.app, profile: state.profileDir ?? profileDir(), note: "no browser session for this computer session yet — browser {action:\"start\"} launches a self-owned instance" };
491 }
492 try {
493 const tabs = await listTabs();
494 if (state.attached) {
495 return {
496 running: true, attached: true, shared: true, browser: state.product, socket: attach,
497 tabs: tabs.map((t) => ({ id: t.targetId, title: t.title, url: t.url, agent: t.targetId === state.targetId })),
498 activeTab: tabs.some((t) => t.targetId === state.targetId) ? (({ url, title }) => ({ id: state.targetId, url, title }))(await targetInfo(state.targetId)) : null,
499 note: "every page tab in the shared browser, including the person's; start {tab} moves the agent to one of them",
500 };
501 }
502 return {
503 running: true, browser: state.app, port: state.port, profile: state.profileDir,
504 tabs: tabs.map((t) => ({ id: t.targetId, title: t.title, url: t.url })),
505 activeTab: tabs.some((t) => t.targetId === state.targetId) ? (({ url, title }) => ({ id: state.targetId, url, title }))(await targetInfo(state.targetId)) : null,
506 };
507 } catch (error) {
508 state.channel?.close();
509 state.channel = null; state.targetId = null; state.sessionId = null;
510 return { running: false, note: `the browser went away: ${error.message}` };
511 }
512 },
513
514 async navigate({ url } = {}) {
515 requireRunning();
516 const target = checkBrowserUrl(url);
517 const loadPromise = target === "about:blank" ? null : waitLoad(loadTimeout());
518 await state.channel.send("Page.navigate", { url: target }, state.sessionId);
519 let verified = true;
520 if (loadPromise) {
521 try { await loadPromise; } catch (error) { verified = false; void error; }
522 }
523 const info = await targetInfo(state.targetId).catch(() => ({ url: target, title: "" }));
524 return {
525 action_sent: true, url: info.url || target, title: info.title, verified,
526 ...(verified ? {} : { note: "the page did not report load completion (slow page or same-document navigation) — observe before relying on it" }),
527 };
528 },
529
530 async click({ selector, point } = {}) {
531 requireRunning();
532 let where;
533 if (typeof selector === "string" && selector.trim()) {
534 const nodeId = await resolveNode(selector);
535 await state.channel.send("DOM.scrollIntoViewIfNeeded", { nodeId }, state.sessionId);
536 const { model } = await state.channel.send("DOM.getBoxModel", { nodeId }, state.sessionId);
537 const quad = model.content;
538 where = { x: Math.round((quad[0] + quad[2] + quad[4] + quad[6]) / 4), y: Math.round((quad[1] + quad[3] + quad[5] + quad[7]) / 4), selector };
539 } else if (point && Number.isFinite(point.x) && Number.isFinite(point.y)) {
540 const vp = await viewport();
541 if (point.x < 0 || point.y < 0 || (vp.w && point.x >= vp.w) || (vp.h && point.y >= vp.h)) {
542 throw Object.assign(new ExecError(`(${point.x}, ${point.y}) is outside the page viewport (${vp.w}x${vp.h} CSS px) — browser {action:\"screenshot\"} shows this space`), { code: "bad_target" });
543 }
544 where = { x: point.x, y: point.y };
545 } else {
546 throw badArgs("browser click needs selector (CSS) or point {x,y} (page viewport pixels)");
547 }
548 await mousePoint(where.x, where.y);
549 return {
550 action_sent: true, ...(where.selector ? { selector: where.selector } : {}), point: { x: where.x, y: where.y },
551 pointer_moved: false, verified: false, verification_required: "screenshot or status",
552 note: "clicked in the page viewport; the user's pointer never moved",
553 };
554 },
555
556 async type({ text, selector, enter } = {}) {
557 requireRunning();
558 if (typeof text !== "string" || !text.length) throw badArgs("browser type needs text");
559 let focused = null;
560 if (selector != null) {
561 const nodeId = await resolveNode(selector);
562 await state.channel.send("DOM.focus", { nodeId }, state.sessionId);
563 focused = selector;
564 }
565 await state.channel.send("Input.insertText", { text }, state.sessionId);
566 if (enter) {
567 for (const type of ["keyDown", "keyUp"]) {
568 await state.channel.send("Input.dispatchKeyEvent", { type, key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13, ...(type === "keyDown" ? { text: "\r" } : {}) }, state.sessionId);
569 }
570 }
571 return {
572 action_sent: true, chars: text.length, ...(focused ? { selector: focused } : {}), ...(enter ? { entered: true } : {}),
573 verified: false, verification_required: "screenshot or status",
574 note: focused ? "text was inserted into the selector's element" : "text was inserted at the page's current focus",
575 };
576 },
577
578 async screenshot({ full } = {}) {
579 requireRunning();
580 const vp = await viewport();
581 const shot = await state.channel.send("Page.captureScreenshot", { format: "png", ...(full ? { captureBeyondViewport: true } : {}) }, state.sessionId);
582 const dir = path.join(recordingsDir(), "captures");
583 fs.mkdirSync(dir, { recursive: true });
584 const file = path.join(dir, `browser-${crypto.randomBytes(4).toString("hex")}.png`);
585 fs.writeFileSync(file, Buffer.from(shot.data, "base64"));
586 return {
587 file, bytes: fs.statSync(file).size, format: "png", space: "page-viewport",
588 viewport: { w: vp.w, h: vp.h }, scale: vp.scale,
589 note: "page pixels, not screen pixels — the same space browser click point targets use",
590 };
591 },
592
593 async stop() {
594 if (!state.channel) return { running: false, note: "no browser session for this computer session" };
595 if (state.attached) {
596 // The person's browser: never close a tab or the browser, only detach.
597 if (state.sessionId) await state.channel.send("Target.detachFromTarget", { sessionId: state.sessionId }, undefined, teardown()).catch(() => {});
598 state.channel.close();
599 state.channel = null; state.targetId = null; state.sessionId = null; state.pageEnabled = false; state.domEnabled = false; state.attached = false;
600 return { running: false, detached: true, browser_closed: false, note: "detached from the shared browser; its window and tabs stay as they are" };
601 }
602 try { await state.channel.send("Target.closeTarget", { targetId: state.targetId }, undefined, teardown()); } catch { /* the tab may already be gone */ }
603 let remaining = null;
604 try { remaining = (await listTabs(teardown())).length; } catch { remaining = null; }
605 let browserClosed = false;
606 if (remaining === 0) {
607 try { await state.channel.send("Browser.close", {}, undefined, teardown()); browserClosed = true; } catch {}
608 await sleep(200);
609 }
610 state.channel.close();
611 state.channel = null; state.targetId = null; state.sessionId = null; state.pageEnabled = false; state.domEnabled = false;
612 return { running: false, closed: true, browser_closed: browserClosed,
613 ...(remaining ? { note: `${remaining} tab(s) from other sessions remain open; the shared browser stays up` } : {}) };
614 },
615
616 /** Session teardown: close this session's tab; last one out closes the browser. */
617 async close() {
618 if (!state.channel) return;
619 try { await this.stop(); } catch { state.channel?.close(); state.channel = null; }
620 },
621 };
622 // Bound once so backends can hand the methods out individually without
623 // losing `this` (start/close re-enter the api by name).
624 for (const key of Object.keys(api)) api[key] = api[key].bind(api);
625 return api;
626 }
627
627 lines Plain Text