mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
* fix(wsl): read machine output from a fenced login shell Orca runs WSL reads through the distro's *interactive* login shell so PATH matches the user's own terminal (nvm, mise and asdf only install into rc files interactive shells read). An interactive shell also runs the distro's rc/motd, and stock Ubuntu 24.04 writes its "run a command as administrator" hint to stdout -- no user customization required. Every caller parsing that stream was reading the banner as data: statPath -> "To run a command as administrator...\n\ndirectory" readPath -> banner prepended to the contents of every file read preflight -> banner prepended to `gh --version` / auth output `.trim()` cannot recover any of these, so a WSL worktree's file explorer sees no valid entry types and file reads return junk. Three call sites had independently grown their own marker to survive this (`__ORCA_AGENT_PATH__`, `ORCA_WSL_GIT_READ_ENV_V1`, and a `>/dev/null` fd dance), which is the tell that it belongs in one place. Fence the payload once, in the shared builder, and hand callers a reader that returns just their bytes. The fence carries a per-call nonce so `cat`-ing a file that happens to quote a marker is not truncated. Exit status is preserved, so the ENOENT mapping still works. wsl-git-read-environment drops its bespoke marker and parsing. * test(wsl): fence the login-shell path-lookup boundary test It asserted a raw interactive login-shell read matched an absolute path, so the distro rc banner made it fail on any stock Ubuntu. It is part of the shell-contracts CI gate, where it skips on Linux and hid the break. * docs(wsl): record the guest command-execution contract Both failure modes are silent - the command runs, exits 0, and returns the wrong bytes - so the rules need to live somewhere a reader will find them before writing the next wsl.exe call site. * fix(codex): fence the WSL Codex identity probe buildWslCodexBinaryStamp reads the login shell's stdout positionally -- path before the first newline, version after -- through an interactive login shell. On a stock Ubuntu the rc banner lands ahead of the payload, so the first newline falls inside the banner and the stamp becomes path="To run a command as administrator..." with the rest as version. Both halves are non-empty, so nothing throws: the stamp is silently wrong, and an unstable stamp reads as "the Codex binary changed" and reissues the trust grant. The identity script ends in `exec`, so it never writes a closing fence; the reader returns everything after the opening one, which is exactly this case. buildWslCodexIdentityArgs becomes buildWslCodexIdentityProbe and returns the reader with the argv so the two cannot drift apart. The other three WSL Codex commands are deliberately left unfenced: availability is exit-code only, and app-server/login hand stdout to a long-running program. * fix(wsl): harden the capture fence after review - readStdout now takes the LAST opening fence, matching the lastIndexOf the wsl-git-read-environment marker used deliberately: a login shell can echo the command text before running it, repeating the fence. - local-worktree-filesystem throws instead of falling back to raw stdout when the fence is missing. The fallback silently reinstated the bug being fixed -- statPath would return the banner as a file type and readPath would return banner+contents, with no signal. Preflight keeps its fallback; its matchers scan the whole blob and tolerate a prefix. - The exit-status test asserted only that the script CONTAINS `exit $?`, which is true for any input and never executed those lines. It now runs a real distro and asserts status 2 reaches the caller, which is what statPath's ENOENT mapping depends on. - Corrected the doc: a sed backreference has no `$`, so `--` never rewrote it. Replaced with the positional and shell-local cases that were measured to differ. * fix(wsl): stop running a login shell for filesystem reads statPath/readPath/rm run coreutils at standard paths and shell builtins. They need nothing from the user's PATH, so there was never a reason to start a login shell -- and starting one is what put the distro's rc/motd on the stdout these callers parse. Fencing that output treated the symptom. Using a plain `sh -c` removes the cause: no profile, no rc, no banner, by construction. The fence and its missing-fence error go away with it. The fence stays where it is actually needed: the three places that must run the user's shell to resolve their PATH (the preflight CLI probe, the WSL git environment probe, and the Codex identity probe). Net -12 lines. --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
315 lines
10 KiB
TypeScript
315 lines
10 KiB
TypeScript
import { execFileSync } from 'node:child_process'
|
|
import {
|
|
accessSync,
|
|
chmodSync,
|
|
constants,
|
|
existsSync,
|
|
mkdtempSync,
|
|
mkdirSync,
|
|
realpathSync,
|
|
rmSync,
|
|
writeFileSync
|
|
} from 'node:fs'
|
|
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 { buildWslCapturedLoginShellCommand, buildWslExecArgs } from './wsl-login-shell-command'
|
|
|
|
type ShellCase = {
|
|
name: string
|
|
path: string | null
|
|
}
|
|
|
|
const isWindows = process.platform === 'win32'
|
|
const WSL_TEST_COMMAND_TIMEOUT_MS = 10_000
|
|
let wslShAvailable: boolean | null = null
|
|
const shellCases: ShellCase[] = [
|
|
{ name: 'sh', path: executablePath(['/bin/sh']) },
|
|
{ name: 'bash', path: executablePath(['/bin/bash', '/usr/bin/bash']) },
|
|
{ name: 'zsh', path: executablePath(['/bin/zsh', '/usr/bin/zsh']) },
|
|
{ name: 'dash', path: executablePath(['/bin/dash', '/usr/bin/dash']) }
|
|
]
|
|
|
|
describe('buildPosixCommandPathLookupScript', () => {
|
|
for (const shell of shellCases) {
|
|
it.skipIf(isWindows || shell.path === null)(
|
|
`resolves without mutating alias and function masks in ${shell.name}`,
|
|
() => {
|
|
const commandName = basename(process.execPath)
|
|
const script = [
|
|
`${commandName}() { printf '%s\\n' masked-function; }`,
|
|
`alias ${commandName}='printf "%s\\n" masked-alias'`,
|
|
buildPosixCommandPathLookupScript({ kind: 'literal', value: commandName }),
|
|
`printf '%s\\n' "$resolved"`,
|
|
`alias ${commandName} >/dev/null`,
|
|
`unalias ${commandName}`,
|
|
`${commandName}`
|
|
].join('\n')
|
|
|
|
const resolved = execFileSync(shell.path!, ['-c', script], {
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${dirname(process.execPath)}${delimiter}${process.env.PATH ?? ''}`
|
|
}
|
|
})
|
|
.trim()
|
|
.split('\n')
|
|
|
|
expect(isAbsolute(resolved[0])).toBe(true)
|
|
expect(realpathSync(resolved[0])).toBe(realpathSync(process.execPath))
|
|
expect(resolved[1]).toBe('masked-function')
|
|
}
|
|
)
|
|
}
|
|
|
|
it.skipIf(isWindows || executablePath(['/bin/sh']) === null)(
|
|
'resolves a command held in a validated shell variable',
|
|
() => {
|
|
const commandName = basename(process.execPath)
|
|
const script = [
|
|
`cmd='${commandName}'`,
|
|
`${commandName}() { printf '%s\\n' masked-function; }`,
|
|
`alias ${commandName}='printf "%s\\n" masked-alias'`,
|
|
buildPosixCommandPathLookupScript({ kind: 'shell-variable', name: 'cmd' }),
|
|
`printf '%s\\n' "$resolved"`
|
|
].join('\n')
|
|
|
|
const resolved = execFileSync('/bin/sh', ['-c', script], {
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${dirname(process.execPath)}${delimiter}${process.env.PATH ?? ''}`
|
|
}
|
|
}).trim()
|
|
|
|
expect(isAbsolute(resolved)).toBe(true)
|
|
expect(realpathSync(resolved)).toBe(realpathSync(process.execPath))
|
|
}
|
|
)
|
|
|
|
it.skipIf(isWindows || executablePath(['/bin/bash', '/usr/bin/bash']) === null)(
|
|
'resolves past a readonly bash function mask',
|
|
() => {
|
|
const commandName = basename(process.execPath)
|
|
const script = [
|
|
`${commandName}() { printf '%s\\n' masked-function; }`,
|
|
`readonly -f ${commandName}`,
|
|
buildPosixCommandPathLookupScript({ kind: 'literal', value: commandName }),
|
|
`printf '%s\\n' "$resolved"`
|
|
].join('\n')
|
|
|
|
const resolved = execFileSync(
|
|
executablePath(['/bin/bash', '/usr/bin/bash'])!,
|
|
['-c', script],
|
|
{
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${dirname(process.execPath)}${delimiter}${process.env.PATH ?? ''}`
|
|
}
|
|
}
|
|
).trim()
|
|
|
|
expect(realpathSync(resolved)).toBe(realpathSync(process.execPath))
|
|
}
|
|
)
|
|
|
|
it.skipIf(isWindows || executablePath(['/bin/sh']) === null)(
|
|
'prefers the first external executable even when its name is a shell builtin',
|
|
() => {
|
|
withExecutableFixture('printf', (directory, executable, root) => {
|
|
const secondDirectory = join(root, 'second-bin')
|
|
const secondExecutable = join(secondDirectory, 'printf')
|
|
mkdirSync(secondDirectory)
|
|
writeFileSync(secondExecutable, '#!/bin/sh\nexit 0\n')
|
|
chmodSync(secondExecutable, 0o755)
|
|
const script = [
|
|
buildPosixCommandPathLookupScript({ kind: 'literal', value: 'printf' }),
|
|
`printf '%s\\n' "$resolved"`
|
|
].join('\n')
|
|
const resolved = execFileSync('/bin/sh', ['-c', script], {
|
|
encoding: 'utf8',
|
|
env: { ...process.env, PATH: `${directory}:${secondDirectory}` }
|
|
}).trim()
|
|
|
|
expectResolvedExecutable(resolved, executable)
|
|
})
|
|
}
|
|
)
|
|
|
|
it.skipIf(isWindows || executablePath(['/bin/sh']) === null)(
|
|
'makes relative and trailing-empty PATH matches absolute',
|
|
() => {
|
|
withExecutableFixture('relative-agent', (directory, executable, root) => {
|
|
const relativeDirectory = basename(directory)
|
|
const script = [
|
|
buildPosixCommandPathLookupScript({ kind: 'literal', value: 'relative-agent' }),
|
|
`printf '%s\\n' "$resolved"`
|
|
].join('\n')
|
|
const relativeResolved = execFileSync('/bin/sh', ['-c', script], {
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
env: { ...process.env, PATH: `${relativeDirectory}:` }
|
|
}).trim()
|
|
const trailingResolved = execFileSync('/bin/sh', ['-c', script], {
|
|
cwd: directory,
|
|
encoding: 'utf8',
|
|
env: { ...process.env, PATH: '/missing:' }
|
|
}).trim()
|
|
const emptyResolved = execFileSync('/bin/sh', ['-c', script], {
|
|
cwd: directory,
|
|
encoding: 'utf8',
|
|
env: { ...process.env, PATH: ':/missing' }
|
|
}).trim()
|
|
|
|
expectResolvedExecutable(relativeResolved, executable)
|
|
expectResolvedExecutable(trailingResolved, executable)
|
|
expectResolvedExecutable(emptyResolved, executable)
|
|
})
|
|
}
|
|
)
|
|
|
|
it.skipIf(isWindows || executablePath(['/bin/sh']) === null)(
|
|
'handles leading-dash names and explicit relative paths',
|
|
() => {
|
|
withExecutableFixture('-agent', (directory, executable) => {
|
|
const script = [
|
|
buildPosixCommandPathLookupScript({ kind: 'shell-variable', name: 'cmd' }),
|
|
`printf '%s\\n' "$resolved"`,
|
|
buildPosixCommandPathLookupScript({ kind: 'literal', value: './-agent' }),
|
|
`printf '%s\\n' "$resolved"`
|
|
].join('\n')
|
|
const output = execFileSync('/bin/sh', ['-c', script], {
|
|
cwd: directory,
|
|
encoding: 'utf8',
|
|
env: { ...process.env, PATH: directory, cmd: '-agent' }
|
|
})
|
|
.trim()
|
|
.split('\n')
|
|
|
|
expectResolvedExecutable(output[0], executable)
|
|
expectResolvedExecutable(output[1], executable)
|
|
})
|
|
}
|
|
)
|
|
|
|
it.skipIf(isWindows || executablePath(['/bin/sh']) === null)(
|
|
'keeps set -e callers running when the command is absent',
|
|
() => {
|
|
const script = [
|
|
'set -e',
|
|
buildPosixCommandPathLookupScript({
|
|
kind: 'literal',
|
|
value: '__orca_missing_command_path_lookup__'
|
|
}),
|
|
`printf '%s\\n' survived`
|
|
].join('\n')
|
|
|
|
expect(execFileSync('/bin/sh', ['-c', script], { encoding: 'utf8' }).trim()).toBe('survived')
|
|
}
|
|
)
|
|
|
|
it.each(['', '$cmd', 'cmd-name', 'cmd; echo injected'])(
|
|
'rejects an unsafe shell variable name: %s',
|
|
(name) => {
|
|
expect(() => buildPosixCommandPathLookupScript({ kind: 'shell-variable', name })).toThrow(
|
|
'Invalid shell variable name'
|
|
)
|
|
}
|
|
)
|
|
|
|
it('quotes literal targets before assigning them in the generated shell fragment', () => {
|
|
const script = buildPosixCommandPathLookupScript({
|
|
kind: 'literal',
|
|
value: "agent'; echo injected; '"
|
|
})
|
|
|
|
expect(script).toContain(`_orca_lookup_command='agent'\\''; echo injected; '\\'''`)
|
|
})
|
|
|
|
it.skipIf(!canRunWslSh())(
|
|
'resolves through the Windows-to-WSL login-shell boundary with inline masks',
|
|
() => {
|
|
const lookup = buildPosixCommandPathLookupScript({ kind: 'literal', value: 'sh' })
|
|
// Why the captured form: an interactive login shell also prints the distro's
|
|
// rc/motd to stdout, which would land in front of the resolved path.
|
|
const captured = buildWslCapturedLoginShellCommand(
|
|
[
|
|
`sh() { printf '%s\\n' masked-function; }`,
|
|
`alias sh='printf masked-alias'`,
|
|
lookup,
|
|
`printf '%s' "$resolved"`
|
|
].join('\n')
|
|
)
|
|
const stdout = execFileSync(
|
|
'wsl.exe',
|
|
buildWslExecArgs(undefined, ['sh', '-lc', captured.command]),
|
|
{ encoding: 'utf8', timeout: WSL_TEST_COMMAND_TIMEOUT_MS }
|
|
)
|
|
|
|
expect(captured.readStdout(stdout)?.trim()).toMatch(/^\/.+\/sh$/)
|
|
},
|
|
30_000
|
|
)
|
|
})
|
|
|
|
function expectResolvedExecutable(resolved: string, executable: string): void {
|
|
expect(isAbsolute(resolved)).toBe(true)
|
|
expect(realpathSync(resolved)).toBe(realpathSync(executable))
|
|
}
|
|
|
|
function canRunWslSh(): boolean {
|
|
if (!isWindows) {
|
|
return false
|
|
}
|
|
if (wslShAvailable !== null) {
|
|
return wslShAvailable
|
|
}
|
|
try {
|
|
execFileSync('wsl.exe', ['--exec', 'sh', '-lc', 'true'], {
|
|
timeout: WSL_TEST_COMMAND_TIMEOUT_MS
|
|
})
|
|
wslShAvailable = true
|
|
} catch {
|
|
wslShAvailable = false
|
|
}
|
|
return wslShAvailable
|
|
}
|
|
|
|
function withExecutableFixture(
|
|
name: string,
|
|
run: (directory: string, executable: string, root: string) => void
|
|
): void {
|
|
const root = mkdtempSync(join(tmpdir(), 'orca-path-lookup-'))
|
|
const directory = join(root, 'bin')
|
|
const executable = join(directory, name)
|
|
try {
|
|
mkdirSync(directory)
|
|
writeFileSync(executable, '#!/bin/sh\nexit 0\n')
|
|
chmodSync(executable, 0o755)
|
|
run(directory, executable, root)
|
|
} finally {
|
|
rmSync(root, { force: true, recursive: true })
|
|
}
|
|
}
|
|
|
|
function executablePath(candidates: readonly string[]): string | null {
|
|
if (isWindows) {
|
|
return null
|
|
}
|
|
for (const candidate of candidates) {
|
|
if (!existsSync(candidate)) {
|
|
continue
|
|
}
|
|
try {
|
|
accessSync(candidate, constants.X_OK)
|
|
return candidate
|
|
} catch {
|
|
// Keep checking alternate standard locations when this entry is not executable.
|
|
}
|
|
}
|
|
return null
|
|
}
|