From 6e8da1df8d91c774ed664974dc3de61749ada04f Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Tue, 18 Aug 2026 02:12:21 -0700 Subject: [PATCH] fix(wsl): pass guest argv verbatim through --exec (#15039) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(wsl): pass guest argv verbatim through --exec `wsl.exe <...> -- ` expands `$name` in every argument against the guest environment before the guest ever runs. It does this even when no shell is involved, so `-- /usr/bin/printf %s '$HOME'` prints /home/you. Every WSL invocation went through that preprocessor, so scripts arrived already rewritten: `awk '{print $2}'` lost its field reference, and a POSIX script asking for the literal `$HOME` got the expanded path. `escapeWslShCommandForWindows` tried to compensate by escaping `$`, but it skipped any `$` preceded by a backslash, so a script containing `\$` was still corrupted -- and half the call sites never applied it at all. Route every invocation through `--exec`, which passes argv through untouched, and delete the escaper. The direct-git path already used `--exec`, so this is not a new compatibility dependency. A guard test fails if the `--` form reappears anywhere in the tree. Net -50 lines of production code. * test(wsl): drop remaining escaped-dollar assertions * fix(wsl): cover the --exec migration's blind spots An audit of every wsl.exe invocation found sites the first pass missed, including two it actively broke: - config/scripts/wsl-git-shell-benchmark.mjs imported escapeWslShCommandForWindows, which no longer exists, so the script threw on startup. Its wslShellArgs helper also still used `--`; the file already had an --exec helper, so route both call sites there. - classifySubprocessCommand unwrapped `wsl.exe <...> -- ` by breaking on `--` alone. With every Orca spawn now on --exec it never found the guest binary and bucketed all WSL subprocesses as plain "wsl", losing the git/gh/glab breakdown. Break on either separator, since foreign wsl.exe processes still use `--`. CliSkillRuntimeSetup builds its setup command as a template literal rather than an argv array, so no array-shaped search could see it. Its decoder accepts both separators so commands persisted before this change still decode. The guard now scans config/ and tests/ as well as src/, and checks the command-string spelling alongside the argv one — the two shapes that have each shipped a regression. It skips comment lines so prose about the old form stays allowed, and asserts it scanned a plausible file count so a bad root cannot make it vacuous. * fix(wsl): restore the guard's multi-line sensitivity The guard matched line by line, so `'--',\s*'bash'` could not span a newline -- and every argv array in this repo is formatted one element per line, which is exactly the shape it exists to catch. Measured against the pre-migration tree it caught 17 files before and 9 fewer after. It now strips comment lines and matches the rejoined text, with a case that pins the multi-line shape so this cannot silently return. The program list is wider than shells now, which surfaced a false positive: tmux takes a `--` separator followed by a program too (`split-window ... -- cat`). Matching is scoped to files that mention WSL rather than narrowing the list back. Also: - Replaced the `sed` regression case, which was vacuous. A backreference contains no `$`, so it returned `bac` under both separators and would have passed without the fix. The block claimed every case proved the bug. Swapped in a positional argument and a shell local, both measured to differ -- the positional is the shape `wslUncDirectoryExists` uses, where `--` blanked `$1` so every existing directory probed as missing. - windows-shell-args.test.ts derived its expected argv from buildWslExecArgs, the helper under test, so six assertions would still pass if it regressed to `--`. Spelled the expectation out. - Dropped two comments citing the removed `--` behavior as rationale. --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../wsl-workspace-fixture.mjs | 2 +- config/scripts/wsl-git-shell-benchmark.mjs | 13 +-- ...aude-account-service-login-process.test.ts | 2 +- .../claude-accounts/runtime-auth-service.ts | 2 +- src/main/claude-accounts/service.ts | 12 +-- src/main/cli/wsl-cli-installer.ts | 2 +- .../codex-accounts/runtime-home-service.ts | 4 +- .../runtime-home-wsl-session-bridge.test.ts | 2 +- .../service-wsl-accounts.test.ts | 4 +- src/main/codex-accounts/service.ts | 10 +- .../codex-accounts/wsl-codex-command.test.ts | 8 +- src/main/codex-accounts/wsl-codex-command.ts | 11 +-- .../codex/codex-wsl-hook-install-plan.test.ts | 4 +- src/main/codex/codex-wsl-hook-install-plan.ts | 4 +- .../codex/wsl-codex-session-bridge.test.ts | 16 ++-- src/main/codex/wsl-codex-session-bridge.ts | 16 ++-- .../daemon/pty-subprocess-wsl-launch.test.ts | 14 +-- .../main-thread-churn-probe.test.ts | 19 +++- .../diagnostics/main-thread-churn-probe.ts | 4 +- src/main/git/runner-command-exec.test.ts | 6 +- src/main/git/runner-wsl-gh-fallback.test.ts | 12 +-- src/main/git/runner.ts | 13 +-- src/main/git/wsl-git-read-environment.ts | 9 +- src/main/hooks.test.ts | 4 +- src/main/hooks.ts | 2 +- src/main/ipc/filesystem-watcher-wsl.test.ts | 2 +- src/main/ipc/filesystem-watcher-wsl.ts | 2 +- .../ipc/preflight-agent-detection.test.ts | 4 +- .../ipc/preflight-host-cli-status.test.ts | 4 +- .../ipc/preflight-wsl-agent-detection.test.ts | 5 +- src/main/ipc/preflight-wsl-agent-detection.ts | 14 +-- src/main/ipc/preflight-wsl-command.ts | 14 +-- src/main/local-worktree-filesystem.ts | 11 +-- ...-pty-provider-windows-shell-launch.test.ts | 10 +- src/main/providers/windows-shell-args.test.ts | 16 ++-- src/main/providers/windows-shell-args.ts | 5 +- src/main/rate-limits/claude-pty.ts | 2 +- src/main/rate-limits/codex-fetcher.test.ts | 20 ++-- src/main/rate-limits/codex-fetcher.ts | 7 +- .../skills/claude-plugin-skill-sources-wsl.ts | 2 +- src/main/skills/skill-discovery-wsl.ts | 2 +- .../skills/skill-wsl-provider-detection.ts | 2 +- ...e-text-generation-local-subprocess.test.ts | 4 +- ...ge-text-generation-model-discovery.test.ts | 2 +- src/main/wsl-bash-command.ts | 5 +- src/main/wsl.test.ts | 4 +- src/main/wsl.ts | 6 +- .../settings/CliSkillRuntimeSetup.test.tsx | 8 +- .../settings/CliSkillRuntimeSetup.tsx | 6 +- ...ntSkillSetupPrompt.reminder-toast.test.tsx | 2 +- src/shared/posix-command-path-lookup.test.ts | 6 +- src/shared/wsl-exec-mode-separator.test.ts | 92 +++++++++++++++++++ src/shared/wsl-login-shell-command.test.ts | 80 ++++++++++------ src/shared/wsl-login-shell-command.ts | 28 +++--- ...inal-windows-shell-paste-ownership.spec.ts | 2 +- 55 files changed, 320 insertions(+), 242 deletions(-) create mode 100644 src/shared/wsl-exec-mode-separator.test.ts diff --git a/config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs b/config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs index cd38f400d47..392d9b9cce6 100644 --- a/config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs +++ b/config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url' const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url))) function runWsl(distro, script, options = {}) { - return execFileSync('wsl.exe', ['-d', distro, '--', 'bash', '-se'], { + return execFileSync('wsl.exe', ['-d', distro, '--exec', 'bash', '-se'], { cwd: rootDir, encoding: 'utf8', input: script, diff --git a/config/scripts/wsl-git-shell-benchmark.mjs b/config/scripts/wsl-git-shell-benchmark.mjs index d29aaea2824..f8c02c7bfc8 100644 --- a/config/scripts/wsl-git-shell-benchmark.mjs +++ b/config/scripts/wsl-git-shell-benchmark.mjs @@ -86,10 +86,6 @@ function wslArgs(distro, args) { return ['-d', distro, '--exec', ...args] } -function wslShellArgs(distro, args) { - return ['-d', distro, '--', ...args] -} - async function resolveDistro(requested) { if (requested) { return requested @@ -139,15 +135,16 @@ async function main() { assertRepoPath(options.mountedRepo, '/mnt/') const distro = await resolveDistro(options.distro) const jiti = createJiti(import.meta.url) - const { buildWslLoginShellCommand, escapeWslShCommandForWindows, quotePosixShell } = - await jiti.import('../../src/shared/wsl-login-shell-command.ts') + const { buildWslLoginShellCommand, quotePosixShell } = await jiti.import( + '../../src/shared/wsl-login-shell-command.ts' + ) const loginProbe = buildWslLoginShellCommand( `printf '\\n__ORCA_PATH__%s\\n__ORCA_GIT__%s\\n__ORCA_HOME__%s\\n' "$PATH" "$(command -v git)" "$HOME"` ) const probe = await run( 'wsl.exe', - wslShellArgs(distro, ['/bin/sh', '-lc', escapeWslShCommandForWindows(loginProbe)]) + wslArgs(distro, ['/bin/sh', '-lc', loginProbe]) ) const probeText = probe.stdout.toString('utf8') const loginPath = /__ORCA_PATH__(.*)/.exec(probeText)?.[1]?.trim() @@ -172,7 +169,7 @@ async function main() { const script = `${delay}${buildWslLoginShellCommand(command)}` result = await run( 'wsl.exe', - wslShellArgs(distro, ['/bin/sh', '-lc', escapeWslShCommandForWindows(script)]) + wslArgs(distro, ['/bin/sh', '-lc', script]) ) const markerOffset = result.stdout.indexOf(outputMarker) if (markerOffset === -1) { diff --git a/src/main/claude-accounts/claude-account-service-login-process.test.ts b/src/main/claude-accounts/claude-account-service-login-process.test.ts index 0aa6225af29..cb387a659d5 100644 --- a/src/main/claude-accounts/claude-account-service-login-process.test.ts +++ b/src/main/claude-accounts/claude-account-service-login-process.test.ts @@ -215,7 +215,7 @@ describe('ClaudeAccountService credential capture', () => { [ '-d', 'Ubuntu Test', - '--', + '--exec', 'bash', '-lc', "export CLAUDE_CONFIG_DIR='/home/user/.config/orca auth'; exec claude 'auth' 'status' '--json'" diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 4e808bbb55a..b78b4f8724b 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -1090,7 +1090,7 @@ export class ClaudeRuntimeAuthService { [ '-d', wslInfo.distro, - '--', + '--exec', 'bash', '-lc', buildEncodedWslBashCommand( diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index adbe1f4613d..d6ac7c49790 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -667,7 +667,7 @@ export class ClaudeAccountService { [ '-d', location.wslDistro, - '--', + '--exec', 'bash', '-lc', 'mktemp -d "${TMPDIR:-/tmp}/orca-claude-login.XXXXXX"' @@ -698,7 +698,7 @@ export class ClaudeAccountService { [ '-d', tempConfig.wslDistro, - '--', + '--exec', 'bash', '-lc', `rm -rf -- ${shellQuote(tempConfig.linuxPath)}` @@ -915,7 +915,7 @@ export class ClaudeAccountService { const distroArgs = target.wslDistro?.trim() ? ['-d', target.wslDistro.trim()] : [] const infoOutput = execFileSync( 'wsl.exe', - [...distroArgs, '--', 'bash', '-lc', 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"'], + [...distroArgs, '--exec', 'bash', '-lc', 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"'], { encoding: 'utf-8', timeout: 5000 } ) const [rawDistro, rawHome] = infoOutput @@ -935,7 +935,7 @@ export class ClaudeAccountService { [ '-d', distro, - '--', + '--exec', 'bash', '-lc', `mkdir -p ${shellQuote(wslLinuxAuthPath)} && printf '%s\\n' ${shellQuote(accountId)} > ${shellQuote(markerPath)}` @@ -974,7 +974,7 @@ export class ClaudeAccountService { [ '-d', wslInfo.distro, - '--', + '--exec', 'bash', '-lc', buildEncodedWslBashCommand( @@ -1058,7 +1058,7 @@ export class ClaudeAccountService { args: [ '-d', configDir.wslDistro, - '--', + '--exec', 'bash', '-lc', `export CLAUDE_CONFIG_DIR=${shellQuote(configDir.linuxPath)}; exec claude ${args.map(shellQuote).join(' ')}` diff --git a/src/main/cli/wsl-cli-installer.ts b/src/main/cli/wsl-cli-installer.ts index 3dee00a871b..b0203c64a6e 100644 --- a/src/main/cli/wsl-cli-installer.ts +++ b/src/main/cli/wsl-cli-installer.ts @@ -484,7 +484,7 @@ async function runWslCommand(distro: string, command: string): Promise { try { child = execFile( 'wsl.exe', - ['-d', distro, '--', 'bash', '-lc', buildEncodedWslBashCommand(command)], + ['-d', distro, '--exec', 'bash', '-lc', buildEncodedWslBashCommand(command)], { encoding: 'utf8', timeout: WSL_COMMAND_TIMEOUT_MS diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 62ef099031b..d14ac6cffb8 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -1212,13 +1212,13 @@ export class CodexRuntimeHomeService { ) const nextLinuxPath = `${activeLinuxPath}.next-${process.pid}-${Date.now()}` const activeLinuxParentPath = this.dirnameLinuxPath(activeLinuxPath) - // Why: WSL drops bash argv, so keep the script literal; login-shell cleanup turns `exit 0` into status 1, so fall through. + // Why: login-shell cleanup turns `exit 0` into status 1, so fall through. execFileSync( 'wsl.exe', [ '-d', distro, - '--', + '--exec', 'bash', '-lc', [ diff --git a/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts b/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts index 4d62afba93f..0c3b418b06a 100644 --- a/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts @@ -55,7 +55,7 @@ describe('CodexRuntimeHomeService', () => { expect(firstCall).toBeDefined() const [command, args] = firstCall as [string, string[]] expect(command).toBe('wsl.exe') - expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--', 'bash', '-lc']) + expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', 'bash', '-lc']) expect(args).toHaveLength(6) const shellCommand = args[5] diff --git a/src/main/codex-accounts/service-wsl-accounts.test.ts b/src/main/codex-accounts/service-wsl-accounts.test.ts index e100414faba..95f825d3d57 100644 --- a/src/main/codex-accounts/service-wsl-accounts.test.ts +++ b/src/main/codex-accounts/service-wsl-accounts.test.ts @@ -65,7 +65,7 @@ describe('CodexAccountService config sync', () => { if (script.includes('command -v codex')) { throw new Error('bash -ic does not inherit the distro login-shell PATH') } - if (args.slice(2, 5).join(' ') === '-- sh -c') { + if (args.slice(2, 5).join(' ') === '--exec sh -c') { expect(args).toEqual(buildWslCodexAvailabilityArgs('Debian')) return '' } @@ -178,7 +178,7 @@ describe('CodexAccountService config sync', () => { if (script.includes('readlink -f')) { return `${wslLinuxHomePath}\n` } - if (args.slice(2, 5).join(' ') === '-- sh -c') { + if (args.slice(2, 5).join(' ') === '--exec sh -c') { expect(args).toEqual(buildWslCodexAvailabilityArgs('Debian')) throw new Error('codex missing') } diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 4b5a91c03f0..fe0215b3258 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -1203,7 +1203,7 @@ export class CodexAccountService { const distroArgs = target.wslDistro?.trim() ? ['-d', target.wslDistro.trim()] : [] const infoOutput = execFileSync( 'wsl.exe', - [...distroArgs, '--', 'bash', '-lc', 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"'], + [...distroArgs, '--exec', 'bash', '-lc', 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"'], { encoding: 'utf-8', timeout: 5000 } ) const [rawDistro, rawHome] = infoOutput @@ -1223,7 +1223,7 @@ export class CodexAccountService { [ '-d', distro, - '--', + '--exec', 'bash', '-lc', `mkdir -p ${shellQuote(wslLinuxHomePath)} && printf '%s\\n' ${shellQuote(accountId)} > ${shellQuote(markerPath)}` @@ -1460,7 +1460,7 @@ export class CodexAccountService { [ '-d', wslInfo.distro, - '--', + '--exec', 'bash', '-lc', buildEncodedWslBashCommand( @@ -1519,7 +1519,7 @@ export class CodexAccountService { [ '-d', wslInfo.distro, - '--', + '--exec', 'bash', '-lc', buildEncodedWslBashCommand( @@ -1595,7 +1595,7 @@ export class CodexAccountService { [ '-d', distro, - '--', + '--exec', 'bash', '-lc', buildEncodedWslBashCommand( diff --git a/src/main/codex-accounts/wsl-codex-command.test.ts b/src/main/codex-accounts/wsl-codex-command.test.ts index 18c92d63323..203774c1091 100644 --- a/src/main/codex-accounts/wsl-codex-command.test.ts +++ b/src/main/codex-accounts/wsl-codex-command.test.ts @@ -9,7 +9,7 @@ describe('WSL Codex commands', () => { it('checks the alias-neutral PATH from the distro login shell', () => { const args = buildWslCodexAvailabilityArgs('Ubuntu24-Dev') - expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu24-Dev', '--', 'sh', '-c']) + expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu24-Dev', '--exec', 'sh', '-c']) expect(args.at(-1)).toContain('getent passwd') expect(args.at(-1)).toContain('_orca_lookup_command=') expect(args.at(-1)).toContain('codex') @@ -22,13 +22,13 @@ describe('WSL Codex commands', () => { expect(command).toContain('export CODEX_HOME=') expect(command).toContain('/home/alice/managed-home') - expect(command).toContain('exec "\\$resolved" login') + expect(command).toContain('exec "$resolved" login') }) it('reports the login-shell binary path and version for identity checks', () => { const command = buildWslCodexIdentityArgs('Ubuntu').at(-1) - expect(command).toMatch(/printf .*"\\\$resolved"/) - expect(command).toContain('exec "\\$resolved" --version') + expect(command).toMatch(/printf .*"\$resolved"/) + expect(command).toContain('exec "$resolved" --version') }) }) diff --git a/src/main/codex-accounts/wsl-codex-command.ts b/src/main/codex-accounts/wsl-codex-command.ts index 92ead25fb76..cf187ae0ccd 100644 --- a/src/main/codex-accounts/wsl-codex-command.ts +++ b/src/main/codex-accounts/wsl-codex-command.ts @@ -1,7 +1,7 @@ import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' import { + buildWslExecArgs, buildWslLoginShellCommand, - escapeWslShCommandForWindows, quotePosixShell } from '../../shared/wsl-login-shell-command' @@ -59,12 +59,5 @@ function buildCodexPathLookup(): string { function buildWslCodexShellArgs(distro: string, command: string): string[] { // Why: Codex must use the distro user's configured login shell, whose PATH // can differ from a hard-coded non-login bash invocation. - return [ - '-d', - distro, - '--', - 'sh', - '-c', - escapeWslShCommandForWindows(buildWslLoginShellCommand(command)) - ] + return buildWslExecArgs(distro, ['sh', '-c', buildWslLoginShellCommand(command)]) } diff --git a/src/main/codex/codex-wsl-hook-install-plan.test.ts b/src/main/codex/codex-wsl-hook-install-plan.test.ts index 83ab0141a34..c4726d46c7a 100644 --- a/src/main/codex/codex-wsl-hook-install-plan.test.ts +++ b/src/main/codex/codex-wsl-hook-install-plan.test.ts @@ -52,7 +52,7 @@ describe('canonicalizeWslLinuxPath', () => { expect(args).toEqual([ '-d', 'Ubuntu', - '--', + '--exec', 'sh', '-c', `if [ ! -d "$1" ]; then printf '%s\\n' '__ORCA_WSL_PATH_MISSING__'; exit 0; fi; readlink -f -- "$1"`, @@ -91,7 +91,7 @@ describe('canonicalizeWslLinuxPath', () => { expect(args).toEqual([ '-d', 'Ubuntu', - '--', + '--exec', 'sh', '-c', `resolved=$(wslpath -a -u "$1") || exit; if [ ! -d "$resolved" ]; then printf '%s\\n' '__ORCA_WSL_PATH_MISSING__'; exit 0; fi; readlink -f -- "$resolved"`, diff --git a/src/main/codex/codex-wsl-hook-install-plan.ts b/src/main/codex/codex-wsl-hook-install-plan.ts index 4db2bdea93f..fbce302a73d 100644 --- a/src/main/codex/codex-wsl-hook-install-plan.ts +++ b/src/main/codex/codex-wsl-hook-install-plan.ts @@ -85,7 +85,7 @@ function scheduleWslLinuxPathCanonicalization( ? [ '-d', distro, - '--', + '--exec', 'sh', '-c', `resolved=$(wslpath -a -u "$1") || exit; if [ ! -d "$resolved" ]; then printf '%s\\n' '${WSL_PATH_MISSING_OUTPUT}'; exit 0; fi; readlink -f -- "$resolved"`, @@ -95,7 +95,7 @@ function scheduleWslLinuxPathCanonicalization( : [ '-d', distro, - '--', + '--exec', 'sh', '-c', `if [ ! -d "$1" ]; then printf '%s\\n' '${WSL_PATH_MISSING_OUTPUT}'; exit 0; fi; readlink -f -- "$1"`, diff --git a/src/main/codex/wsl-codex-session-bridge.test.ts b/src/main/codex/wsl-codex-session-bridge.test.ts index c4218913a5b..ec7a1df8e22 100644 --- a/src/main/codex/wsl-codex-session-bridge.test.ts +++ b/src/main/codex/wsl-codex-session-bridge.test.ts @@ -55,7 +55,7 @@ describe('syncWslCodexSessionsIntoManagedHome', () => { { timeout?: number; windowsHide?: boolean } ] expect(command).toBe('wsl.exe') - expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--', 'bash', '-lc']) + expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', 'bash', '-lc']) expect(args).toHaveLength(6) expect(options.timeout).toBe(30_000) expect(options.windowsHide).toBe(true) @@ -65,9 +65,9 @@ describe('syncWslCodexSessionsIntoManagedHome', () => { expect(shellCommand).toContain( "managed_sessions_root='/home/alice/.local/share/orca/codex-runtime-home/home/sessions'" ) - expect(shellCommand).toContain(`find "\\$source_sessions_root" -type f -name '*.jsonl' -print0`) - expect(shellCommand).toContain('ln -- "\\$source_file" "\\$target_file"') - expect(shellCommand).toContain('if [ -e "\\$target_file" ] || [ -L "\\$target_file" ]; then') + expect(shellCommand).toContain(`find "$source_sessions_root" -type f -name '*.jsonl' -print0`) + expect(shellCommand).toContain('ln -- "$source_file" "$target_file"') + expect(shellCommand).toContain('if [ -e "$target_file" ] || [ -L "$target_file" ]; then') expect(shellCommand).not.toContain('ln -s') expect(shellCommand).not.toContain('cp ') expect(shellCommand).not.toContain('sqlite') @@ -155,14 +155,14 @@ describe('buildWslCodexSessionBridgeShellCommand', () => { expect(shellCommand).not.toContain('.sqlite') }) - it('escapes Linux-side shell variable expansion for wsl.exe argv', () => { + it('keeps Linux-side shell variable expansion intact for the guest shell', () => { const shellCommand = buildWslCodexSessionBridgeShellCommand({ systemSessionsRoot: '/home/alice/.codex/sessions', managedSessionsRoot: '/home/alice/.local/share/orca/codex-runtime-home/home/sessions' }) - expect(shellCommand).toContain('\\$source_sessions_root') - expect(shellCommand).toContain('\\$source_file') - expect(shellCommand).toContain('\\$((scanned_files + 1))') + expect(shellCommand).toContain('$source_sessions_root') + expect(shellCommand).toContain('$source_file') + expect(shellCommand).toContain('$((scanned_files + 1))') }) }) diff --git a/src/main/codex/wsl-codex-session-bridge.ts b/src/main/codex/wsl-codex-session-bridge.ts index e83abfadd95..aa69c0d8ba8 100644 --- a/src/main/codex/wsl-codex-session-bridge.ts +++ b/src/main/codex/wsl-codex-session-bridge.ts @@ -1,6 +1,6 @@ import { execFile } from 'node:child_process' import { posix as pathPosix } from 'node:path' -import { escapeWslShCommandForWindows } from '../../shared/wsl-login-shell-command' +import { buildWslExecArgs } from '../../shared/wsl-login-shell-command' import { parseWslUncPath } from '../../shared/wsl-paths' export type WslCodexSessionBridgeTarget = { @@ -54,14 +54,10 @@ export async function syncWslCodexSessionsIntoManagedHome( return emptySummary } - const stdout = await execFileUtf8('wsl.exe', [ - '-d', - target.distro, - '--', - 'bash', - '-lc', - buildWslCodexSessionBridgeShellCommand(paths) - ]) + const stdout = await execFileUtf8( + 'wsl.exe', + buildWslExecArgs(target.distro, ['bash', '-lc', buildWslCodexSessionBridgeShellCommand(paths)]) + ) return parseWslSessionBridgeSummary(stdout) } @@ -110,7 +106,7 @@ export function buildWslCodexSessionBridgeShellCommand( `done < <(find "$source_sessions_root" -type f -name '*.jsonl' -print0 2>/dev/null)`, `printf '{"scannedFiles":%s,"linkedFiles":%s}\\n' "$scanned_files" "$linked_files"` ].join('\n') - return escapeWslShCommandForWindows(shellCommand) + return shellCommand } function getWslSessionBridgeTaskKey(target: WslCodexSessionBridgeTarget): string { diff --git a/src/main/daemon/pty-subprocess-wsl-launch.test.ts b/src/main/daemon/pty-subprocess-wsl-launch.test.ts index 5d5031cec52..ffc042d3509 100644 --- a/src/main/daemon/pty-subprocess-wsl-launch.test.ts +++ b/src/main/daemon/pty-subprocess-wsl-launch.test.ts @@ -146,7 +146,7 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - ['--', 'sh', '-c', expect.stringContaining(`cd '${expectedLinuxCwd}'`)], + ['--exec', 'sh', '-c', expect.stringContaining(`cd '${expectedLinuxCwd}'`)], expect.objectContaining({ cwd: expect.any(String) }) ) }) @@ -177,7 +177,7 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Debian', '--', 'sh', '-c', expect.stringContaining("cd '/mnt/c/repo'")], + ['-d', 'Debian', '--exec', 'sh', '-c', expect.stringContaining("cd '/mnt/c/repo'")], expect.objectContaining({ cwd: expect.any(String) }) ) }) @@ -205,7 +205,7 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo'")], + ['-d', 'Ubuntu', '--exec', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo'")], expect.objectContaining({ cwd: expect.any(String) }) ) }) @@ -233,7 +233,7 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo'")], + ['-d', 'Ubuntu', '--exec', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo'")], expect.objectContaining({ env: expect.not.objectContaining({ CODEX_HOME: expect.anything(), @@ -318,7 +318,7 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining(`cd '${expectedLinuxCwd}'`)], + ['-d', 'Ubuntu', '--exec', 'sh', '-c', expect.stringContaining(`cd '${expectedLinuxCwd}'`)], expect.objectContaining({ env: expect.objectContaining({ CODEX_HOME: '/home/jin/.local/share/orca/codex-accounts/a/home', @@ -352,7 +352,7 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo'")], + ['-d', 'Ubuntu', '--exec', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo'")], expect.objectContaining({ env: expect.objectContaining({ CODEX_HOME: '/home/jin/.codex-alt' }) }) @@ -470,7 +470,7 @@ describe('createPtySubprocess', () => { ) expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining(`cd '${cwd}'`)], + ['-d', 'Ubuntu', '--exec', 'sh', '-c', expect.stringContaining(`cd '${cwd}'`)], expect.objectContaining({ cwd: expect.any(String) }) ) } diff --git a/src/main/diagnostics/main-thread-churn-probe.test.ts b/src/main/diagnostics/main-thread-churn-probe.test.ts index cfc159fc1fb..62ede5c99c8 100644 --- a/src/main/diagnostics/main-thread-churn-probe.test.ts +++ b/src/main/diagnostics/main-thread-churn-probe.test.ts @@ -33,10 +33,25 @@ describe('classifySubprocessCommand', () => { expect(classifySubprocessCommand('gh', ['api', 'rate_limit'])).toBe('gh api') }) - it('unwraps wsl.exe-routed commands', () => { + it.each([ + // Orca's own spawns use --exec; `--`/`-e` still arrive from foreign wsl.exe processes. + ['--exec', '--exec'], + ['-e', '-e'], + ['--', '--'] + ])('unwraps wsl.exe-routed commands past %s', (_label, separator) => { expect( - classifySubprocessCommand('wsl.exe', ['-d', 'Ubuntu', '--', 'git', 'status', '--porcelain']) + classifySubprocessCommand('wsl.exe', [ + '-d', + 'Ubuntu', + separator, + 'git', + 'status', + '--porcelain' + ]) ).toBe('git status') + }) + + it('reports bare wsl.exe with no guest command as wsl', () => { expect(classifySubprocessCommand('wsl.exe', ['-d', 'Ubuntu'])).toBe('wsl') }) diff --git a/src/main/diagnostics/main-thread-churn-probe.ts b/src/main/diagnostics/main-thread-churn-probe.ts index f4fe900cf8c..1d70999bc14 100644 --- a/src/main/diagnostics/main-thread-churn-probe.ts +++ b/src/main/diagnostics/main-thread-churn-probe.ts @@ -38,9 +38,11 @@ export function classifySubprocessCommand(command: string, args: readonly string let binary = binaryName(command) const rest = [...args] if (binary === 'wsl') { + // Orca spawns guest commands with `--exec`; `--`/`-e` still appear on + // wsl.exe processes started outside Orca, so unwrap either separator. while (rest.length > 0) { const arg = rest.shift() - if (arg === '--') { + if (arg === '--' || arg === '--exec' || arg === '-e') { break } } diff --git a/src/main/git/runner-command-exec.test.ts b/src/main/git/runner-command-exec.test.ts index 8d07bd5c555..29a3d903cc7 100644 --- a/src/main/git/runner-command-exec.test.ts +++ b/src/main/git/runner-command-exec.test.ts @@ -503,13 +503,13 @@ describe('runner execFile timeout handling', () => { expect(execFileMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'sh', '-lc', expect.any(String)], + ['-d', 'Ubuntu', '--exec', 'sh', '-lc', expect.any(String)], expect.objectContaining({ cwd: undefined }), expect.any(Function) ) const shellCommand = execFileMock.mock.calls[0]?.[1]?.[5] as string expect(shellCommand).toContain('getent passwd') - expect(shellCommand).toContain('exec "\\$_orca_wsl_shell" -ilc') + expect(shellCommand).toContain('exec "$_orca_wsl_shell" -ilc') expect(shellCommand).toContain('/mnt/c/repo') expect(shellCommand).toContain("'git'") expect(shellCommand).toContain('status') @@ -533,7 +533,7 @@ describe('runner execFile timeout handling', () => { expect(execFileMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'bash', '-c', expect.any(String)], + ['-d', 'Ubuntu', '--exec', 'bash', '-c', expect.any(String)], expect.objectContaining({ cwd: undefined }), expect.any(Function) ) diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index 35a422e1b39..57dd86ba26c 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -94,7 +94,7 @@ describe('ghExecFileAsync WSL fallback', () => { [ '-d', 'Ubuntu', - '--', + '--exec', 'bash', '-c', "cd '/home/jinwoo/stably/noqa' && 'gh' 'issue' 'list' '--repo' 'stablyhq/noqa' '--json' 'number,title'" @@ -381,7 +381,7 @@ describe('ghExecFileAsync WSL fallback', () => { expect(execFileMock).toHaveBeenNthCalledWith( 2, 'wsl.exe', - ['-d', 'Ubuntu', '--', 'bash', '-c', "'gh' 'api' 'rate_limit'"], + ['-d', 'Ubuntu', '--exec', 'bash', '-c', "'gh' 'api' 'rate_limit'"], expect.objectContaining({ cwd: undefined }), expect.any(Function) ) @@ -500,7 +500,7 @@ describe('ghExecFileAsync WSL fallback', () => { expect(execFileMock).toHaveBeenNthCalledWith( 2, 'wsl.exe', - ['-d', 'Ubuntu', '--', 'bash', '-c', "'glab' 'api' 'projects'"], + ['-d', 'Ubuntu', '--exec', 'bash', '-c', "'glab' 'api' 'projects'"], expect.objectContaining({ cwd: undefined }), expect.any(Function) ) @@ -565,7 +565,7 @@ describe('ghExecFileAsync WSL fallback', () => { expect(execFileMock).toHaveBeenNthCalledWith( 2, 'wsl.exe', - ['-d', 'Ubuntu', '--', 'bash', '-c', "'glab' 'auth' 'status'"], + ['-d', 'Ubuntu', '--exec', 'bash', '-c', "'glab' 'auth' 'status'"], expect.not.objectContaining({ signal: controller.signal }), expect.any(Function) ) @@ -652,7 +652,7 @@ describe('ghExecFileAsync WSL fallback', () => { expect(execFileMock).toHaveBeenNthCalledWith( 2, 'wsl.exe', - ['-d', 'Debian', '--', 'bash', '-c', "'gh' 'auth' 'status'"], + ['-d', 'Debian', '--exec', 'bash', '-c', "'gh' 'auth' 'status'"], expect.any(Object), expect.any(Function) ) @@ -682,7 +682,7 @@ describe('ghExecFileAsync WSL fallback', () => { expect(execFileMock).toHaveBeenNthCalledWith( 2, 'wsl.exe', - ['-d', 'Ubuntu', '--', 'bash', '-c', "'gh' 'auth' 'status'"], + ['-d', 'Ubuntu', '--exec', 'bash', '-c', "'gh' 'auth' 'status'"], expect.any(Object), expect.any(Function) ) diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 264bd7cbfca..c3ad4b0a132 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -35,8 +35,8 @@ import { } from '../../shared/git-credential-prompt-env' import { getSpawnArgsForWindows, isWindowsBatchScript, resolveWindowsCommand } from '../win32-utils' import { + buildWslExecArgs, buildWslLoginShellCommand, - escapeWslShCommandForWindows, quotePosixShell } from '../../shared/wsl-login-shell-command' import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../../shared/git-output-locale' @@ -364,14 +364,7 @@ function resolveCommand( if (options.useWslLoginShell) { return { binary: 'wsl.exe', - args: [ - '-d', - wsl.distro, - '--', - 'sh', - '-lc', - escapeWslShCommandForWindows(buildWslLoginShellCommand(shellCmd)) - ], + args: buildWslExecArgs(wsl.distro, ['sh', '-lc', buildWslLoginShellCommand(shellCmd)]), cwd: undefined, wsl, wslMode: 'login-shell' @@ -380,7 +373,7 @@ function resolveCommand( return { binary: 'wsl.exe', - args: ['-d', wsl.distro, '--', 'bash', '-c', shellCmd], + args: buildWslExecArgs(wsl.distro, ['bash', '-c', shellCmd]), // Why: the `cd` inside bash -c handles the directory; a UNC cwd on the Node process is redundant and can break Node internals. cwd: undefined, wsl, diff --git a/src/main/git/wsl-git-read-environment.ts b/src/main/git/wsl-git-read-environment.ts index 7df86a44a94..1c7fb635b86 100644 --- a/src/main/git/wsl-git-read-environment.ts +++ b/src/main/git/wsl-git-read-environment.ts @@ -1,8 +1,5 @@ import { execFile } from 'node:child_process' -import { - buildWslLoginShellCommand, - escapeWslShCommandForWindows -} from '../../shared/wsl-login-shell-command' +import { buildWslExecArgs, buildWslLoginShellCommand } from '../../shared/wsl-login-shell-command' export type WslGitReadEnvironment = { gitPath: string; home: string; path: string } @@ -48,11 +45,11 @@ function probeWslGitReadEnvironment(distro: string): Promise { 'if [ -n "${XDG_CONFIG_HOME:-}" ] || [ -n "${LD_LIBRARY_PATH:-}" ] || env | grep -q \'^GIT_\'; then exit 78; fi', `printf '\\0${PROBE_MARKER}\\0%s\\0%s\\0%s\\0' "$PATH" "$_orca_git_path" "$HOME"` ].join('\n') - const script = escapeWslShCommandForWindows(buildWslLoginShellCommand(probeCommand)) + const script = buildWslLoginShellCommand(probeCommand) return new Promise((resolve) => { execFile( 'wsl.exe', - ['-d', distro, '--', 'sh', '-lc', script], + buildWslExecArgs(distro, ['sh', '-lc', script]), { encoding: 'utf8', maxBuffer: PROBE_MAX_BUFFER, diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index 08323e881e0..9876b1ab9ef 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -173,7 +173,7 @@ describe('runHook', () => { expect(result).toEqual({ success: true, output: '' }) expect(execFileMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'bash', '-c', "cd '/home/jin/feature' && echo hello"], + ['-d', 'Ubuntu', '--exec', 'bash', '-c', "cd '/home/jin/feature' && echo hello"], // #7652 regression: the unattended WSL hook branch must carry the // credential guard, and WSLENV is what carries it into the distro. expect.objectContaining({ @@ -238,7 +238,7 @@ describe('runHook', () => { [ '-d', 'Ubuntu', - '--', + '--exec', 'bash', '-c', "cd '/mnt/c/Users/jinwo/git/orca-feature' && echo hello" diff --git a/src/main/hooks.ts b/src/main/hooks.ts index 3ccb328a715..9b646ab6d62 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -181,7 +181,7 @@ export function runHook( const distroArgs = wslInfo.distro ? ['-d', wslInfo.distro] : [] child = execFile( 'wsl.exe', - [...distroArgs, '--', 'bash', '-c', bashCmd], + [...distroArgs, '--exec', 'bash', '-c', bashCmd], { timeout: HOOK_TIMEOUT, encoding: 'utf-8', diff --git a/src/main/ipc/filesystem-watcher-wsl.test.ts b/src/main/ipc/filesystem-watcher-wsl.test.ts index 5b1d1347a15..13346047d86 100644 --- a/src/main/ipc/filesystem-watcher-wsl.test.ts +++ b/src/main/ipc/filesystem-watcher-wsl.test.ts @@ -92,7 +92,7 @@ describe('createWslWatcher', () => { expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'sh', '-s', '--', '/home/me/repo'], + ['-d', 'Ubuntu', '--exec', 'sh', '-s', '--', '/home/me/repo'], expect.objectContaining({ stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true diff --git a/src/main/ipc/filesystem-watcher-wsl.ts b/src/main/ipc/filesystem-watcher-wsl.ts index 49ff8f20c7d..f2599f41d61 100644 --- a/src/main/ipc/filesystem-watcher-wsl.ts +++ b/src/main/ipc/filesystem-watcher-wsl.ts @@ -245,7 +245,7 @@ export async function createWslWatcher( throw new WatcherChildCapacityError() } try { - child = spawn('wsl.exe', ['-d', distro, '--', 'sh', '-s', '--', linuxPath], { + child = spawn('wsl.exe', ['-d', distro, '--exec', 'sh', '-s', '--', linuxPath], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }) diff --git a/src/main/ipc/preflight-agent-detection.test.ts b/src/main/ipc/preflight-agent-detection.test.ts index 17a407c167b..9fc2b5b09a0 100644 --- a/src/main/ipc/preflight-agent-detection.test.ts +++ b/src/main/ipc/preflight-agent-detection.test.ts @@ -412,7 +412,7 @@ describe('preflight', () => { expect.arrayContaining([ '-d', 'Ubuntu', - '--', + '--exec', 'sh', '-c', expect.stringContaining("'claude'") @@ -443,7 +443,7 @@ describe('preflight', () => { expect(resolveCliCommandsMock).not.toHaveBeenCalled() expect(execFileAsyncMock).toHaveBeenCalledWith( 'wsl.exe', - expect.arrayContaining(['--', 'sh', '-c', expect.stringContaining("'codex'")]), + expect.arrayContaining(['--exec', 'sh', '-c', expect.stringContaining("'codex'")]), { encoding: 'utf-8', timeout: 10000 } ) }) diff --git a/src/main/ipc/preflight-host-cli-status.test.ts b/src/main/ipc/preflight-host-cli-status.test.ts index 95ed81e2a9d..fb478c47e2f 100644 --- a/src/main/ipc/preflight-host-cli-status.test.ts +++ b/src/main/ipc/preflight-host-cli-status.test.ts @@ -282,12 +282,12 @@ describe('preflight', () => { expect(status.gh).toEqual({ installed: true, authenticated: true }) expect(execFileAsyncMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringMatching(/gh[\s\S]*--version/)], + ['-d', 'Ubuntu', '--exec', 'sh', '-c', expect.stringMatching(/gh[\s\S]*--version/)], { encoding: 'utf-8', timeout: 5000 } ) expect(execFileAsyncMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringMatching(/gh[\s\S]*auth status/)], + ['-d', 'Ubuntu', '--exec', 'sh', '-c', expect.stringMatching(/gh[\s\S]*auth status/)], { encoding: 'utf-8', timeout: 5000 } ) }) diff --git a/src/main/ipc/preflight-wsl-agent-detection.test.ts b/src/main/ipc/preflight-wsl-agent-detection.test.ts index 1853540d8db..dfb55f54250 100644 --- a/src/main/ipc/preflight-wsl-agent-detection.test.ts +++ b/src/main/ipc/preflight-wsl-agent-detection.test.ts @@ -17,14 +17,13 @@ vi.mock('child_process', () => { import { detectWslCommandsOnPath } from './preflight-wsl-agent-detection' import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' -import { escapeWslShCommandForWindows } from '../../shared/wsl-login-shell-command' function lastShCommandPayload(): string { const call = execFileAsyncMock.mock.calls.at(-1) expect(call).toBeDefined() const [file, args] = call as [string, string[]] expect(file).toBe('wsl.exe') - // args: [...distroArgs, '--', 'sh', '-c', ] + // args: [...distroArgs, '--exec', 'sh', '-c', ] return args.at(-1) as string } @@ -59,7 +58,7 @@ describe('detectWslCommandsOnPath', () => { kind: 'shell-variable', name: 'cmd' }) - expect(payload).toContain(escapeWslShCommandForWindows(lookupScript)) + expect(payload).toContain(lookupScript) expect(payload).not.toContain('type -P') }) diff --git a/src/main/ipc/preflight-wsl-agent-detection.ts b/src/main/ipc/preflight-wsl-agent-detection.ts index b5eff064325..0d8e4b2f0fa 100644 --- a/src/main/ipc/preflight-wsl-agent-detection.ts +++ b/src/main/ipc/preflight-wsl-agent-detection.ts @@ -2,10 +2,7 @@ import { execFile } from 'node:child_process' import { promisify } from 'node:util' import path from 'node:path' import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' -import { - buildWslLoginShellCommand, - escapeWslShCommandForWindows -} from '../../shared/wsl-login-shell-command' +import { buildWslExecArgs, buildWslLoginShellCommand } from '../../shared/wsl-login-shell-command' const execFileAsync = promisify(execFile) const WSL_AGENT_DETECTION_TIMEOUT_MS = 10000 @@ -58,16 +55,9 @@ async function execWslAgentDetectionCommand( target: WslPreflightTarget, command: string ): Promise<{ stdout: string; stderr: string }> { - const distroArgs = target.distro ? ['-d', target.distro] : [] const commandPromise = execFileAsync( 'wsl.exe', - [ - ...distroArgs, - '--', - 'sh', - '-c', - escapeWslShCommandForWindows(buildWslLoginShellCommand(command)) - ], + buildWslExecArgs(target.distro, ['sh', '-c', buildWslLoginShellCommand(command)]), { encoding: 'utf-8', timeout: WSL_AGENT_DETECTION_TIMEOUT_MS diff --git a/src/main/ipc/preflight-wsl-command.ts b/src/main/ipc/preflight-wsl-command.ts index 905740cef92..8128d9f065d 100644 --- a/src/main/ipc/preflight-wsl-command.ts +++ b/src/main/ipc/preflight-wsl-command.ts @@ -1,9 +1,6 @@ import { execFile } from 'node:child_process' import { promisify } from 'node:util' -import { - buildWslLoginShellCommand, - escapeWslShCommandForWindows -} from '../../shared/wsl-login-shell-command' +import { buildWslExecArgs, buildWslLoginShellCommand } from '../../shared/wsl-login-shell-command' import type { WslPreflightTarget } from './preflight-wsl-agent-detection' const execFileAsync = promisify(execFile) @@ -15,16 +12,9 @@ export function runPreflightCommandInWsl( command: string, timeoutMs: number ): Promise { - const distroArgs = target.distro ? ['-d', target.distro] : [] return execFileAsync( 'wsl.exe', - [ - ...distroArgs, - '--', - 'sh', - '-c', - escapeWslShCommandForWindows(buildWslLoginShellCommand(command)) - ], + buildWslExecArgs(target.distro, ['sh', '-c', buildWslLoginShellCommand(command)]), { encoding: 'utf-8', timeout: timeoutMs diff --git a/src/main/local-worktree-filesystem.ts b/src/main/local-worktree-filesystem.ts index 6cf50ed1fdc..bb293196ada 100644 --- a/src/main/local-worktree-filesystem.ts +++ b/src/main/local-worktree-filesystem.ts @@ -1,8 +1,8 @@ import { execFile } from 'node:child_process' import { lstat, readFile } from 'node:fs/promises' import { + buildWslExecArgs, buildWslLoginShellCommand, - escapeWslShCommandForWindows, quotePosixShell } from '../shared/wsl-login-shell-command' import { removeHostTree } from './host-tree-removal' @@ -58,14 +58,7 @@ function execFileText( function runWslLoginShellCommand(distro: string, command: string): Promise { return execFileText( 'wsl.exe', - [ - '-d', - distro, - '--', - 'sh', - '-lc', - escapeWslShCommandForWindows(buildWslLoginShellCommand(command)) - ], + buildWslExecArgs(distro, ['sh', '-lc', buildWslLoginShellCommand(command)]), { timeout: WSL_FILE_OPERATION_TIMEOUT_MS } ) } diff --git a/src/main/providers/local-pty-provider-windows-shell-launch.test.ts b/src/main/providers/local-pty-provider-windows-shell-launch.test.ts index 67c05f461b9..a77c57b2463 100644 --- a/src/main/providers/local-pty-provider-windows-shell-launch.test.ts +++ b/src/main/providers/local-pty-provider-windows-shell-launch.test.ts @@ -292,12 +292,12 @@ describe('LocalPtyProvider', () => { expect(spawnCall[1]).toEqual([ '-d', 'Debian', - '--', + '--exec', 'sh', '-c', expect.stringContaining("cd '/mnt/c/Users/jin/repo'") ]) - expect(spawnCall[1][5]).toContain('exec "\\$_orca_wsl_shell" -l') + expect(spawnCall[1][5]).toContain('exec "$_orca_wsl_shell" -l') expect(spawnCall[2].env.HISTFILE).toContain('terminal-history-wsl/Debian') }) @@ -321,7 +321,7 @@ describe('LocalPtyProvider', () => { expect(spawnCall[1]).toEqual([ '-d', 'Debian', - '--', + '--exec', 'sh', '-c', expect.stringContaining(`cd '${cwd}'`) @@ -371,7 +371,7 @@ describe('LocalPtyProvider', () => { expect(spawnCall[1]).toEqual([ '-d', 'Ubuntu', - '--', + '--exec', 'sh', '-c', expect.stringContaining("cd '/mnt/c/Users/jin/repo'") @@ -517,7 +517,7 @@ describe('LocalPtyProvider', () => { expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo/subdir'")], + ['-d', 'Ubuntu', '--exec', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo/subdir'")], expect.objectContaining({ cwd: expect.any(String) }) ) }) diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index f0e2e1be4b3..bb797da5b93 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -2,10 +2,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { - buildWslInteractiveLoginShellCommand, - escapeWslShCommandForWindows -} from '../../shared/wsl-login-shell-command' +import { buildWslInteractiveLoginShellCommand } from '../../shared/wsl-login-shell-command' import { resolveSetupRunnerCommand } from '../../shared/setup-runner-command' import { resolveWindowsShellLaunchArgs } from './windows-shell-args' @@ -15,7 +12,10 @@ const CMD_CODEX_LAUNCH_PREFLIGHT = function expectedWslArgs(linuxCwd: string, distro?: string): string[] { const command = `cd '${linuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && ${buildWslInteractiveLoginShellCommand()}` - const shellArgs = ['--', 'sh', '-c', escapeWslShCommandForWindows(command)] + // Why spelled out rather than calling buildWslExecArgs: deriving the + // expectation from the helper under test would still pass if it regressed + // to the `--` separator. + const shellArgs = ['--exec', 'sh', '-c', command] return distro ? ['-d', distro, ...shellArgs] : shellArgs } @@ -425,14 +425,12 @@ describe('resolveWindowsShellLaunchArgs', () => { // The injected sh cmd must not break out of the surrounding single quotes // when the path contains a ' character. expect(result.shellArgs[3]).toContain("cd '/mnt/c/weird'\\''path'") - expect(result.shellArgs[3]).toContain('exec "\\$_orca_wsl_shell" -l') + expect(result.shellArgs[3]).toContain('exec "$_orca_wsl_shell" -l') }) it('falls back to /mnt/c when cwd is not a drive-letter path', () => { const result = resolveWindowsShellLaunchArgs('wsl.exe', '\\\\server\\share', 'C:\\Users\\alice') - expect(result.shellArgs[3]).toContain( - 'cd \'/mnt/c\' && export PATH="\\$HOME/.local/bin:\\$PATH"' - ) + expect(result.shellArgs[3]).toContain('cd \'/mnt/c\' && export PATH="$HOME/.local/bin:$PATH"') }) it('keeps WSL UNC worktree cwd inside the matching distro', () => { diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index c7a5f3b8a53..dbd50df1eb1 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -2,8 +2,8 @@ import { win32 as pathWin32 } from 'node:path' import { isWindowsGitBashShellPath } from '../git-bash' import { parseWslPath, toLinuxPath, toWindowsWslPath } from '../wsl' import { + buildWslExecArgs, buildWslInteractiveLoginShellCommand, - escapeWslShCommandForWindows, quotePosixShell } from '../../shared/wsl-login-shell-command' import { getMarkerlessShellLaunchConfig } from './local-pty-shell-ready' @@ -146,8 +146,7 @@ function buildWslShellArgs(linuxCwd: string, distro?: string): string[] { ].join(' && ') // Why: WSL users often customize zsh rather than bash; launch the distro's // login shell so terminal PATH matches the environment Orca detects. - const shellArgs = ['--', 'sh', '-c', escapeWslShCommandForWindows(setupCommand)] - return distro ? ['-d', distro, ...shellArgs] : shellArgs + return buildWslExecArgs(distro, ['sh', '-c', setupCommand]) } /** Converts an MSYS drive spelling to the native cwd used by Windows terminal processes. */ diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index b7a8c96dfba..10feb7e90b2 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -267,7 +267,7 @@ export async function fetchViaPty(options?: { ? [ '-d', wslConfig.distro, - '--', + '--exec', 'bash', '-lc', // Why: Windows-side env does not cross into the distro without WSLENV, diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index 2f7ee834d93..32900412ec0 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -709,15 +709,15 @@ describe('fetchCodexRateLimits', () => { const [spawnFile, spawnArgs, spawnOptions] = childSpawnMock.mock.calls[0] expect(spawnFile).toBe('wsl.exe') - expect(spawnArgs.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--', 'sh', '-c']) + expect(spawnArgs.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', 'sh', '-c']) const shellCommand = spawnArgs.at(-1) as string - expect(shellCommand).toContain('_orca_wsl_shell=\\$(getent passwd') - expect(shellCommand).toContain('bash|zsh|ksh|mksh|ash) exec "\\$_orca_wsl_shell" -ilc') + expect(shellCommand).toContain('_orca_wsl_shell=$(getent passwd') + expect(shellCommand).toContain('bash|zsh|ksh|mksh|ash) exec "$_orca_wsl_shell" -ilc') expect(shellCommand).toContain( 'exec 3<&0\nexec 4>&1\nexec /dev/null\n_orca_wsl_shell=' ) - expect(shellCommand).toContain('mkdir -p "\\$orca_rate_limit_cwd"') - expect(shellCommand).toContain('cd "\\$orca_rate_limit_cwd"') + expect(shellCommand).toContain('mkdir -p "$orca_rate_limit_cwd"') + expect(shellCommand).toContain('cd "$orca_rate_limit_cwd"') expect(shellCommand).toContain( "export CODEX_HOME='\\''/home/alice/.local/share/orca/account/home'\\''" ) @@ -830,16 +830,16 @@ describe('fetchCodexRateLimits', () => { const [spawnFile, spawnArgs, spawnOptions] = ptySpawnMock.mock.calls[0] expect(spawnFile).toBe('wsl.exe') - expect(spawnArgs.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--', 'sh', '-c']) + expect(spawnArgs.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', 'sh', '-c']) const shellCommand = spawnArgs.at(-1) as string - expect(shellCommand).toContain('_orca_wsl_shell=\\$(getent passwd') - expect(shellCommand).toContain('bash|zsh|ksh|mksh|ash) exec "\\$_orca_wsl_shell" -ilc') + expect(shellCommand).toContain('_orca_wsl_shell=$(getent passwd') + expect(shellCommand).toContain('bash|zsh|ksh|mksh|ash) exec "$_orca_wsl_shell" -ilc') expect(shellCommand).not.toContain('exec 3<&0') expect(shellCommand).not.toContain('exec /dev/null') expect(shellCommand).not.toContain('<&3 >&4 3<&- 4>&-') - expect(shellCommand).toContain('mkdir -p "\\$orca_rate_limit_cwd"') - expect(shellCommand).toContain('cd "\\$orca_rate_limit_cwd"') + expect(shellCommand).toContain('mkdir -p "$orca_rate_limit_cwd"') + expect(shellCommand).toContain('cd "$orca_rate_limit_cwd"') expect(shellCommand).toContain( "export CODEX_HOME='\\''/home/alice/.local/share/orca/account/home'\\''" ) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 381875741d6..d0f3e34319a 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -24,10 +24,7 @@ import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils' import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup' import { parseWslUncPath } from '../../shared/wsl-paths' import { extractCodexAuthError, isCodexAuthError } from '../../shared/codex-auth-errors' -import { - buildWslLoginShellCommand, - escapeWslShCommandForWindows -} from '../../shared/wsl-login-shell-command' +import { buildWslExecArgs, buildWslLoginShellCommand } from '../../shared/wsl-login-shell-command' import { getHiddenRateLimitWslCwdSetupCommands, resolveHiddenRateLimitPtyCwd @@ -190,7 +187,7 @@ function buildWslCodexCommand( : loginShellCommand return { command: 'wsl.exe', - args: ['-d', wslInfo.distro, '--', 'sh', '-c', escapeWslShCommandForWindows(command)] + args: buildWslExecArgs(wslInfo.distro, ['sh', '-c', command]) } } diff --git a/src/main/skills/claude-plugin-skill-sources-wsl.ts b/src/main/skills/claude-plugin-skill-sources-wsl.ts index d3adddfde55..9806a092ee0 100644 --- a/src/main/skills/claude-plugin-skill-sources-wsl.ts +++ b/src/main/skills/claude-plugin-skill-sources-wsl.ts @@ -60,7 +60,7 @@ function executeWslMetadataRead(distro: string, command: string): Promise { execFile( 'wsl.exe', - ['-d', distro, '--', 'bash', '-c', command], + ['-d', distro, '--exec', 'bash', '-c', command], { encoding: 'utf8', maxBuffer: WSL_METADATA_MAX_BUFFER_BYTES, diff --git a/src/main/skills/skill-discovery-wsl.ts b/src/main/skills/skill-discovery-wsl.ts index e81dc069c94..b091fc714bf 100644 --- a/src/main/skills/skill-discovery-wsl.ts +++ b/src/main/skills/skill-discovery-wsl.ts @@ -56,7 +56,7 @@ function executeWslSkillDiscovery(distro: string, command: string): Promise { execFile( 'wsl.exe', - ['-d', distro, '--', 'bash', '-c', command], + ['-d', distro, '--exec', 'bash', '-c', command], { encoding: 'utf8', maxBuffer: WSL_SCAN_MAX_BUFFER_BYTES, diff --git a/src/main/skills/skill-wsl-provider-detection.ts b/src/main/skills/skill-wsl-provider-detection.ts index e3794e27b18..f296398550f 100644 --- a/src/main/skills/skill-wsl-provider-detection.ts +++ b/src/main/skills/skill-wsl-provider-detection.ts @@ -10,7 +10,7 @@ export function detectSkillProvidersInWsl(distro: string): Promise { return new Promise((resolve, reject) => { execFile( 'wsl.exe', - ['-d', distro, '--', 'sh', '-c', DETECTION_SCRIPT], + ['-d', distro, '--exec', 'sh', '-c', DETECTION_SCRIPT], { encoding: 'utf8', timeout: 10_000, windowsHide: true }, (error, stdout) => { if (error) { diff --git a/src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts b/src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts index 5eaf2237eb1..fcdbe4719cc 100644 --- a/src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts +++ b/src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts @@ -162,7 +162,7 @@ describe('generateCommitMessageFromContext', () => { }) expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu 24.04', '--', 'sh', '-lc', expect.any(String)], + ['-d', 'Ubuntu 24.04', '--exec', 'sh', '-lc', expect.any(String)], expect.objectContaining({ cwd: undefined, windowsHide: true, @@ -173,7 +173,7 @@ describe('generateCommitMessageFromContext', () => { expect(spawnEnv.ORCA_HOST_ONLY_SECRET).toBeUndefined() const shellCommand = spawnMock.mock.calls[0]?.[1]?.[5] as string expect(shellCommand).toContain('getent passwd') - expect(shellCommand).toContain('exec "\\$_orca_wsl_shell" -ilc') + expect(shellCommand).toContain('exec "$_orca_wsl_shell" -ilc') expect(shellCommand).toContain('/mnt/c/repo') expect(shellCommand).toContain("'agent'") expect(shellCommand).toContain('--mode') diff --git a/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts b/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts index abbbcaf7f3b..d54f1d995f6 100644 --- a/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts +++ b/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts @@ -233,7 +233,7 @@ describe('discoverCommitMessageModelsLocal', () => { }) expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'sh', '-lc', expect.any(String)], + ['-d', 'Ubuntu', '--exec', 'sh', '-lc', expect.any(String)], expect.objectContaining({ cwd: undefined, windowsHide: true diff --git a/src/main/wsl-bash-command.ts b/src/main/wsl-bash-command.ts index 1b561a1182f..307fbd52801 100644 --- a/src/main/wsl-bash-command.ts +++ b/src/main/wsl-bash-command.ts @@ -4,8 +4,9 @@ export function quoteBashString(value: string): string { } export function buildEncodedWslBashCommand(command: string): string { - // Why: wsl.exe preprocesses `$local_shell_vars` in command arguments before - // Bash sees them. Base64 keeps validation scripts intact across that boundary. + // Why: keeps a multi-line validation script intact as a single argument, and + // keeps quoting out of the caller's hands. (argv itself now survives verbatim + // via --exec; this is about the payload, not the wsl.exe boundary.) const encoded = Buffer.from(command, 'utf8').toString('base64') return `set -o pipefail; printf %s ${quoteBashString(encoded)} | base64 -d | bash` } diff --git a/src/main/wsl.test.ts b/src/main/wsl.test.ts index 719368e3af4..c433d3e48a6 100644 --- a/src/main/wsl.test.ts +++ b/src/main/wsl.test.ts @@ -816,7 +816,7 @@ describe('wslUncDirectoryExists', () => { [ '-d', 'Ubuntu', - '--', + '--exec', 'sh', '-c', expect.stringContaining('__ORCA_DIRECTORY_EXISTS__'), @@ -876,7 +876,7 @@ describe('wslUncDirectoryExistsAsync', () => { [ '-d', 'Ubuntu', - '--', + '--exec', 'sh', '-c', expect.stringContaining('__ORCA_DIRECTORY_EXISTS__'), diff --git a/src/main/wsl.ts b/src/main/wsl.ts index e3b783cc4b8..e09fbe02e4b 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -30,7 +30,7 @@ function getWslDirectoryProbeArgs(info: WslPathInfo): string[] { return [ '-d', info.distro, - '--', + '--exec', 'sh', '-c', `if [ -d "$1" ]; then printf ${WSL_DIRECTORY_EXISTS_MARKER}; else printf ${WSL_DIRECTORY_MISSING_MARKER}; fi`, @@ -262,7 +262,7 @@ export function getWslHome(distro: string): string | null { } try { - const home = execFileSync('wsl.exe', ['-d', distro, '--', 'bash', '-c', 'echo $HOME'], { + const home = execFileSync('wsl.exe', ['-d', distro, '--exec', 'bash', '-c', 'echo $HOME'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 @@ -293,7 +293,7 @@ export async function getWslHomeAsync(distro: string): Promise { try { const home = ( - await execFileUtf8('wsl.exe', ['-d', distro, '--', 'bash', '-c', 'echo $HOME']) + await execFileUtf8('wsl.exe', ['-d', distro, '--exec', 'bash', '-c', 'echo $HOME']) ).trim() if (!home || !home.startsWith('/')) { diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx index b362af3abff..31a0796ade4 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx @@ -17,7 +17,7 @@ import { } from './CliSkillRuntimeSetup' function decodeWslLoginShellScript(command: string): string { - const encoded = /-- sh -c 'eval \\"`printf %s ([A-Za-z0-9+/=]+) \| base64 -d`\\"'/.exec( + const encoded = /(?:--|--exec) sh -c 'eval \\"`printf %s ([A-Za-z0-9+/=]+) \| base64 -d`\\"'/.exec( command )?.[1] expect(encoded).toBeDefined() @@ -25,7 +25,7 @@ function decodeWslLoginShellScript(command: string): string { } function getWslOuterShellScript(command: string): string { - const script = /-- sh -c '([^']+)' \} # Runs:/.exec(command)?.[1] + const script = /(?:--|--exec) sh -c '([^']+)' \} # Runs:/.exec(command)?.[1] expect(script).toBeDefined() // Simulate PowerShell 5.1's native argv boundary consuming quote escapes. return script!.replaceAll('\\"', '"') @@ -49,7 +49,7 @@ describe('CliSkillRuntimeSetup runtime helpers', () => { expect(command).toBe(skillCommand) expect(setupCommand).toBe( - `& { $PSNativeCommandArgumentPassing = 'Legacy'; wsl.exe -d 'Ubuntu' -- sh -c 'eval \\"\`printf %s ${encoded} | base64 -d\`\\"' } # Runs: ${skillCommand}` + `& { $PSNativeCommandArgumentPassing = 'Legacy'; wsl.exe -d 'Ubuntu' --exec sh -c 'eval \\"\`printf %s ${encoded} | base64 -d\`\\"' } # Runs: ${skillCommand}` ) expect(decodeWslLoginShellScript(setupCommand)).toContain( 'exec "$_orca_wsl_shell" -ilc \'npx skills add orchestration --global\'' @@ -94,7 +94,7 @@ describe('CliSkillRuntimeSetup runtime helpers', () => { const setupCommand = buildSkillSetupTerminalCommand(command, 'powershell.exe', runtime, 'win32') expect(setupCommand).toMatch( - /^& \{ \$PSNativeCommandArgumentPassing = 'Legacy'; wsl\.exe -- sh -c 'eval \\"`printf/ + /^& \{ \$PSNativeCommandArgumentPassing = 'Legacy'; wsl\.exe --exec sh -c 'eval \\"`printf/ ) expect(setupCommand).toContain('`\\"\' } # Runs: npx skills update orchestration --global') }) diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx index 4206fb46c87..28573e9ba6a 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx @@ -165,7 +165,8 @@ function buildPowerShellWslSkillCommand(command: string, runtime: LocalAgentRunt const encodedScript = encodeWslLoginShellScript(command) const visibleCommand = command.replace(/[\r\n]+/g, ' ') const shellScript = `eval "\`printf %s ${encodedScript} | base64 -d\`"` - const wslCommand = `wsl.exe${distroArg} -- sh -c ${quotePowerShellNativeArgument(shellScript)}` + // Why --exec: `--` makes wsl.exe expand $name in the argv it forwards to the guest. + const wslCommand = `wsl.exe${distroArg} --exec sh -c ${quotePowerShellNativeArgument(shellScript)}` return `& { $PSNativeCommandArgumentPassing = 'Legacy'; ${wslCommand} } # Runs: ${visibleCommand}` } @@ -177,7 +178,8 @@ function decodeWslSetupTerminalCommand(command: string): string | null { return null } - const encoded = /-- sh -c 'eval \\"`printf %s ([A-Za-z0-9+/=]+) \| base64 -d`\\"'/.exec( + // Why both separators: commands persisted before the --exec switch must still decode. + const encoded = /(?:--|--exec) sh -c 'eval \\"`printf %s ([A-Za-z0-9+/=]+) \| base64 -d`\\"'/.exec( command )?.[1] if (!encoded) { diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx index 72fcede0b7b..40d6e6259ac 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx @@ -61,7 +61,7 @@ vi.mock('../settings/CliSkillRuntimeSetup', () => ({ runtime: { runtime: string; wslDistro?: string | null } ) => runtime.runtime === 'wsl' - ? `wsl.exe${runtime.wslDistro ? ` -d '${runtime.wslDistro}'` : ''} -- bash -lc '${command}'` + ? `wsl.exe${runtime.wslDistro ? ` -d '${runtime.wslDistro}'` : ''} --exec bash -lc '${command}'` : command, ensureWslCliAvailableForAgentSkillTerminal: mocks.ensureWslCli, getWslCliDistroRequest: (runtime?: { runtime: string; wslDistro?: string | null }) => diff --git a/src/shared/posix-command-path-lookup.test.ts b/src/shared/posix-command-path-lookup.test.ts index 1ec2a398d32..5fc883abd72 100644 --- a/src/shared/posix-command-path-lookup.test.ts +++ b/src/shared/posix-command-path-lookup.test.ts @@ -14,7 +14,7 @@ import { tmpdir } from 'node:os' import { basename, delimiter, dirname, isAbsolute, join } from 'node:path' import { describe, expect, it } from 'vitest' import { buildPosixCommandPathLookupScript } from './posix-command-path-lookup' -import { buildWslLoginShellCommand, escapeWslShCommandForWindows } from './wsl-login-shell-command' +import { buildWslExecArgs, buildWslLoginShellCommand } from './wsl-login-shell-command' type ShellCase = { name: string @@ -243,7 +243,7 @@ describe('buildPosixCommandPathLookupScript', () => { ) const resolved = execFileSync( 'wsl.exe', - ['--', 'sh', '-lc', escapeWslShCommandForWindows(command)], + buildWslExecArgs(undefined, ['sh', '-lc', command]), { encoding: 'utf8', timeout: WSL_TEST_COMMAND_TIMEOUT_MS } ).trim() @@ -266,7 +266,7 @@ function canRunWslSh(): boolean { return wslShAvailable } try { - execFileSync('wsl.exe', ['--', 'sh', '-lc', 'true'], { + execFileSync('wsl.exe', ['--exec', 'sh', '-lc', 'true'], { timeout: WSL_TEST_COMMAND_TIMEOUT_MS }) wslShAvailable = true diff --git a/src/shared/wsl-exec-mode-separator.test.ts b/src/shared/wsl-exec-mode-separator.test.ts new file mode 100644 index 00000000000..02b23268bfd --- /dev/null +++ b/src/shared/wsl-exec-mode-separator.test.ts @@ -0,0 +1,92 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +// Why: `wsl.exe <...> -- ` expands $name in every argument against the guest +// environment before the guest runs, silently rewriting scripts (an `awk '{print $2}'` +// loses its field reference). `--exec` passes argv through untouched. Nothing may +// reintroduce the `--` mode separator, so guard the tree instead of each call site. +// +// Two spellings reach wsl.exe, and both have shipped a regression: an argv array, and +// a command line built as a string (PowerShell setup commands). Comments describing +// the old form are allowed — only code is scanned. +// Guest programs Orca hands to wsl.exe. An allowlist rather than "any quoted +// token", because git's own `--` pathspec separator is followed by one too. +const GUEST_PROGRAM = String.raw`(?:\/[\w./-]+\/)?(?:sh|bash|zsh|dash|ash|ksh|mksh|env|rm|cat|printf|node|python3?)` +// `\s*` has to cross newlines: the formatter puts each argv element on its own +// line, which is the exact shape this guard exists to catch. +const ARGV_FORM = new RegExp(String.raw`'--',\s*'${GUEST_PROGRAM}'`) +const STRING_FORM = new RegExp(String.raw`wsl(?:\.exe)?\b[^\n]*?[^-]--\s+${GUEST_PROGRAM}\b`) + +const SCANNED_ROOTS = ['src', 'config', 'tests'] +const SCANNED_EXTENSIONS = ['.ts', '.tsx', '.mjs', '.js'] +const IGNORED_DIRECTORIES = new Set(['node_modules', 'dist', 'out', 'build', '.git']) + +function collectSourceFiles(root: string): string[] { + let found: string[] = [] + let entries: string[] + try { + entries = readdirSync(root) + } catch { + return found + } + for (const entry of entries) { + if (IGNORED_DIRECTORIES.has(entry)) { + continue + } + const full = join(root, entry) + if (statSync(full).isDirectory()) { + found = found.concat(collectSourceFiles(full)) + continue + } + if (SCANNED_EXTENSIONS.some((extension) => full.endsWith(extension))) { + found.push(full) + } + } + return found +} + +/** + * Drop comment-only lines, then rejoin. + * + * Why rejoin: an argv array is formatted one element per line, so matching each + * line in isolation cannot see `'--',\n 'bash'` — the shape the guard is for. + */ +function codeText(contents: string): string { + return contents + .split('\n') + .filter((line) => !/^\s*(?:\/\/|\/\*|\*)/.test(line)) + .join('\n') +} + +describe('wsl.exe mode separator', () => { + const repoRoot = resolve(__dirname, '..', '..') + const files = SCANNED_ROOTS.flatMap((root) => collectSourceFiles(join(repoRoot, root))) + + it('scans a plausible number of files', () => { + // A broken root or extension list would make the guard silently vacuous. + expect(files.length).toBeGreaterThan(500) + }) + + it.each([ + ['argv array', ARGV_FORM], + ['command string', STRING_FORM] + ])('never hands the guest shell to wsl.exe through `--` (%s)', (_form, pattern) => { + const offenders = files.filter((file) => { + const text = codeText(readFileSync(file, 'utf8')) + // Why scoped to WSL files: other tools take a `--` separator followed by a + // program too (tmux `split-window … -- cat`), and those are correct. + return /wsl/i.test(text) && pattern.test(text) + }) + + expect(offenders.map((file) => relative(repoRoot, file))).toEqual([]) + }) + + it('sees a `--` separator split across lines by the formatter', () => { + // Why: the guard once matched line-by-line and was blind to this exact shape, + // which is how every multi-element argv array in this repo is formatted. + const formatted = ["args: [", " '-d',", " distro,", " '--',", " 'bash'", "]"].join('\n') + + expect(ARGV_FORM.test(codeText(formatted))).toBe(true) + }) +}) diff --git a/src/shared/wsl-login-shell-command.test.ts b/src/shared/wsl-login-shell-command.test.ts index db9bee3a8d6..808e8ce5694 100644 --- a/src/shared/wsl-login-shell-command.test.ts +++ b/src/shared/wsl-login-shell-command.test.ts @@ -4,9 +4,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { + buildWslExecArgs, buildWslInteractiveLoginShellCommand, buildWslLoginShellCommand, - escapeWslShCommandForWindows, quotePosixShell } from './wsl-login-shell-command' @@ -21,7 +21,7 @@ function canRunWslSh(): boolean { return wslShAvailable } try { - execFileSync('wsl.exe', ['--', 'sh', '-lc', 'true'], { + execFileSync('wsl.exe', ['--exec', 'sh', '-lc', 'true'], { timeout: WSL_TEST_COMMAND_TIMEOUT_MS }) wslShAvailable = true @@ -46,7 +46,7 @@ function expectValidShSyntax(command: string): void { if (!canRunWslSh()) { return } - execFileSync('wsl.exe', ['--', 'sh', '-n'], { + execFileSync('wsl.exe', ['--exec', 'sh', '-n'], { input: command, timeout: WSL_TEST_COMMAND_TIMEOUT_MS }) @@ -122,54 +122,76 @@ describe('wsl login shell command helpers', () => { } ) - it('preserves command-scoped environment variables through the outer WSL shell', () => { + it('keeps command-scoped environment variables in the quoted payload', () => { const command = buildWslLoginShellCommand('HISTFILE=/tmp/orca-history printf "$HISTFILE"') - const escaped = escapeWslShCommandForWindows(command) expect(command).toContain('\'HISTFILE=/tmp/orca-history printf "$HISTFILE"\'') - expect(escaped).toContain('\\$_orca_wsl_shell') - expect(escaped).toContain('\\${SHELL:-/bin/bash}') - expect(escaped).toContain('\\$(getent passwd "\\$(id -un)"') - expect(escaped).toContain('\\$HISTFILE') expectValidShSyntax(command) }, 30_000) - it('does not double-escape wrapper shell variables', () => { - const command = 'echo \\$_orca_wsl_shell "$_orca_wsl_shell"' - - expect(escapeWslShCommandForWindows(command)).toBe( - 'echo \\$_orca_wsl_shell "\\$_orca_wsl_shell"' - ) + it('routes through --exec so wsl.exe cannot preprocess argv', () => { + expect(buildWslExecArgs('Ubuntu', ['sh', '-lc', 'printf "$HOME"'])).toEqual([ + '-d', + 'Ubuntu', + '--exec', + 'sh', + '-lc', + 'printf "$HOME"' + ]) + // A distro-less target still has to bypass the `--` preprocessor. + expect(buildWslExecArgs(undefined, ['sh', '-c', 'true'])).toEqual([ + '--exec', + 'sh', + '-c', + 'true' + ]) }) - it('escapes user command dollars inside POSIX-quoted payloads for WSL argv', () => { - const command = buildWslLoginShellCommand( - 'HISTFILE=/tmp/orca-history printf "$HISTFILE"; printf \'%s\' "$SHELL"' - ) - const escaped = escapeWslShCommandForWindows(command) - - expect(escaped).toContain( - "'HISTFILE=/tmp/orca-history printf \"\\$HISTFILE\"; printf '\\''%s'\\'' \"\\$SHELL\"'" - ) - expectValidShSyntax(command) - }, 30_000) - it('preserves user command variables across the Windows-to-WSL argv boundary', () => { if (!canRunWslSh()) { return } const command = buildWslLoginShellCommand('orca_value=ok; printf "<%s>" "$orca_value"') - const escaped = escapeWslShCommandForWindows(command) expect( - execFileSync('wsl.exe', ['--', 'sh', '-lc', escaped], { + execFileSync('wsl.exe', buildWslExecArgs(undefined, ['sh', '-lc', command]), { encoding: 'utf8', timeout: WSL_TEST_COMMAND_TIMEOUT_MS }) ).toBe('') }, 30_000) + // Why: `--` expands $name in argv against the guest env before the guest runs, + // so these scripts reached the shell already rewritten. Each case below was + // measured to return DIFFERENT bytes under `--` than under `--exec`; cases + // that merely look risky but are unaffected (a sed backreference has no `$`) + // are deliberately not here, because they would pass either way. + it.each([ + ['awk field reference', ['sh', '-c', `echo 'a b' | awk '{print $2}'`], 'b\n'], + ['literal escaped dollar', ['sh', '-c', `printf '[%s]' "\\$HOME"`], '[$HOME]'], + ['single-quoted dollar', ['sh', '-c', `printf '[%s]' '$PATH'`], '[$PATH]'], + // The shape wslUncDirectoryExists uses: `--` blanked $1, so every existing + // directory probed as missing. + ['positional argument', ['sh', '-c', 'printf "[%s]" "$1"', 'sh', 'ARG'], '[ARG]'], + ['shell local', ['sh', '-c', 'x=hi; printf "[%s]" "$x"'], '[hi]'] + ])( + 'passes %s to the guest byte-for-byte', + (_name, shellArgs, expected) => { + if (!canRunWslSh()) { + return + } + + expect( + execFileSync('wsl.exe', buildWslExecArgs(undefined, shellArgs), { + encoding: 'utf8', + timeout: WSL_TEST_COMMAND_TIMEOUT_MS + }) + ).toBe(expected) + }, + 30_000 + ) + it('starts an interactive login shell without assuming bash', () => { const command = buildWslInteractiveLoginShellCommand() diff --git a/src/shared/wsl-login-shell-command.ts b/src/shared/wsl-login-shell-command.ts index d95939eaa36..0c19c13394c 100644 --- a/src/shared/wsl-login-shell-command.ts +++ b/src/shared/wsl-login-shell-command.ts @@ -2,19 +2,21 @@ export function quotePosixShell(value: string): string { return `'${value.replace(/'/g, "'\\''")}'` } -export function escapeWslShCommandForWindows(command: string): string { - // WSL preprocesses unescaped $ in Windows argv before the WSL-side shell - // sees it, even when the POSIX script text would single-quote the dollar. - let escaped = '' - for (let index = 0; index < command.length; index += 1) { - const char = command[index] - if (char === '$' && command[index - 1] !== '\\') { - escaped += '\\$' - continue - } - escaped += char - } - return escaped +/** + * Build `wsl.exe` argv that hands a POSIX script to the guest byte-for-byte. + * + * Why: `wsl.exe -d -- ` expands `$name` in every argument against + * the guest environment before the guest ever runs — it does this even when no + * shell is in the command, so `-- /usr/bin/printf %s '$HOME'` prints /home/you. + * That silently rewrites scripts (`awk '{print $2}'` loses its field ref) and no + * amount of escaping on our side is reliable. `--exec` skips that preprocessing + * and passes argv through untouched, so scripts mean what they say. + */ +export function buildWslExecArgs( + distro: string | undefined, + shellArgs: readonly string[] +): string[] { + return [...(distro ? ['-d', distro] : []), '--exec', ...shellArgs] } export function buildWslLoginShellCommand(command: string): string { diff --git a/tests/e2e/terminal-windows-shell-paste-ownership.spec.ts b/tests/e2e/terminal-windows-shell-paste-ownership.spec.ts index ccee699c71a..43fad9122a2 100644 --- a/tests/e2e/terminal-windows-shell-paste-ownership.spec.ts +++ b/tests/e2e/terminal-windows-shell-paste-ownership.spec.ts @@ -26,7 +26,7 @@ function hasWslNodeRuntime(): boolean { return false } try { - execFileSync('wsl.exe', ['--', 'sh', '-lc', 'command -v node'], { + execFileSync('wsl.exe', ['--exec', 'sh', '-lc', 'command -v node'], { encoding: 'utf8', stdio: 'pipe', timeout: 15_000