mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 00:03:55 +00:00
* 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>
212 lines
7.2 KiB
TypeScript
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()
|
|
})
|
|
})
|