diff --git a/config/scripts/verify-agent-hook-stdin-lifecycle.mjs b/config/scripts/verify-agent-hook-stdin-lifecycle.mjs index 06ee9734783..079735c41ec 100644 --- a/config/scripts/verify-agent-hook-stdin-lifecycle.mjs +++ b/config/scripts/verify-agent-hook-stdin-lifecycle.mjs @@ -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') diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index a5d0acd6987..572e9dcd0f9 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -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", diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 9e41b374828..2013865aa96 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.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) }) }) diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index 0c06d78acbf..f6e4c131826 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -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. diff --git a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts index 2ccfe4e54dd..7c27c2fadee 100644 --- a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts +++ b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts @@ -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) { diff --git a/src/main/agent-hooks/runtime-home-hook-command.ts b/src/main/agent-hooks/runtime-home-hook-command.ts new file mode 100644 index 00000000000..3ae887fc054 --- /dev/null +++ b/src/main/agent-hooks/runtime-home-hook-command.ts @@ -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` +} diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index 76bf9dce9ac..b3fa02debc5 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -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') }) }) diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index ae6c62d3a12..47269b1e17d 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -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) diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index c9e69abc762..d39b8e27bcb 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -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(