diff --git a/app/src/main/hl/engines/claude-code/adapter.ts b/app/src/main/hl/engines/claude-code/adapter.ts index 4a36fb63..aa788371 100644 --- a/app/src/main/hl/engines/claude-code/adapter.ts +++ b/app/src/main/hl/engines/claude-code/adapter.ts @@ -135,10 +135,16 @@ const claudeCodeAdapter: EngineAdapter = { wrapPrompt(ctx: SpawnContext): string { const lines: string[] = [ 'You are driving a specific Chromium browser view on this machine.', - `Your target is CDP target_id=${ctx.targetId} on port ${ctx.cdpPort} (env BU_TARGET_ID / BU_CDP_PORT).`, + ]; + if (ctx.cdpUrl) { + lines.push(`You are connected to an external browser via CDP (env BU_DAEMON_SOCKET).`); + } else { + lines.push(`Your target is CDP target_id=${ctx.targetId} on port ${ctx.cdpPort} (env BU_TARGET_ID / BU_CDP_PORT).`); + } + lines.push( 'Read `./AGENTS.md` for how to drive the browser in this harness.', 'Always read `./helpers.js` before writing scripts — that is where the functions live. Edit it if a helper is missing.', - ]; + ); if (ctx.attachmentRefs.length > 0) { lines.push('', 'The user attached these files for this task. Read each with your Read tool before acting:'); for (const a of ctx.attachmentRefs) lines.push(` - ${a.relPath} (${a.mime}, ${a.size} bytes)`); @@ -176,8 +182,12 @@ const claudeCodeAdapter: EngineAdapter = { delete env.CLAUDE_CODE_USE_VERTEX; delete env.CLAUDE_CODE_USE_FOUNDRY; if (ctx.savedApiKey) env.ANTHROPIC_API_KEY = ctx.savedApiKey; - env.BU_TARGET_ID = ctx.targetId; - env.BU_CDP_PORT = String(ctx.cdpPort); + if (ctx.cdpUrl) { + env.BU_CDP_WS = ctx.cdpUrl; + } else { + env.BU_TARGET_ID = ctx.targetId; + env.BU_CDP_PORT = String(ctx.cdpPort); + } return env; }, diff --git a/app/src/main/hl/engines/codex/adapter.ts b/app/src/main/hl/engines/codex/adapter.ts index 3cd63b5f..0bc4c403 100644 --- a/app/src/main/hl/engines/codex/adapter.ts +++ b/app/src/main/hl/engines/codex/adapter.ts @@ -125,10 +125,16 @@ const codexAdapter: EngineAdapter = { wrapPrompt(ctx: SpawnContext): string { const lines: string[] = [ 'You are driving a specific Chromium browser view on this machine.', - `Your target is CDP target_id=${ctx.targetId} on port ${ctx.cdpPort} (env BU_TARGET_ID / BU_CDP_PORT).`, + ]; + if (ctx.cdpUrl) { + lines.push(`You are connected to an external browser via CDP (env BU_DAEMON_SOCKET).`); + } else { + lines.push(`Your target is CDP target_id=${ctx.targetId} on port ${ctx.cdpPort} (env BU_TARGET_ID / BU_CDP_PORT).`); + } + lines.push( 'Read `./AGENTS.md` for how to drive the browser in this harness.', 'Always read `./helpers.js` before writing scripts — that is where the functions live. Edit it if a helper is missing.', - ]; + ); if (ctx.attachmentRefs.length > 0) { lines.push('', 'The user attached these files for this task. Read each one before acting:'); for (const a of ctx.attachmentRefs) lines.push(` - ${a.relPath} (${a.mime}, ${a.size} bytes)`); @@ -176,8 +182,12 @@ const codexAdapter: EngineAdapter = { // Prefer CODEX_API_KEY — the docs recommend it for `codex exec` mode. env.CODEX_API_KEY = ctx.savedApiKey; } - env.BU_TARGET_ID = ctx.targetId; - env.BU_CDP_PORT = String(ctx.cdpPort); + if (ctx.cdpUrl) { + env.BU_CDP_WS = ctx.cdpUrl; + } else { + env.BU_TARGET_ID = ctx.targetId; + env.BU_CDP_PORT = String(ctx.cdpPort); + } return env; }, diff --git a/app/src/main/hl/engines/runEngine.ts b/app/src/main/hl/engines/runEngine.ts index a0037f8d..90ce47a4 100644 --- a/app/src/main/hl/engines/runEngine.ts +++ b/app/src/main/hl/engines/runEngine.ts @@ -62,14 +62,21 @@ export async function runEngine(opts: RunEngineOptions): Promise { return; } - // 1. Resolve CDP target for the session's browser view. + // 1. Resolve CDP target for the session's browser view (or external CDP). let targetId: string; - try { - targetId = await resolveTargetIdForWebContents(opts.webContents); - } catch (err) { - const msg = `Failed to resolve CDP target id: ${(err as Error).message}`; - engineLogger.error('engines.run.resolveTarget.failed', { engineId: opts.engineId, error: msg }); - opts.onEvent({ type: 'error', message: msg }); + if (opts.cdpUrl) { + targetId = 'external'; + } else if (opts.webContents) { + try { + targetId = await resolveTargetIdForWebContents(opts.webContents); + } catch (err) { + const msg = `Failed to resolve CDP target id: ${(err as Error).message}`; + engineLogger.error('engines.run.resolveTarget.failed', { engineId: opts.engineId, error: msg }); + opts.onEvent({ type: 'error', message: msg }); + return; + } + } else { + opts.onEvent({ type: 'error', message: 'runEngine: must provide webContents or cdpUrl' }); return; } @@ -178,6 +185,7 @@ export async function runEngine(opts: RunEngineOptions): Promise { sessionId: opts.sessionId, targetId, cdpPort: opts.cdpPort, + cdpUrl: opts.cdpUrl, resumeSessionId: opts.resumeSessionId, savedApiKey, attachmentRefs, @@ -185,6 +193,10 @@ export async function runEngine(opts: RunEngineOptions): Promise { const wrappedPrompt = adapter.wrapPrompt(spawnCtx); const args = adapter.buildSpawnArgs(spawnCtx, wrappedPrompt); const env = adapter.buildEnv(spawnCtx, { ...process.env }); + if (opts.daemonSocket) { + env.BU_DAEMON_SOCKET = opts.daemonSocket; + try { fs.writeFileSync(path.join(opts.harnessDir, 'DAEMON_SOCKET'), opts.daemonSocket, 'utf-8'); } catch { /* noop */ } + } engineLogger.info('engines.run.spawn', { engineId: adapter.id, @@ -196,11 +208,13 @@ export async function runEngine(opts: RunEngineOptions): Promise { attachmentCount: attachmentRefs.length, authSource: savedApiKey ? 'savedApiKey' : 'cliManaged', args: args.map((a) => (a.length > 120 ? `${a.slice(0, 100)}…<${a.length}ch>` : a)), - envAuthFlags: { + envFlags: { ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY ? `set(${env.ANTHROPIC_API_KEY.length}ch)` : 'unset', ANTHROPIC_AUTH_TOKEN: env.ANTHROPIC_AUTH_TOKEN ? 'set' : 'unset', CLAUDE_CODE_USE_BEDROCK: env.CLAUDE_CODE_USE_BEDROCK ?? 'unset', CLAUDE_CODE_USE_VERTEX: env.CLAUDE_CODE_USE_VERTEX ?? 'unset', + BU_DAEMON_SOCKET: env.BU_DAEMON_SOCKET ? `set(${env.BU_DAEMON_SOCKET.length}ch)` : 'unset', + BU_CDP_WS: env.BU_CDP_WS ? `set(${env.BU_CDP_WS.length}ch)` : 'unset', }, }); diff --git a/app/src/main/hl/engines/types.ts b/app/src/main/hl/engines/types.ts index 7028de8d..e2e6c484 100644 --- a/app/src/main/hl/engines/types.ts +++ b/app/src/main/hl/engines/types.ts @@ -20,6 +20,8 @@ export interface SpawnContext { targetId: string; /** Port Electron exposes CDP on. */ cdpPort: number; + /** If set, connect to an existing browser via CDP WebSocket instead of the embedded view. */ + cdpUrl?: string; /** If set, ask the CLI to continue a prior conversation with this id. */ resumeSessionId?: string; /** Optional user-supplied API key; adapter decides how to inject. */ @@ -119,8 +121,11 @@ export interface RunEngineOptions { engineId: string; prompt: string; sessionId: string; - webContents: WebContents; + webContents?: WebContents; cdpPort: number; + cdpUrl?: string; + /** If set, reuse an existing daemon socket instead of starting a new one. */ + daemonSocket?: string; harnessDir: string; attachments?: Array<{ name: string; mime: string; bytes: Buffer | Uint8Array }>; resumeSessionId?: string; diff --git a/app/src/main/hl/harness.ts b/app/src/main/hl/harness.ts index cf5126e5..c5a5ef97 100644 --- a/app/src/main/hl/harness.ts +++ b/app/src/main/hl/harness.ts @@ -20,6 +20,7 @@ import { mainLogger } from '../logger'; import STOCK_HELPERS_JS from './stock/helpers.js?raw'; import STOCK_TOOLS_JSON from './stock/TOOLS.json?raw'; import STOCK_SKILL_MD from './stock/AGENTS.md?raw'; +import STOCK_DAEMON_JS from './stock/daemon.js?raw'; // Bundled domain-skills tree. Vite eagerly inlines every file under // stock/domain-skills/ as a raw string at build time. Keys are the full @@ -38,6 +39,7 @@ export function harnessDir(): string { export function helpersPath(): string { return path.join(harnessDir(), 'helpers.js'); } export function toolsPath(): string { return path.join(harnessDir(), 'TOOLS.json'); } export function skillPath(): string { return path.join(harnessDir(), 'AGENTS.md'); } +export function daemonPath(): string { return path.join(harnessDir(), 'daemon.js'); } export function domainSkillsDir(): string { return path.join(harnessDir(), 'domain-skills'); } /** @@ -61,7 +63,10 @@ export function bootstrapHarness(): void { const hp = helpersPath(); const needsHelpers = !fs.existsSync(hp) || (() => { - try { return !fs.readFileSync(hp, 'utf-8').includes('createContext'); } + try { + const content = fs.readFileSync(hp, 'utf-8'); + return !content.includes('createContext') || !content.includes('BU_CDP_WS'); + } catch { return true; } })(); if (needsHelpers) { @@ -90,6 +95,18 @@ export function bootstrapHarness(): void { mainLogger.info('harness.bootstrap.wroteTools', { path: tp, bytes: (STOCK_TOOLS_JSON as string).length }); } + const dp = daemonPath(); + const needsDaemon = !fs.existsSync(dp) || (() => { + try { + const content = fs.readFileSync(dp, 'utf-8'); + return !content.includes('CdpWs'); + } catch { return true; } + })(); + if (needsDaemon) { + fs.writeFileSync(dp, STOCK_DAEMON_JS as string, 'utf-8'); + mainLogger.info('harness.bootstrap.wroteDaemon', { path: dp, bytes: (STOCK_DAEMON_JS as string).length }); + } + materializeDomainSkills(); } diff --git a/app/src/main/hl/stock/daemon.js b/app/src/main/hl/stock/daemon.js new file mode 100644 index 00000000..3f8cf75c --- /dev/null +++ b/app/src/main/hl/stock/daemon.js @@ -0,0 +1,380 @@ +#!/usr/bin/env node +/** + * Long-running CDP WebSocket holder + local IPC relay. + * + * Chrome 144+: reads ws URL from /DevToolsActivePort (written when user + * enables chrome://inspect/#remote-debugging). Avoids the per-connect "Allow?" + * dialog that repeated WebSocket handshakes would trigger. + * + * Single-file port of harnessless/daemon.js — paths inlined so it has zero + * external deps beyond Node built-ins (and optionally `ws` for old Node). + */ + +const fs = require('fs'); +const net = require('net'); +const os = require('os'); +const path = require('path'); + +// ─── inlined paths.js ────────────────────────────────────────────────────── + +function safeName(name) { + return name.replace(/[^a-zA-Z0-9_.-]/g, '_'); +} + +function runtimePaths(opts = {}) { + const platform = opts.platform || process.platform; + const env = opts.env || process.env; + const name = opts.name || env.BU_NAME || 'default'; + const sanitized = safeName(name); + const pathMod = platform === 'win32' ? path.win32 : path.posix; + const runDir = opts.runDir || env.BU_RUN_DIR || os.tmpdir(); + + return { + name, + safeName: sanitized, + runDir, + socketPath: platform === 'win32' + ? `\\\\.\\pipe\\browser-use-bh-${sanitized}` + : pathMod.join(runDir, `bh-${sanitized}.sock`), + logPath: pathMod.join(runDir, `bh-${sanitized}.log`), + pidPath: pathMod.join(runDir, `bh-${sanitized}.pid`), + }; +} + +function chromeProfileCandidates(opts = {}) { + const platform = opts.platform || process.platform; + const env = opts.env || process.env; + const home = opts.home || os.homedir(); + const pathMod = platform === 'win32' ? path.win32 : path.posix; + + if (platform === 'darwin') { + return [ + pathMod.join(home, 'Library', 'Application Support', 'Google', 'Chrome'), + pathMod.join(home, 'Library', 'Application Support', 'Chromium'), + pathMod.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary'), + ]; + } + if (platform === 'win32') { + const localAppData = env.LOCALAPPDATA || pathMod.join(home, 'AppData', 'Local'); + return [ + pathMod.join(localAppData, 'Google', 'Chrome', 'User Data'), + pathMod.join(localAppData, 'Google', 'Chrome SxS', 'User Data'), + pathMod.join(localAppData, 'Chromium', 'User Data'), + ]; + } + const configHome = env.XDG_CONFIG_HOME || pathMod.join(home, '.config'); + return [ + pathMod.join(configHome, 'google-chrome'), + pathMod.join(configHome, 'google-chrome-beta'), + pathMod.join(configHome, 'google-chrome-unstable'), + pathMod.join(configHome, 'chromium'), + ]; +} + +// ─── config ──────────────────────────────────────────────────────────────── + +const PATHS = runtimePaths(); +const NAME = PATHS.name; +const RUN_DIR = PATHS.runDir; +const SOCK = PATHS.socketPath; +const LOG = PATHS.logPath; +const PID = PATHS.pidPath; +const BUF = 500; + +const INTERNAL = ['chrome://', 'chrome-untrusted://', 'devtools://', 'chrome-extension://', 'about:']; + +function log(msg) { + try { fs.mkdirSync(RUN_DIR, { recursive: true }); } catch {} + fs.appendFileSync(LOG, msg + '\n'); +} + +function getWsUrl() { + const override = process.env.BU_CDP_WS; + if (override) return override; + const profiles = chromeProfileCandidates(); + for (const base of profiles) { + try { + const raw = fs.readFileSync(path.join(base, 'DevToolsActivePort'), 'utf-8').trim(); + const [port, wsPath] = raw.split('\n', 2); + return `ws://127.0.0.1:${port.trim()}${wsPath.trim()}`; + } catch { continue; } + } + throw new Error(`DevToolsActivePort not found — enable chrome://inspect/#remote-debugging or set BU_CDP_WS`); +} + +function isRealPage(t) { + return t.type === 'page' && !INTERNAL.some(p => (t.url || '').startsWith(p)); +} + +function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } + +// ─── WebSocket ───────────────────────────────────────────────────────────── + +function wsConnect(url) { + return new Promise((resolve, reject) => { + let WebSocketCtor = globalThis.WebSocket; + if (!WebSocketCtor) { + try { WebSocketCtor = require('ws'); } catch { /* no ws package */ } + } + if (!WebSocketCtor) throw new Error('No WebSocket available (Node >=22 or `ws` package needed)'); + + const ws = new WebSocketCtor(url); + const onOpen = () => { cleanup(); resolve(ws); }; + const onError = (err) => { cleanup(); reject(err instanceof Error ? err : new Error(String(err))); }; + function cleanup() { + ws.removeEventListener?.('open', onOpen); ws.removeEventListener?.('error', onError); + ws.off?.('open', onOpen); ws.off?.('error', onError); + } + ws.addEventListener?.('open', onOpen); ws.addEventListener?.('error', onError); + ws.on?.('open', onOpen); ws.on?.('error', onError); + }); +} + +class CdpWs { + constructor(url) { + this.url = url; + this.ws = null; + this.nextId = 1; + this.pending = new Map(); + this.onEvent = null; + this.onReconnect = null; + this._reconnecting = false; + } + + async connect() { + this.ws = await wsConnect(this.url); + const messageHandler = (raw) => { + let msg; + try { msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()); } catch { return; } + if (msg.id !== undefined) { + const p = this.pending.get(msg.id); + if (!p) return; + this.pending.delete(msg.id); + if (msg.error) p.reject(new Error(msg.error.message || JSON.stringify(msg.error))); + else p.resolve(msg.result || {}); + } else if (msg.method && this.onEvent) { + this.onEvent(msg.method, msg.params || {}, msg.sessionId || null); + } + }; + + const onClose = async () => { + log('ws closed'); + for (const p of this.pending.values()) p.reject(new Error('CDP websocket closed')); + this.pending.clear(); + if (this._reconnecting || !this.onReconnect) return; + this._reconnecting = true; + try { + await this.onReconnect(); + this._reconnecting = false; + } catch (e) { + log(`reconnect failed: ${e.message}`); + process.exit(1); + } + }; + + if (typeof this.ws.addEventListener === 'function') { + this.ws.addEventListener('message', (e) => messageHandler(e.data)); + this.ws.addEventListener('close', onClose); + } else { + this.ws.on('message', messageHandler); + this.ws.on('close', onClose); + } + } + + send(method, params = {}, sessionId = null) { + const id = this.nextId++; + const payload = { id, method, params }; + if (sessionId) payload.sessionId = sessionId; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + try { + // Node 22+ native WebSocket.send() does not accept a callback; + // the `ws` npm package does. Use a try/catch only; transport errors + // are surfaced via the on('close') handler. + this.ws.send(JSON.stringify(payload)); + } catch (err) { + this.pending.delete(id); + reject(err); + } + }); + } + + close() { if (this.ws) { try { this.ws.close(); } catch {} } } +} + +// ─── Daemon ──────────────────────────────────────────────────────────────── + +class Daemon { + constructor() { + this.cdp = null; + this.session = null; + this.events = []; + } + + async attachFirstPage() { + const r = await this.cdp.send('Target.getTargets'); + const targets = r.targetInfos || []; + let pages = targets.filter(isRealPage); + if (!pages.length) pages = targets.filter(t => t.type === 'page'); + if (!pages.length) { this.session = null; return null; } + + const a = await this.cdp.send('Target.attachToTarget', { targetId: pages[0].targetId, flatten: true }); + this.session = a.sessionId; + log(`attached ${pages[0].targetId} (${(pages[0].url || '').slice(0, 80)}) session=${this.session}`); + + for (const d of ['Page', 'DOM', 'Runtime', 'Network']) { + try { await this.cdp.send(`${d}.enable`, {}, this.session); } + catch (e) { log(`enable ${d}: ${e.message}`); } + } + return pages[0]; + } + + async start() { + const url = getWsUrl(); + log(`connecting to ${url}`); + this.cdp = new CdpWs(url); + this.cdp.onReconnect = () => this._doReconnect(); + await this._connectWithRetry(); + await this.attachFirstPage(); + this.cdp.onEvent = (method, params, sessionId) => { + this.events.push({ method, params, session_id: sessionId }); + if (this.events.length > BUF) this.events.shift(); + }; + } + + async _connectWithRetry() { + const url = this.cdp.url; + for (let attempt = 0; attempt < 12; attempt++) { + try { await this.cdp.connect(); return; } + catch (e) { + log(`ws handshake attempt ${attempt + 1} failed: ${e.message} -- retrying`); + this.cdp = new CdpWs(url); + this.cdp.onReconnect = () => this._doReconnect(); + await sleep(5000); + if (attempt === 11) throw new Error("CDP WS handshake never succeeded -- did you accept Chrome's Allow dialog?"); + } + } + } + + async _doReconnect() { + log('ws reconnecting after close...'); + await this._connectWithRetry(); + await this.attachFirstPage(); + log('ws reconnected and reattached'); + this.cdp.onEvent = (method, params, sessionId) => { + this.events.push({ method, params, session_id: sessionId }); + if (this.events.length > BUF) this.events.shift(); + }; + } + + async handle(req) { + const meta = req.meta; + if (meta === 'drain_events') { const out = this.events.slice(); this.events.length = 0; return { events: out }; } + if (meta === 'session') return { session_id: this.session }; + if (meta === 'set_session') { this.session = req.session_id || null; return { session_id: this.session }; } + if (meta === 'shutdown') return { ok: true, _shutdown: true }; + + const method = req.method; + const params = req.params || {}; + const sid = method.startsWith('Target.') ? null : (req.session_id || this.session); + + if (!this.cdp || !this.cdp.ws) { + return { error: 'CDP connection not yet established' }; + } + + try { + return { result: await this.cdp.send(method, params, sid) }; + } catch (e) { + const msg = e.message || String(e); + if (msg.includes('Session with given id not found') && sid === this.session && sid) { + log(`stale session ${sid}, re-attaching`); + if (await this.attachFirstPage()) { + return { result: await this.cdp.send(method, params, this.session) }; + } + } + return { error: msg }; + } + } +} + +function alreadyRunning() { + return new Promise(resolve => { + const s = net.createConnection(SOCK, () => { s.end(); resolve(true); }); + s.on('error', () => resolve(false)); + s.setTimeout(1000, () => { s.destroy(); resolve(false); }); + }); +} + +async function serve(daemon) { + if (process.platform !== 'win32') { + try { fs.unlinkSync(SOCK); } catch {} + } + + const server = net.createServer((conn) => { + let buf = ''; + conn.on('data', (chunk) => { + buf += chunk.toString(); + let idx; + while ((idx = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, idx); + buf = buf.slice(idx + 1); + (async () => { + try { + const req = JSON.parse(line); + const resp = await daemon.handle(req); + conn.write(JSON.stringify(resp) + '\n'); + if (resp._shutdown) { server.close(); process.exit(0); } + } catch (e) { + log(`conn error: ${e.message}`); + try { conn.write(JSON.stringify({ error: String(e) }) + '\n'); } catch {} + } + })(); + } + }); + conn.on('error', (e) => log(`client conn error: ${e.message}`)); + }); + + server.on('error', (err) => { + log(`server error: ${err.message}`); + process.exit(1); + }); + server.listen(SOCK, () => { + if (process.platform !== 'win32') fs.chmodSync(SOCK, 0o600); + log(`listening on ${SOCK}`); + }); +} + +async function main() { + if (await alreadyRunning()) { + process.stderr.write(`daemon already running on ${SOCK}\n`); + process.exit(0); + } + fs.mkdirSync(RUN_DIR, { recursive: true }); + fs.writeFileSync(LOG, ''); + fs.writeFileSync(PID, String(process.pid)); + + const d = new Daemon(); + // Start the IPC server first so the parent process can connect immediately. + // The CDP WebSocket connection may block waiting for the user to accept + // Chrome's "Allow remote debugging?" dialog, so we must not hold the IPC + // server behind it. + await serve(d); + await d.start(); +} + +main().catch(e => { + log(`fatal: ${e.message}`); + console.error(e.message); + process.exit(1); +}); + +function cleanup() { + try { fs.unlinkSync(PID); } catch {} + if (process.platform !== 'win32') { + try { fs.unlinkSync(SOCK); } catch {} + } +} + +process.on('exit', cleanup); +process.on('SIGTERM', () => { cleanup(); process.exit(0); }); +process.on('SIGINT', () => { cleanup(); process.exit(0); }); diff --git a/app/src/main/hl/stock/helpers.js b/app/src/main/hl/stock/helpers.js index f9d7b0de..5c54ae3b 100644 --- a/app/src/main/hl/stock/helpers.js +++ b/app/src/main/hl/stock/helpers.js @@ -18,6 +18,7 @@ const path = require('node:path'); const fs = require('node:fs/promises'); const http = require('node:http'); +const net = require('node:net'); const { exec: execCb } = require('node:child_process'); const { promisify } = require('node:util'); @@ -50,6 +51,20 @@ async function resolveTargetWsUrl(port, targetId) { return match.webSocketDebuggerUrl; } +// If the user passes a browser-level ws URL (e.g. /devtools/browser/...), +// resolve the first real page's ws URL via the HTTP list endpoint. +async function resolvePageWsUrl(browserWsUrl) { + try { + const url = new URL(browserWsUrl); + if (!url.pathname.includes('/devtools/browser/')) return browserWsUrl; + const targets = await jsonFetch(url.port || 9222, '/json/list'); + const pages = (targets || []).filter((t) => t.type === 'page' && t.url && !t.url.startsWith('chrome://') && !t.url.startsWith('devtools://') && !t.url.startsWith('about:') && !t.url.startsWith('chrome-extension://')); + const match = pages[0] || targets.find((t) => t.type === 'page'); + if (match?.webSocketDebuggerUrl) return match.webSocketDebuggerUrl; + } catch { /* fall through */ } + return browserWsUrl; +} + // Cross-API WebSocket subscribe: Node 22+ native WebSocket uses // addEventListener (with Event-wrapped payloads); the `ws` npm package // uses EventEmitter .on() (with raw payloads). Normalize both to a @@ -63,8 +78,9 @@ function wsOn(ws, event, handler) { } class CdpSession { - constructor(ws) { + constructor(ws, sessionId = null) { this.ws = ws; + this.sessionId = sessionId; this.nextId = 1; this.pending = new Map(); this.events = []; @@ -91,9 +107,11 @@ class CdpSession { send(method, params = {}) { const id = this.nextId++; + const payload = { id, method, params }; + if (this.sessionId) payload.sessionId = this.sessionId; return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); - this.ws.send(JSON.stringify({ id, method, params })); + this.ws.send(JSON.stringify(payload)); }); } @@ -102,19 +120,118 @@ class CdpSession { } } +// ─── Daemon IPC client ───────────────────────────────────────────────────── +// When BU_DAEMON_SOCKET is set, talk to the long-running daemon over a local +// net socket instead of opening a fresh WebSocket per script. This avoids the +// Chrome 144+ "Allow remote debugging?" dialog that fires on every WS handshake. + +function daemonRequest(socketPath, payload) { + return new Promise((resolve, reject) => { + const client = net.createConnection(socketPath); + let buf = ''; + let resolved = false; + client.on('data', (chunk) => { + buf += chunk.toString(); + const idx = buf.indexOf('\n'); + if (idx >= 0 && !resolved) { + resolved = true; + try { + const resp = JSON.parse(buf.slice(0, idx)); + resolve(resp); + } catch (e) { reject(e); } + client.end(); + } + }); + client.on('error', (err) => { if (!resolved) { resolved = true; reject(err); } }); + client.on('close', () => { if (!resolved) { resolved = true; reject(new Error('daemon socket closed')); } }); + client.setTimeout(30000, () => { if (!resolved) { resolved = true; client.destroy(); reject(new Error('daemon request timeout')); } }); + client.write(JSON.stringify(payload) + '\n'); + }); +} + +class CdpDaemonClient { + constructor(socketPath) { + this.socketPath = socketPath; + this._events = []; + } + + async send(method, params = {}) { + const resp = await daemonRequest(this.socketPath, { method, params }); + if (resp.error) throw new Error(`CDP ${resp.error}`); + return resp.result ?? {}; + } + + async drainEvents() { + const resp = await daemonRequest(this.socketPath, { meta: 'drain_events' }); + return (resp.events || []).map((e) => ({ method: e.method, params: e.params })); + } + + close() { /* nothing to close — connection is per-request */ } +} + /** * Open a CDP session to the agent's assigned browser target. * Reads BU_TARGET_ID (required) and BU_CDP_PORT (default 9222) from env. + * When BU_CDP_WS is set, connects directly to that WebSocket URL instead. * Returns an opaque ctx. Call ctx.close() when done (usually at script end). */ +function readDaemonSocketFile() { + try { + const p = path.join(__dirname, 'DAEMON_SOCKET'); + return require('fs').readFileSync(p, 'utf-8').trim(); + } catch { + return null; + } +} + async function createContext(opts = {}) { const targetId = opts.targetId ?? process.env.BU_TARGET_ID; const port = Number(opts.port ?? process.env.BU_CDP_PORT ?? 9222); - if (!targetId) throw new Error('createContext: BU_TARGET_ID env var or opts.targetId is required'); + const cdpUrl = opts.cdpUrl ?? process.env.BU_CDP_WS ?? null; + const daemonSocket = opts.daemonSocket ?? process.env.BU_DAEMON_SOCKET ?? readDaemonSocketFile() ?? null; + console.error('[helpers.createContext] daemonSocket=', daemonSocket, 'file=', readDaemonSocketFile()); + + // ── Daemon IPC path (preferred when available) ──────────────────────────── + if (daemonSocket) { + const client = new CdpDaemonClient(daemonSocket); + // The daemon may still be hand-shaking with Chrome (waiting for the user + // to accept the "Allow remote debugging?" dialog). Poll until it's ready. + let info = {}; + for (let i = 0; i < 30; i++) { + info = await client.send('Browser.getVersion').catch(() => ({})); + if (info.product != null) break; + await new Promise((r) => setTimeout(r, 2000)); + } + const isBrowserLevel = info.product != null; // any CDP response means we're alive + if (isBrowserLevel) { + // Ask daemon for its current session so helpers know whether we're attached. + const sess = await daemonRequest(daemonSocket, { meta: 'session' }); + if (!sess.session_id) { + // Force re-attach via the daemon's stale-session recovery. + await client.send('Runtime.evaluate', { expression: '1' }).catch(() => {}); + } + } + return { + targetId: targetId || 'external', + port, + get events() { return client.drainEvents(); }, + cdp: { + send: (method, params) => client.send(method, params), + transport: 'daemon', + }, + close: () => client.close(), + }; + } - const wsUrl = await resolveTargetWsUrl(port, targetId); + // ── Direct WebSocket path ──────────────────────────────────────────────── + let wsUrl; + if (cdpUrl) { + wsUrl = await resolvePageWsUrl(cdpUrl); + } else { + if (!targetId) throw new Error('createContext: BU_TARGET_ID env var or opts.targetId is required'); + wsUrl = await resolveTargetWsUrl(port, targetId); + } - // Node 22+ has WebSocket as a global. Use that; fall back to `ws` package. let WebSocketCtor = globalThis.WebSocket; if (!WebSocketCtor) { try { WebSocketCtor = require('ws'); } catch { /* no ws package */ } @@ -134,7 +251,16 @@ async function createContext(opts = {}) { }); const session = new CdpSession(ws); - // Enable the common domains so events flow. + + const isBrowserLevel = wsUrl.includes('/devtools/browser/'); + if (isBrowserLevel) { + const targets = (await session.send('Target.getTargets')).targetInfos || []; + const page = targets.find((t) => t.type === 'page' && t.url && !t.url.startsWith('chrome://') && !t.url.startsWith('devtools://') && !t.url.startsWith('about:') && !t.url.startsWith('chrome-extension://')) + || targets.find((t) => t.type === 'page'); + if (!page) throw new Error('No page target found on browser-level CDP endpoint'); + const attach = await session.send('Target.attachToTarget', { targetId: page.targetId, flatten: true }); + session.sessionId = attach.sessionId; + } await session.send('Page.enable').catch(() => {}); await session.send('DOM.enable').catch(() => {}); await session.send('Runtime.enable').catch(() => {}); @@ -152,13 +278,121 @@ async function createContext(opts = {}) { }; } +// Convenience wrappers so agent code that explicitly calls these still works +// if the runtime helpers.js is ever rewritten by bootstrapHarness. +async function createContextFromDaemonSocket(socketPath) { + const client = new CdpDaemonClient(socketPath); + let info = {}; + for (let i = 0; i < 30; i++) { + info = await client.send('Browser.getVersion').catch(() => ({})); + if (info.product != null) break; + await new Promise((r) => setTimeout(r, 2000)); + } + return { + targetId: 'external', + port: null, + get events() { return client.drainEvents(); }, + cdp: { + send: (method, params) => client.send(method, params), + transport: 'daemon', + }, + close: () => client.close(), + }; +} + +async function createContextFromBrowserUrl(browserWsUrl) { + let WebSocketCtor = globalThis.WebSocket; + if (!WebSocketCtor) { + try { WebSocketCtor = require('ws'); } catch { /* no ws package */ } + } + if (!WebSocketCtor) throw new Error('No WebSocket available (Node >=22 or `ws` package needed)'); + + const ws = new WebSocketCtor(browserWsUrl); + await new Promise((resolve, reject) => { + const onOpen = () => { cleanup(); resolve(); }; + const onError = (err) => { cleanup(); reject(err instanceof Error ? err : new Error(String(err))); }; + function cleanup() { + ws.removeEventListener?.('open', onOpen); ws.removeEventListener?.('error', onError); + ws.off?.('open', onOpen); ws.off?.('error', onError); + } + ws.addEventListener?.('open', onOpen); ws.addEventListener?.('error', onError); + ws.on?.('open', onOpen); ws.on?.('error', onError); + }); + + class BrowserSession { + constructor(ws) { + this.ws = ws; + this.nextId = 1; + this.pending = new Map(); + this.events = []; + this.maxEvents = 500; + wsOn(ws, 'message', (raw) => { + let msg; + try { msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()); } catch { return; } + if (msg.id !== undefined) { + const p = this.pending.get(msg.id); + if (!p) return; + this.pending.delete(msg.id); + if (msg.error) p.reject(new Error(`CDP ${msg.error.message ?? JSON.stringify(msg.error)}`)); + else p.resolve(msg.result ?? {}); + } else if (msg.method) { + this.events.push({ method: msg.method, params: msg.params, sessionId: msg.sessionId }); + if (this.events.length > this.maxEvents) this.events.shift(); + } + }); + wsOn(ws, 'close', () => { + for (const p of this.pending.values()) p.reject(new Error('CDP websocket closed')); + this.pending.clear(); + }); + } + send(method, params = {}, sessionId = null) { + const id = this.nextId++; + const payload = { id, method, params }; + if (sessionId) payload.sessionId = sessionId; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.ws.send(JSON.stringify(payload)); + }); + } + close() { try { this.ws.close(); } catch {} } + } + + const browser = new BrowserSession(ws); + const r = await browser.send('Target.getTargets'); + const targets = r.targetInfos || []; + const isRealPage = (t) => t.type === 'page' && !INTERNAL_URL_PREFIXES.some(p => (t.url || '').startsWith(p)); + let pages = targets.filter(isRealPage); + if (!pages.length) pages = targets.filter(t => t.type === 'page'); + if (!pages.length) { browser.close(); throw new Error('No page target found'); } + + const attach = await browser.send('Target.attachToTarget', { targetId: pages[0].targetId, flatten: true }); + const sessionId = attach.sessionId; + for (const d of ['Page', 'DOM', 'Runtime', 'Network']) { + try { await browser.send(`${d}.enable`, {}, sessionId); } catch {} + } + + return { + targetId: pages[0].targetId, + port: null, + events: browser.events, + cdp: { + send: (method, params) => browser.send(method, params, sessionId), + transport: 'ws', + }, + close: () => browser.close(), + _browser: browser, + _sessionId: sessionId, + }; +} + // ─── navigation ───────────────────────────────────────────────────────────── async function goto(ctx, url) { return ctx.cdp.send('Page.navigate', { url }); } async function pageInfo(ctx) { - const pendingDialog = ctx.events.find((e) => e.method === 'Page.javascriptDialogOpening'); + const events = await Promise.resolve(ctx.events); + const pendingDialog = events.find((e) => e.method === 'Page.javascriptDialogOpening'); if (pendingDialog) return { dialog: pendingDialog.params }; const expr = 'JSON.stringify({url:location.href,title:document.title,w:innerWidth,h:innerHeight,sx:scrollX,sy:scrollY,pw:document.documentElement.scrollWidth,ph:document.documentElement.scrollHeight})'; const r = await ctx.cdp.send('Runtime.evaluate', { expression: expr, returnByValue: true }); @@ -315,6 +549,8 @@ async function httpGet(_ctx, url, headers, timeoutMs = 20_000) { module.exports = { createContext, + createContextFromDaemonSocket, + createContextFromBrowserUrl, goto, pageInfo, click, typeText, pressKey, scroll, screenshot, wait, waitForLoad, js, reactSetValue, dispatchKey, uploadFile, captureDialogs, dialogs, httpGet, diff --git a/app/src/main/index.ts b/app/src/main/index.ts index 6bdd635d..dac1ed1f 100644 --- a/app/src/main/index.ts +++ b/app/src/main/index.ts @@ -73,7 +73,7 @@ import { createShellWindow } from './window'; import { createTray, refreshTrayMenu } from './tray'; // Track B — Pill + hotkeys import { createPillWindow, togglePill, showPill, hidePill, sendToPill, setPillHeight, PILL_HEIGHT_COLLAPSED, PILL_HEIGHT_EXPANDED } from './pill'; -import { createLogsWindow, attachToHub as attachLogsToHub, toggleLogs, hideLogs, getLogsWindow, showLogs, setLogsMode, updateLogsAnchor, focusLogsFollowUp } from './logsPill'; +import { createLogsWindow, attachToHub as attachLogsToHub, toggleLogs, hideLogs, getLogsWindow, showLogs, setLogsMode, updateLogsAnchor, focusLogsFollowUp, setSettingsVisibleChecker } from './logsPill'; import * as takeoverOverlay from './takeoverOverlay'; import { sendSessionNotification } from './notifications'; import { registerHotkeys, unregisterHotkeys, getGlobalCmdbarAccelerator, setGlobalCmdbarAccelerator } from './hotkeys'; @@ -84,7 +84,9 @@ import { AccountStore } from './identity/AccountStore'; import { createOnboardingWindow } from './identity/onboardingWindow'; import { registerOnboardingHandlers } from './identity/onboardingHandlers'; import { registerApiKeyHandlers } from './settings/apiKeyIpc'; +import { registerCdpUrlHandlers } from './settings/cdpUrlIpc'; import { registerConsentHandlers } from './consentIpc'; +import { getCdpUrl, getAlwaysAllow, setCdpUrlChangeCallback } from './settings/cdpUrlStore'; import { registerTelemetryHandlers } from './telemetryIpc'; import { captureEvent } from './telemetry'; import { registerChromeImportHandlers } from './chrome-import/ipc'; @@ -98,8 +100,11 @@ import { import { assertString, assertAttachments } from './ipc-validators'; // Agent loop: CLI subprocess driving the browser harness. Engine is // pluggable (claude-code, codex, …) — see src/main/hl/engines/. -import { bootstrapHarness, harnessDir } from './hl/harness'; +import { bootstrapHarness, harnessDir, daemonPath } from './hl/harness'; import { runEngine, DEFAULT_ENGINE_ID } from './hl/engines'; +import { spawn } from 'node:child_process'; +import net from 'node:net'; +import os from 'node:os'; import { getEngine, setEngine, type EngineId } from './hl/engine'; import { forwardAgentEvent } from './pill'; // Session management @@ -235,6 +240,7 @@ function openShellAndWire(): BrowserWindow { } registerApiKeyHandlers(); + registerCdpUrlHandlers(); captureEvent('app_launched'); ipcMain.handle('hotkeys:get-global', () => getGlobalCmdbarAccelerator()); @@ -344,6 +350,14 @@ app.whenReady().then(async () => { } } + // --------------------------------------------------------------------------- + // Logs overlay behaviour + // --------------------------------------------------------------------------- + setSettingsVisibleChecker(() => { + const sw = getSettingsWindow(); + return sw !== null && !sw.isDestroyed() && sw.isVisible(); + }); + // --------------------------------------------------------------------------- // Channel IPC handlers (registered early so onboarding can use them too) // --------------------------------------------------------------------------- @@ -372,6 +386,200 @@ app.whenReady().then(async () => { const steerQueues = new Map(); const startingSessionIds = new Set(); + // Per-session CDP daemons for external-browser mode. A session may be + // resumed multiple times; we keep the daemon alive across runs so Chrome + // doesn't re-prompt "Allow remote debugging?" on every follow-up. + const sessionDaemons = new Map; socket: string }>(); + let globalDaemon: { proc: ReturnType; socket: string } | null = null; + + function safeDaemonName(name: string): string { + return name.replace(/[^a-zA-Z0-9_.-]/g, '_'); + } + + function daemonSocketPath(name: string): string { + const sanitized = safeDaemonName(name); + return process.platform === 'win32' + ? `\\\\.\\pipe\\browser-use-bh-${sanitized}` + : path.join(os.tmpdir(), `bh-${sanitized}.sock`); + } + + function waitForDaemonSocket(socketPath: string, timeoutMs = 8000): Promise { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + function tryConnect() { + const client = net.createConnection(socketPath); + let done = false; + const cleanup = () => { done = true; client.destroy(); }; + client.on('connect', () => { cleanup(); resolve(); }); + client.on('error', () => { + if (done) return; + cleanup(); + if (Date.now() >= deadline) { + reject(new Error(`Daemon socket never became ready: ${socketPath}`)); + } else { + setTimeout(tryConnect, 300); + } + }); + client.setTimeout(5000, () => { + if (done) return; + cleanup(); + if (Date.now() >= deadline) { + reject(new Error(`Daemon socket never became ready: ${socketPath}`)); + } else { + setTimeout(tryConnect, 300); + } + }); + } + tryConnect(); + }); + } + + async function startDaemon(socket: string, cdpUrl: string, name: string): Promise> { + const daemonEnv = { ...process.env, ELECTRON_RUN_AS_NODE: '1', BU_NAME: name, BU_CDP_WS: cdpUrl }; + if (process.platform !== 'win32') { + try { fs.unlinkSync(socket); } catch { /* noop */ } + } + const proc = spawn(process.execPath, [daemonPath()], { + env: daemonEnv, + stdio: ['ignore', 'pipe', 'pipe'], + detached: false, + }); + proc.stdout.on('data', (chunk: Buffer) => { + mainLogger.info('main.daemon.stdout', { name, text: chunk.toString('utf-8').trim() }); + }); + proc.stderr.on('data', (chunk: Buffer) => { + mainLogger.warn('main.daemon.stderr', { name, text: chunk.toString('utf-8').trim() }); + }); + proc.on('exit', (code, signal) => { + mainLogger.info('main.daemon.exit', { name, socket, code, signal }); + }); + mainLogger.info('main.daemon.started', { name, socket, pid: proc.pid }); + // Timeout must exceed the daemon's WebSocket retry window (12 × 5s = 60s) + // so the user has time to click Chrome's "Allow remote debugging?" dialog. + await waitForDaemonSocket(socket, 65000); + mainLogger.info('main.daemon.ready', { name, socket }); + return proc; + } + + function stopDaemon(d: { proc: ReturnType; socket: string }, name: string): void { + try { d.proc.kill('SIGTERM'); } catch { /* noop */ } + if (process.platform !== 'win32') { + try { fs.unlinkSync(d.socket); } catch { /* noop */ } + } + const sanitized = safeDaemonName(name); + const logPath = path.join(os.tmpdir(), `bh-${sanitized}.log`); + const pidPath = path.join(os.tmpdir(), `bh-${sanitized}.pid`); + try { fs.unlinkSync(logPath); } catch { /* noop */ } + try { fs.unlinkSync(pidPath); } catch { /* noop */ } + mainLogger.info('main.daemon.stopped', { name, socket: d.socket }); + } + + async function getOrStartGlobalDaemon(cdpUrl: string): Promise { + if (globalDaemon) { + // 1) IPC socket alive check + const ipcAlive = await new Promise((resolve) => { + const client = net.createConnection(globalDaemon!.socket); + client.on('connect', () => { client.end(); resolve(true); }); + client.on('error', () => resolve(false)); + client.setTimeout(2000, () => { client.destroy(); resolve(false); }); + }); + + if (ipcAlive) { + // 2) CDP WebSocket alive check — the daemon IPC may be up but the + // Chrome WS underneath could have died (user closed browser, etc.) + const cdpAlive = await new Promise((resolve) => { + const client = net.createConnection(globalDaemon!.socket); + let buf = ''; + client.on('data', (chunk) => { + buf += chunk.toString(); + const idx = buf.indexOf('\n'); + if (idx >= 0) { + try { + const resp = JSON.parse(buf.slice(0, idx)); + resolve(!resp.error && resp.result?.product != null); + } catch { resolve(false); } + client.end(); + } + }); + client.on('error', () => resolve(false)); + client.setTimeout(3000, () => { client.destroy(); resolve(false); }); + client.write(JSON.stringify({ method: 'Browser.getVersion' }) + '\n'); + }); + + if (cdpAlive) { + return globalDaemon.socket; + } + mainLogger.warn('main.globalDaemon.cdpDead', { socket: globalDaemon.socket }); + } else { + mainLogger.warn('main.globalDaemon.ipcDead', { socket: globalDaemon.socket }); + } + + stopGlobalDaemon(); + globalDaemon = null; + } + const socket = daemonSocketPath('global'); + try { + const proc = await startDaemon(socket, cdpUrl, 'global'); + globalDaemon = { proc, socket }; + return socket; + } catch (err) { + mainLogger.warn('main.globalDaemon.failed', { error: (err as Error).message }); + return null; + } + } + + function stopGlobalDaemon(): void { + if (!globalDaemon) return; + stopDaemon(globalDaemon, 'global'); + globalDaemon = null; + try { fs.unlinkSync(path.join(harnessDir(), 'DAEMON_SOCKET')); } catch { /* noop */ } + } + + // Stop the global daemon whenever the CDP URL changes or alwaysAllow is + // turned off, so the next session starts a fresh daemon with the new URL. + let lastCdpUrl: string | null = getCdpUrl(); + setCdpUrlChangeCallback((state) => { + if (!state.url || !state.alwaysAllow || state.url !== lastCdpUrl) { + stopGlobalDaemon(); + } + lastCdpUrl = state.url; + }); + + async function getOrStartSessionDaemon(sessionId: string, cdpUrl: string): Promise { + if (getAlwaysAllow()) { + return getOrStartGlobalDaemon(cdpUrl); + } + const existing = sessionDaemons.get(sessionId); + if (existing) { + const alive = await new Promise((resolve) => { + const client = net.createConnection(existing.socket); + client.on('connect', () => { client.end(); resolve(true); }); + client.on('error', () => resolve(false)); + client.setTimeout(2000, () => { client.destroy(); resolve(false); }); + }); + if (alive) return existing.socket; + mainLogger.warn('main.sessionDaemon.dead', { sessionId, socket: existing.socket }); + stopSessionDaemon(sessionId); + } + + const socket = daemonSocketPath(sessionId); + try { + const proc = await startDaemon(socket, cdpUrl, sessionId); + sessionDaemons.set(sessionId, { proc, socket }); + return socket; + } catch (err) { + mainLogger.warn('main.daemon.failed', { sessionId, error: (err as Error).message }); + return null; + } + } + + function stopSessionDaemon(sessionId: string): void { + const d = sessionDaemons.get(sessionId); + if (!d) return; + stopDaemon(d, sessionId); + sessionDaemons.delete(sessionId); + } + // pill:submit — creates a session via the standard pipeline, hides pill ipcMain.handle('pill:submit', async (_event, payload: unknown) => { let promptRaw: unknown; @@ -490,6 +698,17 @@ app.whenReady().then(async () => { }); ipcMain.handle('logs:show', (_evt, sessionId: string, anchor?: { x: number; y: number; width: number; height: number }) => { mainLogger.info('main.logs:show', { sessionId, anchor }); + // If the settings window is open, don't surface the logs overlay on top of it. + const settingsWin = getSettingsWindow(); + if (settingsWin && !settingsWin.isDestroyed() && settingsWin.isVisible()) { + mainLogger.debug('main.logs:show.skipped', { reason: 'settings-visible', sessionId }); + return true; + } + // Auto-expand logs to full mode when using an external browser (no embedded + // view), so the terminal fills the pane area instead of floating small. + if (getCdpUrl()) { + setLogsMode('full'); + } showLogs(sessionId, anchor ?? null); // Only take OS-level focus when the SHELL window itself is already // focused (user actively interacting with the hub). The previous gate @@ -660,6 +879,7 @@ app.whenReady().then(async () => { mainLogger.info('main.startSessionWithAgent', { id }); let launched = false; let view: ReturnType | null = null; + const cdpUrl = getCdpUrl(); try { const engineId = await assertSessionEngineReady(id); @@ -668,26 +888,32 @@ app.whenReady().then(async () => { const abortController = sessionManager.startSession(id); mainLogger.info('main.startSessionWithAgent.timing', { id, step: 'startSession', ms: Date.now() - t0 }); - view = browserPool.create(id, t0); - mainLogger.info('main.startSessionWithAgent.timing', { id, step: 'poolCreate', ms: Date.now() - t0 }); - if (!view) { - sessionManager.failSession(id, `Browser pool full (max ${browserPool.activeCount}), session queued`); - mainLogger.warn('main.startSessionWithAgent.poolFull', { id, stats: browserPool.getStats() }); - return; - } + let daemonSocket: string | null = null; + if (!cdpUrl) { + view = browserPool.create(id, t0); + mainLogger.info('main.startSessionWithAgent.timing', { id, step: 'poolCreate', ms: Date.now() - t0 }); + if (!view) { + sessionManager.failSession(id, `Browser pool full (max ${browserPool.activeCount}), session queued`); + mainLogger.warn('main.startSessionWithAgent.poolFull', { id, stats: browserPool.getStats() }); + return; + } - if (shellWindow && !shellWindow.isDestroyed()) { - // Detach existing views — only one session is visible at a time. - // We DON'T attach here: main doesn't know the exact pane rect. - // The renderer (AgentPane) is authoritative for bounds and will call - // sessions:view-attach with the exact .pane__output getBoundingClientRect. - browserPool.detachAll(shellWindow); - mainLogger.info('main.startSessionWithAgent.detachedAwaitingRenderer', { id }); - } - mainLogger.info('main.startSessionWithAgent.timing', { id, step: 'attach', ms: Date.now() - t0 }); + if (shellWindow && !shellWindow.isDestroyed()) { + // Detach existing views — only one session is visible at a time. + // We DON'T attach here: main doesn't know the exact pane rect. + // The renderer (AgentPane) is authoritative for bounds and will call + // sessions:view-attach with the exact .pane__output getBoundingClientRect. + browserPool.detachAll(shellWindow); + mainLogger.info('main.startSessionWithAgent.detachedAwaitingRenderer', { id }); + } + mainLogger.info('main.startSessionWithAgent.timing', { id, step: 'attach', ms: Date.now() - t0 }); - await view.webContents.loadURL('about:blank'); - mainLogger.info('main.startSessionWithAgent.timing', { id, step: 'loadBlank', ms: Date.now() - t0 }); + await view.webContents.loadURL('about:blank'); + mainLogger.info('main.startSessionWithAgent.timing', { id, step: 'loadBlank', ms: Date.now() - t0 }); + } else { + daemonSocket = await getOrStartSessionDaemon(id, cdpUrl); + mainLogger.info('main.startSessionWithAgent.externalCdp', { id, cdpUrl, daemonSocket }); + } const attachmentsForRun = sessionManager.loadAttachmentsForRun(id); if (attachmentsForRun.length > 0) { @@ -701,8 +927,10 @@ app.whenReady().then(async () => { sessionId: id, prompt: sessionManager.getSession(id)!.prompt, attachments: attachmentsForRun.map((a) => ({ name: a.name, mime: a.mime, bytes: a.bytes })), - webContents: view.webContents, + webContents: view?.webContents, cdpPort: resolvedCdp.port, + cdpUrl: cdpUrl ?? undefined, + daemonSocket: daemonSocket ?? undefined, signal: abortController.signal, onSessionId: (sid) => sessionManager.setClaudeSessionId(id, sid), onAuthResolved: ({ authMode, subscriptionType }) => sessionManager.setSessionAuth(id, authMode, subscriptionType), @@ -712,7 +940,7 @@ app.whenReady().then(async () => { sessionManager.completeSession(id); } else if (event.type === 'error') { sessionManager.failSession(id, event.message); - browserPool.destroy(id, shellWindow ?? undefined); + if (!cdpUrl) browserPool.destroy(id, shellWindow ?? undefined); } else { sessionManager.appendOutput(id, event); } @@ -720,7 +948,7 @@ app.whenReady().then(async () => { }).catch((err: Error) => { mainLogger.error('main.startSessionWithAgent.agentError', { id, error: err.message }); sessionManager.failSession(id, err.message); - browserPool.destroy(id, shellWindow ?? undefined); + if (!cdpUrl) browserPool.destroy(id, shellWindow ?? undefined); }).finally(() => { steerQueues.delete(id); startingSessionIds.delete(id); @@ -805,12 +1033,15 @@ app.whenReady().then(async () => { mainLogger.info('main.sessions:resume.persistedAttachments', { id: validatedId, turnIndex, count: resumeAttachments.length }); } - const webContents = browserPool.getWebContents(validatedId); - if (!webContents) { + const cdpUrl = getCdpUrl(); + const webContents = !cdpUrl ? browserPool.getWebContents(validatedId) : undefined; + if (!cdpUrl && !webContents) { mainLogger.warn('main.sessions:resume.noBrowser', { id: validatedId }); return { error: 'Browser session expired — start a new session' }; } + const daemonSocket = cdpUrl ? await getOrStartSessionDaemon(validatedId, cdpUrl) : null; + const abortController = sessionManager.resumeSession(validatedId, validatedPrompt); if (resumeAttachments.length > 0) { mainLogger.info('main.sessions:resume.attachments', { id: validatedId, count: resumeAttachments.length }); @@ -830,6 +1061,8 @@ app.whenReady().then(async () => { attachments: resumeAttachments.map((a) => ({ name: a.name, mime: a.mime, bytes: a.bytes })), webContents, cdpPort: resolvedCdp.port, + cdpUrl: cdpUrl ?? undefined, + daemonSocket: daemonSocket ?? undefined, signal: abortController.signal, resumeSessionId: sessionManager.getClaudeSessionId(validatedId), onSessionId: (sid) => sessionManager.setClaudeSessionId(validatedId, sid), @@ -840,7 +1073,7 @@ app.whenReady().then(async () => { sessionManager.completeSession(validatedId); } else if (event.type === 'error') { sessionManager.failSession(validatedId, event.message); - browserPool.destroy(validatedId, shellWindow ?? undefined); + if (!cdpUrl) browserPool.destroy(validatedId, shellWindow ?? undefined); } else { sessionManager.appendOutput(validatedId, event); } @@ -848,7 +1081,7 @@ app.whenReady().then(async () => { }).catch((err: Error) => { mainLogger.error('main.sessions:resume.agentError', { id: validatedId, error: err.message }); sessionManager.failSession(validatedId, err.message); - browserPool.destroy(validatedId, shellWindow ?? undefined); + if (!cdpUrl) browserPool.destroy(validatedId, shellWindow ?? undefined); }).finally(() => { steerQueues.delete(validatedId); mainLogger.info('main.sessions:resume.agentFinished', { id: validatedId, poolStats: browserPool.getStats() }); @@ -865,29 +1098,39 @@ app.whenReady().then(async () => { const session = sessionManager.getSession(validatedId); if (!session) return { error: 'Session not found' }; - browserPool.destroy(validatedId, shellWindow ?? undefined); + const cdpUrl = getCdpUrl(); + if (!cdpUrl) { + browserPool.destroy(validatedId, shellWindow ?? undefined); + } const abortController = sessionManager.rerunSession(validatedId); captureEvent('session_rerun', { engine: sessionManager.getSessionEngine(validatedId) ?? 'unknown', }); - const view = browserPool.create(validatedId, t0); - if (!view) { - sessionManager.failSession(validatedId, 'Browser pool full'); - return { error: 'Browser pool full' }; - } + let view: ReturnType | null = null; + let daemonSocket: string | null = null; + if (!cdpUrl) { + view = browserPool.create(validatedId, t0); + if (!view) { + sessionManager.failSession(validatedId, 'Browser pool full'); + return { error: 'Browser pool full' }; + } - if (shellWindow && !shellWindow.isDestroyed()) { - // See startSessionWithAgent comment — renderer is authoritative for bounds. - browserPool.detachAll(shellWindow); - mainLogger.info('main.sessions:rerun.detachedAwaitingRenderer', { id: validatedId }); - } + if (shellWindow && !shellWindow.isDestroyed()) { + // See startSessionWithAgent comment — renderer is authoritative for bounds. + browserPool.detachAll(shellWindow); + mainLogger.info('main.sessions:rerun.detachedAwaitingRenderer', { id: validatedId }); + } - try { - await view.webContents.loadURL('about:blank'); - } catch (err) { - mainLogger.warn('main.sessions:rerun.loadBlank.failed', { id: validatedId, error: (err as Error).message }); + try { + await view.webContents.loadURL('about:blank'); + } catch (err) { + mainLogger.warn('main.sessions:rerun.loadBlank.failed', { id: validatedId, error: (err as Error).message }); + } + } else { + daemonSocket = await getOrStartSessionDaemon(validatedId, cdpUrl); + mainLogger.info('main.sessions:rerun.externalCdp', { id: validatedId, cdpUrl, daemonSocket }); } const rerunAttachments = sessionManager.loadAttachmentsForRun(validatedId); @@ -901,8 +1144,10 @@ app.whenReady().then(async () => { sessionId: validatedId, prompt: session.prompt, attachments: rerunAttachments.map((a) => ({ name: a.name, mime: a.mime, bytes: a.bytes })), - webContents: view.webContents, + webContents: view?.webContents, cdpPort: resolvedCdp.port, + cdpUrl: cdpUrl ?? undefined, + daemonSocket: daemonSocket ?? undefined, signal: abortController.signal, // Rerun intentionally starts a fresh conversation; SessionManager.rerunSession // already cleared any stored resume id. @@ -914,7 +1159,7 @@ app.whenReady().then(async () => { sessionManager.completeSession(validatedId); } else if (event.type === 'error') { sessionManager.failSession(validatedId, event.message); - browserPool.destroy(validatedId, shellWindow ?? undefined); + if (!cdpUrl) browserPool.destroy(validatedId, shellWindow ?? undefined); } else { sessionManager.appendOutput(validatedId, event); } @@ -922,7 +1167,7 @@ app.whenReady().then(async () => { }).catch((err: Error) => { mainLogger.error('main.sessions:rerun.agentError', { id: validatedId, error: err.message }); sessionManager.failSession(validatedId, err.message); - browserPool.destroy(validatedId, shellWindow ?? undefined); + if (!cdpUrl) browserPool.destroy(validatedId, shellWindow ?? undefined); }).finally(() => { steerQueues.delete(validatedId); }); @@ -969,6 +1214,7 @@ app.whenReady().then(async () => { const validatedId = assertString(id, 'id', 100); mainLogger.info('main.sessions:delete', { id: validatedId }); browserPool.destroy(validatedId, shellWindow ?? undefined); + stopSessionDaemon(validatedId); sessionManager.deleteSession(validatedId); }); @@ -1070,18 +1316,22 @@ app.whenReady().then(async () => { }); ipcMain.handle('sessions:list', () => { + const cdpUrl = getCdpUrl(); const list = sessionManager.listSessions().map((s) => ({ ...s, hasBrowser: !!browserPool.getWebContents(s.id), + externalBrowser: !!cdpUrl, })); mainLogger.info('main.sessions:list', { returning: list.length, ids: list.map((s) => s.id) }); return list; }); ipcMain.handle('sessions:list-all', () => { + const cdpUrl = getCdpUrl(); return sessionManager.listSessions().map((s) => ({ ...s, hasBrowser: !!browserPool.getWebContents(s.id), + externalBrowser: !!cdpUrl, })); }); @@ -1089,7 +1339,8 @@ app.whenReady().then(async () => { const validatedId = assertString(id, 'id', 100); const session = sessionManager.getSession(validatedId); if (!session) return null; - return { ...session, hasBrowser: !!browserPool.getWebContents(validatedId) }; + const cdpUrl = getCdpUrl(); + return { ...session, hasBrowser: !!browserPool.getWebContents(validatedId), externalBrowser: !!cdpUrl }; }); // Live view: attach/detach agent browser to shell window @@ -1256,7 +1507,7 @@ app.whenReady().then(async () => { // --------------------------------------------------------------------------- ipcMain.handle('settings:open', () => { mainLogger.info('main.settings:open'); - openSettingsWindow(); + openSettingsWindow(shellWindow ?? undefined); }); ipcMain.handle('settings:app:get-info', () => { @@ -1372,6 +1623,16 @@ app.whenReady().then(async () => { ctrl.abort(); } activeAgents.clear(); + for (const [sid, d] of sessionDaemons) { + try { d.proc.kill('SIGTERM'); } catch { /* noop */ } + if (process.platform !== 'win32') { + try { fs.unlinkSync(d.socket); } catch { /* noop */ } + } + mainLogger.info('main.beforeQuit.stopDaemon', { sessionId: sid, socket: d.socket }); + } + sessionDaemons.clear(); + stopGlobalDaemon(); + try { fs.unlinkSync(path.join(harnessDir(), 'DAEMON_SOCKET')); } catch { /* noop */ } browserPool.destroyAll(shellWindow ?? undefined); sessionManager.destroy(); whatsAppAdapter.disconnect().catch(() => {}); diff --git a/app/src/main/logsPill.ts b/app/src/main/logsPill.ts index 455366c1..05905b71 100644 --- a/app/src/main/logsPill.ts +++ b/app/src/main/logsPill.ts @@ -46,6 +46,10 @@ let wasVisibleBeforeBlur = false; // until the user clicks a preset again. let mode: LogsMode = 'normal'; let userCustomized = false; +// When the Settings window is open we suppress showLogs so the logs overlay +// doesn't float above it. The checker is injected from index.ts to avoid a +// circular dependency with SettingsWindow.ts. +let settingsVisibleChecker: (() => boolean) | null = null; // Timestamp until which programmatic bound changes should be ignored by the // resize/move listeners. A single-shot flag wasn't enough because Electron // fires both 'move' and 'resize' (plus intermediate frames) from one @@ -399,6 +403,10 @@ export function showLogs(sessionId: string, anchor: PaneAnchor | null = null): v log.warn('logs.show.no-window', {}); return; } + if (settingsVisibleChecker?.()) { + log.info('logs.show.suppressed', { reason: 'settings-visible', sessionId }); + return; + } activeSessionId = sessionId; if (anchor) lastAnchor = anchor; log.info('logs.show', { sessionId, anchor: anchor ?? lastAnchor, ready: logsReady, mode, userCustomized }); @@ -528,6 +536,10 @@ export function setLogsMode(next: LogsMode): void { safeSend('logs:mode-changed', mode); } +export function setSettingsVisibleChecker(checker: (() => boolean) | null): void { + settingsVisibleChecker = checker; +} + export function getLogsWindow(): BrowserWindow | null { return logsWindow; } diff --git a/app/src/main/settings/SettingsWindow.ts b/app/src/main/settings/SettingsWindow.ts index f50a75cc..79e137e5 100644 --- a/app/src/main/settings/SettingsWindow.ts +++ b/app/src/main/settings/SettingsWindow.ts @@ -18,6 +18,7 @@ import path from 'node:path'; import { BrowserWindow } from 'electron'; import { mainLogger, rendererLogger } from '../logger'; +import { getLogsWindow } from '../logsPill'; // --------------------------------------------------------------------------- // Forge VitePlugin globals (injected at build time) @@ -40,7 +41,7 @@ let settingsWindow: BrowserWindow | null = null; * Open (or focus) the Settings window. * Returns the BrowserWindow instance. */ -export function openSettingsWindow(): BrowserWindow { +export function openSettingsWindow(parent?: BrowserWindow): BrowserWindow { // If already open, focus and return if (settingsWindow && !settingsWindow.isDestroyed()) { mainLogger.info('SettingsWindow.focus', { windowId: settingsWindow.id }); @@ -52,12 +53,25 @@ export function openSettingsWindow(): BrowserWindow { const preloadPath = path.join(__dirname, 'settings.js'); + // Temporarily lower the logs overlay so it doesn't cover the settings window. + // On Windows the logs window has alwaysOnTop, which keeps it above child + // windows regardless of focus. We disable that flag while settings is open. + const logsWin = getLogsWindow(); + const logsWasAlwaysOnTop = logsWin && !logsWin.isDestroyed() && logsWin.isVisible(); + if (logsWin && !logsWin.isDestroyed()) { + try { logsWin.setAlwaysOnTop(false); } catch { /* noop */ } + if (logsWasAlwaysOnTop) { + try { logsWin.hide(); } catch { /* noop */ } + } + } + settingsWindow = new BrowserWindow({ width: 720, height: 560, resizable: false, titleBarStyle: 'hiddenInset', show: false, + parent, backgroundColor: '#1a1a1f', // Match --color-bg-base (onboarding theme) webPreferences: { preload: preloadPath, @@ -84,6 +98,14 @@ export function openSettingsWindow(): BrowserWindow { settingsWindow.on('closed', () => { mainLogger.info('SettingsWindow.closed'); settingsWindow = null; + // Restore the logs overlay if it was visible before settings opened. + const lw = getLogsWindow(); + if (lw && !lw.isDestroyed()) { + try { lw.setAlwaysOnTop(true, 'floating'); } catch { /* noop */ } + if (logsWasAlwaysOnTop && !lw.isVisible()) { + try { lw.showInactive(); } catch { /* noop */ } + } + } }); settingsWindow.webContents.on('did-fail-load', (_e, code, desc, url) => { diff --git a/app/src/main/settings/cdpUrlIpc.ts b/app/src/main/settings/cdpUrlIpc.ts new file mode 100644 index 00000000..fd3abce8 --- /dev/null +++ b/app/src/main/settings/cdpUrlIpc.ts @@ -0,0 +1,185 @@ +import { ipcMain } from 'electron'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { mainLogger } from '../logger'; +import { assertString } from '../ipc-validators'; +import { getCdpUrlState, setCdpUrl, setAlwaysAllow, getAlwaysAllow } from './cdpUrlStore'; + +const CH_GET = 'settings:cdp-url:get'; +const CH_SET = 'settings:cdp-url:set'; +const CH_TEST = 'settings:cdp-url:test'; +const CH_ALWAYS_ALLOW_GET = 'settings:cdp-url:always-allow:get'; +const CH_ALWAYS_ALLOW_SET = 'settings:cdp-url:always-allow:set'; + +function chromeProfileCandidates(): string[] { + const home = os.homedir(); + if (process.platform === 'darwin') { + return [ + path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'), + path.join(home, 'Library', 'Application Support', 'Chromium'), + path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary'), + ]; + } + if (process.platform === 'win32') { + const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); + return [ + path.join(localAppData, 'Google', 'Chrome', 'User Data'), + path.join(localAppData, 'Google', 'Chrome SxS', 'User Data'), + path.join(localAppData, 'Chromium', 'User Data'), + ]; + } + const configHome = process.env.XDG_CONFIG_HOME || path.join(home, '.config'); + return [ + path.join(configHome, 'google-chrome'), + path.join(configHome, 'google-chrome-beta'), + path.join(configHome, 'google-chrome-unstable'), + path.join(configHome, 'chromium'), + ]; +} + +function readDevToolsActivePort(): { port: string; wsPath: string } | null { + for (const base of chromeProfileCandidates()) { + // DevToolsActivePort lives in the profile dir (e.g. Default/ or Profile 1/) + // Try the base itself first, then common profile subdirs. + const candidates = [base, path.join(base, 'Default'), path.join(base, 'Profile 1')]; + for (const dir of candidates) { + try { + const raw = fs.readFileSync(path.join(dir, 'DevToolsActivePort'), 'utf-8').trim(); + const [port, wsPath] = raw.split('\n', 2); + if (port && wsPath) return { port: port.trim(), wsPath: wsPath.trim() }; + } catch { continue; } + } + } + return null; +} + +/** + * If the user gives a bare host:port like ws://127.0.0.1:9222 (no path, or + * just "/"), probe /json/version over HTTP and return the full + * webSocketDebuggerUrl so the agent gets a real endpoint. + * + * Chrome 144+ hides HTTP endpoints when remote-debugging is toggled via + * chrome://inspect — on 404 we fall back to reading DevToolsActivePort from + * the local Chrome profile. + */ +async function resolveCdpWsUrl(input: string): Promise { + const trimmed = input.trim(); + const needsResolve = + !trimmed.includes('/devtools/') || new URL(trimmed).pathname === '/'; + if (!needsResolve) return trimmed; + + const httpUrl = trimmed.replace(/^ws:/, 'http:').replace(/^wss:/, 'https:'); + const versionUrl = new URL(httpUrl); + versionUrl.pathname = '/json/version'; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + try { + const res = await fetch(versionUrl.toString(), { signal: controller.signal }); + if (res.ok) { + const body = (await res.json()) as { webSocketDebuggerUrl?: string }; + if (body.webSocketDebuggerUrl) return body.webSocketDebuggerUrl; + } + } catch { + // Network error — fall through to DevToolsActivePort + } finally { + clearTimeout(timer); + } + + // Chrome 144+ returns 404 on HTTP endpoints — read DevToolsActivePort instead. + const active = readDevToolsActivePort(); + if (active) { + const { port, wsPath } = active; + const parsed = new URL(trimmed); + const inputPort = parsed.port || '9222'; + if (inputPort === port) { + return `ws://127.0.0.1:${port}${wsPath}`; + } + } + + throw new Error('Could not resolve CDP endpoint: HTTP 404 and DevToolsActivePort not found'); +} + +export function registerCdpUrlHandlers(): void { + ipcMain.handle(CH_GET, (): { url: string | null; alwaysAllow: boolean } => { + const state = getCdpUrlState(); + return { url: state.url, alwaysAllow: state.alwaysAllow }; + }); + + ipcMain.handle(CH_SET, async (_evt, url: unknown): Promise<{ url: string | null }> => { + if (url === null || url === undefined) { + mainLogger.info('cdpUrlIpc.clear'); + return setCdpUrl(null); + } + const validated = assertString(url, 'url', 2000); + mainLogger.info('cdpUrlIpc.save', { urlLength: validated.length }); + try { + const resolved = await resolveCdpWsUrl(validated); + mainLogger.info('cdpUrlIpc.resolved', { input: validated, resolved }); + return setCdpUrl(resolved); + } catch (err) { + // If resolution fails, still save the raw URL so the user can fix it. + mainLogger.warn('cdpUrlIpc.resolveFailed', { input: validated, error: (err as Error).message }); + return setCdpUrl(validated); + } + }); + + ipcMain.handle(CH_TEST, async (_evt, url: unknown): Promise<{ ok: boolean; error?: string }> => { + const validated = assertString(url, 'url', 2000); + mainLogger.info('cdpUrlIpc.test', { urlLength: validated.length }); + let resolved: string; + try { + resolved = await resolveCdpWsUrl(validated); + } catch (err) { + const msg = (err as Error).message ?? 'Connection failed'; + mainLogger.warn('cdpUrlIpc.test.resolveFailed', { error: msg }); + return { ok: false, error: msg }; + } + + // Chrome 144+ blocks HTTP /json/* when remote-debugging is toggled via UI. + // Try HTTP first; on 404 fall back to a direct WebSocket handshake. + try { + const httpUrl = resolved.replace(/^ws:/, 'http:').replace(/^wss:/, 'https:'); + const target = new URL(httpUrl); + target.pathname = '/json/version'; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + const res = await fetch(target.toString(), { signal: controller.signal }); + clearTimeout(timer); + if (res.ok) { + const body = (await res.json()) as { Browser?: string }; + return { ok: true, error: body.Browser }; + } + // HTTP 404 — fall through to WS probe + } catch { + // Network error — fall through to WS probe + } + + // WebSocket handshake test (works even when HTTP endpoints are hidden). + try { + const ws = new (await import('ws')).default(resolved); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { ws.close(); reject(new Error('WS handshake timeout')); }, 5000); + ws.once('open', () => { clearTimeout(timer); ws.close(); resolve(); }); + ws.once('error', (err: Error) => { clearTimeout(timer); reject(err); }); + }); + return { ok: true, error: 'CDP WebSocket reachable' }; + } catch (err) { + const msg = (err as Error).message ?? 'WebSocket connection failed'; + mainLogger.warn('cdpUrlIpc.test.wsFailed', { error: msg }); + return { ok: false, error: msg }; + } + }); + + ipcMain.handle(CH_ALWAYS_ALLOW_GET, (): { alwaysAllow: boolean } => { + return { alwaysAllow: getAlwaysAllow() }; + }); + + ipcMain.handle(CH_ALWAYS_ALLOW_SET, (_evt, value: unknown): { alwaysAllow: boolean } => { + const bool = value === true; + mainLogger.info('cdpUrlIpc.alwaysAllow.set', { bool }); + const state = setAlwaysAllow(bool); + return { alwaysAllow: state.alwaysAllow }; + }); +} diff --git a/app/src/main/settings/cdpUrlStore.ts b/app/src/main/settings/cdpUrlStore.ts new file mode 100644 index 00000000..9ed763e7 --- /dev/null +++ b/app/src/main/settings/cdpUrlStore.ts @@ -0,0 +1,94 @@ +/** + * CDP WebSocket URL preference storage. + * + * Persisted to /cdp-url.json as plain JSON (not Keychain — a URL + * preference doesn't need encryption). + * + * When set, the app connects to an existing browser via CDP WebSocket instead + * of creating a new embedded WebContentsView. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { app } from 'electron'; +import { mainLogger } from '../logger'; + +const CDP_URL_FILE = 'cdp-url.json'; + +export interface CdpUrlState { + /** CDP WebSocket URL, e.g. ws://127.0.0.1:9222/devtools/browser/... */ + url: string | null; + /** When true, a single global daemon is kept alive across all sessions. */ + alwaysAllow: boolean; +} + +const DEFAULT_STATE: CdpUrlState = { + url: null, + alwaysAllow: false, +}; + +let onChange: ((state: CdpUrlState) => void) | null = null; + +export function setCdpUrlChangeCallback(cb: ((state: CdpUrlState) => void) | null): void { + onChange = cb; +} + +function cdpUrlFilePath(): string { + return path.join(app.getPath('userData'), CDP_URL_FILE); +} + +export function getCdpUrlState(): CdpUrlState { + try { + const raw = fs.readFileSync(cdpUrlFilePath(), 'utf-8'); + const parsed = JSON.parse(raw) as Partial; + return { + url: typeof parsed.url === 'string' && parsed.url.length > 0 ? parsed.url : null, + alwaysAllow: parsed.alwaysAllow === true, + }; + } catch { + // File missing or corrupt — fall through to default. + } + return { ...DEFAULT_STATE }; +} + +export function getCdpUrl(): string | null { + return getCdpUrlState().url; +} + +export function getAlwaysAllow(): boolean { + return getCdpUrlState().alwaysAllow; +} + +export function setCdpUrl(url: string | null): CdpUrlState { + const existing = getCdpUrlState(); + const next: CdpUrlState = { + url: url && url.trim().length > 0 ? url.trim() : null, + alwaysAllow: existing.alwaysAllow, + }; + try { + fs.mkdirSync(path.dirname(cdpUrlFilePath()), { recursive: true }); + fs.writeFileSync(cdpUrlFilePath(), JSON.stringify(next, null, 2), 'utf-8'); + mainLogger.info('cdpUrl.set', { url: next.url }); + } catch (err) { + mainLogger.error('cdpUrl.set-failed', { error: (err as Error).message }); + } + onChange?.(next); + return next; +} + +export function setAlwaysAllow(alwaysAllow: boolean): CdpUrlState { + const existing = getCdpUrlState(); + const next: CdpUrlState = { + url: existing.url, + alwaysAllow, + }; + try { + fs.mkdirSync(path.dirname(cdpUrlFilePath()), { recursive: true }); + fs.writeFileSync(cdpUrlFilePath(), JSON.stringify(next, null, 2), 'utf-8'); + mainLogger.info('cdpUrl.alwaysAllow.set', { alwaysAllow }); + } catch (err) { + mainLogger.error('cdpUrl.alwaysAllow.set-failed', { error: (err as Error).message }); + } + onChange?.(next); + return next; +} diff --git a/app/src/preload/shell.ts b/app/src/preload/shell.ts index 0111d4a5..a3e74265 100644 --- a/app/src/preload/shell.ts +++ b/app/src/preload/shell.ts @@ -99,6 +99,13 @@ contextBridge.exposeInMainWorld('electronAPI', { openSystemNotifications: (): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('settings:open-system-notifications'), }, + cdpUrl: { + get: (): Promise<{ url: string | null; alwaysAllow: boolean }> => ipcRenderer.invoke('settings:cdp-url:get'), + set: (url: string | null): Promise<{ url: string | null; alwaysAllow: boolean }> => ipcRenderer.invoke('settings:cdp-url:set', url), + test: (url: string): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('settings:cdp-url:test', url), + getAlwaysAllow: (): Promise<{ alwaysAllow: boolean }> => ipcRenderer.invoke('settings:cdp-url:always-allow:get'), + setAlwaysAllow: (value: boolean): Promise<{ alwaysAllow: boolean }> => ipcRenderer.invoke('settings:cdp-url:always-allow:set', value), + }, app: { getInfo: (): Promise<{ version: string; diff --git a/app/src/renderer/hub/AgentPane.tsx b/app/src/renderer/hub/AgentPane.tsx index 72ef79e5..83bd4c1e 100644 --- a/app/src/renderer/hub/AgentPane.tsx +++ b/app/src/renderer/hub/AgentPane.tsx @@ -1140,7 +1140,9 @@ export function AgentPane({ session, focused, onRerun, onFollowUp, onDismiss, on >
- {browserDead ? ( + {session.externalBrowser ? ( + External browser connected + ) : browserDead ? ( Browser ended ) : browserMissing ? ( diff --git a/app/src/renderer/hub/SettingsPane.tsx b/app/src/renderer/hub/SettingsPane.tsx index 25e1b900..4c7ab20b 100644 --- a/app/src/renderer/hub/SettingsPane.tsx +++ b/app/src/renderer/hub/SettingsPane.tsx @@ -196,6 +196,138 @@ function AppSection(): React.ReactElement { ); } +function CdpUrlSection(): React.ReactElement { + const [url, setUrl] = useState(''); + const [savedUrl, setSavedUrl] = useState(null); + const [alwaysAllow, setAlwaysAllow] = useState(false); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null); + const [saving, setSaving] = useState(false); + + const api = (window as unknown as { electronAPI: { settings: { cdpUrl: { + get: () => Promise<{ url: string | null; alwaysAllow: boolean }>; + set: (url: string | null) => Promise<{ url: string | null; alwaysAllow: boolean }>; + test: (url: string) => Promise<{ ok: boolean; error?: string }>; + getAlwaysAllow: () => Promise<{ alwaysAllow: boolean }>; + setAlwaysAllow: (value: boolean) => Promise<{ alwaysAllow: boolean }>; + } } } }).electronAPI.settings.cdpUrl; + + useEffect(() => { + let cancelled = false; + api.get().then((state) => { + if (cancelled) return; + setSavedUrl(state.url); + setUrl(state.url ?? ''); + setAlwaysAllow(state.alwaysAllow); + }).catch(() => { if (!cancelled) setSavedUrl(null); }); + return () => { cancelled = true; }; + }, [api]); + + return ( +
+ Browser +

+ Connect to an existing Chrome / Chromium via CDP WebSocket instead of launching a built-in browser. + Leave empty to use the default embedded browser. +

+
+ setUrl(e.target.value)} + style={{ width: '100%', padding: '8px 10px', borderRadius: 6, border: '1px solid var(--color-border)', background: 'var(--color-bg-elevated)', color: 'inherit' }} + /> +
+ + {savedUrl && ( + + Active: {savedUrl} + + )} +
+ {testResult && ( +
+ {testResult.message} +
+ )} + {savedUrl && ( + + )} +
+
+ ); +} + function PrivacySection(): React.ReactElement { const [telemetry, setTelemetry] = useState(null); const [saving, setSaving] = useState(false); @@ -416,6 +548,7 @@ export function SettingsPane({ open, onClose, keybindings, overrides, onUpdateBi Connections
+
diff --git a/app/src/renderer/hub/types.ts b/app/src/renderer/hub/types.ts index 2a93fd43..8c0d24ba 100644 --- a/app/src/renderer/hub/types.ts +++ b/app/src/renderer/hub/types.ts @@ -23,6 +23,7 @@ export interface AgentSession { error?: string; group?: string; hasBrowser?: boolean; + externalBrowser?: boolean; primarySite?: string | null; lastActivityAt?: number; engine?: string; diff --git a/app/src/renderer/hub/useSessionsQuery.ts b/app/src/renderer/hub/useSessionsQuery.ts index abd51f65..db62a944 100644 --- a/app/src/renderer/hub/useSessionsQuery.ts +++ b/app/src/renderer/hub/useSessionsQuery.ts @@ -43,7 +43,12 @@ export function useSessionsQuery() { const idx = prev.findIndex((s) => s.id === session.id); if (idx >= 0) { const next = [...prev]; - next[idx] = { ...prev[idx], ...session, hasBrowser: session.hasBrowser ?? prev[idx].hasBrowser }; + next[idx] = { + ...prev[idx], + ...session, + hasBrowser: session.hasBrowser ?? prev[idx].hasBrowser, + externalBrowser: session.externalBrowser ?? prev[idx].externalBrowser, + }; return next; } return [...prev, session]; diff --git a/app/tests/unit/hub/SettingsPane.spec.tsx b/app/tests/unit/hub/SettingsPane.spec.tsx index 6fa62416..111b7024 100644 --- a/app/tests/unit/hub/SettingsPane.spec.tsx +++ b/app/tests/unit/hub/SettingsPane.spec.tsx @@ -37,6 +37,13 @@ function installElectronApi(): void { setTelemetry: vi.fn(async (telemetry: boolean) => ({ telemetry, telemetryUpdatedAt: null, version: 1 })), openSystemNotifications: vi.fn(async () => ({ ok: true })), }, + cdpUrl: { + get: vi.fn(async () => ({ url: null, alwaysAllow: false })), + set: vi.fn(async () => ({ url: null, alwaysAllow: false })), + test: vi.fn(async () => ({ ok: true })), + getAlwaysAllow: vi.fn(async () => ({ alwaysAllow: false })), + setAlwaysAllow: vi.fn(async () => ({ alwaysAllow: false })), + }, }, on: {}, },