mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user