mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
test: fail unit tests that run a real agent CLI from PATH (#38961)
* test: stop unit tests from running the developer's real agent CLIs A live relay test reached the host's real `claude --version` through the inherited PATH. On a machine whose claude wrapper never exits, each run left an orphaned loop behind (14 were found on one host, running for days). - Add a unit-test setup guard that shadows agent CLI names on PATH with stand-ins that fail the test which ran them. - Give the live relay test its own claude/codex stubs. - Kill the whole process tree when the WSL guest's version probe times out. * test: keep structured-session tests off the real codex CLI These hosts get a scripted transport, but the at-rest `/` command read spawns the resolved codex command directly, so on a machine with codex installed each run started a real `codex app-server`. Point them at a missing binary, as on CI. * test: isolate agent discovery in session fixtures
This commit is contained in:
@@ -15,6 +15,7 @@ export const UNIT_INCLUDE = [
|
||||
|
||||
export const UNIT_SETUP_FILES = [
|
||||
'config/scripts/test-runtime/environment/vitest-real-agent-home-write-guard.ts',
|
||||
'config/scripts/test-runtime/environment/vitest-real-agent-cli-guard.ts',
|
||||
'config/scripts/test-runtime/environment/vitest-bun-node-builtins.ts',
|
||||
'config/scripts/test-runtime/dom/vitest-dom-setup.ts',
|
||||
'config/scripts/test-runtime/environment/vitest-host-ports-setup.ts',
|
||||
|
||||
@@ -23,7 +23,8 @@ const BUILD_SCRIPTS = [
|
||||
'config/scripts/test-runtime/dom/happy-dom-offscreen-canvas.ts',
|
||||
'config/scripts/test-runtime/dom/happy-dom-mutation-observer-retention.ts',
|
||||
'config/scripts/test-runtime/environment/vitest-host-ports-setup.ts',
|
||||
'config/scripts/test-runtime/environment/vitest-real-agent-home-write-guard.ts'
|
||||
'config/scripts/test-runtime/environment/vitest-real-agent-home-write-guard.ts',
|
||||
'config/scripts/test-runtime/environment/vitest-real-agent-cli-guard.ts'
|
||||
]
|
||||
const NODE_SERVER_POLICY_TESTS = new Set([
|
||||
'config/scripts/node-server/qualification/node-server-change-scope.test.mjs'
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { takeRealAgentCliCalls } from './vitest-real-agent-cli-guard'
|
||||
|
||||
afterEach(() => {
|
||||
takeRealAgentCliCalls()
|
||||
})
|
||||
|
||||
describe.skipIf(!globalThis.orcaRealAgentCliGuard)('vitest real-agent-cli guard', () => {
|
||||
it('blocks version discovery without treating an absent agent as a test failure', () => {
|
||||
const result = spawnSync('claude', ['--version'], { encoding: 'utf8', env: process.env })
|
||||
expect(result.status).toBe(127)
|
||||
expect(takeRealAgentCliCalls()).toEqual([])
|
||||
})
|
||||
|
||||
it('blocks and records an agent CLI resolved from the inherited PATH', () => {
|
||||
const result = spawnSync('claude', ['-p', 'hello'], { encoding: 'utf8', env: process.env })
|
||||
expect(result.status).toBe(127)
|
||||
expect(result.stderr).toMatch(/real-agent-cli guard/)
|
||||
expect(takeRealAgentCliCalls()).toEqual(['claude -p hello'])
|
||||
})
|
||||
|
||||
it('records calls from a shell that keeps the inherited PATH', () => {
|
||||
spawnSync('/bin/sh', ['-c', 'copilot --help; codex exec x'], { env: process.env })
|
||||
expect(takeRealAgentCliCalls()).toEqual(['copilot --help', 'codex exec x'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { afterAll, afterEach } from 'vitest'
|
||||
|
||||
// Why: a test that inherits PATH can launch the developer's real agent CLI. A wrapper that never
|
||||
// exits there (e.g. a version-manager shim that re-resolves itself) outlives the worker as an
|
||||
// orphan loop, so stand-ins shadow those names and fail the test that reached them.
|
||||
const AGENT_CLI_NAMES = [
|
||||
'amp',
|
||||
'auggie',
|
||||
'claude',
|
||||
'codex',
|
||||
'copilot',
|
||||
'crush',
|
||||
'cursor-agent',
|
||||
'droid',
|
||||
'gemini',
|
||||
'goose',
|
||||
'grok',
|
||||
'kimi',
|
||||
'omp',
|
||||
'opencode',
|
||||
'pi',
|
||||
'qwen'
|
||||
]
|
||||
// Real-agent opt-in suites intentionally run the installed CLIs.
|
||||
const REAL_AGENT_CLI_SWITCHES = [
|
||||
'ORCA_REAL_CLAUDE_CLI_TEST',
|
||||
'ORCA_REAL_CLAUDE_SUPERVISED_STOP',
|
||||
'ORCA_CODEX_CONTRACT_BINARY',
|
||||
'ORCA_CODEX_NO_DAEMON_CONTRACT_BINARY',
|
||||
'ORCA_CODEX_TRUST_CONTRACT_BINARY',
|
||||
'ORCA_REPRO_CODEX_BINARY'
|
||||
]
|
||||
|
||||
type GuardState = { dir: string; log: string }
|
||||
|
||||
function quoteSh(value: string): string {
|
||||
return `'${value.replaceAll("'", `'\\''`)}'`
|
||||
}
|
||||
|
||||
function install(): GuardState | null {
|
||||
if (process.platform === 'win32' || REAL_AGENT_CLI_SWITCHES.some((name) => process.env[name])) {
|
||||
return null
|
||||
}
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-agent-cli-guard-'))
|
||||
const log = join(dir, 'calls.log')
|
||||
writeFileSync(log, '')
|
||||
const script = [
|
||||
'#!/bin/sh',
|
||||
// Discovery is expected in daemon tests; report the CLI as unavailable.
|
||||
'if [ "$#" -eq 1 ] && [ "$1" = "--version" ]; then exit 127; fi',
|
||||
`printf '%s %s\\n' "\${0##*/}" "$*" >> ${quoteSh(log)}`,
|
||||
`echo "[vitest real-agent-cli guard] blocked real \${0##*/}; give the test its own stub on PATH" >&2`,
|
||||
'exit 127',
|
||||
''
|
||||
].join('\n')
|
||||
for (const name of AGENT_CLI_NAMES) {
|
||||
const path = join(dir, name)
|
||||
writeFileSync(path, script)
|
||||
chmodSync(path, 0o755)
|
||||
}
|
||||
process.env.PATH = process.env.PATH ? `${dir}${delimiter}${process.env.PATH}` : dir
|
||||
process.once('exit', () => rmSync(dir, { recursive: true, force: true }))
|
||||
return { dir, log }
|
||||
}
|
||||
|
||||
declare global {
|
||||
// Install once per process; per-file installs would stack PATH entries.
|
||||
var orcaRealAgentCliGuard: GuardState | null | undefined
|
||||
}
|
||||
if (globalThis.orcaRealAgentCliGuard === undefined) {
|
||||
globalThis.orcaRealAgentCliGuard = install()
|
||||
}
|
||||
const state = globalThis.orcaRealAgentCliGuard
|
||||
|
||||
/** Drains recorded calls; only the guard's own self-test should need this. */
|
||||
export function takeRealAgentCliCalls(): string[] {
|
||||
if (!state) {
|
||||
return []
|
||||
}
|
||||
let text = ''
|
||||
try {
|
||||
text = readFileSync(state.log, 'utf8')
|
||||
writeFileSync(state.log, '')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
return text.split('\n').filter(Boolean)
|
||||
}
|
||||
|
||||
function failOnCalls(): void {
|
||||
const calls = takeRealAgentCliCalls()
|
||||
if (calls.length > 0) {
|
||||
throw new Error(
|
||||
`[vitest real-agent-cli guard] test ran an agent CLI from the inherited PATH:\n${calls.join('\n')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(failOnCalls)
|
||||
afterAll(failOnCalls)
|
||||
@@ -8,6 +8,7 @@
|
||||
"./scripts/test-runtime/environment/vitest-caller-identity-env-setup.ts",
|
||||
"./scripts/test-runtime/environment/vitest-inherited-git-env-setup.ts",
|
||||
"./scripts/test-runtime/environment/vitest-real-agent-home-write-guard.ts",
|
||||
"./scripts/test-runtime/environment/vitest-real-agent-cli-guard.ts",
|
||||
"./scripts/test-runtime/environment/vitest-bun-node-builtins.ts",
|
||||
"./scripts/test-runtime/dom/vitest-dom-setup.ts",
|
||||
"./scripts/test-runtime/dom/happy-dom-offscreen-canvas.ts",
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
// Run the real bundle, stdio mux, installers, and HTTP ingest using node in place of wsl.exe.
|
||||
import { execFileSync, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { createServer } from 'node:net'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -57,6 +65,16 @@ describe.skipIf(process.platform === 'win32')(
|
||||
|
||||
it('delivers a Claude hook POST from the live relay into ingestRemote and installs guest hooks', async () => {
|
||||
fakeHome = mkdtempSync(join('/tmp', 'wsl-live-home-'))
|
||||
// Guest detection runs `claude --version`; stub it so the relay never runs the host's real CLI.
|
||||
const agentBin = join(fakeHome, 'agent-bin')
|
||||
mkdirSync(agentBin)
|
||||
for (const [name, version] of [
|
||||
['claude', '2.1.0 (Claude Code)'],
|
||||
['codex', 'codex-cli 0.160.0']
|
||||
]) {
|
||||
writeFileSync(join(agentBin, name), `#!/bin/sh\necho '${version}'\n`)
|
||||
chmodSync(join(agentBin, name), 0o755)
|
||||
}
|
||||
const preferredPort = await pickFreePort()
|
||||
const version = readFileSync(join(BUNDLE_DIR, '.version'), 'utf8').trim()
|
||||
|
||||
@@ -87,9 +105,12 @@ describe.skipIf(process.platform === 'win32')(
|
||||
listDistros: async () => ['LiveDistro'],
|
||||
spawnRelay: (_distro, env) => {
|
||||
child = spawn(process.execPath, [BUNDLE_JS], {
|
||||
env: { ...env, HOME: fakeHome },
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
}) as ChildProcessWithoutNullStreams
|
||||
env: {
|
||||
...env,
|
||||
HOME: fakeHome,
|
||||
PATH: `${agentBin}${delimiter}${env.PATH ?? process.env.PATH ?? ''}`
|
||||
}
|
||||
})
|
||||
return child
|
||||
},
|
||||
runInstall: async () => {
|
||||
|
||||
+9
@@ -52,6 +52,15 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
export class AuthorityRuntime extends OrcaRuntimeService {
|
||||
protected override structuredAgentCatalogDependencies() {
|
||||
const missing = () => ({ command: join(root, 'agent-not-installed'), prefixArgs: [] })
|
||||
return {
|
||||
...super.structuredAgentCatalogDependencies(),
|
||||
resolveClaudeCommand: missing,
|
||||
resolveCodexCommand: missing
|
||||
}
|
||||
}
|
||||
|
||||
protected override async refreshMobileSessionPtyRecords() {
|
||||
return new Set<string>()
|
||||
}
|
||||
|
||||
@@ -121,7 +121,8 @@ describe('the chat strip and the session list read the same host child records',
|
||||
resolveWorkspacePath: async () => root,
|
||||
resolveLaunchArgs: () => [],
|
||||
resolveClaudeAuthPolicy: () => ({ account: 'managed' }),
|
||||
resolveCodexCommand: () => ({ command: 'codex', prefixArgs: [] }),
|
||||
// Side reads (at-rest `/` commands) spawn the CLI directly; a missing binary keeps them off the real codex.
|
||||
resolveCodexCommand: () => ({ command: join(root, 'codex-not-installed'), prefixArgs: [] }),
|
||||
resolveEnvironment: async () => ({ PATH: process.env.PATH }),
|
||||
...scriptedCodexTransport(openConnection),
|
||||
statusSink
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('structured Codex child work through the production runtime', () => {
|
||||
resolveWorkspacePath: async () => root!,
|
||||
resolveLaunchArgs: () => [],
|
||||
resolveClaudeAuthPolicy: () => ({ account: 'managed' }),
|
||||
resolveCodexCommand: () => ({ command: 'codex', prefixArgs: [] }),
|
||||
resolveCodexCommand: () => ({ command: join(root!, 'codex-not-installed'), prefixArgs: [] }),
|
||||
resolveEnvironment: async () => ({ PATH: process.env.PATH }),
|
||||
...scriptedCodexTransport(openConnection),
|
||||
statusSink: {
|
||||
|
||||
@@ -151,6 +151,8 @@ async function probeCommandVersion(executablePath: string): Promise<string | nul
|
||||
? `${executableDir}${path.delimiter}${inheritedPath}`
|
||||
: executableDir
|
||||
},
|
||||
// Why: a CLI wrapper that never exits keeps its children alive after a root-only kill.
|
||||
terminationBarrier: true,
|
||||
timeoutMs: 5_000,
|
||||
maxOutputBytes: 4_096
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
fakeCodex,
|
||||
THREAD_ID as CODEX_THREAD_ID
|
||||
@@ -519,7 +520,7 @@ it('publishes accepted Codex fresh policy to desktop and phone without stale sta
|
||||
resolveWorkspacePath: async () => root,
|
||||
resolveLaunchArgs: () => [],
|
||||
resolveClaudeAuthPolicy: () => ({ account: 'managed' as const }),
|
||||
resolveCodexCommand: () => ({ command: 'codex', prefixArgs: [] }),
|
||||
resolveCodexCommand: () => ({ command: join(root, 'codex-not-installed'), prefixArgs: [] }),
|
||||
readCodexInstallation: async () => ({ version: '0.136.0' }),
|
||||
readProcessStartTime: async () => 1_700_000_000_000,
|
||||
openCodexConnection: codex.openConnection
|
||||
|
||||
Reference in New Issue
Block a user