diff --git a/src/main/agent-hooks/spool.test.ts b/src/main/agent-hooks/spool.test.ts index 27694ce62f00..3b396d87e7c9 100644 --- a/src/main/agent-hooks/spool.test.ts +++ b/src/main/agent-hooks/spool.test.ts @@ -21,6 +21,7 @@ import { import { AgentHookServer, _internals } from './server' import { buildBody } from './server.test-fixtures' import { _internals as codexInternals } from '../codex/hook-service' +import { getManagedScript as getClaudeManagedScript } from '../claude/hook-script' import { makePaneKey } from '../../shared/stable-pane-id' import { buildPosixHookSpoolLines } from './hook-stdin-contract' @@ -247,6 +248,37 @@ describe('agent hook spool', () => { ) }) + it('keeps a Claude hook attributed when CLAUDE_CODE_SUBPROCESS_ENV_SCRUB drops the pane key', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-scrubbed-')) + const endpointDir = join(dir, 'agent-hooks') + mkdirSync(endpointDir, { recursive: true }) + const endpoint = join(endpointDir, 'endpoint.env') + writeFileSync(endpoint, 'ORCA_AGENT_HOOK_PORT=9\nORCA_AGENT_HOOK_TOKEN=stale\n') + const script = join(dir, 'claude-hook.sh') + writeFileSync(script, getClaudeManagedScript('posix')) + const env: NodeJS.ProcessEnv = { + ...process.env, + ORCA_AGENT_HOOK_ENDPOINT: endpoint, + ORCA_AGENT_PANE: 'tab-scrubbed:0', + ORCA_AGENT_LAUNCH: 'generation-token' + } + // Claude's scrub removes every *KEY/*TOKEN name; only the scrub-safe aliases reach the hook. + delete env.ORCA_PANE_KEY + delete env.ORCA_AGENT_LAUNCH_TOKEN + delete env.CLAUDE_JOB_DIR + execFileSync('/bin/sh', [script], { + input: '{"hook_event_name":"Stop","session_id":"claude-session"}\n', + env, + timeout: 5000 + }) + const spoolFiles = readdirSync(join(endpointDir, 'spool')) + expect(spoolFiles).toHaveLength(1) + const record: unknown = JSON.parse( + readFileSync(join(endpointDir, 'spool', spoolFiles[0]!), 'utf8').trim() + ) + expect(record).toMatchObject({ paneKey: 'tab-scrubbed:0', launchToken: 'generation-token' }) + }) + it('does not mark a non-terminal downtime replay as runtime-observed', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-spool-observed-')) const paneKey = makePaneKey('tab-observed', '00000000-0000-4000-8000-000000000002') diff --git a/src/main/claude/hook-script.ts b/src/main/claude/hook-script.ts index 1841073f4a90..ff2143437977 100644 --- a/src/main/claude/hook-script.ts +++ b/src/main/claude/hook-script.ts @@ -2,6 +2,10 @@ import { buildHookProcessCapture } from '../agent-hooks/hook-process-capture' import type { AgentHookSource } from '../../shared/agent-hook-relay' import { buildWindowsAgentHookCurlPostCommand } from '../agent-hooks/installer-utils' import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command' +import { + buildPosixScrubSafeEnvRestoreLines, + buildWindowsScrubSafeEnvRestoreLines +} from '../../shared/agent-hook-scrub-safe-env' import { buildPosixCursorReplayGuardLines, buildWindowsCursorReplayGuardLines @@ -37,6 +41,8 @@ export function getManagedScript( 'setlocal', // Permission hooks fail closed on empty stdout, including skip paths. 'echo {}', + // CLAUDE_CODE_SUBPROCESS_ENV_SCRUB drops ORCA_PANE_KEY; restore it before any guard reads it. + ...buildWindowsScrubSafeEnvRestoreLines(), // Refresh endpoint coordinates for PTYs surviving an Orca restart. 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', // Guard abandoned stdin before any skip path can drain it. @@ -62,6 +68,8 @@ export function getManagedScript( '#!/bin/sh', // Permission hooks fail closed on empty stdout, including skip paths. 'printf "{}\\n"', + // CLAUDE_CODE_SUBPROCESS_ENV_SCRUB drops ORCA_PANE_KEY; restore it before any guard or spool reads it. + ...buildPosixScrubSafeEnvRestoreLines(), ...buildPosixHookPayloadCapture(), ...(options.skipWhenGrokImportsClaude ? buildPosixGrokReplayGuardLines() : []), ...(options.skipWhenCursorImportsClaude ? buildPosixCursorReplayGuardLines() : []), diff --git a/src/main/dsh/hook-service.ts b/src/main/dsh/hook-service.ts index 88e89ea9b334..609ceed947ad 100644 --- a/src/main/dsh/hook-service.ts +++ b/src/main/dsh/hook-service.ts @@ -22,6 +22,10 @@ import { buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command' +import { + buildPosixScrubSafeEnvRestoreLines, + buildWindowsScrubSafeEnvRestoreLines +} from '../../shared/agent-hook-scrub-safe-env' import { applyManagedDshPatch, isDshPatchFileUnappendable, @@ -48,8 +52,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { '@echo off', 'setlocal', // Restore DSH-scrubbed KEY/TOKEN variables before any guard or spool code reads them. - 'if not defined ORCA_PANE_KEY if defined ORCA_AGENT_PANE set "ORCA_PANE_KEY=%ORCA_AGENT_PANE%"', - 'if not defined ORCA_AGENT_LAUNCH_TOKEN if defined ORCA_AGENT_LAUNCH set "ORCA_AGENT_LAUNCH_TOKEN=%ORCA_AGENT_LAUNCH%"', + ...buildWindowsScrubSafeEnvRestoreLines(), 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', ...buildWindowsHookEnvironmentGuardLines(), buildWindowsAgentHookCurlPostCommand('dsh'), @@ -62,9 +65,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', // DSH scrubs KEY/TOKEN variables; recover canonical names from the aliases set at spawn. - ': "${ORCA_PANE_KEY:=${ORCA_AGENT_PANE:-}}"', - ': "${ORCA_AGENT_LAUNCH_TOKEN:=${ORCA_AGENT_LAUNCH:-}}"', - 'export ORCA_PANE_KEY ORCA_AGENT_LAUNCH_TOKEN', + ...buildPosixScrubSafeEnvRestoreLines(), ...buildPosixHookPayloadCapture(), ...buildPosixHookSpoolLines('dsh'), // Refresh endpoint credentials for PTYs that outlive an Orca restart. diff --git a/src/shared/agent-hook-scrub-safe-env.ts b/src/shared/agent-hook-scrub-safe-env.ts index e83895e2a30d..7dd297e6795f 100644 --- a/src/shared/agent-hook-scrub-safe-env.ts +++ b/src/shared/agent-hook-scrub-safe-env.ts @@ -4,9 +4,10 @@ * * DeepSeek Harness runs command hooks through its own shell executor, which drops every * variable whose name contains `KEY`, `TOKEN`, `SECRET` or `PASSWORD` before the hook - * process starts. That is a reasonable credential-scrub default, and it is not something - * Orca can turn off — but it takes `ORCA_PANE_KEY` and `ORCA_AGENT_LAUNCH_TOKEN` with it, - * which are the two values a status hook needs to say *which pane* it is reporting for. + * process starts. Claude Code does the same for hooks when `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB` + * is on (verified on 2.1.310). That is a reasonable credential-scrub default, and it is not + * something Orca can turn off — but it takes `ORCA_PANE_KEY` and `ORCA_AGENT_LAUNCH_TOKEN` + * with it, which are the two values a status hook needs to say *which pane* it is reporting for. * Without them the hook has nothing to attribute to and exits without posting, so the pane * reads as an agent Orca never hears from. * @@ -47,3 +48,18 @@ export function applyScrubSafeAgentEnvAliases(env: Record): void env[alias] = value } } + +/** POSIX hook prologue: refill the canonical names from their aliases; never overwrites a live value. */ +export function buildPosixScrubSafeEnvRestoreLines(): string[] { + return [ + ...ALIASES.map(([source, alias]) => `: "\${${source}:=\${${alias}:-}}"`), + `export ${ALIASES.map(([source]) => source).join(' ')}` + ] +} + +/** Windows `.cmd` hook prologue with the same contract. */ +export function buildWindowsScrubSafeEnvRestoreLines(): string[] { + return ALIASES.map( + ([source, alias]) => `if not defined ${source} if defined ${alias} set "${source}=%${alias}%"` + ) +}