From d2ba67d1090a0e7707532ebbf4a12e935dbe0185 Mon Sep 17 00:00:00 2001 From: Vladislav <51202067+vampik33@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:25:20 +0300 Subject: [PATCH] fix(claude): register a short PostCompact hook command (#25211) * fix(claude): register a short PostCompact hook command Claude Code echoes every PostCompact hook's full command into the post-compact transcript, so Orca's ~3KB portable wrapper landed in the model's context after every /compact. On POSIX hosts (local macOS/Linux and SSH remotes) PostCompact now gets only the POSIX branch of the wrapper; every other event, and Windows, keep the full wrapper. Fixes #25209 * fix(claude): guard runtime home and verify hook process identity --------- Co-authored-by: Neil --- .../managed-hook-command-contract.test.ts | 33 ++-- .../agent-hooks/runtime-home-hook-command.ts | 33 +++- .../claude/claude-hook-event-install.test.ts | 2 +- .../compact-status-registration.test.ts | 2 +- src/main/claude/hook-service.ts | 13 +- src/main/claude/hook-settings.ts | 42 ++++- .../claude/post-compact-hook-command.test.ts | 176 ++++++++++++++++++ 7 files changed, 263 insertions(+), 38 deletions(-) create mode 100644 src/main/claude/post-compact-hook-command.test.ts diff --git a/src/main/agent-hooks/managed-hook-command-contract.test.ts b/src/main/agent-hooks/managed-hook-command-contract.test.ts index 745d5fe92a9..7e22508c10f 100644 --- a/src/main/agent-hooks/managed-hook-command-contract.test.ts +++ b/src/main/agent-hooks/managed-hook-command-contract.test.ts @@ -2,8 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { CLAUDE_HOOK_SETTINGS, OPENCLAUDE_HOOK_SETTINGS, + getManagedEventHook, getManagedLifecycleHook, - getRemoteManagedCommand as getClaudeRemoteCommand + getRemoteManagedCommand as getClaudeRemoteCommand, + getRemoteManagedEventCommand } from '../claude/hook-settings' import { getManagedCommand as getCodexCommand, @@ -65,21 +67,22 @@ function antigravityPosixCommands(path: string): string[] { ) } +function claudeFamilyBuilders(settings: typeof CLAUDE_HOOK_SETTINGS): CommandBuilders { + return { + local: (path) => [ + getManagedLifecycleHook(path, settings).command, + getManagedEventHook('PostCompact', path, settings).command + ], + remote: (path) => [ + getClaudeRemoteCommand(path), + getRemoteManagedEventCommand('PostCompact', path) + ] + } +} + const buildersByAgent = new Map([ - [ - 'claude', - { - local: (path) => [getManagedLifecycleHook(path, CLAUDE_HOOK_SETTINGS).command], - remote: (path) => [getClaudeRemoteCommand(path)] - } - ], - [ - 'openclaude', - { - local: (path) => [getManagedLifecycleHook(path, OPENCLAUDE_HOOK_SETTINGS).command], - remote: (path) => [getClaudeRemoteCommand(path)] - } - ], + ['claude', claudeFamilyBuilders(CLAUDE_HOOK_SETTINGS)], + ['openclaude', claudeFamilyBuilders(OPENCLAUDE_HOOK_SETTINGS)], [ 'qoder', { diff --git a/src/main/agent-hooks/runtime-home-hook-command.ts b/src/main/agent-hooks/runtime-home-hook-command.ts index 4b59f7b53ed..4cc61d2988f 100644 --- a/src/main/agent-hooks/runtime-home-hook-command.ts +++ b/src/main/agent-hooks/runtime-home-hook-command.ts @@ -12,17 +12,40 @@ import { const MANAGED_SCRIPT_BASE_NAME = /^[A-Za-z0-9_-]+$/ const WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE = '*\\&*|*\\^*|*\\(*|*\\)*|*\\;*|*,*|*=*|*%*|*\\!*' +function assertManagedScriptBaseName(scriptBaseName: string): void { + if (!MANAGED_SCRIPT_BASE_NAME.test(scriptBaseName)) { + throw new Error(`Invalid managed script base name: ${scriptBaseName}`) + } +} + +function buildPosixBranch( + scriptBaseName: string, + missingScriptFallback: string, + options: { requireHome?: boolean } = {} +): string { + const posixScript = `"\${HOME-}/.orca/agent-hooks/${scriptBaseName}.sh"` + const homeGuard = options.requireHome ? '[ -n "${HOME-}" ] && ' : '' + const parentCapture = + scriptBaseName === 'claude-hook' || scriptBaseName === 'openclaude-hook' + ? CLAUDE_HOOK_PARENT_CAPTURE + : '' + return `if ${homeGuard}[ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then ${parentCapture}/bin/sh ${posixScript}; else ${missingScriptFallback}; fi` +} + +/** The POSIX branch of wrapRuntimeHomeHookCommand alone, for hosts known to be POSIX. */ +export function wrapRuntimeHomePosixHookCommand(scriptBaseName: string): string { + assertManagedScriptBaseName(scriptBaseName) + return buildPosixBranch(scriptBaseName, POSIX_HOOK_STDIN_DRAIN_COMMAND, { requireHome: true }) +} + export function wrapRuntimeHomeHookCommand( scriptBaseName: string, options: { neutralJsonWhenMissing?: boolean } = {} ): string { - if (!MANAGED_SCRIPT_BASE_NAME.test(scriptBaseName)) { - throw new Error(`Invalid managed script base name: ${scriptBaseName}`) - } + assertManagedScriptBaseName(scriptBaseName) // Why: default-form every var — a static hook precheck (Grok) rejects the whole command on a bare // reference it cannot resolve, even in a branch that platform never takes. const windowsScript = `"\${HOME-}/.orca/agent-hooks/${scriptBaseName}.cmd"` - const posixScript = `"\${HOME-}/.orca/agent-hooks/${scriptBaseName}.sh"` const drain = POSIX_HOOK_STDIN_DRAIN_COMMAND const neutralJson = options.neutralJsonWhenMissing ? `printf '{}\\n'` : '' // Why two forms: the missing-script fallback owns stdin, so it follows the rule of the host @@ -47,7 +70,7 @@ export function wrapRuntimeHomeHookCommand( const powershellInvocation = `${powershell} ${WINDOWS_POWERSHELL_HOOK_SWITCHES} -EncodedCommand ${encodedCommand}` const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${windowsMissingScriptFallback}; fi` const windowsBranch = `if [ -f ${windowsScript} ]; then case "\${HOME-}" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${windowsMissingScriptFallback}; fi` - const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then ${scriptBaseName === 'claude-hook' || scriptBaseName === 'openclaude-hook' ? CLAUDE_HOOK_PARENT_CAPTURE : ''}/bin/sh ${posixScript}; else ${posixMissingScriptFallback}; fi` + const posixBranch = buildPosixBranch(scriptBaseName, posixMissingScriptFallback) // Why: OSTYPE is shell-owned, so platform selection adds no process to every hook invocation. return `if [ -z "\${HOME-}" ]; then ${missingScriptFallback}; else case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsBranch} ;; *) ${posixBranch} ;; esac; fi` } diff --git a/src/main/claude/claude-hook-event-install.test.ts b/src/main/claude/claude-hook-event-install.test.ts index 99af9a3fa53..f27a35ad7c1 100644 --- a/src/main/claude/claude-hook-event-install.test.ts +++ b/src/main/claude/claude-hook-event-install.test.ts @@ -14,7 +14,7 @@ const topLevelSettings: Record = fixture.topLevelSet function install(config: HooksConfig, claudeVersion: string | undefined): HooksConfig { return applyManagedHooks( config, - managedHook, + () => managedHook, SCRIPT_FILE_NAME, getClaudeManagedHookPlan(claudeVersion) ) diff --git a/src/main/claude/compact-status-registration.test.ts b/src/main/claude/compact-status-registration.test.ts index 225a52423c3..f57ac35fed6 100644 --- a/src/main/claude/compact-status-registration.test.ts +++ b/src/main/claude/compact-status-registration.test.ts @@ -109,7 +109,7 @@ describe('Claude compact hook registration', () => { it('writes PostCompact, and no PreCompact, into the settings Claude actually reads', () => { const written = applyManagedHooks( { hooks: {} }, - { type: 'command', command: 'orca-claude-hook' }, + () => ({ type: 'command', command: 'orca-claude-hook' }), 'claude-hook.sh', getClaudeManagedHookPlan(CLAUDE_HOOK_EVENT_FIRST_VERSIONS.PostCompact) ) diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 58a62d797e6..ba32d9eb051 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -32,11 +32,11 @@ import { getManagedScriptFileName, getConfigPath, getManagedCommand, - getManagedLifecycleHook, + getManagedEventHook, getManagedScriptPath, getPosixManagedScriptFileName, getRemoteConfigPath, - getRemoteManagedCommand, + getRemoteManagedEventCommand, getStatusLineInstallMarkerPath, getStatusLineScriptFileName, getStatusLineScriptPath, @@ -122,10 +122,10 @@ export class ClaudeHookService { } // Why: report partial registration instead of a false installed state. - const expectedHook = getManagedLifecycleHook(scriptPath, this.options.settings) const missing: string[] = [] let presentCount = 0 for (const event of this.managedHookPlan(options).install) { + const expectedHook = getManagedEventHook(event.eventName, scriptPath, this.options.settings) const definitions = Array.isArray(config.hooks?.[event.eventName]) ? config.hooks![event.eventName]! : [] @@ -186,11 +186,10 @@ export class ClaudeHookService { } } - const hook = getManagedLifecycleHook(scriptPath, this.options.settings) const plan = this.managedHookPlan(options) let nextConfig = applyManagedHooks( config, - hook, + (eventName) => getManagedEventHook(eventName, scriptPath, this.options.settings), getManagedScriptFileName(this.options.settings), plan ) @@ -275,10 +274,10 @@ export class ClaudeHookService { } // Why: settings resolve HOME at runtime while SFTP still targets the discovered remote home. - const hook = buildManagedCommandHook(getRemoteManagedCommand(remoteScriptPath)) const nextConfig = applyManagedHooks( config, - hook, + (eventName) => + buildManagedCommandHook(getRemoteManagedEventCommand(eventName, remoteScriptPath)), remoteScriptFileName, this.managedHookPlan(options) ) diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index 3e15f26c4be..328e53fffcd 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -14,7 +14,10 @@ import { type HooksConfig } from '../agent-hooks/installer-utils' import { quotePowerShellLiteral } from '../../shared/powershell-native-argument' -import { wrapRuntimeHomeHookCommand } from '../agent-hooks/runtime-home-hook-command' +import { + wrapRuntimeHomeHookCommand, + wrapRuntimeHomePosixHookCommand +} from '../agent-hooks/runtime-home-hook-command' import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' import type { ClaudeManagedHookPlan } from './claude-managed-hook-events' @@ -83,16 +86,34 @@ export function getRemoteConfigPath(remoteHome: string, settings = CLAUDE_HOOK_S return `${remoteHome.replace(/\/$/, '')}/${settings.configDirName}/settings.json` } +function getScriptBaseName(scriptPath: string): string { + const scriptFileName = basename(scriptPath) + const extension = extname(scriptFileName) + return extension ? scriptFileName.slice(0, -extension.length) : scriptFileName +} + export function getManagedCommand( scriptPath: string, options: { neutralJsonWhenMissing?: boolean } = {} ): string { - const scriptFileName = basename(scriptPath) - const extension = extname(scriptFileName) - return wrapRuntimeHomeHookCommand( - extension ? scriptFileName.slice(0, -extension.length) : scriptFileName, - options - ) + return wrapRuntimeHomeHookCommand(getScriptBaseName(scriptPath), options) +} + +export function getManagedEventHook( + eventName: string, + scriptPath: string, + settings = CLAUDE_HOOK_SETTINGS +): HookCommandConfig { + return process.platform === 'win32' + ? getManagedLifecycleHook(scriptPath, settings) + : buildManagedCommandHook(getRemoteManagedEventCommand(eventName, scriptPath)) +} + +// Claude adds the full PostCompact command to the model's context after compaction. +export function getRemoteManagedEventCommand(eventName: string, scriptPath: string): string { + return eventName === 'PostCompact' + ? wrapRuntimeHomePosixHookCommand(getScriptBaseName(scriptPath)) + : getRemoteManagedCommand(scriptPath) } export function getManagedLifecycleHook( @@ -155,7 +176,7 @@ export function getRemoteManagedCommand(scriptPath: string): string { export function applyManagedHooks( config: HooksConfig, - hook: HookCommandConfig, + hookForEvent: (eventName: string) => HookCommandConfig, scriptFileName: string, plan: ClaudeManagedHookPlan ): HooksConfig { @@ -165,7 +186,10 @@ export function applyManagedHooks( for (const event of plan.install) { const current = nextHooks[event.eventName] const cleaned = Array.isArray(current) ? removeManagedCommands(current, isManagedCommand) : [] - const definition: HookDefinition = { ...event.definition, hooks: [hook] } + const definition: HookDefinition = { + ...event.definition, + hooks: [hookForEvent(event.eventName)] + } nextHooks[event.eventName] = [...cleaned, definition] } diff --git a/src/main/claude/post-compact-hook-command.test.ts b/src/main/claude/post-compact-hook-command.test.ts new file mode 100644 index 00000000000..98d4c0b3638 --- /dev/null +++ b/src/main/claude/post-compact-hook-command.test.ts @@ -0,0 +1,176 @@ +import { runProcess } from '@orca/process-host' +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createAgentHookMemorySftp } from '../agent-hooks/agent-hook-memory-sftp.test-fixture' +import { wrapRuntimeHomePosixHookCommand } from '../agent-hooks/runtime-home-hook-command' +import { ClaudeHookService } from './hook-service' +import { + CLAUDE_HOOK_SETTINGS, + getManagedEventHook, + getManagedLifecycleHook, + getRemoteManagedCommand, + getRemoteManagedEventCommand +} from './hook-settings' + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp/userData' } })) + +const SCRIPT_PATH = '/home/test/.orca/agent-hooks/claude-hook.sh' +const CURRENT_CLAUDE = { claudeVersion: '2.1.261 (Claude Code)' } +// Why: Claude prints the command verbatim after every /compact; the full wrapper is ~3KB. +const MAX_ECHOED_COMMAND_LENGTH = 400 + +afterEach(() => vi.restoreAllMocks()) + +describe('PostCompact managed hook command', () => { + it.each(['darwin', 'linux'] as const)('is the short POSIX form on %s', (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + const { command } = getManagedEventHook('PostCompact', SCRIPT_PATH) + + expect(command.length).toBeLessThan(MAX_ECHOED_COMMAND_LENGTH) + expect(command).toContain('if [ -n "${HOME-}" ] && ') + expect(command).toContain('/bin/sh "${HOME-}/.orca/agent-hooks/claude-hook.sh"') + expect(command).toContain('ORCA_HOOK_AGENT_PID="${PPID:-}"') + expect(command).not.toMatch(/powershell|OSTYPE|\/home\/test/i) + }) + + it('leaves every other event on the full wrapper', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + expect(getManagedEventHook('Stop', SCRIPT_PATH)).toEqual( + getManagedLifecycleHook(SCRIPT_PATH, CLAUDE_HOOK_SETTINGS) + ) + }) + + it('keeps the Windows launcher on win32, whose bare script path is already short', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const windowsPath = 'C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd' + expect(getManagedEventHook('PostCompact', windowsPath)).toEqual( + getManagedLifecycleHook(windowsPath, CLAUDE_HOOK_SETTINGS) + ) + }) + + // Why every client platform: a remote host is POSIX even when Orca runs on Windows. + it.each(['darwin', 'linux', 'win32'] as const)( + 'uses the short POSIX form only for remote PostCompact from %s', + (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + expect(getRemoteManagedEventCommand('PostCompact', SCRIPT_PATH)).toBe( + wrapRuntimeHomePosixHookCommand('claude-hook') + ) + expect(getRemoteManagedEventCommand('Stop', SCRIPT_PATH)).toBe( + getRemoteManagedCommand(SCRIPT_PATH) + ) + } + ) +}) + +describe.skipIf(process.platform === 'win32')('PostCompact command execution', () => { + let home: string + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-post-compact-')) + }) + + afterEach(() => rmSync(home, { recursive: true, force: true })) + + function run(command: string, env: NodeJS.ProcessEnv) { + return runProcess({ + program: '/bin/sh', + args: ['-c', command], + env: { PATH: '/usr/bin:/bin', ...env }, + input: 'x'.repeat(1_000_000), + timeoutMs: 5_000 + }) + } + + it('runs the HOME script with the agent pid captured', async () => { + const dir = join(home, '.orca', 'agent-hooks') + mkdirSync(dir, { recursive: true }) + const script = join(dir, 'claude-hook.sh') + writeFileSync(script, '#!/bin/sh\ncat >/dev/null\nprintf "%s" "$ORCA_HOOK_AGENT_PID"\nexit 7\n') + chmodSync(script, 0o755) + + const result = await run(getManagedEventHook('PostCompact', SCRIPT_PATH).command, { + HOME: home + }) + + expect(result.code, result.stderr).toBe(7) + expect(result.stdout).toBe(String(process.pid)) + }) + + it.each([ + ['the script is missing', (dir: string) => ({ HOME: dir })], + ['HOME is empty', () => ({ HOME: '' })], + ['HOME is unset', () => ({})] + ])('drains stdin silently when %s', async (_label, env) => { + const result = await run(getManagedEventHook('PostCompact', SCRIPT_PATH).command, env(home)) + + expect(result.code).toBe(0) + expect(result.stdout).toBe('') + expect(result.stderr).toBe('') + }) + + it.each([{}, { HOME: '' }])('skips script lookup without a runtime HOME: %j', async (env) => { + const lookupProbe = [ + 'probe_test() {', + ' case "$1" in -f|-r|-x) printf "unexpected script lookup: %s\\n" "$2" >&2; return 1 ;;', + ' *) command [ "$@" ;; esac', + '}', + 'alias [=probe_test', + getManagedEventHook('PostCompact', SCRIPT_PATH).command + ].join('\n') + + const result = await run(lookupProbe, env) + + expect(result.code).toBe(0) + expect(result.stdout).toBe('') + expect(result.stderr).toBe('') + }) +}) + +describe.skipIf(process.platform === 'win32')('ClaudeHookService PostCompact install', () => { + it('writes the short form, reports installed, and replaces an old full-wrapper entry', () => { + const home = mkdtempSync(join(tmpdir(), 'orca-post-compact-install-')) + vi.stubEnv('HOME', home) + try { + const settingsPath = join(home, '.claude', 'settings.json') + mkdirSync(join(home, '.claude'), { recursive: true }) + const fullWrapper = getManagedLifecycleHook(SCRIPT_PATH).command + writeFileSync( + settingsPath, + JSON.stringify({ + hooks: { + PostCompact: [{ hooks: [{ type: 'command', command: fullWrapper }] }] + } + }) + ) + + const status = new ClaudeHookService().install(CURRENT_CLAUDE) + + expect(status.state).toBe('installed') + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')) + expect(written.hooks.PostCompact).toEqual([ + { hooks: [getManagedEventHook('PostCompact', SCRIPT_PATH)] } + ]) + expect(written.hooks.Stop[0].hooks[0].command).toBe(fullWrapper) + } finally { + vi.unstubAllEnvs() + rmSync(home, { recursive: true, force: true }) + } + }) +}) + +describe('ClaudeHookService remote PostCompact install', () => { + it('writes the short form for PostCompact and the full wrapper elsewhere', async () => { + const { sftp, fs } = createAgentHookMemorySftp() + await new ClaudeHookService().installRemote(sftp, '/home/dev', CURRENT_CLAUDE) + + const written = JSON.parse(fs.files.get('/home/dev/.claude/settings.json') ?? '{}') + const remoteScript = '/home/dev/.orca/agent-hooks/claude-hook.sh' + expect(written.hooks.PostCompact[0].hooks[0].command).toBe( + getRemoteManagedEventCommand('PostCompact', remoteScript) + ) + expect(written.hooks.Stop[0].hooks[0].command).toBe(getRemoteManagedCommand(remoteScript)) + }) +})