Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions app/src/main/hl/engines/pathEnrich.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
? ['']
Expand Down Expand Up @@ -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: {},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
@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"

rem `exit /b` (no arg) propagates the *current* ERRORLEVEL. Inside a
rem parenthesized block, `exit /b %errorlevel%` would expand at parse time
rem and silently report 0 even if bash failed.

if defined BROWSER_HARNESS_JS_BASH (
if exist "%BROWSER_HARNESS_JS_BASH%" (
"%BROWSER_HARNESS_JS_BASH%" "%BASH_SCRIPT%" %*
exit /b
)
)

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
)
)

>&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
10 changes: 10 additions & 0 deletions app/src/renderer/hub/ConnectionsPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1181,6 +1181,15 @@ export function ConnectionsPane({

</section>

{/*
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' && (
<section
id={browserSyncSectionId}
className={embedded ? 'settings-page__section' : 'conn-pane__group'}
Expand Down Expand Up @@ -1210,6 +1219,7 @@ export function ConnectionsPane({
</div>
)}
</section>
)}
</div>
);
}
Expand Down
13 changes: 9 additions & 4 deletions app/src/renderer/hub/SettingsPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,11 @@ export function SettingsPane({ intent, keybindings, overrides, onUpdateBinding,
const scrollerRef = useRef<HTMLDivElement>(null);
const [activeSection, setActiveSection] = useState<SettingsSectionId>('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;
Expand All @@ -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<HTMLElement>(`#${tab.id}`);
if (section && section.offsetTop <= threshold) next = tab.id;
}
setActiveSection(next);
}, []);
}, [tabs]);

useEffect(() => {
const sectionId = intent?.sectionId ?? (
Expand All @@ -467,7 +472,7 @@ export function SettingsPane({ intent, keybindings, overrides, onUpdateBinding,
</header>

<nav className="settings-page__tabs" aria-label="Settings sections">
{SETTINGS_TABS.map((tab) => (
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
Expand Down
96 changes: 86 additions & 10 deletions app/src/renderer/onboarding/OnboardingApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,28 @@ function PreferencesStep({

const VALID_STEPS: readonly Step[] = ['intro', 'profile', 'apikey', 'notifications', 'shortcut'];

// Cookie sync is unsupported on Windows: Chromium 127+ uses App-Bound
// Encryption (v20) keyed to the original user-data-dir, so a temp-copy
// profile decrypts to nothing, and the alternative path of launching
// headless against the real profile is blocked by the Chromium DevTools
// hardening that refuses --remote-debugging-port for the default profile.
// We hide the onboarding step + Settings card on Windows until we have a
// native v20 decryption path.
const COOKIE_SYNC_SUPPORTED = typeof window !== 'undefined'
&& window.onboardingAPI?.platform !== 'win32';

const IS_WINDOWS = typeof window !== 'undefined'
&& window.onboardingAPI?.platform === 'win32';

// On Windows we don't run the engine installers ourselves: the npm-install
// scripts shell out through cmd.exe in ways that have been unreliable on
// real user machines, so we instead copy the command to the user's
// clipboard and poll detect-IPC until they finish running it manually.
const ENGINE_INSTALL_COMMANDS: Record<InstallableOnboardingEngine, string> = {
'claude-code': 'npm install -g @anthropic-ai/claude-code',
codex: 'npm install -g @openai/codex',
};

export function OnboardingApp() {
const [step, setStep] = useState<Step>('intro');
const [hydrated, setHydrated] = useState(false);
Expand All @@ -265,7 +287,14 @@ export function OnboardingApp() {
if (cancelled) return;
const candidate = state?.lastStep;
if (candidate && (VALID_STEPS as readonly string[]).includes(candidate)) {
setStep(candidate as Step);
// If a previous run persisted lastStep === 'profile' (e.g. on a
// different platform, or before cookie sync was disabled here),
// skip past it on win32 so the user doesn't land on a hidden step.
if (candidate === 'profile' && !COOKIE_SYNC_SUPPORTED) {
setStep('apikey');
} else {
setStep(candidate as Step);
}
}
setHydrated(true);
}).catch(() => { setHydrated(true); });
Expand Down Expand Up @@ -557,6 +586,33 @@ export function OnboardingApp() {
void handleInstallEngine('codex');
}, [handleInstallEngine]);

// Windows-only: copy the npm install command to the clipboard, then poll
// the detect-IPC until the user has run it themselves. We don't spawn the
// installer ourselves on win32 because the cmd.exe path-out has been
// unreliable. The polling reuses `waitForInstalledStatus` (~2 min window).
const handleManualInstallEngine = useCallback(async (engineId: InstallableOnboardingEngine) => {
if (installingEnginesRef.current[engineId]) return;
try {
await navigator.clipboard.writeText(ENGINE_INSTALL_COMMANDS[engineId]);
} catch (err) {
console.warn('[onboarding] manual install: clipboard write failed', err);
}
setEngineInstalling(engineId, true);
try {
await waitForInstalledStatus(engineId);
} finally {
setEngineInstalling(engineId, false);
}
}, [setEngineInstalling, waitForInstalledStatus]);

const handleManualInstallClaudeCode = useCallback(() => {
void handleManualInstallEngine('claude-code');
}, [handleManualInstallEngine]);

const handleManualInstallCodex = useCallback(() => {
void handleManualInstallEngine('codex');
}, [handleManualInstallEngine]);

const claudeCodeReady = Boolean(claudeCode?.installed && claudeCode.authed);
const codexReady = Boolean(codex?.installed && codex.authed);
const hasUsableAnthropicKey = Boolean(claudeCode?.installed && apiKey.trim());
Expand Down Expand Up @@ -830,7 +886,9 @@ export function OnboardingApp() {

<div className={`onboarding-content ${step === 'intro' ? 'onboarding-content-wide' : ''}`}>
<div className="step-indicator">
{(['intro', 'profile', 'apikey', 'notifications', 'shortcut'] as Step[]).map((s, i, all) => {
{((COOKIE_SYNC_SUPPORTED
? ['intro', 'profile', 'apikey', 'notifications', 'shortcut']
: ['intro', 'apikey', 'notifications', 'shortcut']) as Step[]).map((s, i, all) => {
const currentIdx = all.indexOf(step);
const thisIdx = i;
const cls = thisIdx < currentIdx ? 'done' : thisIdx === currentIdx ? 'active' : '';
Expand All @@ -851,7 +909,10 @@ export function OnboardingApp() {
<p className="intro-subtitle">
Run AI agents that browse the web, complete tasks, and report back — all from your desktop.
</p>
<button className="btn btn-primary intro-cta" onClick={() => setStep('profile')}>
<button
className="btn btn-primary intro-cta"
onClick={() => setStep(COOKIE_SYNC_SUPPORTED ? 'profile' : 'apikey')}
>
Get started
</button>
</div>
Expand Down Expand Up @@ -1083,18 +1144,24 @@ export function OnboardingApp() {
<button
type="button"
className="provider-card__action"
onClick={handleInstallClaudeCode}
onClick={IS_WINDOWS ? handleManualInstallClaudeCode : handleInstallClaudeCode}
disabled={installingClaudeCode}
>
<div className="claude-code-card__icon">
<img src={claudeCodeLogo} alt="" />
</div>
<div className="claude-code-card__text">
<div className="claude-code-card__title">
{installingClaudeCode ? 'Installing Claude Code…' : 'Install Claude Code'}
{installingClaudeCode
? (IS_WINDOWS ? 'Waiting for Claude Code…' : 'Installing Claude Code…')
: (IS_WINDOWS ? 'Copy install command' : 'Install Claude Code')}
</div>
<div className="claude-code-card__sub">
Runs the installer in the background. We’ll detect it when it finishes.
{IS_WINDOWS
? (installingClaudeCode
? `Run ${ENGINE_INSTALL_COMMANDS['claude-code']} in your terminal — we’ll detect it when it finishes.`
: `Click to copy ${ENGINE_INSTALL_COMMANDS['claude-code']}. Paste it into PowerShell, and we’ll detect when it finishes.`)
: 'Runs the installer in the background. We’ll detect it when it finishes.'}
</div>
</div>
<div className="claude-code-card__chevron">{installingClaudeCode ? '\u2026' : '\u203A'}</div>
Expand Down Expand Up @@ -1174,18 +1241,24 @@ export function OnboardingApp() {
<button
type="button"
className="provider-card__action"
onClick={handleInstallCodex}
onClick={IS_WINDOWS ? handleManualInstallCodex : handleInstallCodex}
disabled={installingCodex}
>
<div className="claude-code-card__icon">
<img src={codexLogo} alt="" />
</div>
<div className="claude-code-card__text">
<div className="claude-code-card__title">
{installingCodex ? 'Installing Codex…' : 'Install Codex CLI'}
{installingCodex
? (IS_WINDOWS ? 'Waiting for Codex…' : 'Installing Codex…')
: (IS_WINDOWS ? 'Copy install command' : 'Install Codex CLI')}
</div>
<div className="claude-code-card__sub">
Runs the installer in the background. We’ll detect it when it finishes.
{IS_WINDOWS
? (installingCodex
? `Run ${ENGINE_INSTALL_COMMANDS.codex} in your terminal — we’ll detect it when it finishes.`
: `Click to copy ${ENGINE_INSTALL_COMMANDS.codex}. Paste it into PowerShell, and we’ll detect when it finishes.`)
: 'Runs the installer in the background. We’ll detect it when it finishes.'}
</div>
</div>
<div className="claude-code-card__chevron">{installingCodex ? '\u2026' : '\u203A'}</div>
Expand Down Expand Up @@ -1280,7 +1353,10 @@ export function OnboardingApp() {
</button>
</div>

<button className="back-btn" onClick={() => setStep('profile')}>
<button
className="back-btn"
onClick={() => setStep(COOKIE_SYNC_SUPPORTED ? 'profile' : 'intro')}
>
Back
</button>
</div>
Expand Down
30 changes: 28 additions & 2 deletions app/tests/unit/hl/codexStdinWindows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ const MULTILINE_PROMPT = [
].join('\n');

describe.skipIf(!onWindows)('codex stdin path on Windows', () => {
it('resolves bcode PowerShell shims before cmd/bat shims', () => {
it('prefers cmd shims over PowerShell shims when both exist', () => {
// npm-installed CLIs typically ship both .cmd and .ps1 (e.g. codex.cmd +
// codex.ps1). We pick .cmd as the tiebreaker because cmd.exe is always
// on COMSPEC and avoids powershell.exe's cold-start cost (and its
// observed `spawn powershell.exe ENOENT` failures from Electron's main
// process). The .ps1-only fallback is exercised by the next test.
const tmpRaw = fs.mkdtempSync(path.join(os.tmpdir(), 'bcode-shim-'));
const tmp = fs.realpathSync.native(tmpRaw);
const ps1 = path.join(tmp, 'bcode.ps1');
Expand All @@ -31,7 +36,28 @@ describe.skipIf(!onWindows)('codex stdin path on Windows', () => {
};
const resolved = resolveCliSpawn('bcode', ['--version'], { env, platform: 'win32' });

expect(resolved.command).toBe('powershell.exe');
expect(resolved.command.toLowerCase()).toMatch(/cmd\.exe$/);
expect(resolved.viaCmdShell).toBe(true);
expect(resolved.spawnOptions).toEqual({ windowsVerbatimArguments: true });
expect(resolved.args[3]).toContain(cmd);
});

it('falls back to PowerShell when only a .ps1 shim exists', () => {
const tmpRaw = fs.mkdtempSync(path.join(os.tmpdir(), 'bcode-ps1-only-'));
const tmp = fs.realpathSync.native(tmpRaw);
const ps1 = path.join(tmp, 'bcode.ps1');
fs.writeFileSync(ps1, 'Write-Output "bcode ps1"', 'utf-8');

const env = {
...process.env,
Path: `${tmp};${process.env.Path ?? ''}`,
PATHEXT: '.COM;.EXE;.BAT;.CMD',
};
const resolved = resolveCliSpawn('bcode', ['--version'], { env, platform: 'win32' });

// Absolute path to powershell.exe under %SystemRoot%, not bare
// 'powershell.exe' — main-process spawns have ENOENTed on the bare name.
expect(resolved.command.toLowerCase()).toMatch(/system32[\\/]+windowspowershell[\\/]+v1\.0[\\/]+powershell\.exe$/);
expect(resolved.args).toEqual(['-ExecutionPolicy', 'Bypass', '-NoProfile', '-File', ps1, '--version']);
expect(resolved.spawnOptions).toEqual({});
});
Expand Down
12 changes: 11 additions & 1 deletion app/tests/unit/hl/harnessBootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,21 @@ describe('bootstrapHarness browser-harness-js materialization', () => {
bootstrapHarness();

const cli = path.join(browserHarnessJsDir(), 'sdk', 'browser-harness-js');
const cliCmd = path.join(browserHarnessJsDir(), 'sdk', 'browser-harness-js.cmd');
expect(fs.existsSync(helpersPath())).toBe(true);
expect(fs.readFileSync(skillPath(), 'utf-8')).toContain('Browser Harness JS');
expect(fs.existsSync(toolsPath())).toBe(false);
expect(fs.existsSync(cli)).toBe(true);
expect(fs.statSync(cli).mode & 0o111).not.toBe(0);
// Windows launcher ships alongside the bash script so Codex can find it
// via PATHEXT (.CMD) instead of hitting the no-handler popup on the
// extensionless bash file.
expect(fs.existsSync(cliCmd)).toBe(true);
expect(fs.readFileSync(cliCmd, 'utf-8')).toContain('bash.exe');
expect(fs.existsSync(path.join(interactionSkillsDir(), 'screenshots.md'))).toBe(true);
// Executable-bit assert: skipped on Windows because NTFS permission
// mapping doesn't expose POSIX exec bits the way the test asserts.
if (process.platform !== 'win32') {
expect(fs.statSync(cli).mode & 0o111).not.toBe(0);
}
});
});
Loading