Prevent background agents from mislabeling terminal tabs (#39778)

* Prevent background agents from mislabeling terminal tabs

* fix: isolate background agent hook recovery
This commit is contained in:
Jinjing
2026-11-08 15:31:46 -08:00
committed by GitHub
parent c50d4cd3e4
commit 09b0a25276
8 changed files with 272 additions and 24 deletions
@@ -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',
@@ -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<void>((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<number | null>((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<void>((resolve) => server.close(() => resolve()))
}
}
)
itPosix('does not let stale endpoint files clobber recovered connection fields', async () => {
new CommandCodeHookService().install()
@@ -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',
@@ -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<string, string>
@@ -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<number | null>((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')
})
})
@@ -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<string>([
...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
@@ -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<number | null>((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) => {
@@ -0,0 +1 @@
export const ORCA_AGENT_HOOKS_DISABLED_ENV = 'ORCA_AGENT_HOOKS_DISABLED'