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
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,32 @@ jobs:
name: vitest-results
path: app/tests/results/
if-no-files-found: ignore

windows-spawn:
name: Windows spawn smoke (cmd.exe shim)
runs-on: windows-latest
timeout-minutes: 10
# Guards the Windows-only regression where cmd.exe + .cmd shims mangle
# multi-line prompts passed via argv (codex saw second word as an
# unexpected positional). The fix routes the prompt through stdin; this
# job runs the smoke test that verifies the round-trip on a real
# cmd.exe.
steps:
- uses: actions/checkout@v6

- uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: yarn
cache-dependency-path: app/yarn.lock

- name: Rewrite git+ssh → https (transitive deps use ssh://git@github.com)
run: git config --global url."https://github.com/".insteadOf ssh://git@github.com/

- name: Install dependencies
working-directory: app
run: yarn install --frozen-lockfile

- name: Run Windows-only spawn tests
working-directory: app
run: yarn vitest run tests/unit/hl/codexStdinWindows.test.ts
2 changes: 1 addition & 1 deletion app/src/main/hl/engines/claude-code/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ function runCli(args: string[], timeoutMs = 5000): Promise<{ ok: boolean; stdout
try {
const env = enrichedEnv();
const resolved = resolveCliSpawn(BIN, args, { env });
child = spawn(resolved.command, resolved.args, { stdio: ['ignore', 'pipe', 'pipe'], env });
child = spawn(resolved.command, resolved.args, { stdio: ['ignore', 'pipe', 'pipe'], env, ...resolved.spawnOptions });
}
catch { resolve({ ok: false, stdout: '', stderr: 'spawn failed' }); return; }
let stdout = ''; let stderr = '';
Expand Down
23 changes: 18 additions & 5 deletions app/src/main/hl/engines/codex/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
try {
const env = enrichedEnv();
const resolved = resolveCliSpawn(BIN, args, { env });
child = spawn(resolved.command, resolved.args, { stdio: ['ignore', 'pipe', 'pipe'], env });
child = spawn(resolved.command, resolved.args, { stdio: ['ignore', 'pipe', 'pipe'], env, ...resolved.spawnOptions });
}
catch { resolve({ ok: false, stdout: '', stderr: 'spawn failed' }); return; }
let stdout = ''; let stderr = '';
Expand Down Expand Up @@ -142,15 +142,28 @@
return lines.join('\n');
},

buildSpawnArgs(ctx: SpawnContext, wrappedPrompt: string): string[] {
// `codex exec resume <id> <prompt>` for continuation; otherwise plain exec.
buildSpawnArgs(ctx: SpawnContext): string[] {
// `codex exec resume <id> -` for continuation; otherwise plain exec.
// The trailing `-` tells codex to read the prompt from stdin — see
// getStdinPayload below for why we never pass the prompt via argv.
// --yolo skips sandbox + approvals — acceptable because the agent is
// already scoped by env BU_TARGET_ID and cwd. Equivalent to Claude Code's
// --dangerously-skip-permissions.
if (ctx.resumeSessionId) {
return ['exec', 'resume', ctx.resumeSessionId, '--json', '--yolo', wrappedPrompt];
return ['exec', 'resume', ctx.resumeSessionId, '--json', '--yolo', '-'];
}
return ['exec', '--json', '--yolo', wrappedPrompt];
return ['exec', '--json', '--yolo', '-'];
},

getStdinPayload(_ctx: SpawnContext, wrappedPrompt: string): string {
// Pass the prompt via stdin to dodge a Windows-specific bug: codex on
// Windows installs as `codex.cmd` (an npm batch shim), which we route
// through `cmd.exe /d /s /c`. cmd.exe terminates argument parsing at any
// raw newline inside a quoted arg, so a multi-line wrappedPrompt gets
// truncated and word-split — codex then sees the second word ("are") as
// an unexpected positional and exits with a clap parse error. Stdin
// sidesteps argv quoting entirely on every platform.
return wrappedPrompt;
},

buildEnv(ctx: SpawnContext, baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
Expand All @@ -176,7 +189,7 @@
const type = e.type as string | undefined;
const events: HlEvent[] = [];
let capturedSessionId: string | undefined;
let terminalDone = false;

Check warning on line 192 in app/src/main/hl/engines/codex/adapter.ts

View workflow job for this annotation

GitHub Actions / Lint (TS)

'terminalDone' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 192 in app/src/main/hl/engines/codex/adapter.ts

View workflow job for this annotation

GitHub Actions / Lint (TS)

'terminalDone' is never reassigned. Use 'const' instead
let terminalError: string | undefined;

if (type === 'thread.started') {
Expand Down
22 changes: 19 additions & 3 deletions app/src/main/hl/engines/pathEnrich.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,12 @@ export interface ResolvedCli {
args: string[];
/** True iff we rewrote the call to go through cmd.exe. */
viaCmdShell: boolean;
/** Spread into spawn options. Carries `windowsVerbatimArguments: true`
* whenever we hand-built the cmd.exe command line — without it, Node's
* libuv `quote_cmd_arg` will re-quote our already-quoted cmdline arg
* and cmd.exe ends up trying to execute the entire quoted blob as a
* single program name. */
spawnOptions: { windowsVerbatimArguments?: boolean };
}

export function resolveCliSpawn(
Expand All @@ -200,17 +206,17 @@ export function resolveCliSpawn(
opts: { platform?: Platform; env?: NodeJS.ProcessEnv } = {},
): ResolvedCli {
const platform = opts.platform ?? process.platform;
if (platform !== 'win32') return { command: name, args: [...args], viaCmdShell: false };
if (platform !== 'win32') return { command: name, args: [...args], viaCmdShell: false, spawnOptions: {} };

const env = opts.env ?? enrichedEnv();
const resolved = findOnWindowsPath(name, env);
if (!resolved) return { command: name, args: [...args], viaCmdShell: false };
if (!resolved) return { command: name, args: [...args], viaCmdShell: false, spawnOptions: {} };

const ext = path.win32.extname(resolved).toLowerCase();
if (!WIN_SHIM_EXTS.includes(ext as (typeof WIN_SHIM_EXTS)[number])) {
// Native .exe (or .com) — spawn it directly. Use the resolved absolute
// path so we're not at the mercy of PATH ordering at exec time.
return { command: resolved, args: [...args], viaCmdShell: false };
return { command: resolved, args: [...args], viaCmdShell: false, spawnOptions: {} };
}

if (ext === '.ps1') {
Expand All @@ -220,6 +226,7 @@ export function resolveCliSpawn(
command: 'powershell.exe',
args: ['-ExecutionPolicy', 'Bypass', '-NoProfile', '-File', resolved, ...args],
viaCmdShell: false,
spawnOptions: {},
};
}

Expand All @@ -229,10 +236,19 @@ export function resolveCliSpawn(
// the outer layer and leaves the inner quoted tokens intact for parsing.
// Without this, a leading quote (from a space-containing path like
// "C:\Program Files\...") gets stripped by /s, breaking the command.
//
// CRITICAL: callers MUST spread spawnOptions onto their spawn() call.
// `windowsVerbatimArguments: true` disables Node's libuv arg re-quoting,
// which would otherwise wrap our already-quoted cmdline in a SECOND pair
// of escaped quotes — cmd.exe then sees `"\"path args\""`, the backslashes
// are literal (cmd doesn't recognize `\"` as an escape), and the whole
// mess is treated as a single program name. Verified against Win11 +
// 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',
args: ['/d', '/s', '/c', `"${cmdline}"`],
viaCmdShell: true,
spawnOptions: { windowsVerbatimArguments: true },
};
}
24 changes: 23 additions & 1 deletion app/src/main/hl/engines/runEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,15 +204,37 @@ export async function runEngine(opts: RunEngineOptions): Promise<void> {
},
});

// If the adapter wants to feed the prompt via stdin (Windows-safe path —
// see EngineAdapter.getStdinPayload), open stdin as a pipe instead of
// ignoring it, then write+end after spawn.
const stdinPayload = adapter.getStdinPayload?.(spawnCtx, wrappedPrompt);
const stdinMode: 'pipe' | 'ignore' = stdinPayload != null ? 'pipe' : 'ignore';

let child: ChildProcessWithoutNullStreams;
try {
const resolved = resolveCliSpawn(adapter.binaryName, args, { env });
child = spawn(resolved.command, resolved.args, { cwd: opts.harnessDir, env, stdio: ['ignore', 'pipe', 'pipe'] });
child = spawn(resolved.command, resolved.args, { cwd: opts.harnessDir, env, stdio: [stdinMode, 'pipe', 'pipe'], ...resolved.spawnOptions });
} catch (err) {
opts.onEvent({ type: 'error', message: `spawn_failed: ${(err as Error).message}` });
return;
}

if (stdinPayload != null) {
// Attach error listener BEFORE writing — if the child exits early (bad
// args, missing auth, killed by SIGTERM), Node emits 'error' (EPIPE) on
// stdin asynchronously. Without a listener it propagates as an unhandled
// error and crashes the main process. The exit handler below already
// surfaces the real failure to the user, so we just log here.
child.stdin.on('error', (err) => {
engineLogger.warn('engines.run.stdinPipe.error', { engineId: adapter.id, error: (err as NodeJS.ErrnoException).message, code: (err as NodeJS.ErrnoException).code });
});
try {
child.stdin.end(stdinPayload, 'utf-8');
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
} catch (err) {
engineLogger.warn('engines.run.stdinWrite.failed', { engineId: adapter.id, error: (err as Error).message });
}
}

const onAbort = () => {
try { child.kill('SIGTERM'); } catch { /* already dead */ }
};
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/hl/engines/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,15 @@ export interface EngineAdapter {
buildEnv(ctx: SpawnContext, baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
/** Build the seed/wrapper prompt the CLI will receive. */
wrapPrompt(ctx: SpawnContext): string;
/** Optional: return a payload to write to the child's stdin instead of
* passing the prompt via argv. Required on Windows for any prompt with
* newlines, because `.cmd` shims routed through `cmd.exe /c` cannot
* carry a literal newline inside a quoted argument — the line break
* truncates the command and the prompt gets word-split. Returning a
* string here makes runEngine open stdin as a pipe, write the payload,
* and close it. Adapters that opt in must omit the prompt from
* `buildSpawnArgs` and tell their CLI to read stdin (e.g. `codex exec -`). */
getStdinPayload?(ctx: SpawnContext, wrappedPrompt: string): string | undefined;
/** Translate one NDJSON line from stdout into HlEvents. */
parseLine(line: string, ctx: ParseContext): ParseResult;
}
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/identity/onboardingHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import { normalizeAccelerator } from '../../shared/hotkeys';
import { getGlobalCmdbarAccelerator, registerHotkeys, setGlobalCmdbarAccelerator } from '../hotkeys';

const ANTHROPIC_SERVICE = 'com.browser-use.desktop.anthropic';

Check warning on line 13 in app/src/main/identity/onboardingHandlers.ts

View workflow job for this annotation

GitHub Actions / Lint (TS)

'ANTHROPIC_SERVICE' is assigned a value but never used. Allowed unused vars must match /^_/u
const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages';
const ANTHROPIC_VERSION = '2023-06-01';
const API_TEST_MODEL = 'claude-haiku-4-5-20251001';
Expand Down Expand Up @@ -58,7 +58,7 @@
mainLogger.error('onboardingHandlers.saveApiKey.failed', {
error: (err as Error).message,
});
throw new Error('Failed to save API key to the OS credential store');

Check warning on line 61 in app/src/main/identity/onboardingHandlers.ts

View workflow job for this annotation

GitHub Actions / Lint (TS)

There is no `cause` attached to the symptom error being thrown
}
});

Expand Down Expand Up @@ -102,7 +102,7 @@
ipcMain.handle('onboarding:run-claude-login', async () => {
const env = enrichedEnv();
const resolved = resolveCliSpawn('claude', ['auth', 'login', '--claudeai'], { env });
const child = spawn(resolved.command, resolved.args, { stdio: ['ignore', 'pipe', 'pipe'], env });
const child = spawn(resolved.command, resolved.args, { stdio: ['ignore', 'pipe', 'pipe'], env, ...resolved.spawnOptions });
let stderrBuf = '';
let stdoutBuf = '';
child.stdout?.on('data', (d) => { stdoutBuf += String(d); if (stdoutBuf.length > 4096) stdoutBuf = stdoutBuf.slice(-4096); });
Expand Down Expand Up @@ -285,7 +285,7 @@
await authSaveOpenAIKey(validated);
} catch (err) {
mainLogger.error('onboardingHandlers.saveOpenAIKey.failed', { error: (err as Error).message });
throw new Error('Failed to save OpenAI key to the OS credential store');

Check warning on line 288 in app/src/main/identity/onboardingHandlers.ts

View workflow job for this annotation

GitHub Actions / Lint (TS)

There is no `cause` attached to the symptom error being thrown
}
});

