mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(' ')}`
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'\\''"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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 }) =>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user