From aad0a69f531fe324d4b9ad417e496477bbf94e7a Mon Sep 17 00:00:00 2001 From: Reagan Hsu Date: Fri, 8 May 2026 14:55:29 -0700 Subject: [PATCH 1/2] Stabilize Windows Codex flow and gate cookie sync Three Windows-only gaps showed up in real dev testing. Cookie sync silently returned 0 cookies because Chrome 127+ App-Bound Encryption binds keys to the original user-data-dir (a copied profile decrypts to empty), and Chrome 136+ refuses --remote-debugging-port on the default profile, so neither copy-then-launch nor launch-against-original returns plaintext cookies. The provider-step engine installer ran npm through cmd.exe in ways that have been unreliable on real machines. The vendored browser-harness-js script has no Windows extension, so Codex's Rust spawn path falls through to ShellExecuteW and triggers the OS "How do you want to open this file?" dialog every time the model emits a browser-harness-js call. Skip the cookie sync UI on Windows: the onboarding profile step and the Settings Browser Sync section render only on macOS/Linux. Replace the provider-step background install on Windows with a copy-command flow that puts the npm command on the clipboard and polls detect-IPC until the user has run it manually. Fix shim resolution so .cmd wins the PATHEXT tiebreaker over .ps1 (cmd.exe avoids the powershell.exe ENOENT and cold-start observed inside Electron's main process), and route .ps1-only packages through the absolute %SystemRoot%\System32\WindowsPowerShell\ v1.0\powershell.exe path so .ps1 fallback no longer depends on env.Path lookup. Add browser-harness-js.cmd alongside the bash CLI so Codex's CreateProcessW finds a PATHEXT-resolvable entry that delegates to Git Bash and keeps Claude Code's existing bash-direct path untouched. Constraint: macOS and Linux flows must remain byte-identical -- the existing copy-then-launch cookie sync, the background install spawn, and the bare-name harness CLI invocation all keep working on POSIX. Constraint: The .cmd shim must fail cleanly when Git for Windows is missing -- exits 1 with a stderr hint instead of silently regressing into the OS popup. Rejected: Implement Chrome v20 ABE decryption (DPAPI outer + IElevator COM inner) | real native code, brittle to Chromium updates, larger than this branch should carry; Windows is hidden until that lands. Rejected: Always prefer .ps1 over .cmd in the resolver | leaves the Electron-main powershell.exe ENOENT failures in place even when a working .cmd shim is sitting next to it. Rejected: Auto-install Codex/Claude in the background on Windows | cmd.exe quoting around npm shims has been observed to hang or trigger UAC prompts in real installs; manual run + clipboard handoff is more predictable. Confidence: high Scope-risk: narrow Directive: Keep cookie sync hidden on Windows until ABE decryption ships. The onboarding step and Settings tab are gated on window.onboardingAPI.platform === 'win32' and window.electronAPI.shell.platform === 'win32' respectively. Directive: When adding new shim-resolved CLIs that ship .cmd plus .ps1, trust the existing pathEnrich tiebreaker -- do not reintroduce a .ps1-first preference without revisiting the powershell.exe spawn issue. Tested: npm run typecheck Tested: npm run lint Tested: npx vitest run tests/unit/hl/harnessBootstrap.test.ts tests/unit/hl/codexStdinWindows.test.ts tests/unit/pathEnrich.test.ts tests/unit/identity/codexLogin.test.ts tests/unit/onboarding/OnboardingApp.spec.tsx tests/unit/renderer/ipcErrors.test.ts Tested: Live Windows dev run -- Codex sessions complete tool calls without the OS file-association popup; engine-status probes return installed:true once cold-start storms warm up. Not-tested: macOS/Linux behavior on this branch -- only Windows-gated code paths changed, but POSIX hasn't been re-exercised end-to-end. Not-tested: A Windows host without Git for Windows installed -- the .cmd's stderr fallback path is logically correct but hasn't been observed in the wild. --- app/src/main/hl/engines/pathEnrich.ts | 25 +++-- .../sdk/browser-harness-js.cmd | 40 ++++++++ app/src/renderer/hub/ConnectionsPane.tsx | 10 ++ app/src/renderer/hub/SettingsPane.tsx | 13 ++- app/src/renderer/onboarding/OnboardingApp.tsx | 96 +++++++++++++++++-- app/tests/unit/hl/codexStdinWindows.test.ts | 30 +++++- app/tests/unit/hl/harnessBootstrap.test.ts | 12 ++- 7 files changed, 201 insertions(+), 25 deletions(-) create mode 100644 app/src/main/hl/stock/browser-harness-js/sdk/browser-harness-js.cmd diff --git a/app/src/main/hl/engines/pathEnrich.ts b/app/src/main/hl/engines/pathEnrich.ts index 6f47b2e8..e918afe9 100644 --- a/app/src/main/hl/engines/pathEnrich.ts +++ b/app/src/main/hl/engines/pathEnrich.ts @@ -246,14 +246,15 @@ function findOnWindowsPath(name: string, env: NodeJS.ProcessEnv): string | null // PATHEXT is the canonical search order. We always check `.exe` first so // a native binary wins over an npm shim with the same stem. const pathExt = (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').map((e) => e.toLowerCase()).filter(Boolean); - // Always include .ps1 since npm-installed global CLIs may ship as PowerShell - // scripts on Windows (e.g. Claude Code's claude.ps1). Insert it before .bat/.cmd - // so native binaries still win, but working .ps1 shims take precedence over - // potentially broken/dummy .cmd stubs generated by older npm versions. + // npm-installed global CLIs typically ship .cmd, .ps1, AND a sh wrapper + // (e.g. claude.cmd + claude.ps1). When both .cmd and .ps1 are present we + // route through cmd.exe — it's already on COMSPEC, doesn't depend on + // env.Path-based powershell.exe lookup (which has been observed to fail + // with ENOENT inside Electron's main process), and avoids PowerShell's + // cold-start cost (~1-2s + Defender script scan). .ps1 stays as a + // fallback for packages that ship only PowerShell shims. if (!pathExt.includes('.ps1')) { - const batIdx = pathExt.findIndex((e) => e === '.bat' || e === '.cmd'); - if (batIdx >= 0) pathExt.splice(batIdx, 0, '.ps1'); - else pathExt.push('.ps1'); + pathExt.push('.ps1'); } const exts = name.includes('.') && pathExt.includes(path.win32.extname(name).toLowerCase()) ? [''] @@ -313,8 +314,16 @@ export function resolveCliSpawn( if (ext === '.ps1') { // PowerShell scripts: route through powershell.exe so execution policy // and profile loading don't block npm-installed CLIs like claude.ps1. + // Use the absolute path under %SystemRoot% rather than relying on + // env.Path / Path: Electron's main process has been observed to spawn + // with `spawn powershell.exe ENOENT` even when the WindowsPowerShell + // dir is on the system PATH, because libuv on Windows reads the env + // block's `Path` key (capitalized) and our enriched env may only + // expose `PATH`. The absolute path sidesteps that lookup entirely. + const systemRoot = env.SystemRoot ?? env.SYSTEMROOT ?? 'C:\\Windows'; + const absPwsh = path.win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); return { - command: 'powershell.exe', + command: absPwsh, args: ['-ExecutionPolicy', 'Bypass', '-NoProfile', '-File', resolved, ...args], viaCmdShell: false, spawnOptions: {}, diff --git a/app/src/main/hl/stock/browser-harness-js/sdk/browser-harness-js.cmd b/app/src/main/hl/stock/browser-harness-js/sdk/browser-harness-js.cmd new file mode 100644 index 00000000..8c8e8a4d --- /dev/null +++ b/app/src/main/hl/stock/browser-harness-js/sdk/browser-harness-js.cmd @@ -0,0 +1,40 @@ +@echo off +rem browser-harness-js Windows launcher — runs the bundled bash script under +rem Git for Windows' bash.exe. The script is POSIX-only (curl, nohup, /tmp, +rem bun bootstrap) so we delegate to bash rather than re-implement in cmd.exe. +rem +rem Discovery order: +rem 1. %BROWSER_HARNESS_JS_BASH% (explicit env override) +rem 2. %ProgramFiles%\Git\bin\bash.exe +rem 3. %ProgramFiles(x86)%\Git\bin\bash.exe +rem 4. %LocalAppData%\Programs\Git\bin\bash.exe (per-user install) +rem +rem Without Git for Windows, exits 1 with a stderr hint so the agent sees a +rem clean error instead of triggering Windows' "Open with..." association +rem dialog on the extensionless bash script next to this file. + +setlocal + +set "SCRIPT_DIR=%~dp0" +set "BASH_SCRIPT=%SCRIPT_DIR%browser-harness-js" + +if defined BROWSER_HARNESS_JS_BASH ( + if exist "%BROWSER_HARNESS_JS_BASH%" ( + "%BROWSER_HARNESS_JS_BASH%" "%BASH_SCRIPT%" %* + exit /b %errorlevel% + ) +) + +for %%P in ( + "%ProgramFiles%\Git\bin\bash.exe" + "%ProgramFiles(x86)%\Git\bin\bash.exe" + "%LocalAppData%\Programs\Git\bin\bash.exe" +) do ( + if exist %%P ( + %%P "%BASH_SCRIPT%" %* + exit /b %errorlevel% + ) +) + +>&2 echo browser-harness-js: bash.exe not found. Install Git for Windows from https://gitforwindows.org/ or set BROWSER_HARNESS_JS_BASH to a bash.exe path. +exit /b 1 diff --git a/app/src/renderer/hub/ConnectionsPane.tsx b/app/src/renderer/hub/ConnectionsPane.tsx index 417cd7ec..881d0497 100644 --- a/app/src/renderer/hub/ConnectionsPane.tsx +++ b/app/src/renderer/hub/ConnectionsPane.tsx @@ -1181,6 +1181,15 @@ export function ConnectionsPane({ + {/* + Cookie sync is unsupported on Windows: Chromium 127+ uses App-Bound + Encryption (v20) tied to the original user-data-dir, so a temp-copy + profile decrypts to nothing, and launching headless against the real + profile is blocked by the Chromium DevTools hardening that refuses + --remote-debugging-port for the default user-data-dir. Hide the + section entirely on win32 until we have a native v20 decryption path. + */} + {window.electronAPI?.shell?.platform !== 'win32' && (
)}
+ )} ); } diff --git a/app/src/renderer/hub/SettingsPane.tsx b/app/src/renderer/hub/SettingsPane.tsx index 629f5307..8d1fe693 100644 --- a/app/src/renderer/hub/SettingsPane.tsx +++ b/app/src/renderer/hub/SettingsPane.tsx @@ -418,6 +418,11 @@ export function SettingsPane({ intent, keybindings, overrides, onUpdateBinding, const scrollerRef = useRef(null); const [activeSection, setActiveSection] = useState('settings-application'); const platform = window.electronAPI?.shell?.platform ?? fallbackShortcutPlatform(); + // Cookie sync is unsupported on Windows (Chromium ABE + DevTools hardening), + // so the Browser Sync tab + section are hidden on win32. + const tabs = platform === 'win32' + ? SETTINGS_TABS.filter((tab) => tab.id !== 'settings-browser-sync') + : SETTINGS_TABS; const scrollToSection = useCallback((id: SettingsSectionId, behavior: ScrollBehavior = 'smooth') => { const scroller = scrollerRef.current; @@ -434,14 +439,14 @@ export function SettingsPane({ intent, keybindings, overrides, onUpdateBinding, const updateActiveFromScroll = useCallback(() => { const scroller = scrollerRef.current; if (!scroller) return; - let next = SETTINGS_TABS[0].id; + let next = tabs[0].id; const threshold = scroller.scrollTop + 112; - for (const tab of SETTINGS_TABS) { + for (const tab of tabs) { const section = scroller.querySelector(`#${tab.id}`); if (section && section.offsetTop <= threshold) next = tab.id; } setActiveSection(next); - }, []); + }, [tabs]); useEffect(() => { const sectionId = intent?.sectionId ?? ( @@ -467,7 +472,7 @@ export function SettingsPane({ intent, keybindings, overrides, onUpdateBinding,