diff --git a/src/main/command-code/command-code-managed-script.ts b/src/main/command-code/command-code-managed-script.ts index 0c3894f44c3..34a41d97fa1 100644 --- a/src/main/command-code/command-code-managed-script.ts +++ b/src/main/command-code/command-code-managed-script.ts @@ -1,4 +1,5 @@ import { posixCurlCommand } from '../agent-hooks/installation/scripts/hook-post-command' +import { ORCA_AGENT_HOOKS_DISABLED_ENV } from '../../shared/agent-hook-listener/transport/agent-hook-suppression-env' import { buildWindowsAgentHookPostCommand } from '../agent-hooks/installation/commands/windows-agent-hook-post-command' import { buildPosixHookPayloadCapture, @@ -15,6 +16,7 @@ export function buildCommandCodeManagedScript( return [ '@echo off', 'setlocal', + `if "%${ORCA_AGENT_HOOKS_DISABLED_ENV}%"=="1" exit /b 0`, 'if "%ORCA_AGENT_HOOK_PORT%"=="" if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" if not "%ORCA_AGENT_HOOK_PORT%"=="" call :sourceEndpointByPort', ...buildWindowsHookEnvironmentGuardLines(), @@ -42,18 +44,22 @@ export function buildCommandCodeManagedScript( return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + `if [ "\${${ORCA_AGENT_HOOKS_DISABLED_ENV}:-}" = 1 ]; then exit 0; fi`, ...buildPosixHookSpoolLines('command-code'), '__orca_read_ancestor_var() {', ' __orca_name="$1"', ' __orca_pid="${PPID:-}"', ' while [ -n "$__orca_pid" ] && [ "$__orca_pid" != "0" ] && [ "$__orca_pid" != "1" ]; do', - ' __orca_value=""', + ' __orca_env=""', ' if [ -r "/proc/$__orca_pid/environ" ]; then', - ' __orca_value=$(tr "\\000" "\\n" < "/proc/$__orca_pid/environ" 2>/dev/null | sed -n "s/^${__orca_name}=//p" | head -n 1)', + ' __orca_env=$(tr "\\000" "\\n" < "/proc/$__orca_pid/environ" 2>/dev/null)', ' fi', - ' if [ -z "$__orca_value" ]; then', - ' __orca_value=$(ps eww -p "$__orca_pid" -o command= 2>/dev/null | tr " " "\\n" | sed -n "s/^${__orca_name}=//p" | head -n 1)', + ' if [ -z "$__orca_env" ]; then', + ' __orca_env=$(ps eww -p "$__orca_pid" -o command= 2>/dev/null | tr " " "\\n")', ' fi', + // A scrubbed hook may recover identity, but must never cross a background launch. + ` if printf '%s\\n' "$__orca_env" | grep -qx '${ORCA_AGENT_HOOKS_DISABLED_ENV}=1'; then return 1; fi`, + ' __orca_value=$(printf "%s\\n" "$__orca_env" | sed -n "s/^${__orca_name}=//p" | head -n 1)', ' if [ -n "$__orca_value" ]; then', ' printf "%s\\n" "$__orca_value"', ' return 0', diff --git a/src/main/command-code/hook-service.test.ts b/src/main/command-code/hook-service.test.ts index 6dbfd741d5c..3127607b844 100644 --- a/src/main/command-code/hook-service.test.ts +++ b/src/main/command-code/hook-service.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { spawn } from 'node:child_process' +import { spawnProcess } from '@orca/process-host' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { createServer } from 'node:http' import { tmpdir } from 'node:os' @@ -35,6 +36,80 @@ describe('CommandCodeHookService', () => { const itPosix = process.platform === 'win32' ? it.skip : it + itPosix( + 'stops identity recovery at a background ancestor even when the hook env is scrubbed', + async () => { + new CommandCodeHookService().install() + const requests: string[] = [] + const server = createServer((req, res) => { + let body = '' + req.setEncoding('utf8') + req.on('data', (chunk) => (body += chunk)) + req.on('end', () => { + requests.push(new URLSearchParams(body).get('paneKey') ?? '') + res.writeHead(204).end() + }) + }) + const ancestor = join(homeDir, 'ancestor.cjs') + writeFileSync( + ancestor, + `const { spawn } = require('node:child_process') +const [hook, disabled, stage] = process.argv.slice(2) +const env = { PATH: process.env.PATH, HOME: process.env.HOME } +const child = stage === 'ancestor' + ? spawn(process.execPath, [__filename, hook, disabled, 'background'], { + env: { ...env, ORCA_AGENT_HOOKS_DISABLED: disabled }, + stdio: ['ignore', 'inherit', 'inherit'] + }) + : spawn('/bin/sh', [hook], { env, stdio: ['pipe', 'inherit', 'inherit'] }) +if (stage !== 'ancestor') child.stdin.end(JSON.stringify({ hook_event_name: 'Stop' })) +child.on('error', error => { throw error }) +child.on('close', code => { process.exitCode = code ?? 1 }) +` + ) + try { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() as AddressInfo + const run = async (disabled: string) => { + const child = spawnProcess({ + program: process.execPath, + args: [ + ancestor, + join(homeDir, '.orca', 'agent-hooks', 'command-code-hook.sh'), + disabled, + 'ancestor' + ], + env: { + ...process.env, + HOME: homeDir, + ORCA_AGENT_HOOKS_DISABLED: '0', + ORCA_AGENT_HOOK_PORT: String(address.port), + ORCA_AGENT_HOOK_TOKEN: 'test-token', + ORCA_PANE_KEY: 'recovered-tab:leaf', + ORCA_TAB_ID: 'recovered-tab', + ORCA_WORKTREE_ID: 'folder-workspace', + ORCA_AGENT_LAUNCH_TOKEN: 'parent-launch' + }, + stdio: ['ignore', 'ignore', 'pipe'] + }) + let stderr = '' + child.stderr?.on('data', (chunk) => (stderr += chunk)) + const code = await new Promise((resolve, reject) => { + child.on('error', reject) + child.on('close', resolve) + }) + expect(code, stderr).toBe(0) + } + await run('0') + expect(requests).toEqual(['recovered-tab:leaf']) + await run('1') + expect(requests).toEqual(['recovered-tab:leaf']) + } finally { + await new Promise((resolve) => server.close(() => resolve())) + } + } + ) + itPosix('does not let stale endpoint files clobber recovered connection fields', async () => { new CommandCodeHookService().install() diff --git a/src/main/ipc/pty/host-env/spawn-env-keys.ts b/src/main/ipc/pty/host-env/spawn-env-keys.ts index a4cbe0489e5..53e60eccc0c 100644 --- a/src/main/ipc/pty/host-env/spawn-env-keys.ts +++ b/src/main/ipc/pty/host-env/spawn-env-keys.ts @@ -1,5 +1,9 @@ import { ORCA_AGENT_SESSION_ID_ENV } from '../../../../shared/agent-session/lifecycle/agent-session-caller-env' import { ORCA_STRUCTURED_SESSION_ENV } from '../../../../shared/structured-session-marker' +import { + ORCA_SCRUB_SAFE_LAUNCH_ENV, + ORCA_SCRUB_SAFE_PANE_ENV +} from '../../../../shared/agent-hook-listener/transport/agent-hook-scrub-safe-env' export const AGENT_HOOK_RUNTIME_ENV_KEYS = [ 'ORCA_AGENT_HOOK_PORT', @@ -12,6 +16,16 @@ export const AGENT_HOOK_RUNTIME_ENV_KEYS = [ 'ORCA_CLAUDE_AGENT_STATUS_SETTINGS' ] as const +export const AGENT_PANE_HOOK_ENV_KEYS = [ + 'ORCA_PANE_KEY', + 'ORCA_TAB_ID', + 'ORCA_WORKTREE_ID', + 'ORCA_AGENT_LAUNCH_TOKEN', + ORCA_SCRUB_SAFE_PANE_ENV, + ORCA_SCRUB_SAFE_LAUNCH_ENV, + ...AGENT_HOOK_RUNTIME_ENV_KEYS +] as const + // Inherited Claude child stamps make an independent pane act as the launching session's child. export const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [ 'CLAUDECODE', diff --git a/src/main/runtime/agent-session/environment/structured-session-child-env.ts b/src/main/runtime/agent-session/environment/structured-session-child-env.ts index 477968ced55..37bec85bfcf 100644 --- a/src/main/runtime/agent-session/environment/structured-session-child-env.ts +++ b/src/main/runtime/agent-session/environment/structured-session-child-env.ts @@ -9,26 +9,14 @@ import { ORCA_AGENT_SESSION_ID_ENV, ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV } from '../../../../shared/agent-session/lifecycle/agent-session-caller-env' -import { - ORCA_SCRUB_SAFE_LAUNCH_ENV, - ORCA_SCRUB_SAFE_PANE_ENV -} from '../../../../shared/agent-hook-listener/transport/agent-hook-scrub-safe-env' import { ORCA_STRUCTURED_SESSION_ENV } from '../../../../shared/structured-session-marker' import { withCliRuntimeOnPath } from '../../../../shared/node-cli-command-resolution' import { prependOrcaCliDirToChildPath } from '../../../cli/orca-cli-child-path' -import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../../../ipc/pty/host-env/spawn-env-keys' +import { AGENT_PANE_HOOK_ENV_KEYS } from '../../../ipc/pty/host-env/spawn-env-keys' import { resolveStructuredWorkerIdentityForSession } from '../../orchestration/terminal-authority/structured-sessions/structured-worker-authority' /** Remove inherited pane identity so hook status cannot compete with the structured-session producer. */ -export const STRUCTURED_CHILD_ENV_TO_DELETE: readonly string[] = [ - 'ORCA_PANE_KEY', - 'ORCA_TAB_ID', - 'ORCA_WORKTREE_ID', - 'ORCA_AGENT_LAUNCH_TOKEN', - ORCA_SCRUB_SAFE_PANE_ENV, - ORCA_SCRUB_SAFE_LAUNCH_ENV, - ...AGENT_HOOK_RUNTIME_ENV_KEYS -] +export const STRUCTURED_CHILD_ENV_TO_DELETE: readonly string[] = AGENT_PANE_HOOK_ENV_KEYS export type SealedStructuredSessionChild = { env: Record diff --git a/src/main/text-generation/source-control-agent-hook-isolation.test.ts b/src/main/text-generation/source-control-agent-hook-isolation.test.ts new file mode 100644 index 00000000000..7043a4dc273 --- /dev/null +++ b/src/main/text-generation/source-control-agent-hook-isolation.test.ts @@ -0,0 +1,63 @@ +import { afterAll, expect, it } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSourceControlAgent } from './source-control-agent-launch' + +const folder = mkdtempSync(join(tmpdir(), 'orca-generation-hook-isolation-')) +afterAll(() => rmSync(folder, { recursive: true, force: true })) + +it('keeps background generation from reporting hooks against the launching terminal', async () => { + const agent = join(folder, 'report-environment.cjs') + writeFileSync( + agent, + `process.stdin.resume() +console.log(JSON.stringify({ + pane: process.env.ORCA_PANE_KEY ?? null, + fallbackPane: process.env.ORCA_AGENT_PANE ?? null, + launch: process.env.ORCA_AGENT_LAUNCH_TOKEN ?? null, + endpoint: process.env.ORCA_AGENT_HOOK_ENDPOINT ?? null, + port: process.env.ORCA_AGENT_HOOK_PORT ?? null, + disabled: process.env.ORCA_AGENT_HOOKS_DISABLED, + home: process.env.CODEX_HOME +})) +` + ) + const child = spawnSourceControlAgent({ + binary: process.execPath, + args: [agent], + cwd: folder, + env: { + ...process.env, + ORCA_PANE_KEY: 'dev-tab:dev-pane', + ORCA_AGENT_PANE: 'dev-tab:dev-pane', + ORCA_AGENT_LAUNCH_TOKEN: 'parent-launch', + ORCA_AGENT_HOOK_ENDPOINT: '/parent/hook.sock', + ORCA_AGENT_HOOK_PORT: '57279', + ORCA_AGENT_HOOKS_DISABLED: '0', + CODEX_HOME: join(folder, 'managed-home') + }, + stdinMode: 'pipe', + useCwdForNative: true + }) + let stdout = '' + let stderr = '' + child.stdout?.on('data', (chunk) => (stdout += chunk.toString())) + child.stderr?.on('data', (chunk) => (stderr += chunk.toString())) + const closed = new Promise((resolve, reject) => { + child.on('close', resolve) + child.on('error', reject) + }) + child.stdin?.end() + + expect(await closed, stderr).toBe(0) + expect(JSON.parse(stdout)).toEqual({ + pane: null, + fallbackPane: null, + launch: null, + endpoint: null, + port: null, + disabled: '1', + home: join(folder, 'managed-home') + }) +}) diff --git a/src/main/text-generation/source-control-agent-launch.ts b/src/main/text-generation/source-control-agent-launch.ts index 51a18712934..66b97a0ea6a 100644 --- a/src/main/text-generation/source-control-agent-launch.ts +++ b/src/main/text-generation/source-control-agent-launch.ts @@ -1,8 +1,11 @@ import { wslDistroForCommand } from '../../shared/wsl-paths' import { spawnProcess } from '@orca/process-host' import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' +import { ORCA_AGENT_HOOKS_DISABLED_ENV } from '../../shared/agent-hook-listener/transport/agent-hook-suppression-env' +import { quotePosixShell } from '../../shared/shell/launch/shell-argument-quoting' import { resolveCliCommand } from '../codex-cli/command' import { wslAwareSpawn } from '../git/runner' +import { AGENT_PANE_HOOK_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys' import { spawnSupervisedAgent } from './source-control-supervised-agent-spawn' import type { SourceControlAgentSpawnInput, @@ -22,6 +25,11 @@ const WSL_LAUNCHER_ENV_KEYS = [ 'WINDIR' ] as const +const BACKGROUND_HOOK_ENV_KEYS = new Set([ + ...AGENT_PANE_HOOK_ENV_KEYS, + ORCA_AGENT_HOOKS_DISABLED_ENV +]) + function buildWslLauncherEnv(explicitEnv: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {} for (const key of WSL_LAUNCHER_ENV_KEYS) { @@ -42,23 +50,36 @@ export const spawnSourceControlAgent: SpawnSourceControlAgent = (input) => { const wslDistro = input.preparation ? wslDistroForCommand(input.cwd, input.wslDistro) : input.wslDistro - const spawnEnv = input.env ?? process.env + // Background agents cannot report hooks against the terminal that launched Orca. + const spawnEnv = { ...(input.env ?? process.env) } + for (const key of Object.keys(spawnEnv)) { + if (BACKGROUND_HOOK_ENV_KEYS.has(process.platform === 'win32' ? key.toUpperCase() : key)) { + delete spawnEnv[key] + } + } + spawnEnv[ORCA_AGENT_HOOKS_DISABLED_ENV] = '1' if (process.platform === 'win32' && wslDistro) { // Apply command assignments after guest login-shell setup, never to the Windows launcher. - const assignments = Object.entries(input.commandEnv ?? {}).map( - ([key, value]) => `${key}=${value}` - ) + const assignments = Object.entries(input.commandEnv ?? {}) + .filter(([key]) => !BACKGROUND_HOOK_ENV_KEYS.has(key)) + .map(([key, value]) => `${key}=${value}`) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: WSL spawn pipes both output streams and supplies the configured stdin stream. return wslAwareSpawn( assignments.length ? '/usr/bin/env' : input.binary, assignments.length ? [...assignments, input.binary, ...input.args] : input.args, { cwd: input.cwd, - env: buildWslLauncherEnv(input.env), + env: buildWslLauncherEnv(spawnEnv), stdio: [input.stdinMode, 'pipe', 'pipe'], windowsHide: true, wslDistro, useWslLoginShell: true, + // Profiles may restore pane identity; clear it in the guest before invoking the agent. + wslShellCommand: [ + `command unset ${AGENT_PANE_HOOK_ENV_KEYS.join(' ')}`, + `command export ${ORCA_AGENT_HOOKS_DISABLED_ENV}=1`, + quotePosixShell(assignments.length ? '/usr/bin/env' : input.binary) + ].join(' && '), ...(input.preparation ? { preparation: input.preparation } : {}) } ) as SpawnedSourceControlAgentProcess diff --git a/src/main/text-generation/source-control-wsl-launch.test.ts b/src/main/text-generation/source-control-wsl-launch.test.ts index 176e34b4fe1..434ec7df29e 100644 --- a/src/main/text-generation/source-control-wsl-launch.test.ts +++ b/src/main/text-generation/source-control-wsl-launch.test.ts @@ -11,7 +11,7 @@ import { prepareWslPrompt } from './source-control-prompt-files' import { producePromptRecords } from './source-control-prompt-producer' import { planCommitMessageGeneration } from '../../shared/source-control/ai/generation/commit-message-plan' import { withPlatform } from './commit-message-text-generation-test-harness' -import './source-control-agent-launch' +import { spawnSourceControlAgent } from './source-control-agent-launch' import './source-control-local-generation' import { resolveCommand } from '../git/command-runner/wsl-command-resolution' @@ -42,6 +42,86 @@ function planned(agentId: 'omp' | 'codex' = 'omp') { return result.plan } +it.skipIf(process.platform === 'win32')( + 'clears profile-restored hook identity after login while preserving shell function commands', + async () => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), 'orca-wsl-hook-isolation-'))) + const profile = join(directory, 'rc') + const shell = join(directory, 'bash') + const reader = join(directory, 'reader.cjs') + writeFileSync( + shell, + `#!/bin/sh\nexec /bin/bash --noprofile --rcfile ${quotePosixShell(profile)} -ic "$2"\n`, + { mode: 0o700 } + ) + writeFileSync( + join(directory, 'getent'), + `#!/bin/sh\nprintf '%s\\n' ${quotePosixShell(`user:x:1:1:User:${directory}:${shell}`)}\n`, + { mode: 0o700 } + ) + writeFileSync( + profile, + `export ORCA_PANE_KEY=profile-pane ORCA_AGENT_PANE=profile-pane ORCA_AGENT_HOOK_PORT=54321 ORCA_AGENT_HOOKS_DISABLED=0 +export CODEX_HOME=profile-managed-home +review_agent() { ${quotePosixShell(process.execPath)} ${quotePosixShell(reader)} "$@"; } +` + ) + writeFileSync( + reader, + `console.log(JSON.stringify({ + pane: process.env.ORCA_PANE_KEY ?? null, + alias: process.env.ORCA_AGENT_PANE ?? null, + port: process.env.ORCA_AGENT_HOOK_PORT ?? null, + disabled: process.env.ORCA_AGENT_HOOKS_DISABLED, + home: process.env.CODEX_HOME, + args: process.argv.slice(2) +})) +` + ) + // Run the real guest login command locally; only replace the Windows executable boundary. + spawnMock.mockImplementationOnce((_program, args, options) => + nativeSpawn('/bin/sh', ['-c', args.at(-1)], options) + ) + try { + const child = withPlatform('win32', () => + spawnSourceControlAgent({ + binary: 'review_agent', + args: ['literal $HOME'], + wslDistro: 'Review', + env: { + ...process.env, + HOME: directory, + SHELL: shell, + PATH: `${directory}:${process.env.PATH}` + }, + stdinMode: 'pipe', + useCwdForNative: true + }) + ) + let stdout = '' + let stderr = '' + child.stdout?.on('data', (chunk) => (stdout += chunk)) + child.stderr?.on('data', (chunk) => (stderr += chunk)) + const closed = new Promise((resolve, reject) => { + child.on('error', reject) + child.on('close', resolve) + }) + child.stdin?.end() + expect(await closed, stderr).toBe(0) + expect(JSON.parse(stdout)).toEqual({ + pane: null, + alias: null, + port: null, + disabled: '1', + home: 'profile-managed-home', + args: ['literal $HOME'] + }) + } finally { + await rm(directory, { recursive: true, force: true }) + } + } +) + it.each([false, true])( 'prepares before hostile profiles and preserves login invocation/cwd/env (assigned=%s)', async (assigned) => { diff --git a/src/shared/agent-hook-listener/transport/agent-hook-suppression-env.ts b/src/shared/agent-hook-listener/transport/agent-hook-suppression-env.ts new file mode 100644 index 00000000000..41008b9dfc1 --- /dev/null +++ b/src/shared/agent-hook-listener/transport/agent-hook-suppression-env.ts @@ -0,0 +1 @@ +export const ORCA_AGENT_HOOKS_DISABLED_ENV = 'ORCA_AGENT_HOOKS_DISABLED'