mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(claude): make managed hook paths portable (STA-3348) (#13442)
* fix(claude): make managed hook paths portable * perf(claude): keep portable hooks shell-native --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
co-authored by
Brennan Benson
parent
d6e1d84235
commit
f226fcfc4b
@@ -5,6 +5,7 @@ import {
|
||||
accessSync,
|
||||
chmodSync,
|
||||
constants as fsConstants,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
@@ -311,43 +312,37 @@ async function verifyForwarding(scripts, home, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: rewrite from the path embedded in the installed command, not a
|
||||
// reconstructed join(home, ...). That way missing/failing-script cases cannot
|
||||
// silently re-run the real script if the install layout changes.
|
||||
function rewriteLauncherScriptPath(command, nextPath) {
|
||||
const match = /if \[ -f '([^']+)'/.exec(command)
|
||||
if (!match) {
|
||||
throw new Error('Installed launcher command did not reference a quoted script path')
|
||||
}
|
||||
return command.replaceAll(match[1], nextPath)
|
||||
}
|
||||
|
||||
async function verifyInstalledLauncher(home, payload) {
|
||||
const settingsPath = join(home, '.claude', 'settings.json')
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
||||
const command = findStrings(settings).find(
|
||||
(value) => value.includes('claude-hook.sh') && value.includes('if [ -f ')
|
||||
)
|
||||
if (!command || !command.includes('] && [ -r ') || !command.includes('else cat >/dev/null')) {
|
||||
if (
|
||||
!command ||
|
||||
!command.includes('"$HOME/.orca/agent-hooks/claude-hook.sh"') ||
|
||||
!command.includes('] && [ -r ') ||
|
||||
!command.includes('else { command -p cat')
|
||||
) {
|
||||
throw new Error('Electron did not install the guarded Claude launcher')
|
||||
}
|
||||
const scratch = mkdtempSync(join(tmpdir(), 'orca-hook-launcher-'))
|
||||
try {
|
||||
const missingPath = join(scratch, 'missing-hook.sh')
|
||||
const missingResult = await runShell(
|
||||
rewriteLauncherScriptPath(command, missingPath),
|
||||
command,
|
||||
payload,
|
||||
withoutOrcaEnvironment({ HOME: home })
|
||||
withoutOrcaEnvironment({ HOME: scratch })
|
||||
)
|
||||
assertSuccessfulWrite(missingResult, 'installed missing-script launcher')
|
||||
|
||||
const failingPath = join(scratch, 'failing-hook.sh')
|
||||
const failingPath = join(scratch, '.orca', 'agent-hooks', 'claude-hook.sh')
|
||||
mkdirSync(join(scratch, '.orca', 'agent-hooks'), { recursive: true })
|
||||
writeFileSync(failingPath, '#!/bin/sh\ncat >/dev/null\nexit 7\n', 'utf8')
|
||||
chmodSync(failingPath, 0o755)
|
||||
const failingResult = await runShell(
|
||||
rewriteLauncherScriptPath(command, failingPath),
|
||||
command,
|
||||
payload,
|
||||
withoutOrcaEnvironment({ HOME: home })
|
||||
withoutOrcaEnvironment({ HOME: scratch })
|
||||
)
|
||||
if (failingResult.exitCode !== 7 || failingResult.stdinErrors.length > 0) {
|
||||
throw new Error('Installed launcher did not preserve a running script failure')
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
|
||||
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
|
||||
"../src/main/agent-hooks/managed-hook-script-refresh.ts",
|
||||
"../src/main/agent-hooks/runtime-home-hook-command.ts",
|
||||
"../src/main/amp/hook-service.ts",
|
||||
"../src/main/antigravity/hook-service.ts",
|
||||
"../src/main/claude/hook-settings.ts",
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsCmdHookCommand,
|
||||
wrapWindowsGitBashHookCommand,
|
||||
readHooksJsonWithRaw,
|
||||
wrapWindowsHookCommand,
|
||||
writeManagedScript,
|
||||
@@ -32,6 +31,7 @@ import {
|
||||
type HooksConfig
|
||||
} from './installer-utils'
|
||||
import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract'
|
||||
import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command'
|
||||
|
||||
let tmpDir: string
|
||||
let configPath: string
|
||||
@@ -648,28 +648,87 @@ describe('wrapWindowsCmdHookCommand', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('wrapWindowsGitBashHookCommand', () => {
|
||||
it('guards the forward-slash fast path and drains when missing', () => {
|
||||
expect(
|
||||
wrapWindowsGitBashHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd')
|
||||
).toBe(
|
||||
`if [ -f 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd' ]; then 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd'; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi`
|
||||
describe('wrapRuntimeHomeHookCommand', () => {
|
||||
it('selects the runtime platform variant under HOME', () => {
|
||||
const command = wrapRuntimeHomeHookCommand('claude-hook')
|
||||
|
||||
expect(command).toContain('case "${OSTYPE-}" in msys*|cygwin*|win32*)')
|
||||
expect(command).toContain('case "$HOME" in *\\&*|*\\^*|*\\(*|*\\)*|*\\;*|*,*|*=*|*%*|*\\!*)')
|
||||
expect(command).not.toContain('uname')
|
||||
expect(command).toContain('"$HOME/.orca/agent-hooks/claude-hook.cmd"')
|
||||
expect(command).toContain('/bin/sh "$HOME/.orca/agent-hooks/claude-hook.sh"')
|
||||
expect(command).not.toMatch(/[A-Z]:[\\/]|\/Users\/|\/home\//)
|
||||
})
|
||||
|
||||
it('rejects a script base name that could inject shell syntax', () => {
|
||||
expect(() => wrapRuntimeHomeHookCommand('claude-hook; echo injected')).toThrow(
|
||||
'Invalid managed script base name'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the encoded launcher when bash would split the path', () => {
|
||||
const scriptPath = 'C:\\Users\\Jane Doe\\.orca\\agent-hooks\\claude-hook.cmd'
|
||||
const command = wrapWindowsGitBashHookCommand(scriptPath)
|
||||
expect(command).toMatch(qualifiedWindowsPowerShellCommand)
|
||||
expect(decodeWindowsHookCommand(command)).toBe(expectedDecodedWindowsHookCommand(scriptPath))
|
||||
it('executes the destination HOME script for the current runtime', () => {
|
||||
const sourceHome = join(tmpDir, 'source profile')
|
||||
const destinationHome = join(tmpDir, "destination $HOME ' & profile")
|
||||
const sourceScriptDir = join(sourceHome, '.orca', 'agent-hooks')
|
||||
const destinationScriptDir = join(destinationHome, '.orca', 'agent-hooks')
|
||||
mkdirSync(sourceScriptDir, { recursive: true })
|
||||
mkdirSync(destinationScriptDir, { recursive: true })
|
||||
const windowsExitCode = process.platform === 'win32' ? 7 : 9
|
||||
const posixExitCode = process.platform === 'win32' ? 9 : 7
|
||||
writeFileSync(
|
||||
join(destinationScriptDir, 'claude-hook.cmd'),
|
||||
`@echo off\r\nexit /b ${windowsExitCode}\r\n`,
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
join(destinationScriptDir, 'claude-hook.sh'),
|
||||
`#!/bin/sh\nexit ${posixExitCode}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(join(sourceScriptDir, 'claude-hook.cmd'), '@echo off\r\nexit /b 9\r\n', 'utf-8')
|
||||
writeFileSync(join(sourceScriptDir, 'claude-hook.sh'), '#!/bin/sh\nexit 9\n', 'utf-8')
|
||||
chmodSync(join(destinationScriptDir, 'claude-hook.sh'), 0o755)
|
||||
|
||||
const shell =
|
||||
process.platform === 'win32'
|
||||
? join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe')
|
||||
: '/bin/sh'
|
||||
const result = spawnSync(shell, ['-c', wrapRuntimeHomeHookCommand('claude-hook')], {
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: destinationHome.replaceAll('\\', '/'),
|
||||
USERPROFILE: destinationHome
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.status, result.stderr.toString()).toBe(7)
|
||||
})
|
||||
|
||||
it('falls back to the encoded launcher when bash metacharacters are present', () => {
|
||||
const scriptPath = 'C:\\Users\\alice & bob\\.orca\\agent-hooks\\claude-hook.cmd'
|
||||
const command = wrapWindowsGitBashHookCommand(scriptPath)
|
||||
expect(command).toMatch(qualifiedWindowsPowerShellCommand)
|
||||
expect(command).not.toContain('& bob')
|
||||
expect(decodeWindowsHookCommand(command)).toBe(expectedDecodedWindowsHookCommand(scriptPath))
|
||||
it.skipIf(process.platform !== 'win32')('keeps common Windows profiles on the fast path', () => {
|
||||
const destinationHome = join(tmpDir, 'destination 国際 profile')
|
||||
const scriptDir = join(destinationHome, '.orca', 'agent-hooks')
|
||||
mkdirSync(scriptDir, { recursive: true })
|
||||
writeFileSync(join(scriptDir, 'claude-hook.cmd'), '@echo off\r\nexit /b 7\r\n', 'utf-8')
|
||||
const gitBash = join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe')
|
||||
const result = spawnSync(gitBash, ['-c', wrapRuntimeHomeHookCommand('claude-hook')], {
|
||||
env: { ...process.env, HOME: destinationHome.replaceAll('\\', '/') }
|
||||
})
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.status, result.stderr.toString()).toBe(7)
|
||||
})
|
||||
|
||||
it('drains stdin when HOME is unavailable', () => {
|
||||
const command = `unset HOME; ${wrapRuntimeHomeHookCommand('claude-hook')}`
|
||||
const shell =
|
||||
process.platform === 'win32'
|
||||
? join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe')
|
||||
: '/bin/sh'
|
||||
const result = spawnSync(shell, ['-c', command], { input: Buffer.alloc(1_000_000, 'x') })
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.status).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -152,16 +152,6 @@ export function wrapWindowsCmdHookCommand(scriptPath: string): string {
|
||||
return WINDOWS_CMD_SAFE_PATH.test(scriptPath) ? scriptPath : wrapWindowsHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
export const WINDOWS_GIT_BASH_SAFE_PATH = /^[A-Za-z0-9_.:/~-]+$/
|
||||
|
||||
export function wrapWindowsGitBashHookCommand(scriptPath: string): string {
|
||||
const bashPath = scriptPath.replaceAll('\\', '/')
|
||||
// Why: Claude's Git Bash runner can execute a forward-slash .cmd directly; unsafe paths stay encoded.
|
||||
return WINDOWS_GIT_BASH_SAFE_PATH.test(bashPath)
|
||||
? `if [ -f ${quotePosixShellString(bashPath)} ]; then ${quotePosixShellString(bashPath)}; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi`
|
||||
: wrapWindowsHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra form lines inserted before the final `payload@-` line (each should end with ` ^`).
|
||||
* Used by Grok to attach `grokHome` without fragile string replace on the shared template.
|
||||
|
||||
@@ -79,12 +79,9 @@ import { GeminiHookService } from '../gemini/hook-service'
|
||||
import { GrokHookService } from '../grok/hook-service'
|
||||
import { KimiHookService } from '../kimi/hook-service'
|
||||
import { openClaudeHookService } from '../openclaude/hook-service'
|
||||
import {
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsGitBashHookCommand,
|
||||
wrapWindowsHookCommand
|
||||
} from './installer-utils'
|
||||
import { wrapPosixHookCommand, wrapWindowsHookCommand } from './installer-utils'
|
||||
import { POSIX_HOOK_STDIN_READER } from './hook-stdin-contract'
|
||||
import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command'
|
||||
import { createAgentHookMemorySftp } from './agent-hook-memory-sftp.test-fixture'
|
||||
|
||||
const REMOTE_HOME = '/home/dev'
|
||||
@@ -387,9 +384,9 @@ describe('Windows managed hook stdin structure', () => {
|
||||
args: ['/d', '/c', wrapWindowsHookCommand(missingScript)]
|
||||
},
|
||||
{
|
||||
name: 'Git Bash fast path',
|
||||
name: 'portable Git Bash launcher',
|
||||
executable: gitBash,
|
||||
args: ['-lc', wrapWindowsGitBashHookCommand(missingScript)]
|
||||
args: ['-lc', wrapRuntimeHomeHookCommand('missing-orca-hook')]
|
||||
}
|
||||
]
|
||||
for (const launcher of launcherCases) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract'
|
||||
|
||||
const MANAGED_SCRIPT_BASE_NAME = /^[A-Za-z0-9_-]+$/
|
||||
const WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE = '*\\&*|*\\^*|*\\(*|*\\)*|*\\;*|*,*|*=*|*%*|*\\!*'
|
||||
|
||||
export function wrapRuntimeHomeHookCommand(scriptBaseName: string): string {
|
||||
if (!MANAGED_SCRIPT_BASE_NAME.test(scriptBaseName)) {
|
||||
throw new Error(`Invalid managed script base name: ${scriptBaseName}`)
|
||||
}
|
||||
const windowsScript = `"$HOME/.orca/agent-hooks/${scriptBaseName}.cmd"`
|
||||
const posixScript = `"$HOME/.orca/agent-hooks/${scriptBaseName}.sh"`
|
||||
const drain = POSIX_HOOK_STDIN_DRAIN_COMMAND
|
||||
const powershell = '"$SYSTEMROOT/System32/WindowsPowerShell/v1.0/powershell.exe"'
|
||||
const powershellCommand = `$homePath = $env:HOME -replace '^/([A-Za-z])/', '$1:/'; $scriptPath = Join-Path $homePath '.orca\\agent-hooks\\${scriptBaseName}.cmd'; if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0`
|
||||
const encodedCommand = Buffer.from(powershellCommand, 'utf16le').toString('base64')
|
||||
const powershellInvocation = `${powershell} -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}`
|
||||
const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${drain}; fi`
|
||||
const windowsBranch = `if [ -f ${windowsScript} ]; then case "$HOME" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${drain}; fi`
|
||||
const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then /bin/sh ${posixScript}; else ${drain}; fi`
|
||||
// Why: OSTYPE is shell-owned, so platform selection adds no process to every hook invocation.
|
||||
return `if [ -z "\${HOME-}" ]; then ${drain}; else case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsBranch} ;; *) ${posixBranch} ;; esac; fi`
|
||||
}
|
||||
@@ -34,15 +34,14 @@ function hasManagedCommand(hook: TestHook, matcher: (command: string | undefined
|
||||
}
|
||||
|
||||
describe('getWindowsManagedLifecycleHook', () => {
|
||||
it('keeps cmd metacharacters out of the headless client command line', () => {
|
||||
it('resolves the managed script from the runtime Windows profile', () => {
|
||||
const scriptPath = 'C:\\Users\\%name%\\a^b&c\\.orca\\agent-hooks\\claude-hook.cmd'
|
||||
const hook = getWindowsManagedLifecycleHook(scriptPath)
|
||||
const encodedCommand = hook.args?.at(-1)
|
||||
|
||||
expect(hook.args?.[0]).toBe('--headless')
|
||||
expect(hook.args?.[1]).toMatch(/\\WindowsPowerShell\\v1\.0\\powershell\.exe$/i)
|
||||
expect(hook.args?.[1]).toMatch(/\\System32\\cmd\.exe$/i)
|
||||
expect(hook.args?.at(-1)).toBe('%USERPROFILE%\\.orca\\agent-hooks\\claude-hook.cmd')
|
||||
expect(hook.args).not.toContain(scriptPath)
|
||||
expect(Buffer.from(encodedCommand!, 'base64').toString('utf16le')).toContain(scriptPath)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -185,6 +184,10 @@ describe('ClaudeHookService.install', () => {
|
||||
expect(legacyHooks.map((hook: TestHook) => hook.command)).toContain(
|
||||
'/usr/local/bin/user-hook'
|
||||
)
|
||||
const managedHook = legacyHooks.find((hook: TestHook) =>
|
||||
hasManagedCommand(hook, isClaudeManagedCommand)
|
||||
)
|
||||
expect(JSON.stringify(managedHook)).not.toContain(tmpHome.replaceAll('\\', '/'))
|
||||
expect(
|
||||
legacyHooks.some((hook: TestHook) => hasManagedCommand(hook, isClaudeManagedCommand))
|
||||
).toBe(true)
|
||||
@@ -216,7 +219,13 @@ describe('ClaudeHookService.install', () => {
|
||||
readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8')
|
||||
) as { statusLine?: { type: string; command: string } }
|
||||
expect(settings.statusLine?.type).toBe('command')
|
||||
expect(settings.statusLine?.command).toContain('claude-statusline')
|
||||
expect(settings.statusLine?.command).toContain(
|
||||
'"$HOME/.orca/agent-hooks/claude-statusline.cmd"'
|
||||
)
|
||||
expect(settings.statusLine?.command).toContain(
|
||||
'"$HOME/.orca/agent-hooks/claude-statusline.sh"'
|
||||
)
|
||||
expect(settings.statusLine?.command).not.toContain(tmpHome.replaceAll('\\', '/'))
|
||||
|
||||
const script = readFileSync(
|
||||
join(tmpHome, '.orca', 'agent-hooks', STATUSLINE_SCRIPT_FILE_NAME),
|
||||
@@ -330,7 +339,7 @@ describe('ClaudeHookService.install', () => {
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'runs managed hooks through headless exec form and preserves profile-path spaces',
|
||||
'runs portable managed hooks through headless exec form',
|
||||
() => {
|
||||
const tmpHome = mkdtempSync(join(tmpdir(), 'orca claude home with spaces '))
|
||||
vi.stubEnv('HOME', tmpHome)
|
||||
@@ -344,15 +353,22 @@ describe('ClaudeHookService.install', () => {
|
||||
|
||||
const system32 = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32')
|
||||
const scriptPath = join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME)
|
||||
const runtimeScriptPath = join(
|
||||
'%USERPROFILE%',
|
||||
'.orca',
|
||||
'agent-hooks',
|
||||
CLAUDE_SCRIPT_FILE_NAME
|
||||
)
|
||||
|
||||
for (const eventName of ['UserPromptSubmit', 'Stop', 'StopFailure']) {
|
||||
const hook = settings.hooks[eventName]?.[0]?.hooks?.[0]
|
||||
expect(hook).toEqual({
|
||||
type: 'command',
|
||||
command: join(system32, 'conhost.exe'),
|
||||
args: ['--headless', join(system32, 'cmd.exe'), '/d', '/c', scriptPath],
|
||||
args: ['--headless', join(system32, 'cmd.exe'), '/d', '/c', runtimeScriptPath],
|
||||
timeout: 10
|
||||
})
|
||||
expect(hook.args).not.toContain(scriptPath)
|
||||
}
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
@@ -395,11 +411,7 @@ describe('ClaudeHookService.installRemote', () => {
|
||||
const settings = fs.files.get('/home/dev/.claude/settings.json')
|
||||
expect(settings).toBeTruthy()
|
||||
const parsed = JSON.parse(settings!)
|
||||
// Why: every load-bearing event must be present and point at the
|
||||
// remote-shaped script path with the guarded launcher applied. Drift in
|
||||
// any of these is a real bug — Claude
|
||||
// Code rejects unknown shapes silently and the agent-hooks pipeline
|
||||
// goes dark.
|
||||
// Why: Claude silently rejects drifted hook shapes, so assert every load-bearing event.
|
||||
for (const event of [
|
||||
// Why: SessionStart is the only signal a resumed/idle session ever emits;
|
||||
// without it the sidebar row waits for the first prompt (STA-3386).
|
||||
@@ -417,8 +429,8 @@ describe('ClaudeHookService.installRemote', () => {
|
||||
]) {
|
||||
expect(parsed.hooks[event]).toBeTruthy()
|
||||
const cmd = parsed.hooks[event][0].hooks[0].command as string
|
||||
expect(cmd).toContain('/home/dev/.orca/agent-hooks/claude-hook.sh')
|
||||
expect(cmd).toMatch(/^if \[ -f /)
|
||||
expect(cmd).toContain('"$HOME/.orca/agent-hooks/claude-hook.sh"')
|
||||
expect(cmd).not.toContain('/home/dev/.orca/agent-hooks/claude-hook.sh')
|
||||
}
|
||||
// Managed script body
|
||||
const script = fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh')
|
||||
@@ -510,9 +522,9 @@ describe('OpenClaudeHookService-compatible install', () => {
|
||||
for (const event of ['UserPromptSubmit', 'Stop', 'StopFailure']) {
|
||||
const command = parsed.hooks[event][0].hooks[0].command as string
|
||||
expect(isOpenClaudeManagedCommand(command)).toBe(true)
|
||||
if (process.platform !== 'win32') {
|
||||
expect(command).toMatch(/^if \[ -f /)
|
||||
}
|
||||
expect(command).toContain('"$HOME/.orca/agent-hooks/openclaude-hook.cmd"')
|
||||
expect(command).toContain('"$HOME/.orca/agent-hooks/openclaude-hook.sh"')
|
||||
expect(command).not.toContain(tmpHome.replaceAll('\\', '/'))
|
||||
}
|
||||
expect(
|
||||
readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8')
|
||||
@@ -541,7 +553,8 @@ describe('OpenClaudeHookService-compatible install', () => {
|
||||
})
|
||||
const parsed = JSON.parse(fs.files.get('/home/dev/.openclaude/settings.json')!)
|
||||
const command = parsed.hooks.StopFailure[0].hooks[0].command as string
|
||||
expect(command).toContain('/home/dev/.orca/agent-hooks/openclaude-hook.sh')
|
||||
expect(command).toContain('"$HOME/.orca/agent-hooks/openclaude-hook.sh"')
|
||||
expect(command).not.toContain('/home/dev/.orca/agent-hooks/openclaude-hook.sh')
|
||||
expect(fs.files.get('/home/dev/.orca/agent-hooks/openclaude-hook.sh')).toContain('/hook/claude')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -262,7 +262,7 @@ export class ClaudeHookService {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the POSIX wrapper is identical regardless of where the script lands; only the path differs.
|
||||
// Why: settings resolve HOME at runtime while SFTP still targets the discovered remote home.
|
||||
const hook = buildManagedCommandHook(getRemoteManagedCommand(remoteScriptPath))
|
||||
const nextConfig = applyManagedHooks(config, hook, remoteScriptFileName)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { join, win32 } from 'node:path'
|
||||
import { basename, extname, join, win32 } from 'node:path'
|
||||
import {
|
||||
buildManagedCommandHook,
|
||||
createManagedCommandMatcher,
|
||||
@@ -7,13 +7,11 @@ import {
|
||||
isPlainObject,
|
||||
MANAGED_HOOK_TIMEOUT_SECONDS,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsGitBashHookCommand,
|
||||
wrapWindowsHookCommand,
|
||||
type HookCommandConfig,
|
||||
type HookDefinition,
|
||||
type HooksConfig
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import { wrapRuntimeHomeHookCommand } from '../agent-hooks/runtime-home-hook-command'
|
||||
|
||||
export type ClaudeCompatibleHookSettings = {
|
||||
configDirName: '.claude' | '.openclaude'
|
||||
@@ -111,9 +109,11 @@ export function getRemoteConfigPath(remoteHome: string, settings = CLAUDE_HOOK_S
|
||||
}
|
||||
|
||||
export function getManagedCommand(scriptPath: string): string {
|
||||
return process.platform === 'win32'
|
||||
? wrapWindowsGitBashHookCommand(scriptPath)
|
||||
: wrapPosixHookCommand(scriptPath)
|
||||
const scriptFileName = basename(scriptPath)
|
||||
const extension = extname(scriptFileName)
|
||||
return wrapRuntimeHomeHookCommand(
|
||||
extension ? scriptFileName.slice(0, -extension.length) : scriptFileName
|
||||
)
|
||||
}
|
||||
|
||||
export function getManagedLifecycleHook(
|
||||
@@ -126,30 +126,19 @@ export function getManagedLifecycleHook(
|
||||
return getWindowsManagedLifecycleHook(scriptPath)
|
||||
}
|
||||
|
||||
const WINDOWS_CMD_ARGUMENT_META = /[%!^&|<>()"\r\n]/
|
||||
|
||||
export function getWindowsManagedLifecycleHook(scriptPath: string): HookCommandConfig {
|
||||
const system32 = win32.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32')
|
||||
let clientArgs = [win32.join(system32, 'cmd.exe'), '/d', '/c', scriptPath]
|
||||
if (WINDOWS_CMD_ARGUMENT_META.test(scriptPath)) {
|
||||
const encodedCommand = wrapWindowsHookCommand(scriptPath).match(/ -EncodedCommand (\S+)$/)?.[1]
|
||||
if (!encodedCommand) {
|
||||
throw new Error('Failed to encode managed Claude hook path')
|
||||
}
|
||||
clientArgs = [
|
||||
win32.join(system32, 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-EncodedCommand',
|
||||
encodedCommand
|
||||
]
|
||||
}
|
||||
const runtimeScriptPath = win32.join(
|
||||
'%USERPROFILE%',
|
||||
'.orca',
|
||||
'agent-hooks',
|
||||
win32.basename(scriptPath)
|
||||
)
|
||||
// Why: Claude's Windows shell form opens Git Bash consoles; exec form hosts the client in a windowless console.
|
||||
return {
|
||||
type: 'command',
|
||||
command: win32.join(system32, 'conhost.exe'),
|
||||
args: ['--headless', ...clientArgs],
|
||||
args: ['--headless', win32.join(system32, 'cmd.exe'), '/d', '/c', runtimeScriptPath],
|
||||
timeout: MANAGED_HOOK_TIMEOUT_SECONDS
|
||||
}
|
||||
}
|
||||
@@ -165,7 +154,7 @@ export function hasSameManagedHookInvocation(
|
||||
}
|
||||
|
||||
export function getRemoteManagedCommand(scriptPath: string): string {
|
||||
return wrapPosixHookCommand(scriptPath)
|
||||
return getManagedCommand(scriptPath)
|
||||
}
|
||||
|
||||
export function applyManagedHooks(
|
||||
|
||||
Reference in New Issue
Block a user