mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
The pairing ratchet matched spawn|spawnProcess|spawnSync|runProcess only, so
a resolved CLI handed to execFile was the same unpaired launch with none of
the enforcement. codex-trust-grant-host.ts resolves codex and calls
execFileSync, and escaped the ratchet purely through that omission.
Widen to the exec/fork family. The negative lookbehind keeps method calls
such as `RE.exec(` out, which is what made the bare `exec` name safe to
include; a fixture mutation confirms `/x/.exec('x')` does not trip it, and
adding execFileSync(resolvedCli) to a paired file does.
codex-trust-grant-host is allowlisted rather than changed: its only exec is
a wsl.exe identity probe for the binary stamp, and its actual codex launch
is a CodexAppServerInvocation paired centrally in codex-app-server-session.
The entry records what would invalidate it.
106 lines
3.7 KiB
TypeScript
106 lines
3.7 KiB
TypeScript
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
import { join, relative, resolve } from 'node:path'
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
/**
|
|
* Guard the CLI/runtime pairing at the tree level rather than per call site.
|
|
*
|
|
* `resolveCliCommand` falls back to scanning every version-manager install, so
|
|
* it can return a CLI from one Node version while PATH leads with another. The
|
|
* CLI's `#!/usr/bin/env node` shebang then picks the wrong runtime and a native
|
|
* module dies on NODE_MODULE_VERSION (stablyai/orca#10932). Six call sites got
|
|
* this wrong at once because each made the decision separately.
|
|
*
|
|
* Pairing is one call — `withCliRuntimeOnPath` — and this test is what stops the
|
|
* next spawn site from forgetting it.
|
|
*/
|
|
const ALLOWLIST: readonly string[] = readFileSync(
|
|
join(__dirname, '__fixtures__', 'cli-runtime-pairing-allowlist.txt'),
|
|
'utf8'
|
|
)
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0 && !line.startsWith('#'))
|
|
|
|
// Why no call-paren: several sites pass the resolver as a value into a
|
|
// dependency bag (`resolveCommand: resolveCodexCommand`) and call it later, so
|
|
// requiring `(` here let exactly that shape through the ratchet unnoticed.
|
|
const RESOLVES_CLI = /\b(?:resolveCliCommand|resolveCodexCommand|resolveClaudeCommand)\b/
|
|
// Why the exec/fork family too: a resolved CLI handed to execFile is the same
|
|
// unpaired launch as spawn. The first draft matched only the spawn names, and
|
|
// codex-trust-grant-host.ts — which resolves a CLI and calls execFileSync — was
|
|
// invisible to the ratchet purely through that omission.
|
|
// The lookbehind keeps `RE.exec(` and other method calls out.
|
|
const SPAWNS =
|
|
/(?<!\.)\b(?:spawn|spawnProcess|spawnSync|runProcess|execFile|execFileSync|execSync|exec|fork)\s*\(/
|
|
const PAIRS = /\bwithCliRuntimeOnPath\b/
|
|
|
|
const SCANNED_ROOTS = ['src/main', 'src/shared', 'src/cli']
|
|
const IGNORED_DIRECTORIES = new Set([
|
|
'node_modules',
|
|
'dist',
|
|
'out',
|
|
'build',
|
|
'.git',
|
|
'__fixtures__'
|
|
])
|
|
|
|
function isTestFile(path: string): boolean {
|
|
return /\.(?:test|spec)\.tsx?$/.test(path) || path.includes('/__tests__/')
|
|
}
|
|
|
|
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 path = join(root, entry)
|
|
if (statSync(path).isDirectory()) {
|
|
found = found.concat(collectSourceFiles(path))
|
|
} else if (/\.tsx?$/.test(path) && !isTestFile(path)) {
|
|
found.push(path)
|
|
}
|
|
}
|
|
return found
|
|
}
|
|
|
|
describe('CLI runtime pairing boundary', () => {
|
|
const repoRoot = resolve(__dirname, '..', '..')
|
|
|
|
function offenders(): string[] {
|
|
return SCANNED_ROOTS.flatMap((scanRoot) => collectSourceFiles(join(repoRoot, scanRoot)))
|
|
.filter((path) => {
|
|
const source = readFileSync(path, 'utf8')
|
|
return RESOLVES_CLI.test(source) && SPAWNS.test(source) && !PAIRS.test(source)
|
|
})
|
|
.map((path) => relative(repoRoot, path).split('\\').join('/'))
|
|
.sort()
|
|
}
|
|
|
|
it('pairs every resolved CLI with its own node runtime', () => {
|
|
expect(offenders()).toEqual([...ALLOWLIST].sort())
|
|
})
|
|
|
|
it('keeps the allowlist honest — every entry still resolves and spawns a CLI', () => {
|
|
const stale = ALLOWLIST.filter((entry) => {
|
|
let source: string
|
|
try {
|
|
source = readFileSync(join(repoRoot, entry), 'utf8')
|
|
} catch {
|
|
return true
|
|
}
|
|
return !(RESOLVES_CLI.test(source) && SPAWNS.test(source)) || PAIRS.test(source)
|
|
})
|
|
// Why: an entry that no longer needs the exemption must be deleted, not left
|
|
// to quietly re-authorize a future edit to the same file.
|
|
expect(stale).toEqual([])
|
|
})
|
|
})
|