Expand Down Expand Up @@ -485,7 +485,7 @@
try {
const env = enrichedEnv();
const resolved = resolveCliSpawn(bin, args, { env });
child = spawn(resolved.command, resolved.args, { stdio: ['ignore', 'pipe', 'pipe'], env });
child = spawn(resolved.command, resolved.args, { stdio: ['ignore', 'pipe', 'pipe'], env, ...resolved.spawnOptions });
} catch (err) {
resolve({ ok: false, stdout: '', stderr: '', error: (err as Error).message });
return;
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/settings/apiKeyIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ async function handleClaudeCodeLogin(): Promise<{ ok: boolean; error?: string }>
child = spawn(resolved.command, resolved.args, {
stdio: ['ignore', 'pipe', 'pipe'],
env,
...resolved.spawnOptions,
});
} catch (err) {
finish({ ok: false, error: (err as Error).message });
Expand Down Expand Up @@ -256,7 +257,7 @@ function runLogoutCommand(bin: string, args: string[]): Promise<{ opened: boolea
try {
const env = enrichedEnv();
const resolved = resolveCliSpawn(bin, args, { env });
child = spawn(resolved.command, resolved.args, { stdio: ['ignore', 'pipe', 'pipe'], env });
child = spawn(resolved.command, resolved.args, { stdio: ['ignore', 'pipe', 'pipe'], env, ...resolved.spawnOptions });
} catch (err) {
resolve({ opened: false, error: `spawn failed: ${(err as Error).message}` });
return;
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,18 @@ export function createShellWindow(opts?: ShellWindowOptions): BrowserWindow {
minHeight: MIN_HEIGHT,
titleBarStyle: 'hidden',
trafficLightPosition: { x: 16, y: 16 },
// Windows: `titleBarStyle: 'hidden'` removes the entire native title bar
// including the system menu (top-left) and min/max/close buttons
// (top-right) — the user can't close the window from chrome (#388).
// Restore native caption controls via Window Controls Overlay; macOS keeps
// its traffic lights from `hidden` alone, so this branch is Win-only.
...(process.platform === 'win32' && {
titleBarOverlay: {
color: incognito ? '#1a1a2e' : '#0d0d0d',
symbolColor: '#e6eaee',
height: 32,
},
}),
backgroundColor: incognito ? '#1a1a2e' : '#0d0d0d',
webPreferences: {
preload: path.join(__dirname, 'shell.js'),
Expand Down
9 changes: 9 additions & 0 deletions app/src/renderer/hub/hub.css
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,15 @@
justify-content: space-between;
padding: 0 var(--space-10);
padding-top: 36px;
/* Reserve space for the Windows Controls Overlay (min/max/close) so the
"+ New agent" button and other right-side toolbar items can't sit under
the native caption buttons. The env() vars are set by Chromium only when
titleBarOverlay is enabled (Windows); on macOS/Linux they're unset and
this resolves to 0 — Mac keeps its existing space-10 right padding. */
padding-right: calc(
var(--space-10) +
max(0px, 100vw - env(titlebar-area-x, 0px) - env(titlebar-area-width, 100vw))
);
height: calc(40px + 30px);
flex-shrink: 0;
-webkit-app-region: drag;
Expand Down
73 changes: 73 additions & 0 deletions app/tests/unit/hl/codexStdinWindows.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { resolveCliSpawn } from '../../../src/main/hl/engines/pathEnrich';

const onWindows = process.platform === 'win32';

const MULTILINE_PROMPT = [
'You are driving a specific Chromium browser view on this machine.',
'Your target is CDP target_id=abc123 on port 9222.',
'Read `./AGENTS.md` for how to drive the browser in this harness.',
'',
'Task: start google',
].join('\n');

describe.skipIf(!onWindows)('codex stdin path on Windows', () => {
it('round-trips a multi-line prompt through a .cmd shim without word-splitting', async () => {
// realpathSync.native expands 8.3 short names like C:\Users\RUNNER~1
// (which os.tmpdir returns on GitHub Actions Windows runners) to the
// canonical long form. Short names work in most APIs but `~` can confuse
// batch-file redirect parsing in some shells/locales.
const tmpRaw = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-stdin-'));
const tmp = fs.realpathSync.native(tmpRaw);
const argvOut = path.join(tmp, 'argv.txt');
const stdinOut = path.join(tmp, 'stdin.txt');

// The shim uses `more` (not `findstr`) to capture stdin: findstr returns
// exit code 1 when the input lacks a trailing newline. `more` always
// exits 0. Trailing `exit /b 0` is belt-and-suspenders.
const shim = path.join(tmp, 'codex.cmd');
fs.writeFileSync(
shim,
[
'@echo off',
`(for %%A in (%*) do @echo %%A) > "${argvOut}"`,
`more > "${stdinOut}"`,
'exit /b 0',
].join('\r\n'),
'utf-8',
);

const env = { ...process.env, Path: `${tmp};${process.env.Path ?? ''}` };
const resolved = resolveCliSpawn('codex', ['exec', '--json', '--yolo', '-'], { env, platform: 'win32' });
expect(resolved.viaCmdShell).toBe(true);

let stdoutBuf = '';
let stderrBuf = '';
const exitCode = await new Promise<number | null>((resolveSpawn, rejectSpawn) => {
const child = spawn(resolved.command, resolved.args, { env, cwd: tmp, stdio: ['pipe', 'pipe', 'pipe'], ...resolved.spawnOptions });
child.stdout.on('data', (c: Buffer) => { stdoutBuf += c.toString('utf-8'); });
child.stderr.on('data', (c: Buffer) => { stderrBuf += c.toString('utf-8'); });
child.on('error', rejectSpawn);
child.on('close', (code) => resolveSpawn(code));
child.stdin.end(MULTILINE_PROMPT, 'utf-8');
});

// Surface diagnostics in the failure message so future regressions
// (cmd.exe quoting, path-with-spaces, missing `more`) are debuggable
// without re-running the job.
const diag = `\nspawn: ${resolved.command} ${JSON.stringify(resolved.args)}\nexit: ${exitCode}\nstdout: ${stdoutBuf}\nstderr: ${stderrBuf}\ntmp: ${tmp}`;
expect(fs.existsSync(argvOut), `argv file not created${diag}`).toBe(true);
expect(fs.existsSync(stdinOut), `stdin file not created${diag}`).toBe(true);

const argv = fs.readFileSync(argvOut, 'utf-8').trim().split(/\r?\n/);
expect(argv).toEqual(['exec', '--json', '--yolo', '-']);

const stdinSeen = fs.readFileSync(stdinOut, 'utf-8').replace(/\r\n/g, '\n').replace(/\n$/, '');
expect(stdinSeen).toBe(MULTILINE_PROMPT);
expect(stdinSeen.split('\n').length).toBeGreaterThan(1);
});
});
Loading