diff --git a/config/scripts/ci/unit-selection/dependency-graph/ci-unit-files.mjs b/config/scripts/ci/unit-selection/dependency-graph/ci-unit-files.mjs index 14a875a8d23..8275bac1a2e 100644 --- a/config/scripts/ci/unit-selection/dependency-graph/ci-unit-files.mjs +++ b/config/scripts/ci/unit-selection/dependency-graph/ci-unit-files.mjs @@ -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', diff --git a/config/scripts/node-server/qualification/node-server-change-scope.mjs b/config/scripts/node-server/qualification/node-server-change-scope.mjs index 471da5ec1f9..ee5990e62e7 100644 --- a/config/scripts/node-server/qualification/node-server-change-scope.mjs +++ b/config/scripts/node-server/qualification/node-server-change-scope.mjs @@ -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' diff --git a/config/scripts/test-runtime/environment/vitest-real-agent-cli-guard.test.ts b/config/scripts/test-runtime/environment/vitest-real-agent-cli-guard.test.ts new file mode 100644 index 00000000000..8a06f5e35f2 --- /dev/null +++ b/config/scripts/test-runtime/environment/vitest-real-agent-cli-guard.test.ts @@ -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']) + }) +}) diff --git a/config/scripts/test-runtime/environment/vitest-real-agent-cli-guard.ts b/config/scripts/test-runtime/environment/vitest-real-agent-cli-guard.ts new file mode 100644 index 00000000000..66bc28396a1 --- /dev/null +++ b/config/scripts/test-runtime/environment/vitest-real-agent-cli-guard.ts @@ -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) diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index cf479b7db9c..711be001db2 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -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", diff --git a/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts index ff5e36fe1fd..98a192efd91 100644 --- a/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.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 () => { diff --git a/src/main/runtime/agent-session/lifecycle/structured-agent-session-tab-authority.test-support.ts b/src/main/runtime/agent-session/lifecycle/structured-agent-session-tab-authority.test-support.ts index a8fe04039ed..9e4595cbc73 100644 --- a/src/main/runtime/agent-session/lifecycle/structured-agent-session-tab-authority.test-support.ts +++ b/src/main/runtime/agent-session/lifecycle/structured-agent-session-tab-authority.test-support.ts @@ -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() } diff --git a/src/main/runtime/structured-child-records-switch.test.ts b/src/main/runtime/structured-child-records-switch.test.ts index b6a0ba7bdf9..bdff58e1ee2 100644 --- a/src/main/runtime/structured-child-records-switch.test.ts +++ b/src/main/runtime/structured-child-records-switch.test.ts @@ -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 diff --git a/src/main/runtime/structured-codex-child-work-runtime.test.ts b/src/main/runtime/structured-codex-child-work-runtime.test.ts index f33c49a3b80..239dafe4a55 100644 --- a/src/main/runtime/structured-codex-child-work-runtime.test.ts +++ b/src/main/runtime/structured-codex-child-work-runtime.test.ts @@ -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: { diff --git a/src/wsl-guest/preflight-handler.ts b/src/wsl-guest/preflight-handler.ts index 572deeba25a..d043db27b02 100644 --- a/src/wsl-guest/preflight-handler.ts +++ b/src/wsl-guest/preflight-handler.ts @@ -151,6 +151,8 @@ async function probeCommandVersion(executablePath: string): Promise 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