mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 08:07:18 +00:00
* fix(codex): heal WSL hooks before typed launches * test(codex): keep launcher fixture type-safe on Windows * fix(build): list codex-home-wsl-env in the CLI typecheck project `managed-home-shell-preflight.ts` is already in the CLI project's include list and now imports `wslCodexRuntimeHomeForGuestHome` from `src/main/pty/codex-home-wsl-env.ts`, which the list did not cover — TS6307, so the CLI typecheck failed on every push. Added the single module rather than a `src/main/pty/**` glob: it is a 31-line leaf with no imports of its own, so it does not widen what the CLI bundle can reach. * fix(codex): converge the two WSL hook install lanes onto one writer Two independent readiness reviews agreed the Orca-terminal boundary holds, but Codex Sol found a P1 the other rated P2: the new just-in-time repair raced the existing relay installer and the two produced DIFFERENT hook and trust representations for the same managed home. Two unserialized writers emitting different formats is worse than the bug this PR fixes, because it fails intermittently rather than cleanly — a pane works or does not depending on which lane won. - Relay Codex installs now delegate to the runtime-home writer, so there is one canonical representation instead of two. Redirected scripts use the runtime path, the readable wrapper, and the prepended group. - `installForRuntimeHomeSerialized` puts every asynchronous WSL caller for a given home on one queue (`wslInstallQueues`), so concurrent panes cannot interleave writes. Also rewrites the stale pin test the new `-x` guard broke. It asserted the defect — "would run the impostor if the preflight carried an unqualified command name", expecting the hijack marker to exist. The guard is a security improvement, so the test now asserts the contract: an unqualified preflight is skipped and the marker is never written. Rewritten to the new behavior, not loosened or deleted. 818 tests pass across the affected suites; typecheck clean. The changed-file quality gate could not run locally — its pnpm engine-warning JSON parser fails under Node 26 — so CI covers it. The boundary both reviews verified is untouched: paired/relay/mobile clients stay hard-blocked from the RPC, params remain shape-locked to the managed home suffix with traversal rejection, nothing is written outside the managed home, and macOS/Linux stay inert. * fix(codex): serialize resolved WSL hook homes * fix(codex): recover managed WSL homes after restart * fix(wsl): translate Codex preflight through WSLENV * fix(cli): cover bounded WSL Codex repair * fix(codex): coalesce duplicate WSL hook repairs * fix(codex): verify reconstructed WSL homes
176 lines
7.1 KiB
TypeScript
176 lines
7.1 KiB
TypeScript
import { execFileSync } from 'node:child_process'
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync
|
|
} from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { delimiter, join } from 'node:path'
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { getBundledLauncherPath } from '../cli/bundled-cli-launcher-path'
|
|
import { getDaemonBashShellReadyRcfileContent } from '../daemon/daemon-bash-shell-ready-rcfile'
|
|
import { resolveCodexShellLaunchPreflightCommand } from './codex-shell-launch-preflight'
|
|
|
|
// Why (STA-4270): the generated rcfile sources /etc/profile and the user's profile
|
|
// *before* it defines the codex() wrapper, so a profile PATH prepend decides what an
|
|
// unqualified preflight command resolves to. This drives the real generated rcfile
|
|
// through a real bash and uses filesystem markers — not scraped output — as the oracle.
|
|
|
|
const roots: string[] = []
|
|
const bashAvailable = process.platform !== 'win32' && existsSync('/bin/bash')
|
|
|
|
type Fixture = {
|
|
root: string
|
|
resourcesPath: string
|
|
intendedMarker: string
|
|
hijackMarker: string
|
|
codexMarker: string
|
|
postSnippetMarker: string
|
|
homePath: string
|
|
}
|
|
|
|
function writeStub(path: string, markerPath: string): void {
|
|
mkdirSync(join(path, '..'), { recursive: true })
|
|
writeFileSync(path, `#!/bin/sh\nprintf '%s\\n' "$*" > ${JSON.stringify(markerPath)}\n`)
|
|
chmodSync(path, 0o755)
|
|
}
|
|
|
|
function buildFixture(options: { aliasCodex?: boolean } = {}): Fixture {
|
|
const root = mkdtempSync(join(tmpdir(), 'orca-codex-profile-path-'))
|
|
roots.push(root)
|
|
const resourcesPath = join(root, 'resources')
|
|
const hijackDir = join(root, 'hijack')
|
|
const codexDir = join(root, 'codex-bin')
|
|
const homePath = join(root, 'home')
|
|
const intendedMarker = join(root, 'intended-ran')
|
|
const hijackMarker = join(root, 'hijack-ran')
|
|
const codexMarker = join(root, 'codex-ran')
|
|
const postSnippetMarker = join(root, 'rcfile-continued')
|
|
mkdirSync(hijackDir, { recursive: true })
|
|
mkdirSync(codexDir, { recursive: true })
|
|
mkdirSync(homePath, { recursive: true })
|
|
|
|
// The CLI Orca ships, at the absolute path Orca controls.
|
|
writeStub(getBundledLauncherPath(process.platform, resourcesPath) as string, intendedMarker)
|
|
// The impostor a user's own bin directory could hold under every CLI name Orca uses.
|
|
for (const name of ['orca', 'orca-ide', 'orca-dev']) {
|
|
writeStub(join(hijackDir, name), hijackMarker)
|
|
}
|
|
writeStub(join(codexDir, 'codex'), codexMarker)
|
|
|
|
// Why: /etc/profile runs first and macOS path_helper rebuilds PATH from /etc/paths,
|
|
// so the profile must re-prepend both dirs to model a real user's dotfile winning.
|
|
const aliasLine = options.aliasCodex
|
|
? `alias codex='GIT_AUTHOR_NAME=Codex codex --alias-flag'\n`
|
|
: ''
|
|
writeFileSync(
|
|
join(homePath, '.bash_profile'),
|
|
`export PATH=${JSON.stringify(codexDir)}:"$PATH"\nexport PATH=${JSON.stringify(hijackDir)}:"$PATH"\n${aliasLine}`
|
|
)
|
|
return {
|
|
root,
|
|
resourcesPath,
|
|
intendedMarker,
|
|
hijackMarker,
|
|
codexMarker,
|
|
postSnippetMarker,
|
|
homePath
|
|
}
|
|
}
|
|
|
|
function launchCodexThroughRcfile(fixture: Fixture, preflightValue: string): void {
|
|
const rcfilePath = join(fixture.root, 'rcfile')
|
|
writeFileSync(rcfilePath, getDaemonBashShellReadyRcfileContent(), 'utf8')
|
|
// Why the second command: the rcfile defines __orca_osc133_preexec *below* the
|
|
// codex wrapper, so its presence proves bash parsed past the snippet.
|
|
// Why the trailing `:` — the markers are this file's oracle, so a failed probe
|
|
// must surface as a missing marker, not an opaque non-zero exit from bash.
|
|
const command = `codex --version; declare -F __orca_osc133_preexec >/dev/null && printf ran > ${JSON.stringify(fixture.postSnippetMarker)}; :`
|
|
execFileSync('/bin/bash', ['--rcfile', rcfilePath, '-i', '-c', command], {
|
|
stdio: 'ignore',
|
|
env: {
|
|
HOME: fixture.homePath,
|
|
// Why: the hijack dir is deliberately absent here — only the profile adds it.
|
|
PATH: ['/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(delimiter),
|
|
TERM: 'dumb',
|
|
SHELL: '/bin/bash',
|
|
// Why no ORCA_SHELL_FEATURES: absent means no features, so the rcfile
|
|
// emits neither the identity nor the readiness marker into stdout.
|
|
ORCA_CODEX_HOME: join(fixture.root, 'codex-home'),
|
|
ORCA_CODEX_LAUNCH_PREFLIGHT: preflightValue
|
|
}
|
|
})
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const root of roots.splice(0)) {
|
|
rmSync(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
describe.skipIf(!bashAvailable)('Codex preflight under a profile-rewritten PATH', () => {
|
|
it('sources the user profile before emitting the codex wrapper', () => {
|
|
const rcfile = getDaemonBashShellReadyRcfileContent()
|
|
|
|
expect(rcfile.indexOf('source "$HOME/.bash_profile"')).toBeLessThan(
|
|
rcfile.indexOf('ORCA_CODEX_LAUNCH_PREFLIGHT')
|
|
)
|
|
})
|
|
|
|
it('runs the bundled CLI the resolver picked, not the impostor the profile put first', () => {
|
|
const fixture = buildFixture()
|
|
const preflightCommand = resolveCodexShellLaunchPreflightCommand({
|
|
hooksEnabled: true,
|
|
isPackaged: true,
|
|
managedHomePath: join(fixture.root, 'codex-home'),
|
|
userDataPath: join(fixture.root, 'user-data'),
|
|
resourcesPath: fixture.resourcesPath
|
|
})
|
|
expect(preflightCommand).toBe(getBundledLauncherPath(process.platform, fixture.resourcesPath))
|
|
|
|
launchCodexThroughRcfile(fixture, preflightCommand as string)
|
|
|
|
expect(existsSync(fixture.intendedMarker)).toBe(true)
|
|
expect(existsSync(fixture.hijackMarker)).toBe(false)
|
|
// Guard against a vacuous pass: the wrapper must still have reached `command codex`.
|
|
expect(existsSync(fixture.codexMarker)).toBe(true)
|
|
})
|
|
|
|
// Why: a user alias named codex used to expand into the wrapper's own `codex()`
|
|
// header, and bash abandons the rest of the rcfile at that syntax error — taking
|
|
// the OSC 133 hooks and the shell-ready marker defined below it with it.
|
|
it('finishes the rcfile and preserves the alias when the user aliased the name codex', () => {
|
|
const fixture = buildFixture({ aliasCodex: true })
|
|
const preflightCommand = resolveCodexShellLaunchPreflightCommand({
|
|
hooksEnabled: true,
|
|
isPackaged: true,
|
|
managedHomePath: join(fixture.root, 'codex-home'),
|
|
userDataPath: join(fixture.root, 'user-data'),
|
|
resourcesPath: fixture.resourcesPath
|
|
})
|
|
|
|
launchCodexThroughRcfile(fixture, preflightCommand as string)
|
|
|
|
expect(existsSync(fixture.postSnippetMarker)).toBe(true)
|
|
expect(existsSync(fixture.intendedMarker)).toBe(true)
|
|
expect(existsSync(fixture.hijackMarker)).toBe(false)
|
|
// The user's own alias must survive and still reach the binary.
|
|
expect(readFileSync(fixture.codexMarker, 'utf-8').trim()).toBe('--alias-flag --version')
|
|
})
|
|
|
|
// Why: an unqualified preflight is rejected so profile PATH entries cannot hijack it.
|
|
it('skips an unqualified preflight value while still launching codex', () => {
|
|
const fixture = buildFixture()
|
|
|
|
launchCodexThroughRcfile(fixture, 'orca')
|
|
|
|
expect(existsSync(fixture.hijackMarker)).toBe(false)
|
|
expect(existsSync(fixture.intendedMarker)).toBe(false)
|
|
expect(existsSync(fixture.codexMarker)).toBe(true)
|
|
})
|
|
})
|