Files
orca/src/main/codex/codex-wsl-hook-install-plan.test.ts
T
OrcaWinandOrcaWin 6e8da1df8d 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>
2026-08-18 02:12:21 -07:00

212 lines
7.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const execFileMock = vi.hoisted(() => vi.fn())
vi.mock('node:child_process', () => ({
execFile: execFileMock
}))
import { _internals, createCodexWslRuntimeHookInstallPlan } from './codex-wsl-hook-install-plan'
const originalPlatform = process.platform
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
}
beforeEach(() => {
execFileMock.mockReset()
_internals.resetWslCanonicalPathCache()
})
afterEach(() => {
setPlatform(originalPlatform)
})
describe('canonicalizeWslLinuxPath', () => {
it('joins guest paths without producing a double slash at the filesystem root', () => {
const plan = createCodexWslRuntimeHookInstallPlan(
'C:\\runtime',
{ runtime: 'wsl', wslDistro: 'Ubuntu' },
() => '/'
)
expect(plan?.commandScriptPath).toBe('/.orca/agent-hooks/codex-hook.sh')
expect(plan?.trustConfigPath).toBe('/hooks.json')
})
it('returns the path unchanged off Windows without spawning wsl.exe', () => {
setPlatform('linux')
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alice')).toBe('/home/alice')
expect(execFileMock).not.toHaveBeenCalled()
})
it('never blocks: returns null and schedules an async resolution on first call', () => {
setPlatform('win32')
const result = _internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
expect(result).toBeNull()
expect(execFileMock).toHaveBeenCalledTimes(1)
const [file, args] = execFileMock.mock.calls[0]
expect(file).toBe('wsl.exe')
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"`,
'sh',
'/home/alias'
])
})
it('returns the cached path while revalidating it asynchronously', () => {
setPlatform('win32')
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBeNull()
const callback = execFileMock.mock.calls[0][3] as (error: Error | null, stdout: string) => void
callback(null, '/home/alice\n')
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBe('/home/alice')
expect(execFileMock).toHaveBeenCalledTimes(2)
})
it('resolves a custom automount root and notifies the first launch', () => {
setPlatform('win32')
const settled = vi.fn()
const windowsPath = 'D:\\orca\\codex-runtime-home\\home'
expect(
_internals.canonicalizeWslLinuxPath(
'Ubuntu',
'/mnt/d/orca/codex-runtime-home/home',
windowsPath,
settled
)
).toBeNull()
const [file, args, options, callback] = execFileMock.mock.calls[0]
expect(file).toBe('wsl.exe')
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"`,
'sh',
windowsPath
])
expect(options).toMatchObject({ timeout: 5000, windowsHide: true })
callback(null, '/windows/d/orca/codex-runtime-home/home\n')
expect(settled).toHaveBeenCalledWith({
status: 'resolved',
canonicalPath: '/windows/d/orca/codex-runtime-home/home'
})
})
it('does not spawn a second subprocess while one is in flight', () => {
setPlatform('win32')
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
expect(execFileMock).toHaveBeenCalledTimes(1)
})
it('retries after a failed resolution rather than caching the failure', () => {
setPlatform('win32')
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
const callback = execFileMock.mock.calls[0][3] as (error: Error | null, stdout: string) => void
callback(new Error('wsl unreachable'), '')
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBeNull()
expect(execFileMock).toHaveBeenCalledTimes(2)
})
it('keeps the last known-good cache when revalidation later fails', () => {
setPlatform('win32')
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
const firstCallback = execFileMock.mock.calls[0][3] as (
error: Error | null,
stdout: string
) => void
firstCallback(null, '/home/alice\n')
const settled = vi.fn()
expect(
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias', '/home/alias', settled)
).toBe('/home/alice')
const secondCallback = execFileMock.mock.calls[1][3] as (
error: Error | null,
stdout: string
) => void
secondCallback(new Error('path disappeared'), '')
// Why: a cold or wedged distro times out without proving the path changed.
// Dropping the cache would force the next launch onto /mnt/... and rewrite
// trust under a path Codex will not use when automount is customized.
expect(settled).toHaveBeenCalledWith({ status: 'unavailable' })
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBe('/home/alice')
})
it('drops the cached identity when WSL confirms the path is missing', () => {
setPlatform('win32')
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
const firstCallback = execFileMock.mock.calls[0][3] as (
error: Error | null,
stdout: string
) => void
firstCallback(null, '/home/alice\n')
const settled = vi.fn()
expect(
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias', '/home/alias', settled)
).toBe('/home/alice')
const secondCallback = execFileMock.mock.calls[1][3] as (
error: Error | null,
stdout: string
) => void
secondCallback(null, '__ORCA_WSL_PATH_MISSING__\n')
expect(settled).toHaveBeenCalledWith({ status: 'missing' })
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBeNull()
})
it('replaces a cached path when its canonical identity changes', () => {
setPlatform('win32')
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
const firstCallback = execFileMock.mock.calls[0][3] as (
error: Error | null,
stdout: string
) => void
firstCallback(null, '/home/alice-old\n')
const settled = vi.fn()
expect(
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias', '/home/alias', settled)
).toBe('/home/alice-old')
const secondCallback = execFileMock.mock.calls[1][3] as (
error: Error | null,
stdout: string
) => void
secondCallback(null, '/home/alice-new\n')
expect(settled).toHaveBeenCalledWith({
status: 'resolved',
canonicalPath: '/home/alice-new'
})
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBe('/home/alice-new')
})
it('ignores non-absolute readlink output', () => {
setPlatform('win32')
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
const callback = execFileMock.mock.calls[0][3] as (error: Error | null, stdout: string) => void
callback(null, 'readlink: missing operand\n')
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBeNull()
})
})