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 <neil@stably.ai>
This commit is contained in:
Vladislav
2026-10-10 21:25:20 -07:00
committed by GitHub
co-authored by Neil
parent e4f599df05
commit d2ba67d109
7 changed files with 263 additions and 38 deletions
@@ -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<string, CommandBuilders>([
[
'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',
{
@@ -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`
}
@@ -14,7 +14,7 @@ const topLevelSettings: Record<string, { keys: string[] }> = fixture.topLevelSet
function install(config: HooksConfig, claudeVersion: string | undefined): HooksConfig {
return applyManagedHooks(
config,
managedHook,
() => managedHook,
SCRIPT_FILE_NAME,
getClaudeManagedHookPlan(claudeVersion)
)
@@ -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)
)
+6 -7
View File
@@ -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)
)
+33 -9
View File
@@ -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]
}
@@ -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))
})
})