From 18c21bac67a9a3437167be75172e0c69d68f2019 Mon Sep 17 00:00:00 2001 From: Reagan Hsu Date: Thu, 7 May 2026 12:50:27 -0700 Subject: [PATCH 1/3] Make provider onboarding depend on real CLI availability Codex and Claude onboarding now separate CLI installation from authentication, so missing CLIs show install actions instead of sign-in prompts. CLI launches use one high-level router that enriches GUI PATHs on macOS/Linux/Windows and resolves Windows npm shims before spawning. Constraint: Electron GUI launches can miss user shell PATH entries, and Windows npm-installed CLIs often resolve to .cmd or .ps1 shims. Rejected: Keep per-call Windows launch fixes | leaves Codex PTY login and future CLI calls easy to miss. Confidence: high Scope-risk: moderate Tested: npm run test Tested: task typecheck Tested: npm run lint -- --quiet Tested: git diff --check Not-tested: Manual Windows/Linux Electron installer flow on physical machines --- app/src/main/hl/engines/cliSpawn.ts | 7 +- app/src/main/hl/engines/installer.ts | 22 +- app/src/main/hl/engines/pathEnrich.ts | 171 ++++++++++++--- app/src/main/identity/codexLogin.ts | 19 +- app/src/main/identity/onboardingHandlers.ts | 20 +- app/src/preload/onboarding.ts | 3 + app/src/renderer/onboarding/OnboardingApp.tsx | 200 ++++++++++++------ app/tests/unit/hl/installer.test.ts | 19 ++ app/tests/unit/identity/codexLogin.test.ts | 31 +++ .../unit/identity/onboardingHandlers.test.ts | 53 +++++ app/tests/unit/pathEnrich.test.ts | 111 +++++++++- 11 files changed, 538 insertions(+), 118 deletions(-) create mode 100644 app/tests/unit/hl/installer.test.ts create mode 100644 app/tests/unit/identity/codexLogin.test.ts diff --git a/app/src/main/hl/engines/cliSpawn.ts b/app/src/main/hl/engines/cliSpawn.ts index dadaedd3..58249aba 100644 --- a/app/src/main/hl/engines/cliSpawn.ts +++ b/app/src/main/hl/engines/cliSpawn.ts @@ -1,6 +1,6 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import path from 'node:path'; -import { enrichedEnv, resolveCliSpawn } from './pathEnrich'; +import { resolveCliLaunch } from './pathEnrich'; export type CliStdinMode = 'ignore' | 'pipe'; @@ -33,13 +33,12 @@ function assertSafeExecutable(value: string, label: string): void { export function spawnCli(bin: string, args: readonly string[], opts: SpawnCliOptions = {}): ChildProcessWithoutNullStreams { assertSafeExecutable(bin, 'executable name'); - const env = opts.env ?? enrichedEnv(); const stdio = opts.stdio ?? ['ignore', 'pipe', 'pipe']; - const resolved = resolveCliSpawn(bin, args, { env }); + const resolved = resolveCliLaunch(bin, args, { env: opts.env }); assertSafeExecutable(resolved.command, 'resolved executable'); return spawn(resolved.command, resolved.args, { cwd: opts.cwd, - env, + env: resolved.env, stdio, ...resolved.spawnOptions, }) as ChildProcessWithoutNullStreams; diff --git a/app/src/main/hl/engines/installer.ts b/app/src/main/hl/engines/installer.ts index 40dde390..72c903d4 100644 --- a/app/src/main/hl/engines/installer.ts +++ b/app/src/main/hl/engines/installer.ts @@ -113,11 +113,6 @@ function escapeCmdEcho(value: string): string { .replace(/\)/g, '^)'); } -function quoteCmdToken(value: string): string { - if (/[\r\n\0"]/.test(value)) throw new Error('installer command token contains unsupported characters'); - return `"${value}"`; -} - function writeWindowsInstallScript(displayName: string, command: string): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'browser-use-install-')); const scriptPath = path.join(dir, 'install.cmd'); @@ -140,15 +135,18 @@ function writeWindowsInstallScript(displayName: string, command: string): string return scriptPath; } +export function windowsInstallerSpawnSpec(scriptPath: string, env: NodeJS.ProcessEnv = process.env): { command: string; args: string[] } { + if (/[\r\n\0]/.test(scriptPath)) throw new Error('installer script path contains unsupported control characters'); + return { + command: env.ComSpec || 'cmd.exe', + args: ['/d', '/k', scriptPath], + }; +} + function openWindowsTerminal(displayName: string, command: string): EngineInstallResult { const scriptPath = writeWindowsInstallScript(displayName, command); - const comspec = process.env.ComSpec || 'cmd.exe'; - const child = spawn(comspec, [ - '/d', - '/s', - '/c', - `start ${quoteCmdToken(`${displayName} Installer`)} ${quoteCmdToken(comspec)} /k ${quoteCmdToken(scriptPath)}`, - ], { detached: true, stdio: 'ignore', windowsHide: false }); + const spec = windowsInstallerSpawnSpec(scriptPath); + const child = spawn(spec.command, spec.args, { detached: true, stdio: 'ignore', windowsHide: false }); child.unref(); return { opened: true, command, displayName }; } diff --git a/app/src/main/hl/engines/pathEnrich.ts b/app/src/main/hl/engines/pathEnrich.ts index 9f6d90da..3b6cab6d 100644 --- a/app/src/main/hl/engines/pathEnrich.ts +++ b/app/src/main/hl/engines/pathEnrich.ts @@ -21,6 +21,14 @@ interface EnrichOptions { homedir?: string; } +interface CliLaunchOptions extends EnrichOptions { + env?: NodeJS.ProcessEnv; +} + +type PosixPathMod = typeof path.posix; +type WindowsPathMod = typeof path.win32; +type ExtraDirResult = string | string[] | null; + /** * Spawn the user's login shell once and capture its PATH. Catches custom * dirs set in ~/.zshrc / ~/.bashrc / chruby / mise / asdf / etc. that @@ -53,36 +61,99 @@ function queryLoginShellPath(env: NodeJS.ProcessEnv = process.env, platform: Pla return cachedShellPath; } -const POSIX_EXTRA_DIRS_FNS: Array<(home: string, platform: Platform, pathMod: typeof path) => string | null> = [ +function existingChildBins(root: string, pathMod: PosixPathMod | WindowsPathMod, childToBin: (child: string) => string): string[] { + try { + return fs.readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) + .map((entry) => childToBin(pathMod.join(root, entry.name))) + .filter((dir) => { + try { return fs.statSync(dir).isDirectory(); } + catch { return false; } + }) + .sort(); + } catch { + return []; + } +} + +function envPath(env: NodeJS.ProcessEnv, ...keys: string[]): string | null { + for (const key of keys) { + const value = env[key]; + if (value) return value; + } + return null; +} + +const POSIX_EXTRA_DIRS_FNS: Array<(home: string, platform: Platform, env: NodeJS.ProcessEnv, pathMod: PosixPathMod) => ExtraDirResult> = [ + (_home, _platform, env) => envPath(env, 'PNPM_HOME'), + (_home, _platform, env, pathMod) => { + const prefix = envPath(env, 'NPM_CONFIG_PREFIX', 'npm_config_prefix'); + return prefix ? pathMod.join(prefix, 'bin') : null; + }, () => '/opt/homebrew/bin', () => '/opt/homebrew/sbin', () => '/usr/local/bin', () => '/usr/local/sbin', - (home, _platform, pathMod) => pathMod.join(home, '.npm-global', 'bin'), - (home, _platform, pathMod) => pathMod.join(home, '.volta', 'bin'), - (home, _platform, pathMod) => pathMod.join(home, '.nvm', 'versions', 'node'), - (home, _platform, pathMod) => pathMod.join(home, '.bun', 'bin'), - (home, _platform, pathMod) => pathMod.join(home, '.bcode', 'bin'), - (home, _platform, pathMod) => pathMod.join(home, '.deno', 'bin'), - (home, _platform, pathMod) => pathMod.join(home, '.cargo', 'bin'), - (home, _platform, pathMod) => pathMod.join(home, '.local', 'bin'), - (home, _platform, pathMod) => pathMod.join(home, '.yarn', 'bin'), - (home, _platform, pathMod) => pathMod.join(home, 'bin'), + (_home, _platform, _env, pathMod) => platformDir(pathMod, '/snap/bin'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.npm-global', 'bin'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.npm-packages', 'bin'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.volta', 'bin'), + (home, _platform, _env, pathMod) => existingChildBins(pathMod.join(home, '.nvm', 'versions', 'node'), pathMod, (child) => pathMod.join(child, 'bin')), + (home, _platform, _env, pathMod) => pathMod.join(home, '.nodebrew', 'current', 'bin'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.n', 'bin'), + (home, _platform, _env, pathMod) => existingChildBins(pathMod.join(home, '.fnm', 'node-versions'), pathMod, (child) => pathMod.join(child, 'installation', 'bin')), + (home, _platform, _env, pathMod) => existingChildBins(pathMod.join(home, '.local', 'share', 'fnm', 'node-versions'), pathMod, (child) => pathMod.join(child, 'installation', 'bin')), + (home, _platform, _env, pathMod) => pathMod.join(home, '.asdf', 'shims'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.local', 'share', 'mise', 'shims'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.local', 'share', 'rtx', 'shims'), + (home, platform, _env, pathMod) => platform === 'darwin' ? pathMod.join(home, 'Library', 'pnpm') : null, + (home, _platform, _env, pathMod) => pathMod.join(home, '.local', 'share', 'pnpm'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.bun', 'bin'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.bcode', 'bin'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.deno', 'bin'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.cargo', 'bin'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.local', 'bin'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.yarn', 'bin'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.config', 'yarn', 'global', 'node_modules', '.bin'), + (home, _platform, _env, pathMod) => pathMod.join(home, 'bin'), ]; -const WINDOWS_EXTRA_DIRS_FNS: Array<(home: string, env: NodeJS.ProcessEnv, pathMod: typeof path.win32) => string | null> = [ +function platformDir(pathMod: PosixPathMod, dir: string): string { + return pathMod.normalize(dir); +} + +const WINDOWS_EXTRA_DIRS_FNS: Array<(home: string, env: NodeJS.ProcessEnv, pathMod: WindowsPathMod) => ExtraDirResult> = [ + (_home, env) => envPath(env, 'PNPM_HOME'), + (_home, env, pathMod) => { + const prefix = envPath(env, 'NPM_CONFIG_PREFIX', 'npm_config_prefix'); + return prefix ? pathMod.join(prefix, 'bin') : null; + }, (_home, env, pathMod) => env.LOCALAPPDATA ? pathMod.join(env.LOCALAPPDATA, 'Programs', 'Microsoft VS Code', 'bin') : null, (_home, env, pathMod) => env.LOCALAPPDATA ? pathMod.join(env.LOCALAPPDATA, 'Programs', 'cursor', 'resources', 'app', 'bin') : null, (_home, env, pathMod) => env.LOCALAPPDATA ? pathMod.join(env.LOCALAPPDATA, 'Programs', 'Windsurf', 'resources', 'app', 'bin') : null, + (_home, env, pathMod) => env.LOCALAPPDATA ? pathMod.join(env.LOCALAPPDATA, 'pnpm') : null, + (_home, env, pathMod) => env.LOCALAPPDATA ? pathMod.join(env.LOCALAPPDATA, 'Microsoft', 'WindowsApps') : null, + (_home, env, pathMod) => env.APPDATA ? pathMod.join(env.APPDATA, 'npm') : null, + (_home, env, pathMod) => env.APPDATA ? existingChildBins(pathMod.join(env.APPDATA, 'fnm', 'node-versions'), pathMod, (child) => pathMod.join(child, 'installation')) : null, (home, _env, pathMod) => pathMod.join(home, 'AppData', 'Roaming', 'npm'), + (home, _env, pathMod) => pathMod.join(home, '.npm-global'), + (home, _env, pathMod) => pathMod.join(home, '.npm-packages', 'bin'), + (home, _env, pathMod) => pathMod.join(home, '.volta', 'bin'), + (home, _env, pathMod) => existingChildBins(pathMod.join(home, '.fnm', 'node-versions'), pathMod, (child) => pathMod.join(child, 'installation')), (home, _env, pathMod) => pathMod.join(home, '.bun', 'bin'), (home, _env, pathMod) => pathMod.join(home, '.bcode', 'bin'), (home, _env, pathMod) => pathMod.join(home, '.deno', 'bin'), (home, _env, pathMod) => pathMod.join(home, '.cargo', 'bin'), + (home, _env, pathMod) => pathMod.join(home, 'scoop', 'shims'), + (_home, env, pathMod) => env.ProgramData ? pathMod.join(env.ProgramData, 'scoop', 'shims') : pathMod.join('C:\\', 'ProgramData', 'scoop', 'shims'), + (_home, env, pathMod) => env.ChocolateyInstall ? pathMod.join(env.ChocolateyInstall, 'bin') : pathMod.join('C:\\', 'ProgramData', 'chocolatey', 'bin'), ]; function pathValueFromEnv(env: NodeJS.ProcessEnv, platform: Platform): string { - if (platform === 'win32') return env.Path ?? env.PATH ?? ''; + if (platform === 'win32') { + const values = [env.Path, env.PATH].filter((value): value is string => Boolean(value)); + return Array.from(new Set(values)).join(';'); + } return env.PATH ?? ''; } @@ -91,25 +162,29 @@ function pathKeyForEnv(env: NodeJS.ProcessEnv, platform: Platform): 'PATH' | 'Pa return 'PATH'; } -export function enrichedPath(base = pathValueFromEnv(process.env, process.platform), opts: EnrichOptions = {}): string { +export function enrichedPath(base?: string, opts: EnrichOptions = {}): string { const platform = opts.platform ?? process.platform; const env = opts.env ?? process.env; const home = opts.homedir ?? os.homedir(); - const pathMod = platform === 'win32' ? path.win32 : path; + const pathMod = platform === 'win32' ? path.win32 : path.posix; const delimiter = platform === 'win32' ? ';' : ':'; - const existing = base.split(delimiter).filter(Boolean); + const existing = (base ?? pathValueFromEnv(env, platform)).split(delimiter).filter(Boolean); const set = new Set(existing); const out = [...existing]; + const addDir = (dir: string): void => { + if (!set.has(dir)) { + set.add(dir); + out.push(dir); + } + }; + // First: anything the user's login shell knows about on POSIX — covers // custom setups like chruby, asdf, mise, direnv, or ad-hoc PATH exports. const shellPath = queryLoginShellPath(env, platform); if (shellPath) { for (const dir of shellPath.split(delimiter).filter(Boolean)) { - if (!set.has(dir)) { - set.add(dir); - out.push(dir); - } + addDir(dir); } } @@ -117,21 +192,28 @@ export function enrichedPath(base = pathValueFromEnv(process.env, process.platfo // shell query failed or the platform has no login-shell convention. const extraFns = platform === 'win32' ? WINDOWS_EXTRA_DIRS_FNS.map((fn) => () => fn(home, env, pathMod)) - : POSIX_EXTRA_DIRS_FNS.map((fn) => () => fn(home, platform, pathMod)); + : POSIX_EXTRA_DIRS_FNS.map((fn) => () => fn(home, platform, env, pathMod)); for (const fn of extraFns) { - const dir = fn(); - if (dir && !set.has(dir)) { - set.add(dir); - out.push(dir); + const result = fn(); + const dirs = Array.isArray(result) ? result : result ? [result] : []; + for (const dir of dirs) { + addDir(dir); } } return out.join(delimiter); } -export function enrichedEnv(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - const platform = process.platform; +export function enrichedEnv(baseEnv: NodeJS.ProcessEnv = process.env, opts: Omit = {}): NodeJS.ProcessEnv { + const platform = opts.platform ?? process.platform; const key = pathKeyForEnv(baseEnv, platform); - return { ...baseEnv, [key]: enrichedPath(pathValueFromEnv(baseEnv, platform), { platform, env: baseEnv }) }; + return { + ...baseEnv, + [key]: enrichedPath(pathValueFromEnv(baseEnv, platform), { + platform, + env: baseEnv, + homedir: opts.homedir, + }), + }; } /** @@ -210,7 +292,7 @@ export function resolveCliSpawn( const platform = opts.platform ?? process.platform; if (platform !== 'win32') return { command: name, args: [...args], viaCmdShell: false, spawnOptions: {} }; - const env = opts.env ?? enrichedEnv(); + const env = opts.env ?? enrichedEnv(process.env, { platform }); const resolved = findOnWindowsPath(name, env); if (!resolved) return { command: name, args: [...args], viaCmdShell: false, spawnOptions: {} }; @@ -248,9 +330,38 @@ export function resolveCliSpawn( // GitHub Actions windows-latest in tests/unit/hl/codexStdinWindows.test.ts. const cmdline = [resolved, ...args].map(quoteForCmdExe).join(' '); return { - command: process.env.ComSpec || 'cmd.exe', + command: env.ComSpec || 'cmd.exe', args: ['/d', '/s', '/c', `"${cmdline}"`], viaCmdShell: true, spawnOptions: { windowsVerbatimArguments: true }, }; } + +export interface CliLaunchSpec extends ResolvedCli { + env: NodeJS.ProcessEnv; +} + +/** + * High-level router for launching user-installed CLIs from the Electron app. + * + * Every call gets a GUI-safe PATH first. On macOS/Linux, that is the main + * compatibility fix: CLIs installed by Homebrew, pnpm, Volta, asdf, npm, + * mise, etc. become visible even when the app was opened outside a shell. + * + * Windows gets the same PATH enrichment, then adds shim resolution for npm's + * `.cmd`/`.ps1` launchers so callers can keep passing plain names like + * `codex` or `claude` without knowing how the package manager installed them. + */ +export function resolveCliLaunch( + name: string, + args: readonly string[], + opts: CliLaunchOptions = {}, +): CliLaunchSpec { + const platform = opts.platform ?? process.platform; + const env = enrichedEnv(opts.env ?? process.env, { + platform, + homedir: opts.homedir, + }); + const resolved = resolveCliSpawn(name, args, { platform, env }); + return { ...resolved, env }; +} diff --git a/app/src/main/identity/codexLogin.ts b/app/src/main/identity/codexLogin.ts index 260dfabd..e07cd9f1 100644 --- a/app/src/main/identity/codexLogin.ts +++ b/app/src/main/identity/codexLogin.ts @@ -18,7 +18,7 @@ import { shell } from 'electron'; import * as pty from 'node-pty'; import { mainLogger } from '../logger'; -import { enrichedEnv } from '../hl/engines/pathEnrich'; +import { resolveCliLaunch } from '../hl/engines/pathEnrich'; const LOGIN_BIN = 'codex'; const TIMEOUT_MS = 15 * 60 * 1000; // Device-auth codes expire in 15m; cap plain-OAuth at the same. @@ -50,6 +50,18 @@ export interface CodexLoginResult { deviceCode?: string; } +export function codexLoginPtySpawnSpec( + args: readonly string[], + opts: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {}, +): { command: string; args: string[]; env: { [key: string]: string } } { + const resolved = resolveCliLaunch(LOGIN_BIN, args, { env: opts.env, platform: opts.platform }); + return { + command: resolved.command, + args: resolved.args, + env: resolved.env as { [key: string]: string }, + }; +} + // Only one login attempt at a time. A second call kills the previous PTY // (user clicked "login" twice or restarted the flow) before spawning again. let activePty: pty.IPty | null = null; @@ -86,11 +98,12 @@ export function runCodexDeviceLogin(opts: CodexLoginOptions = {}): Promise { const adapter = getAdapter('codex'); if (!adapter) throw new Error('codex adapter not registered'); + const installed = await adapter.probeInstalled(); + if (!installed.installed) throw new Error(installed.error ?? 'Install Codex CLI before signing in.'); const auth = await adapter.probeAuthed(); if (!auth.authed) throw new Error('Codex CLI is not logged in. Run `codex login` first.'); mainLogger.info('onboardingHandlers.useCodex.ok'); diff --git a/app/src/preload/onboarding.ts b/app/src/preload/onboarding.ts index b2ac56d7..e20b0dc1 100644 --- a/app/src/preload/onboarding.ts +++ b/app/src/preload/onboarding.ts @@ -90,6 +90,9 @@ const onboardingAPI = { openCodexLoginTerminal: (opts?: { deviceAuth?: boolean }): Promise<{ opened: boolean; error?: string; verificationUrl?: string; deviceCode?: string }> => ipcRenderer.invoke('onboarding:open-codex-login-terminal', opts), + installEngine: (engineId: 'claude-code' | 'codex'): Promise<{ opened: boolean; error?: string; command?: string; displayName?: string }> => + ipcRenderer.invoke('sessions:engine-install', engineId), + openExternal: (url: string): Promise<{ opened: boolean }> => ipcRenderer.invoke('onboarding:open-external', url), diff --git a/app/src/renderer/onboarding/OnboardingApp.tsx b/app/src/renderer/onboarding/OnboardingApp.tsx index 06509a57..f7dd31e1 100644 --- a/app/src/renderer/onboarding/OnboardingApp.tsx +++ b/app/src/renderer/onboarding/OnboardingApp.tsx @@ -86,6 +86,7 @@ declare global { }>; useCodex: () => Promise<{ ok: boolean }>; openCodexLoginTerminal: (opts?: { deviceAuth?: boolean }) => Promise<{ opened: boolean; error?: string; verificationUrl?: string; deviceCode?: string }>; + installEngine: (engineId: 'claude-code' | 'codex') => Promise<{ opened: boolean; error?: string; command?: string; displayName?: string }>; openExternal: (url: string) => Promise<{ opened: boolean }>; requestNotifications: () => Promise<{ supported: boolean }>; platform: string; @@ -322,6 +323,7 @@ export function OnboardingApp() { // paste. Populated by handleStartCodexLogin and cleared once auth completes. const [codexDeviceCode, setCodexDeviceCode] = useState(null); const [codexVerificationUrl, setCodexVerificationUrl] = useState(null); + const [installingEngine, setInstallingEngine] = useState<'claude-code' | 'codex' | null>(null); const refreshClaudeStatus = useCallback(async () => { try { @@ -392,6 +394,7 @@ export function OnboardingApp() { }, [waitingForCodexLogin, refreshCodexStatus]); const handleUseCodex = useCallback(async () => { + if (!codex?.installed) return; console.log('[onboarding] handleUseCodex: invoking useCodex'); try { const res = await window.onboardingAPI.useCodex(); @@ -402,9 +405,10 @@ export function OnboardingApp() { } catch (err) { console.error('[onboarding] handleUseCodex: useCodex threw', err); } - }, []); + }, [codex?.installed]); const handleStartCodexLogin = useCallback(async (opts?: { deviceAuth?: boolean }) => { + if (!codex?.installed) return; console.log('[onboarding] handleStartCodexLogin: invoking openCodexLoginTerminal', opts); setWaitingForCodexLogin(true); setCodexDeviceCode(null); @@ -423,7 +427,7 @@ export function OnboardingApp() { console.error('[onboarding] openCodexLoginTerminal threw', err); setWaitingForCodexLogin(false); } - }, []); + }, [codex?.installed]); // Click handlers for the card + the explicit device-auth fallback link. // Keeping these as plain references so React binds identity-stable functions. @@ -487,9 +491,44 @@ export function OnboardingApp() { } }, [refreshClaudeStatus]); + const handleInstallEngine = useCallback(async (engineId: 'claude-code' | 'codex') => { + setInstallingEngine(engineId); + try { + const res = await window.onboardingAPI.installEngine(engineId); + if (!res.opened) { + console.warn('[onboarding] installEngine failed', engineId, res.error); + setInstallingEngine(null); + return; + } + window.setTimeout(() => { + if (engineId === 'claude-code') void refreshClaudeStatus(); + if (engineId === 'codex') void refreshCodexStatus(); + }, 3000); + window.setTimeout(() => { + setInstallingEngine((current) => (current === engineId ? null : current)); + }, 120000); + } catch (err) { + console.error('[onboarding] installEngine threw', engineId, err); + setInstallingEngine(null); + } + }, [refreshClaudeStatus, refreshCodexStatus]); + const handleInstallClaudeCode = useCallback(() => { - window.onboardingAPI.openExternal?.('https://docs.anthropic.com/en/docs/claude-code/overview'); - }, []); + void handleInstallEngine('claude-code'); + }, [handleInstallEngine]); + + const handleInstallCodex = useCallback(() => { + void handleInstallEngine('codex'); + }, [handleInstallEngine]); + + const claudeCodeReady = Boolean(claudeCode?.installed && claudeCode.authed); + const codexReady = Boolean(codex?.installed && codex.authed); + const hasUsableAnthropicKey = Boolean(claudeCode?.installed && apiKey.trim()); + const hasUsableOpenaiKey = Boolean(codex?.installed && openaiKey.trim()); + + const canContinueProviderSetup = claudeCodeReady || codexReady || hasUsableAnthropicKey || hasUsableOpenaiKey; + const installingClaudeCode = installingEngine === 'claude-code'; + const installingCodex = installingEngine === 'codex'; const [accelerator, setAccelerator] = useState(() => defaultGlobalCmdbarAccelerator(window.onboardingAPI.platform)); const [recording, setRecording] = useState(false); @@ -612,27 +651,27 @@ export function OnboardingApp() { // advances. Works alongside the provider-subscription path (usingX), which // doesn't need a save step. Verbose logging so we can trace the path taken. const [stepSaving, setStepSaving] = useState(false); - const stepCanContinue = Boolean(claudeCode?.authed) || Boolean(codex?.authed) || apiKey.trim().length > 0 || openaiKey.trim().length > 0; const handleStepSaveAndContinue = useCallback(async () => { console.log('[onboarding] handleStepSaveAndContinue', { - claudeAuthed: Boolean(claudeCode?.authed), - codexAuthed: Boolean(codex?.authed), + claudeAuthed: claudeCodeReady, + codexAuthed: codexReady, hasAnthropicKey: apiKey.trim().length > 0, hasOpenaiKey: openaiKey.trim().length > 0, }); + if (!canContinueProviderSetup) return; setStepSaving(true); try { const ops: Promise[] = []; - if (apiKey.trim()) ops.push(window.onboardingAPI.saveApiKey(apiKey.trim())); - if (openaiKey.trim()) ops.push(window.onboardingAPI.saveOpenAIKey(openaiKey.trim())); + if (claudeCode?.installed && apiKey.trim()) ops.push(window.onboardingAPI.saveApiKey(apiKey.trim())); + if (codex?.installed && openaiKey.trim()) ops.push(window.onboardingAPI.saveOpenAIKey(openaiKey.trim())); if (ops.length > 0) { console.log('[onboarding] handleStepSaveAndContinue: saving', ops.length, 'key(s)'); await Promise.all(ops); } - if (apiKey.trim()) { + if (claudeCode?.installed && apiKey.trim()) { window.onboardingAPI.capture?.('onboarding_provider_selected', { provider: 'anthropic-key' }); } - if (openaiKey.trim()) { + if (codex?.installed && openaiKey.trim()) { window.onboardingAPI.capture?.('onboarding_provider_selected', { provider: 'openai-key' }); } console.log('[onboarding] handleStepSaveAndContinue: advancing to notifications step'); @@ -642,7 +681,7 @@ export function OnboardingApp() { } finally { setStepSaving(false); } - }, [claudeCode?.authed, codex?.authed, apiKey, openaiKey]); + }, [apiKey, canContinueProviderSetup, claudeCode?.installed, claudeCodeReady, codex?.installed, codexReady, openaiKey]); const handleFinish = useCallback(async () => { window.onboardingAPI.capture?.('onboarding_completed'); @@ -933,11 +972,11 @@ export function OnboardingApp() {

Vendor setup

- Sign in with Claude Code or Codex, or paste an API key. Credentials are stored locally in the system keychain. + Install each provider CLI once, then sign in or add that provider’s API key. Credentials are stored locally in the system keychain.

{/* Installed + authed → selectable card. Click flips to configured state. */} - {claudeCode?.installed && claudeCode?.authed && ( + {claudeCodeReady && (
@@ -954,28 +993,30 @@ export function OnboardingApp() { {/* Not authed → one card with two interior options: subscription or API key. Switching once configured happens in Settings. */} - {claudeCode && !claudeCode.authed && !usingClaudeCode && ( + {claudeCode && !claudeCodeReady && !usingClaudeCode && (
-
- - -
+ {claudeCode.installed && ( +
+ + +
+ )}
{!showAnthropicInput && claudeCode.installed && ( @@ -1002,26 +1043,29 @@ export function OnboardingApp() { )} - {!showAnthropicInput && !claudeCode.installed && ( + {!claudeCode.installed && ( )} - {showAnthropicInput && ( + {claudeCode.installed && showAnthropicInput && (
@@ -1064,31 +1108,55 @@ export function OnboardingApp() { )} {/* Codex not authed → same merged card pattern as Claude. */} - {codex && !codex.authed && !usingCodex && ( + {codex && !codexReady && !usingCodex && (
-
- - -
+ {codex.installed && ( +
+ + +
+ )}
- {!showOpenaiInput && ( + {!codex.installed && ( + + )} + + {codex.installed && !showOpenaiInput && ( <>
{waitingForCodexLogin ? '↻' : '›'}
@@ -1139,7 +1207,7 @@ export function OnboardingApp() { )} - {showOpenaiInput && ( + {codex.installed && showOpenaiInput && (
{stepSaving ? 'Saving...' : 'Save & Continue'} diff --git a/app/tests/unit/hl/installer.test.ts b/app/tests/unit/hl/installer.test.ts new file mode 100644 index 00000000..6efb4e51 --- /dev/null +++ b/app/tests/unit/hl/installer.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { windowsInstallerSpawnSpec } from '../../../src/main/hl/engines/installer'; + +describe('engine installer Windows launcher', () => { + it('runs the generated install script directly instead of routing through cmd start', () => { + const scriptPath = 'C:\\Users\\Ada Lovelace\\AppData\\Local\\Temp\\browser-use-install-123\\install.cmd'; + + const spec = windowsInstallerSpawnSpec(scriptPath, { + ComSpec: 'C:\\Windows\\System32\\cmd.exe', + }); + + expect(spec).toEqual({ + command: 'C:\\Windows\\System32\\cmd.exe', + args: ['/d', '/k', scriptPath], + }); + expect(spec.args.join(' ')).not.toContain('start'); + expect(spec.args.join(' ')).not.toContain('Codex Installer'); + }); +}); diff --git a/app/tests/unit/identity/codexLogin.test.ts b/app/tests/unit/identity/codexLogin.test.ts new file mode 100644 index 00000000..0198a5e3 --- /dev/null +++ b/app/tests/unit/identity/codexLogin.test.ts @@ -0,0 +1,31 @@ +import fs from 'node:fs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { codexLoginPtySpawnSpec } from '../../../src/main/identity/codexLogin'; + +describe('codex login PTY spawn spec', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('uses the shared Windows shim resolver for npm-installed codex.cmd', () => { + vi.spyOn(fs, 'statSync').mockImplementation((candidate) => ({ + isFile: () => String(candidate).endsWith('codex.cmd'), + }) as fs.Stats); + + const spec = codexLoginPtySpawnSpec(['login'], { + platform: 'win32', + env: { + PATH: 'C:\\Users\\Ada\\AppData\\Roaming\\npm', + ComSpec: 'C:\\Windows\\System32\\cmd.exe', + PATHEXT: '.COM;.EXE;.BAT;.CMD', + }, + }); + + expect(spec.command).toBe('C:\\Windows\\System32\\cmd.exe'); + expect(spec.args[0]).toBe('/d'); + expect(spec.args[1]).toBe('/s'); + expect(spec.args[2]).toBe('/c'); + expect(spec.args[3]).toContain('codex.cmd'); + expect(spec.args[3]).toContain('login'); + }); +}); diff --git a/app/tests/unit/identity/onboardingHandlers.test.ts b/app/tests/unit/identity/onboardingHandlers.test.ts index 5edfa051..f618a5f5 100644 --- a/app/tests/unit/identity/onboardingHandlers.test.ts +++ b/app/tests/unit/identity/onboardingHandlers.test.ts @@ -24,6 +24,7 @@ const { mockGetGlobalCmdbarAccelerator, mockRegisterHotkeys, mockSetGlobalCmdbarAccelerator, + mockGetAdapter, hotkeyState, } = vi.hoisted(() => { const state = { accelerator: 'CommandOrControl+Shift+Space' }; @@ -44,6 +45,7 @@ const { state.accelerator = accelerator; return { ok: true, accelerator }; }), + mockGetAdapter: vi.fn(), }; }); @@ -83,6 +85,10 @@ vi.mock('../../../src/main/hotkeys', () => ({ setGlobalCmdbarAccelerator: mockSetGlobalCmdbarAccelerator, })); +vi.mock('../../../src/main/hl/engines', () => ({ + getAdapter: mockGetAdapter, +})); + const mockSetPassword = vi.fn(async () => {}); vi.mock('keytar', () => ({ setPassword: mockSetPassword, @@ -144,6 +150,7 @@ describe('onboardingHandlers.ts', () => { return { ok: true, accelerator }; }); handlers.clear(); + mockGetAdapter.mockReset(); accountStore = makeAccountStore(); onboardingWindow = makeWindow(); openShellWindow = vi.fn(() => ({ id: 2 })); @@ -183,6 +190,52 @@ describe('onboardingHandlers.ts', () => { }); }); + describe('codex onboarding handlers', () => { + it('reports Codex as unauthenticated when the CLI is not installed', async () => { + const probeInstalled = vi.fn(async () => ({ installed: false, error: 'codex not found on PATH' })); + const probeAuthed = vi.fn(async () => ({ authed: true })); + mockGetAdapter.mockReturnValue({ + probeInstalled, + probeAuthed, + }); + + const result = await invokeHandler('onboarding:detect-codex'); + + expect(result).toEqual({ + available: false, + installed: false, + authed: false, + version: null, + error: 'codex not found on PATH', + }); + expect(probeAuthed).not.toHaveBeenCalled(); + }); + + it('does not start Codex login when the CLI is not installed', async () => { + const openLoginInTerminal = vi.fn(); + mockGetAdapter.mockReturnValue({ + probeInstalled: vi.fn(async () => ({ installed: false, error: 'codex not found on PATH' })), + openLoginInTerminal, + }); + + const result = await invokeHandler('onboarding:open-codex-login-terminal'); + + expect(result).toEqual({ opened: false, error: 'codex not found on PATH' }); + expect(openLoginInTerminal).not.toHaveBeenCalled(); + }); + + it('does not accept Codex as selected when the CLI is not installed', async () => { + const probeAuthed = vi.fn(async () => ({ authed: true })); + mockGetAdapter.mockReturnValue({ + probeInstalled: vi.fn(async () => ({ installed: false, error: 'codex not found on PATH' })), + probeAuthed, + }); + + await expect(invokeHandler('onboarding:use-codex')).rejects.toThrow('codex not found on PATH'); + expect(probeAuthed).not.toHaveBeenCalled(); + }); + }); + describe('onboarding:complete', () => { it('saves onboarding_completed_at to account store', async () => { await invokeHandler('onboarding:complete'); diff --git a/app/tests/unit/pathEnrich.test.ts b/app/tests/unit/pathEnrich.test.ts index 4f647613..74bc7aa1 100644 --- a/app/tests/unit/pathEnrich.test.ts +++ b/app/tests/unit/pathEnrich.test.ts @@ -1,7 +1,14 @@ -import { describe, expect, it } from 'vitest'; -import { enrichedPath } from '../../src/main/hl/engines/pathEnrich'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { enrichedPath, resolveCliLaunch } from '../../src/main/hl/engines/pathEnrich'; describe('pathEnrich', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('keeps Windows PATH semicolon-delimited and adds common user CLI dirs', () => { const result = enrichedPath('C:\\Windows\\System32;C:\\Tools', { platform: 'win32', @@ -16,6 +23,28 @@ describe('pathEnrich', () => { expect(parts).toContain('C:\\Users\\Ada\\AppData\\Roaming\\npm'); expect(parts).toContain('C:\\Users\\Ada\\.bcode\\bin'); expect(parts).toContain('C:\\Users\\Ada\\.cargo\\bin'); + expect(parts).toContain('C:\\Users\\Ada\\AppData\\Local\\Microsoft\\WindowsApps'); + expect(parts).toContain('C:\\Users\\Ada\\scoop\\shims'); + expect(parts).toContain('C:\\ProgramData\\chocolatey\\bin'); + }); + + it('combines Windows Path and PATH values before adding fallbacks', () => { + const result = enrichedPath(undefined, { + platform: 'win32', + homedir: 'C:\\Users\\Ada', + env: { + Path: 'C:\\Windows\\System32', + PATH: 'C:\\Tools', + APPDATA: 'C:\\Users\\Ada\\AppData\\Roaming', + LOCALAPPDATA: 'C:\\Users\\Ada\\AppData\\Local', + PNPM_HOME: 'C:\\Users\\Ada\\AppData\\Local\\pnpm', + }, + }); + + const parts = result.split(';'); + expect(parts.slice(0, 2)).toEqual(['C:\\Windows\\System32', 'C:\\Tools']); + expect(parts).toContain('C:\\Users\\Ada\\AppData\\Roaming\\npm'); + expect(parts).toContain('C:\\Users\\Ada\\AppData\\Local\\pnpm'); }); it('uses POSIX delimiters for Linux-style paths', () => { @@ -29,5 +58,83 @@ describe('pathEnrich', () => { expect(parts.slice(0, 2)).toEqual(['/usr/bin', '/bin']); expect(parts).toContain('/home/ada/.local/bin'); expect(parts).toContain('/home/ada/.cargo/bin'); + expect(parts).toContain('/home/ada/.asdf/shims'); + expect(parts).toContain('/home/ada/.local/share/mise/shims'); + expect(parts).toContain('/home/ada/.local/share/pnpm'); + expect(parts).toContain('/snap/bin'); + }); + + it('adds existing POSIX Node version-manager bins', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'path-enrich-')); + try { + const home = path.join(tmp, 'home'); + const nvmBin = path.join(home, '.nvm', 'versions', 'node', 'v22.12.0', 'bin'); + const fnmBin = path.join(home, '.local', 'share', 'fnm', 'node-versions', 'v20.18.1', 'installation', 'bin'); + fs.mkdirSync(nvmBin, { recursive: true }); + fs.mkdirSync(fnmBin, { recursive: true }); + + const result = enrichedPath('/usr/bin', { + platform: 'darwin', + homedir: home, + env: { + SHELL: path.join(tmp, 'missing-shell'), + NPM_CONFIG_PREFIX: path.join(home, '.npm-prefix'), + PNPM_HOME: path.join(home, 'Library', 'pnpm'), + }, + }); + + const parts = result.split(':'); + expect(parts).toContain(nvmBin); + expect(parts).toContain(fnmBin); + expect(parts).toContain(path.join(home, '.npm-prefix', 'bin')); + expect(parts).toContain(path.join(home, 'Library', 'pnpm')); + expect(parts).toContain(path.join(home, '.volta', 'bin')); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('routes macOS/Linux CLI launches through a PATH-enriched env without a shell', () => { + const spec = resolveCliLaunch('codex', ['--version'], { + platform: 'linux', + homedir: '/home/ada', + env: { PATH: '/usr/bin' }, + }); + + expect(spec.command).toBe('codex'); + expect(spec.args).toEqual(['--version']); + expect(spec.viaCmdShell).toBe(false); + expect(spec.spawnOptions).toEqual({}); + expect(spec.env.PATH?.split(':')).toEqual(expect.arrayContaining([ + '/usr/bin', + '/home/ada/.local/bin', + '/home/ada/.cargo/bin', + '/home/ada/.asdf/shims', + ])); + }); + + it('routes Windows CLI launches through the same PATH enrichment and shim resolver', () => { + vi.spyOn(fs, 'statSync').mockImplementation((candidate) => ({ + isFile: () => String(candidate).endsWith('codex.cmd'), + isDirectory: () => false, + }) as fs.Stats); + + const spec = resolveCliLaunch('codex', ['login'], { + platform: 'win32', + homedir: 'C:\\Users\\Ada', + env: { + PATH: 'C:\\Users\\Ada\\AppData\\Roaming\\npm', + ComSpec: 'C:\\Windows\\System32\\cmd.exe', + PATHEXT: '.COM;.EXE;.BAT;.CMD', + }, + }); + + expect(spec.command).toBe('C:\\Windows\\System32\\cmd.exe'); + expect(spec.args.slice(0, 3)).toEqual(['/d', '/s', '/c']); + expect(spec.args[3]).toContain('codex.cmd'); + expect(spec.args[3]).toContain('login'); + expect(spec.env.PATH?.split(';')).toContain('C:\\Users\\Ada\\AppData\\Roaming\\npm'); + expect(spec.env.PATH?.split(';')).toContain('C:\\Users\\Ada\\.volta\\bin'); + expect(spec.spawnOptions).toEqual({ windowsVerbatimArguments: true }); }); }); From 7d25421b2048c1483a013f02fc0456b0fd4e9e46 Mon Sep 17 00:00:00 2001 From: Reagan Hsu Date: Thu, 7 May 2026 13:05:10 -0700 Subject: [PATCH 2/3] Bundle Browser Harness JS for desktop agents Agents need a local CDP runtime that does not depend on legacy helper tools or global skill installation. This vendors the upstream browser-harness-js runtime and interaction recipes, while excluding the bundled SDK from app TypeScript and ESLint passes because it is a runtime asset rather than app source. Constraint: App-spawned agents run from userData/harness and need the CLI available relative to that harness. Rejected: Install the upstream skill globally at task time | mutates the user's toolchain and makes launches depend on external setup. Confidence: high Scope-risk: moderate Directive: Treat browser-harness-js/sdk as vendored runtime; update from upstream intentionally, not via incidental formatting or lint fixes. Tested: cd app && npm run typecheck; targeted Vitest harness and adapter tests. Not-tested: Fresh packaged production app launch. --- app/eslint.config.js | 1 + .../main/hl/stock/browser-harness-js/SKILL.md | 248 + .../browser-harness-js/sdk/browser-harness-js | 137 + .../stock/browser-harness-js/sdk/generated.ts | 15160 ++++++++++++++++ .../hl/stock/browser-harness-js/sdk/repl.ts | 140 + .../stock/browser-harness-js/sdk/session.ts | 430 + .../hl/stock/interaction-skills/connection.md | 98 + .../hl/stock/interaction-skills/cookies.md | 61 + .../cross-origin-iframes.md | 67 + .../hl/stock/interaction-skills/dialogs.md | 75 + .../hl/stock/interaction-skills/downloads.md | 77 + .../stock/interaction-skills/drag-and-drop.md | 56 + .../hl/stock/interaction-skills/dropdowns.md | 79 + .../hl/stock/interaction-skills/iframes.md | 69 + .../interaction-skills/network-requests.md | 109 + .../stock/interaction-skills/print-as-pdf.md | 69 + .../stock/interaction-skills/screenshots.md | 54 + .../hl/stock/interaction-skills/scrolling.md | 75 + .../hl/stock/interaction-skills/shadow-dom.md | 80 + .../main/hl/stock/interaction-skills/tabs.md | 75 + .../hl/stock/interaction-skills/uploads.md | 65 + .../hl/stock/interaction-skills/viewport.md | 70 + app/tsconfig.json | 2 +- 23 files changed, 17296 insertions(+), 1 deletion(-) create mode 100644 app/src/main/hl/stock/browser-harness-js/SKILL.md create mode 100755 app/src/main/hl/stock/browser-harness-js/sdk/browser-harness-js create mode 100644 app/src/main/hl/stock/browser-harness-js/sdk/generated.ts create mode 100644 app/src/main/hl/stock/browser-harness-js/sdk/repl.ts create mode 100644 app/src/main/hl/stock/browser-harness-js/sdk/session.ts create mode 100644 app/src/main/hl/stock/interaction-skills/connection.md create mode 100644 app/src/main/hl/stock/interaction-skills/cookies.md create mode 100644 app/src/main/hl/stock/interaction-skills/cross-origin-iframes.md create mode 100644 app/src/main/hl/stock/interaction-skills/dialogs.md create mode 100644 app/src/main/hl/stock/interaction-skills/downloads.md create mode 100644 app/src/main/hl/stock/interaction-skills/drag-and-drop.md create mode 100644 app/src/main/hl/stock/interaction-skills/dropdowns.md create mode 100644 app/src/main/hl/stock/interaction-skills/iframes.md create mode 100644 app/src/main/hl/stock/interaction-skills/network-requests.md create mode 100644 app/src/main/hl/stock/interaction-skills/print-as-pdf.md create mode 100644 app/src/main/hl/stock/interaction-skills/screenshots.md create mode 100644 app/src/main/hl/stock/interaction-skills/scrolling.md create mode 100644 app/src/main/hl/stock/interaction-skills/shadow-dom.md create mode 100644 app/src/main/hl/stock/interaction-skills/tabs.md create mode 100644 app/src/main/hl/stock/interaction-skills/uploads.md create mode 100644 app/src/main/hl/stock/interaction-skills/viewport.md diff --git a/app/eslint.config.js b/app/eslint.config.js index ed4602cd..d8afb13d 100644 --- a/app/eslint.config.js +++ b/app/eslint.config.js @@ -17,6 +17,7 @@ module.exports = [ 'dist/**', 'node_modules/**', 'docker/agent/dist/**', + 'src/main/hl/stock/browser-harness-js/sdk/**', // JS files were never linted under the old --ext .ts,.tsx flag '**/*.js', '**/*.mjs', diff --git a/app/src/main/hl/stock/browser-harness-js/SKILL.md b/app/src/main/hl/stock/browser-harness-js/SKILL.md new file mode 100644 index 00000000..3da37b5c --- /dev/null +++ b/app/src/main/hl/stock/browser-harness-js/SKILL.md @@ -0,0 +1,248 @@ +--- +name: cdp +description: Drive Browser Use Desktop's assigned Chromium target via the DevTools Protocol from JavaScript. Run snippets through the bundled `browser-harness-js` CLI; it auto-spawns a long-lived Bun HTTP server holding a CDP `Session`, and every call executes against the same persistent connection. +--- + +# CDP — `browser-harness-js` skill + +Custom codegen'd CDP SDK (every method from browser_protocol.json + js_protocol.json gets a typed wrapper) plus a tiny HTTP server that holds one persistent CDP `Session`. The `browser-harness-js` CLI auto-starts the server on first use and forwards JS snippets to it. + +Browser Use Desktop bundles the runtime under `./browser-harness-js/sdk/` and puts that directory on PATH for you. Do not run `npx skills add` or create global symlinks from inside the desktop harness. + +## First use in Browser Use Desktop + +Connect to the app-assigned target before page-level calls: + +```bash +browser-harness-js 'await connectToAssignedTarget()' +``` + +`connectToAssignedTarget()` reads `BU_TARGET_ID` and `BU_CDP_PORT`, attaches the assigned target when possible, and enables the common Page/DOM/Runtime/Network domains. The CLI auto-installs `bun` on first run if it is missing. Set `BROWSER_HARNESS_SKIP_BUN_INSTALL=1` to opt out. + +## How to use + +Just run `browser-harness-js ''`. The first call spawns the server in the background; subsequent calls hit the same process and so reuse the same `session`, the same WebSocket to Chrome, and any globals you set. + +```bash +browser-harness-js 'await connectToAssignedTarget()' +browser-harness-js 'await session.Page.navigate({url:"https://example.com"})' +browser-harness-js '(await session.Runtime.evaluate({expression:"document.title",returnByValue:true})).result.value' +``` + +Output is the **raw result content** — no `{ok,result}` envelope. + +| Result type | stdout | +|---|---| +| string | bare text, no JSON quotes (e.g. `Example Domain`) | +| number / boolean | `42`, `true` | +| object / array (non-empty) | compact JSON (e.g. `{"frameId":"..."}`, `[1,2,3]`) | +| `undefined` / `null` / `""` / `{}` / `[]` | empty (no output) | + +**Errors** go to **stderr**, exit code `1`. The CDP error message and JS stack are printed verbatim, e.g.: +``` +Error: CDP -32602: invalid params + at _call (.../session.ts:117:33) + ... +``` +Detect failure with `if browser-harness-js '...'; then ...; else handle_error; fi` or by checking `$?`. + +**Multi-line snippets via stdin (heredoc).** Important: a multi-statement snippet does NOT auto-return the last expression — write `return X` explicitly. Single-expression snippets passed as the first argument DO auto-return. + +```bash +browser-harness-js <<'EOF' +const tabs = await listPageTargets(); +globalThis.tid = tabs[0].targetId; +await session.use(globalThis.tid); +return globalThis.tid; +EOF +``` + +## CLI commands + +| Command | Behavior | +|---|---| +| `browser-harness-js ''` | Auto-start server if needed, eval the JS, print result. | +| `browser-harness-js </DevToolsActivePort` directly. | +| `{ wsUrl }` | You already have `ws://…/devtools/browser/` (e.g. piped from elsewhere). | + +```js +await session.connect({ profileDir: '/Users//Library/Application Support/Google/Chrome' }) +await session.connect({ wsUrl: 'ws://127.0.0.1:9222/devtools/browser/' }) +``` + +Profile paths by OS — use these with `{ profileDir }`: +- macOS: `~/Library/Application Support/` (e.g. `Google/Chrome`, `Comet`, `BraveSoftware/Brave-Browser`, `Arc/User Data`) +- Linux: `~/.config/` (e.g. `google-chrome`, `chromium`, `BraveSoftware/Brave-Browser`) +- Windows: `%LOCALAPPDATA%\\User Data` (e.g. `Google\Chrome`, `Microsoft\Edge`, `BraveSoftware\Brave-Browser`) + +Per-candidate WS-open timeout defaults to **5s** — live browsers answer with open/close within ~100ms, so 5s is already generous. The only case where 5s is too short is when Chrome is showing the **Allow** popup and waiting on the user to click. If you expect that, pass `timeoutMs: 30000`: + +```js +await session.connect({ profileDir: '/Users//Library/Application Support/Google/Chrome', timeoutMs: 30_000 }) +``` + +**If you see `No detected browser accepted a connection`** — the browsers have `DevToolsActivePort` files but none are currently serving WS. Most common cause: remote-debugging is enabled but the user hasn't clicked **Allow** on the prompt yet. Tell them to click Allow, then retry (or bump `timeoutMs`). + +### Picking a target (tab) + +After `connect()`, call `session.use(targetId)` once; subsequent page-level calls (Page/DOM/Runtime/Network/etc.) auto-route to that target's sessionId. `Browser.*` and `Target.*` calls always hit the browser endpoint. + +```js +const tabs = await listPageTargets() // no args; uses the connected session +const sid = await session.use(tabs[0].targetId) +await session.Page.enable() +await session.Page.navigate({ url: 'https://example.com' }) +``` + +`listPageTargets()` uses CDP's `Target.getTargets` (not `/json`), so it works on Chrome 144+ too. It already filters out `chrome://` and `devtools://` URLs. Equivalent raw call: + +```js +const { targetInfos } = await session.Target.getTargets({}) +const tabs = targetInfos.filter(t => t.type === 'page' && !t.url.startsWith('chrome://') && !t.url.startsWith('devtools://')) +``` + +To switch tabs: `session.use(otherTargetId)`. To detach: `session.setActiveSession(undefined)`. + +### Events + +```js +// Subscribe (returns an unsubscribe fn) +const off = session.onEvent((method, params, sessionId) => { ... }) + +// Or wait for a single matching event with optional predicate + timeout +await session.Network.enable() +const ev = await session.waitFor( + 'Page.frameNavigated', + (p) => p.frame.url.includes('example.com'), + 10_000 +) +``` + +### Persisting state across calls + +Each snippet runs inside its own async wrapper, so its `let`/`const` declarations vanish when it returns. To carry data forward, attach to `globalThis`: + +```bash +browser-harness-js '(await listPageTargets()).forEach((t,i)=>globalThis["tab"+i]=t.targetId)' +browser-harness-js 'await session.use(globalThis.tab0)' +browser-harness-js 'await session.Page.navigate({url:"https://example.com"})' +``` + +`session` itself, the active sessionId, and event subscribers are already preserved by the server — globals are only needed for ad-hoc data. + +## Connecting to a running Chrome (chrome://inspect flow) + +When attaching to the user's already-running browser: + +1. **Try `await session.connect()` first** (no args) — auto-detect handles every Chromium-based browser via `DevToolsActivePort`. If it returns, you're done. +2. **If auto-detect fails** with `No running browser with remote debugging detected`, the user needs to turn it on. Open the inspect page: + ```bash + # macOS — prefer AppleScript over `open -a` (reuses current profile, avoids the profile picker) + osascript -e 'open location "chrome://inspect/#remote-debugging"' + + # Linux + google-chrome 'chrome://inspect/#remote-debugging' # or: chromium, google-chrome-stable + + # Windows (PowerShell) + Start-Process chrome 'chrome://inspect/#remote-debugging' + ``` + Only macOS's AppleScript path avoids the profile picker; Linux/Windows may prompt the user to pick a profile first. +3. **Tick "Discover network targets"** in chrome://inspect, then click **Allow** when Chrome prompts. +4. **If auto-detect picks the wrong browser** (multiple running, you want a specific one): list them with `await detectBrowsers()`, then `await session.connect({ profileDir: })`. +5. **If `session.connect()` returns `No detected browser accepted a connection`**, the user has remote-debugging on but hasn't clicked **Allow** yet. Tell them to click it and retry, or pass `timeoutMs: 30000` to wait for the click. + +## Working with targets (tabs) + +- **Filter Chrome internals.** `listPageTargets()` already drops `chrome://` and `devtools://` URLs. If you call `Target.getTargets()` directly, filter manually. +- **CDP target order ≠ visible tab-strip order.** When the user says "the first tab I can see", use a screenshot or page title to identify it — `Target.activateTarget` only switches to a known targetId. + +## Looking up a method + +The full typed surface is in `/sdk/generated.ts` (~655 KB, only loaded if you read it). Each method has its CDP description as a JSDoc comment plus typed `*Params` / `*Return` interfaces in per-domain namespaces. + +```bash +grep -n "navigate" /sdk/generated.ts | head +``` + +## Regenerating the SDK + +This is a maintenance-only workflow, not a normal task step. Browser Use +Desktop already bundles the generated SDK. Do not regenerate or patch it during +ordinary browser tasks unless the user explicitly asks, or a confirmed bundled +runtime defect blocks the task. + +When the upstream protocol JSONs change, replace `sdk/browser_protocol.json` and/or `sdk/js_protocol.json` and re-run: + +```bash +cd /sdk && bun gen.ts +browser-harness-js --restart # pick up the new bindings +``` + +## Files + +All paths are relative to `` (the install path — see top of this doc). + +- `/usr/local/bin/browser-harness-js` → `/sdk/browser-harness-js` (the CLI) +- `sdk/repl.ts` — HTTP server (`Bun.serve` on `127.0.0.1:9876`) +- `sdk/session.ts` — `Session` class (transport, connect, target routing, events) +- `sdk/generated.ts` — codegen output: every CDP method as a typed wrapper +- `sdk/gen.ts` — codegen script +- `sdk/{browser,js}_protocol.json` — upstream protocol (vendored) diff --git a/app/src/main/hl/stock/browser-harness-js/sdk/browser-harness-js b/app/src/main/hl/stock/browser-harness-js/sdk/browser-harness-js new file mode 100755 index 00000000..778dcc51 --- /dev/null +++ b/app/src/main/hl/stock/browser-harness-js/sdk/browser-harness-js @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# browser-harness-js — eval JS in the persistent CDP REPL. Auto-starts the REPL on first use. +# +# Usage: +# browser-harness-js 'await session.connect({port:9222})' +# browser-harness-js 'await session.Page.navigate({url:"https://example.com"})' +# browser-harness-js <<'EOF' +# const t = await listPageTargets("localhost", 9222); +# globalThis.tid = t[0].targetId; +# await session.use(globalThis.tid); +# globalThis.tid +# EOF +# +# browser-harness-js --status # is the REPL running? prints health JSON +# browser-harness-js --stop # gracefully shut it down +# browser-harness-js --logs # tail the REPL log +# browser-harness-js --restart # stop + start fresh (drops session state) +# browser-harness-js --start # explicit start (no-op if already running) + +set -euo pipefail + +PORT="${CDP_REPL_PORT:-9876}" +HOST="127.0.0.1" +URL="http://$HOST:$PORT" + +# Resolve repl.ts alongside this script, following symlinks (e.g. /usr/local/bin/browser-harness-js → /sdk/browser-harness-js). +SCRIPT_PATH="${BASH_SOURCE[0]}" +while [ -L "$SCRIPT_PATH" ]; do + SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" + SCRIPT_PATH="$(readlink "$SCRIPT_PATH")" + [[ "$SCRIPT_PATH" != /* ]] && SCRIPT_PATH="$SCRIPT_DIR/$SCRIPT_PATH" +done +REPL="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)/repl.ts" + +LOG="${CDP_REPL_LOG:-/tmp/browser-harness-js.log}" + +# Bootstrap bun if missing — the REPL server is Bun-native. +ensure_bun() { + if command -v bun >/dev/null 2>&1; then return 0; fi + # Handle fresh install: bun's install script drops the binary here but + # PATH isn't updated until the next login shell. + if [ -x "$HOME/.bun/bin/bun" ]; then + export PATH="$HOME/.bun/bin:$PATH" + return 0 + fi + if [ -n "${BROWSER_HARNESS_SKIP_BUN_INSTALL:-}" ]; then + echo "browser-harness-js: bun not found and BROWSER_HARNESS_SKIP_BUN_INSTALL is set." >&2 + echo " Install manually: curl -fsSL https://bun.sh/install | bash" >&2 + return 1 + fi + echo "browser-harness-js: installing bun (one-time, from https://bun.sh/install)..." >&2 + if ! curl -fsSL https://bun.sh/install | bash >&2; then + echo "browser-harness-js: bun install failed. Install manually from https://bun.sh, or set BROWSER_HARNESS_SKIP_BUN_INSTALL=1 to suppress this prompt." >&2 + return 1 + fi + export PATH="$HOME/.bun/bin:$PATH" + command -v bun >/dev/null 2>&1 || { + echo "browser-harness-js: bun installed but not found at \$HOME/.bun/bin/bun." >&2 + return 1 + } +} + +is_up() { + curl -fsS --max-time 1 "$URL/health" >/dev/null 2>&1 +} + +start_repl() { + is_up && return 0 + ensure_bun || return 1 + CDP_REPL_PORT="$PORT" nohup bun "$REPL" >"$LOG" 2>&1 & + for _ in $(seq 1 100); do + sleep 0.1 + is_up && return 0 + done + echo "browser-harness-js: REPL failed to start on $URL (see $LOG)" >&2 + return 1 +} + +post_eval() { + # Capture body + status separately. Body goes to stdout (only if non-empty) + # on 200; otherwise to stderr with non-zero exit. + local out status body + out=$(curl -sS -w '\n___STATUS___%{http_code}' --data-binary "$1" "$URL/eval") + status="${out##*___STATUS___}" + body="${out%$'\n'___STATUS___*}" + if [ "$status" = "200" ]; then + [ -n "$body" ] && printf '%s\n' "$body" + return 0 + else + [ -n "$body" ] && printf '%s\n' "$body" >&2 + return 1 + fi +} + +case "${1:-}" in + --status) + if is_up; then + curl -sS "$URL/health"; echo + else + echo '{"ok":false,"error":"down"}' + exit 1 + fi + ;; + --start) + start_repl + curl -sS "$URL/health"; echo + ;; + --stop) + if is_up; then + curl -s -X POST "$URL/quit" >/dev/null || true + echo '{"ok":true,"stopped":true}' + else + echo '{"ok":true,"stopped":false,"note":"already down"}' + fi + ;; + --restart) + is_up && curl -s -X POST "$URL/quit" >/dev/null 2>&1 || true + sleep 0.2 + start_repl + curl -sS "$URL/health"; echo + ;; + --logs) + exec tail -f "$LOG" + ;; + --help|-h) + sed -n '2,/^set -euo/p' "$0" | sed 's/^#//; s/^ //; /^set -euo/d' + ;; + "") + start_repl + code="$(cat)" + post_eval "$code" + ;; + *) + start_repl + post_eval "$1" + ;; +esac diff --git a/app/src/main/hl/stock/browser-harness-js/sdk/generated.ts b/app/src/main/hl/stock/browser-harness-js/sdk/generated.ts new file mode 100644 index 00000000..30f31c18 --- /dev/null +++ b/app/src/main/hl/stock/browser-harness-js/sdk/generated.ts @@ -0,0 +1,15160 @@ +/* eslint-disable */ +// AUTO-GENERATED by gen.ts. Do not edit by hand. +// Run `bun gen.ts` to regenerate from browser_protocol.json + js_protocol.json. + +export interface Transport { + _call(method: string, params?: unknown): Promise; +} + + +export namespace Accessibility { + + /** Unique accessibility node identifier. */ + export type AXNodeId = string; + + /** Enum of possible property types. */ + export type AXValueType = "boolean" | "tristate" | "booleanOrUndefined" | "idref" | "idrefList" | "integer" | "node" | "nodeList" | "number" | "string" | "computedString" | "token" | "tokenList" | "domRelation" | "role" | "internalRole" | "valueUndefined"; + + /** Enum of possible property sources. */ + export type AXValueSourceType = "attribute" | "implicit" | "style" | "contents" | "placeholder" | "relatedElement"; + + /** Enum of possible native property sources (as a subtype of a particular AXValueSourceType). */ + export type AXValueNativeSourceType = "description" | "figcaption" | "label" | "labelfor" | "labelwrapped" | "legend" | "rubyannotation" | "tablecaption" | "title" | "other"; + + /** A single source for a computed AX property. */ + export interface AXValueSource { + /** What type of source this is. */ + type: Accessibility.AXValueSourceType; + /** The value of this property source. */ + value?: Accessibility.AXValue; + /** The name of the relevant attribute, if any. */ + attribute?: string; + /** The value of the relevant attribute, if any. */ + attributeValue?: Accessibility.AXValue; + /** Whether this source is superseded by a higher priority source. */ + superseded?: boolean; + /** The native markup source for this value, e.g. a `