fix(wsl): pass guest argv verbatim through --exec (#15039)

* fix(wsl): pass guest argv verbatim through --exec

`wsl.exe <...> -- <argv>` 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 <...> -- <binary>` 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>
This commit is contained in:
OrcaWin
2026-08-18 02:12:21 -07:00
committed by GitHub
co-authored by OrcaWin
parent 96d88dd5fe
commit 6e8da1df8d
55 changed files with 320 additions and 242 deletions
@@ -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,
+5 -8
View File
@@ -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) {
@@ -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'"
@@ -1090,7 +1090,7 @@ export class ClaudeRuntimeAuthService {
[
'-d',
wslInfo.distro,
'--',
'--exec',
'bash',
'-lc',
buildEncodedWslBashCommand(
+6 -6
View File
@@ -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(' ')}`
+1 -1
View File
@@ -484,7 +484,7 @@ async function runWslCommand(distro: string, command: string): Promise<string> {
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
@@ -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',
[
@@ -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]
@@ -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')
}
+5 -5
View File
@@ -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(
@@ -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')
})
})
+2 -9
View File
@@ -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)])
}
@@ -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"`,
@@ -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"`,
@@ -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))')
})
})
+6 -10
View File
@@ -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 {
@@ -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) })
)
}
@@ -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')
})
@@ -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
}
}
+3 -3
View File
@@ -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)
)
+6 -6
View File
@@ -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)
)
+3 -10
View File
@@ -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,
+3 -6
View File
@@ -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<ProbeOutcome> {
'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,
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
})
@@ -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 }
)
})
@@ -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 }
)
})
@@ -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', <payload>]
// args: [...distroArgs, '--exec', 'sh', '-c', <payload>]
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')
})
+2 -12
View File
@@ -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
+2 -12
View File
@@ -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<PreflightWslCommandResult> {
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
+2 -9
View File
@@ -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<ExecFileTextResult> {
return execFileText(
'wsl.exe',
[
'-d',
distro,
'--',
'sh',
'-lc',
escapeWslShCommandForWindows(buildWslLoginShellCommand(command))
],
buildWslExecArgs(distro, ['sh', '-lc', buildWslLoginShellCommand(command)]),
{ timeout: WSL_FILE_OPERATION_TIMEOUT_MS }
)
}
@@ -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) })
)
})
@@ -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', () => {
+2 -3
View File
@@ -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. */
+1 -1
View File
@@ -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,
+10 -10
View File
@@ -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\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('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'\\''"
)
+2 -5
View File
@@ -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])
}
}
@@ -60,7 +60,7 @@ function executeWslMetadataRead(distro: string, command: string): Promise<string
return new Promise((resolve, reject) => {
execFile(
'wsl.exe',
['-d', distro, '--', 'bash', '-c', command],
['-d', distro, '--exec', 'bash', '-c', command],
{
encoding: 'utf8',
maxBuffer: WSL_METADATA_MAX_BUFFER_BYTES,
+1 -1
View File
@@ -56,7 +56,7 @@ function executeWslSkillDiscovery(distro: string, command: string): Promise<stri
return new Promise((resolve, reject) => {
execFile(
'wsl.exe',
['-d', distro, '--', 'bash', '-c', command],
['-d', distro, '--exec', 'bash', '-c', command],
{
encoding: 'utf8',
maxBuffer: WSL_SCAN_MAX_BUFFER_BYTES,
@@ -10,7 +10,7 @@ export function detectSkillProvidersInWsl(distro: string): Promise<string[]> {
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) {
@@ -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')
@@ -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
+3 -2
View File
@@ -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`
}
+2 -2
View File
@@ -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__'),
+3 -3
View File
@@ -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<string | null> {
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('/')) {
@@ -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')
})
@@ -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) {
@@ -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 }) =>
+3 -3
View File
@@ -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
@@ -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 <...> -- <argv>` 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)
})
})
+51 -29
View File
@@ -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('<ok>')
}, 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()
+15 -13
View File
@@ -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 <distro> -- <argv>` 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 {
@@ -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