mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
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:
@@ -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'
|
||||
Reference in New Issue
Block a user