diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 36044633ec1..c3ae247b410 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -23,7 +23,6 @@ import { hookDefinitionHasManagedCommand, removeManagedCommands, wrapPosixHookCommand, - wrapWindowsCmdHookCommand, readHooksJsonWithRaw, wrapWindowsHookCommand, writeManagedScript, @@ -677,36 +676,6 @@ describe('wrapWindowsHookCommand', () => { ) }) -describe('wrapWindowsCmdHookCommand', () => { - it('returns the bare, directly-spawnable path for a cmd-safe managed script', () => { - // Direct-spawn consumers need a launchable argv[0], not a cmd builtin such as `if`. - const scriptPath = 'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd' - const command = wrapWindowsCmdHookCommand(scriptPath) - expect(command).toBe(scriptPath) - expect(command).not.toMatch(/^if\b/) - expect(command).not.toMatch(/powershell/i) - }) - - it.skipIf(process.platform !== 'win32')( - 'resolves the launcher to a real executable file, not a shell fragment', - () => { - // POSIX temp paths contain `/`, which selects the encoded fallback instead. - const scriptPath = join(tmpDir, 'codex-hook.cmd') - writeFileSync(scriptPath, '@echo off\r\nexit /b 0\r\n', 'utf-8') - const command = wrapWindowsCmdHookCommand(scriptPath) - expect(command).toBe(scriptPath) - expect(existsSync(command)).toBe(true) - } - ) - - it('falls back to the encoded launcher when cmd.exe would split or expand the path', () => { - const scriptPath = 'C:\\Users\\Jane Doe\\%ORCA_TEST%\\codex-hook.cmd' - const command = wrapWindowsCmdHookCommand(scriptPath) - expect(command).toMatch(qualifiedWindowsPowerShellCommand) - expect(decodeWindowsHookCommand(command)).toBe(expectedDecodedWindowsHookCommand(scriptPath)) - }) -}) - describe('wrapRuntimeHomeHookCommand', () => { it('selects the runtime platform variant under HOME', () => { const command = wrapRuntimeHomeHookCommand('claude-hook') diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index 244fb59fe25..41454024e0f 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -115,7 +115,6 @@ export { buildWindowsHookPowerShellCommand, isSafeUnicodeWindowsBatchHookPath, WINDOWS_CMD_SAFE_PATH, - wrapWindowsCmdHookCommand, wrapWindowsHookCommand } from './windows-hook-command' 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 e1cce7617ac..6fbe86a4489 100644 --- a/src/main/agent-hooks/managed-hook-command-contract.test.ts +++ b/src/main/agent-hooks/managed-hook-command-contract.test.ts @@ -28,11 +28,8 @@ import { getDshManagedCommand, getDshRemoteManagedCommand } from '../dsh/hook-se import { getZCodeManagedCommand, getZCodeRemoteManagedCommand } from '../zcode/hook-settings' import { getJcodeManagedCommand, getJcodeRemoteManagedCommand } from '../jcode/hook-settings' import { getKiroManagedCommand, getKiroRemoteManagedCommand } from '../kiro/hook-settings' -import { - wrapPosixHookCommand, - wrapWindowsCmdHookCommand, - wrapWindowsHookCommand -} from './installer-utils' +import { wrapPosixHookCommand, wrapWindowsHookCommand } from './installer-utils' +import { wrapWindowsBatchHookCommand } from './windows-direct-cmd-hook-command' import { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-registry' import { REMOTE_MANAGED_HOOK_INSTALLER_AGENTS } from './remote-managed-hook-installers' import { @@ -152,7 +149,7 @@ const buildersByAgent = new Map([ local: (path) => process.platform === 'win32' ? ANTIGRAVITY_EVENTS.map((event) => - wrapWindowsCmdHookCommand( + wrapWindowsBatchHookCommand( path.replace('antigravity-hook.cmd', event.windowsWrapperFileName) ) ) diff --git a/src/main/agent-hooks/windows-batch-hook-launcher.test.ts b/src/main/agent-hooks/windows-batch-hook-launcher.test.ts index 1d3ecb094ff..6f171b4c3ba 100644 --- a/src/main/agent-hooks/windows-batch-hook-launcher.test.ts +++ b/src/main/agent-hooks/windows-batch-hook-launcher.test.ts @@ -5,7 +5,8 @@ import { delimiter, join } from 'node:path' import { runProcess } from '@orca/process-host' import { removeTree } from '../../shared/windows-transient-lock-removal' import { getWindowsManagedLifecycleHook } from '../claude/hook-settings' -import { wrapWindowsCmdHookCommand, wrapWindowsHookCommand } from './installer-utils' +import { wrapWindowsHookCommand } from './installer-utils' +import { wrapWindowsBatchHookCommand } from './windows-direct-cmd-hook-command' import { findGitBash } from './windows-git-bash-path.test-fixture' import { getWindowsPowerShellExecutablePath, @@ -26,7 +27,7 @@ describe('batch hook policy setup', () => { (extension) => { const path = `C:\\Users\\测试用户\\.orca\\agent-hooks\\hook${extension}` const commands = [ - wrapWindowsCmdHookCommand(path), + wrapWindowsBatchHookCommand(path), getWindowsManagedLifecycleHook(path).command ] for (const command of commands) { @@ -79,7 +80,7 @@ describe.skipIf(process.platform !== 'win32')('guarded Windows batch hook comman } const input = JSON.stringify({ prompt: '한국어 日本語 😀 & %PATH% !'.repeat(4096) }) const entries = [ - { command: wrapWindowsCmdHookCommand(scriptPath), missingStdout: '' }, + { command: wrapWindowsBatchHookCommand(scriptPath), missingStdout: '' }, { command: getWindowsManagedLifecycleHook(scriptPath).command, missingStdout: '{}' } ] try { @@ -152,7 +153,7 @@ describe.skipIf(process.platform !== 'win32')('guarded Windows batch hook comman const scriptPath = join(root, '测试-hook.ps1') try { writeFileSync(scriptPath, "Write-Output '{}'; exit 0") - const command = wrapWindowsCmdHookCommand(scriptPath) + const command = wrapWindowsBatchHookCommand(scriptPath) const [program, ...args] = command.split(' ') const result = await runProcess({ program, @@ -182,7 +183,7 @@ describe.skipIf(process.platform !== 'win32')('guarded Windows batch hook comman scriptPath, `@echo off\r\n"${getWindowsPowerShellExecutablePath()}" -NoProfile -File "%~dp0child.ps1"\r\nexit /b %ERRORLEVEL%\r\n` ) - const [program, ...args] = wrapWindowsCmdHookCommand(scriptPath).split(' ') + const [program, ...args] = wrapWindowsBatchHookCommand(scriptPath).split(' ') const result = await runProcess({ program, args, diff --git a/src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts b/src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts index f1012140933..77d4267d3c3 100644 --- a/src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts +++ b/src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts @@ -10,14 +10,17 @@ import { isPlainObject, readHooksJson, WINDOWS_CMD_SAFE_PATH, - wrapWindowsCmdHookCommand, wrapWindowsHookCommand, writeHooksJson } from './installer-utils' -import { wrapWindowsDirectCmdHookCommand } from './windows-direct-cmd-hook-command' +import { + wrapWindowsBatchHookCommand, + wrapWindowsDirectCmdHookCommand +} from './windows-direct-cmd-hook-command' import { AntigravityHookService } from '../antigravity/hook-service' import { ANTIGRAVITY_EVENTS } from '../antigravity/hook-events' import { getWindowsPowerShellExecutablePath } from './windows-powershell-hook-launcher' +import { findGitBash } from './windows-git-bash-path.test-fixture' const { homedirMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>() })) vi.mock('electron', () => ({ app: { getPath: () => process.cwd() } })) @@ -40,15 +43,14 @@ function decodeCommand(command: string): string { describe('Windows Unicode managed hook commands', () => { it('keeps the existing ASCII direct path', () => { const scriptPath = 'C:\\Users\\ascii\\.orca\\agent-hooks\\antigravity-pre-tool-use.cmd' - expect(wrapWindowsCmdHookCommand(scriptPath)).toBe(scriptPath) - expect(wrapWindowsDirectCmdHookCommand(scriptPath)).toBe(scriptPath.replaceAll('\\', '/')) + expect(wrapWindowsBatchHookCommand(scriptPath)).toBe(scriptPath.replaceAll('\\', '/')) }) it.each(PROFILES.slice(1))( 'preserves the guarded launcher for %s without policy setup', (profile) => { const scriptPath = `C:\\Users\\${profile}\\.orca\\agent-hooks\\antigravity-pre-tool-use.cmd` - const decoded = decodeCommand(wrapWindowsCmdHookCommand(scriptPath)) + const decoded = decodeCommand(wrapWindowsBatchHookCommand(scriptPath)) expect(decoded).toContain(`Test-Path -LiteralPath '${scriptPath}' -PathType Leaf`) expect(decoded).toContain('[Console]::In.ReadToEnd() | Out-Null; exit 0') expect(decoded).not.toContain('Set-ExecutionPolicy') @@ -72,7 +74,7 @@ describe('Windows Unicode managed hook commands', () => { '用户\u2019name' ])('retains the encoded launcher for %s', (profile) => { const scriptPath = `C:\\Users\\${profile}\\.orca\\agent-hooks\\antigravity-pre-tool-use.cmd` - expect(wrapWindowsCmdHookCommand(scriptPath)).toBe(wrapWindowsHookCommand(scriptPath)) + expect(wrapWindowsBatchHookCommand(scriptPath)).toBe(wrapWindowsHookCommand(scriptPath)) expect(wrapWindowsDirectCmdHookCommand(scriptPath)).toBeNull() }) }) @@ -152,7 +154,9 @@ describe.skipIf( for (const event of ANTIGRAVITY_EVENTS) { const command = installedCommand(configPath, event.eventName) const scriptPath = join(home, '.orca', 'agent-hooks', event.windowsWrapperFileName) - expect(command).toBe(wrapWindowsCmdHookCommand(scriptPath)) + const directCommand = wrapWindowsDirectCmdHookCommand(scriptPath) + // Why: an ASCII profile registers the forward-slash path Git Bash can start too. + expect(command).toBe(wrapWindowsBatchHookCommand(scriptPath)) if (!WINDOWS_CMD_SAFE_PATH.test(scriptPath)) { expect(decodeCommand(command)).not.toContain('Set-ExecutionPolicy') } @@ -164,7 +168,8 @@ describe.skipIf( { program: getWindowsPowerShellExecutablePath(), args: ['-NoProfile', '-Command', command] - } + }, + ...(directCommand ? [{ program: findGitBash(), args: ['-c', command] }] : []) ] for (const host of hosts) { const before = posts.length diff --git a/src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts b/src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts index 9b6726a4377..64766365873 100644 --- a/src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts +++ b/src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts @@ -65,7 +65,7 @@ describe('wrapWindowsBatchHookCommand', () => { it('keeps the encoded launcher for a path the shells cannot carry bare', () => { const spaced = 'C:\\Users\\Bob Smith\\.orca\\agent-hooks\\gemini-hook.cmd' expect(wrapWindowsBatchHookCommand(spaced)).toBe(wrapWindowsHookCommand(spaced)) - // Guarded Unicode .cmd paths take the cheaper policy switch, as wrapWindowsCmdHookCommand does. + // Guarded Unicode .cmd paths take the cheaper policy switch. const unicode = 'C:\\Users\\rené\\.orca\\agent-hooks\\gemini-hook.cmd' expect(wrapWindowsBatchHookCommand(unicode)).toBe( wrapWindowsHookCommand(unicode, {}, { useProcessPolicyEnvironment: true }) diff --git a/src/main/agent-hooks/windows-direct-cmd-hook-command.ts b/src/main/agent-hooks/windows-direct-cmd-hook-command.ts index dce62ec40a8..ebb3560a47c 100644 --- a/src/main/agent-hooks/windows-direct-cmd-hook-command.ts +++ b/src/main/agent-hooks/windows-direct-cmd-hook-command.ts @@ -27,7 +27,7 @@ export function wrapWindowsBatchHookCommand(scriptPath: string): string { wrapWindowsHookCommand( scriptPath, {}, - // Same policy shortcut as wrapWindowsCmdHookCommand for guarded Unicode .cmd paths (#26382). + // Guarded Unicode .cmd paths skip loading the policy cmdlet (#26382). { useProcessPolicyEnvironment: isSafeUnicodeWindowsBatchHookPath(scriptPath) } ) ) diff --git a/src/main/agent-hooks/windows-hook-command.ts b/src/main/agent-hooks/windows-hook-command.ts index 42d435fd700..287e37eec29 100644 --- a/src/main/agent-hooks/windows-hook-command.ts +++ b/src/main/agent-hooks/windows-hook-command.ts @@ -44,18 +44,3 @@ export function isSafeUnicodeWindowsBatchHookPath(scriptPath: string): boolean { !WINDOWS_CMD_SAFE_PATH.test(scriptPath) && /^[\p{L}\p{N}\p{M}_.:\\~-]+\.cmd$/iu.test(scriptPath) ) } - -export function wrapWindowsCmdHookCommand(scriptPath: string): string { - // Direct-spawn consumers need one executable token; a cmd `if exist` fragment is not one (#8430). - if (WINDOWS_CMD_SAFE_PATH.test(scriptPath)) { - return scriptPath - } - // Unicode batch hooks keep the missing-file guard without loading the policy cmdlet. - return wrapWindowsHookCommand( - scriptPath, - {}, - { - useProcessPolicyEnvironment: isSafeUnicodeWindowsBatchHookPath(scriptPath) - } - ) -} diff --git a/src/main/agent-hooks/windows-hook-payload-delivery.test.ts b/src/main/agent-hooks/windows-hook-payload-delivery.test.ts index 4a661accfb9..4dcf7b88a01 100644 --- a/src/main/agent-hooks/windows-hook-payload-delivery.test.ts +++ b/src/main/agent-hooks/windows-hook-payload-delivery.test.ts @@ -36,12 +36,15 @@ import { ClaudeHookService } from '../claude/hook-service' import { CommandCodeHookService } from '../command-code/hook-service' import { CopilotHookService } from '../copilot/hook-service' import { DroidHookService } from '../droid/hook-service' +import { DevinHookService } from '../devin/hook-service' import { DshHookService } from '../dsh/hook-service' import { GeminiHookService } from '../gemini/hook-service' +import { GrokHookService } from '../grok/hook-service' import { KimiHookService } from '../kimi/hook-service' import { KiroHookService } from '../kiro/hook-service' import { MuseHookService } from '../muse/hook-service' import { getMuseManagedHooksPath } from '../muse/hook-settings' +import { zcodeHookService } from '../zcode/hook-service' import { readHooksJson, WINDOWS_CMD_SAFE_PATH, wrapWindowsHookCommand } from './installer-utils' import { getConfigPath, getWindowsManagedLifecycleHook } from '../claude/hook-settings' import { findGitBash } from './windows-git-bash-path.test-fixture' @@ -201,6 +204,11 @@ function readEvent(path: string, event: string): (string | undefined)[] { return commandsOf(JSON.parse(readFileSync(path, 'utf8')).hooks?.[event]) } +// The pre-forward-slash bare path: Git Bash strips its backslashes and exits 127. +const legacyBackslashEntry = (home: string, script: string): Entry => ({ + hooks: [{ command: join(home, '.orca', 'agent-hooks', script) }] +}) + function writeHooks( path: string, hooks: Record, @@ -325,6 +333,50 @@ const PROVIDERS: Provider[] = [ ), runners: COPILOT_RUNNERS, hookEventName: 'Stop' + }, + { + // Grok spawns a one-token command directly, wraps a spaced one in PowerShell, or uses Git Bash. + name: 'Grok', + script: 'grok-hook.cmd', + stdout: '', + install: () => new GrokHookService().install(), + commands: (home) => readEvent(join(home, '.grok', 'hooks', 'orca-status.json'), 'Stop'), + seedStale: (home, stale) => + writeHooks(join(home, '.grok', 'hooks', 'orca-status.json'), { + Stop: [{ hooks: [{ command: stale }] }, legacyBackslashEntry(home, 'grok-hook.cmd')] + }), + runners: [CMD_PLAIN, GIT_BASH, POWERSHELL_PLAIN] + }, + { + name: 'Devin', + script: 'devin-hook.cmd', + stdout: '', + install: () => new DevinHookService().install(), + commands: (home) => readEvent(join(home, 'AppData', 'Roaming', 'devin', 'config.json'), 'Stop'), + seedStale: (home, stale) => + writeHooks(join(home, 'AppData', 'Roaming', 'devin', 'config.json'), { + Stop: [{ hooks: [{ command: stale }] }, legacyBackslashEntry(home, 'devin-hook.cmd')] + }), + runners: [CMD_PLAIN, GIT_BASH, POWERSHELL_PLAIN] + }, + { + // ZCode nests events one level deeper (`hooks.events`) and runs hooks through its own shell resolver. + name: 'ZCode', + script: 'zcode-hook.cmd', + stdout: '', + install: () => zcodeHookService.install(), + commands: (home) => + commandsOf( + JSON.parse(readFileSync(join(home, '.zcode', 'cli', 'config.json'), 'utf8')).hooks?.events + ?.Stop + ), + seedStale: (home, stale) => { + const path = join(home, '.zcode', 'cli', 'config.json') + mkdirSync(dirname(path), { recursive: true }) + const Stop = [{ hooks: [{ command: stale }] }, legacyBackslashEntry(home, 'zcode-hook.cmd')] + writeFileSync(path, JSON.stringify({ hooks: { enabled: true, events: { Stop } } })) + }, + runners: [CMD_PLAIN, GIT_BASH, POWERSHELL_PLAIN] } ] @@ -388,12 +440,14 @@ describe.skipIf(process.platform !== 'win32')('Windows managed hook payload deli for (const name of ['USERPROFILE', 'HOME']) { vi.stubEnv(name, home) } + vi.stubEnv('APPDATA', join(home, 'AppData', 'Roaming')) for (const name of [ 'XDG_CONFIG_HOME', 'KIRO_HOME', 'DSH_HOME', 'KIMI_CODE_HOME', - 'COPILOT_HOME' + 'COPILOT_HOME', + 'GROK_HOME' ]) { vi.stubEnv(name, undefined) } diff --git a/src/main/antigravity/hook-service.test.ts b/src/main/antigravity/hook-service.test.ts index d682c69520d..ae46f34b363 100644 --- a/src/main/antigravity/hook-service.test.ts +++ b/src/main/antigravity/hook-service.test.ts @@ -405,7 +405,7 @@ describe('AntigravityHookService', () => { ) expect(preToolCommands).toHaveLength(1) expect(preToolCommands[0]).toContain( - join(homeDir, '.orca', 'agent-hooks', ANTIGRAVITY_PRE_TOOL_USE_COMMAND) + join(homeDir, '.orca', 'agent-hooks', ANTIGRAVITY_PRE_TOOL_USE_COMMAND).replaceAll('\\', '/') ) expect(preToolCommands[0]).not.toContain('/tmp/old/agent-hooks/antigravity-hook.sh') const commands = config['orca-status'].PostToolUse.flatMap((definition) => @@ -413,7 +413,43 @@ describe('AntigravityHookService', () => { ) expect(commands).toHaveLength(1) expect(commands[0]).toContain( - join(homeDir, '.orca', 'agent-hooks', ANTIGRAVITY_POST_TOOL_USE_COMMAND) + join(homeDir, '.orca', 'agent-hooks', ANTIGRAVITY_POST_TOOL_USE_COMMAND).replaceAll('\\', '/') ) }) + + it('replaces legacy backslash Windows entries and keeps user hooks', () => { + withPlatform('win32', () => { + const configPath = join(homeDir, '.gemini', 'config', 'hooks.json') + // The pre-forward-slash bare paths: Git Bash strips their backslashes and exits 127. + const legacyStop = 'C:\\Users\\ada\\.orca\\agent-hooks\\antigravity-stop.cmd' + const legacyPreToolUse = 'C:\\Users\\ada\\.orca\\agent-hooks\\antigravity-pre-tool-use.cmd' + const userHook = { type: 'command', command: 'C:\\tools\\user-hook.cmd' } + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync( + configPath, + `${JSON.stringify({ + 'orca-status': { + Stop: [{ type: 'command', command: legacyStop }, userHook], + PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: legacyPreToolUse }] }] + } + })}\n` + ) + + const service = new AntigravityHookService() + expect(service.getStatus().state).toBe('partial') + expect(service.install().state).toBe('installed') + + const config = JSON.parse(readFileSync(configPath, 'utf8')) as { + 'orca-status': Record + } + const [keptUserHook, stop] = config['orca-status'].Stop + expect(config['orca-status'].Stop).toHaveLength(2) + expect(keptUserHook).toEqual(userHook) + expect(stop.command).not.toBe(legacyStop) + expect(createManagedCommandMatcher('antigravity-stop.cmd')(stop.command)).toBe(true) + const preToolUse = config['orca-status'].PreToolUse.flatMap((d) => d.hooks ?? []) + expect(preToolUse).toHaveLength(1) + expect(preToolUse[0].command).not.toBe(legacyPreToolUse) + }) + }) }) diff --git a/src/main/antigravity/hook-service.ts b/src/main/antigravity/hook-service.ts index fafd9d47b22..071ca59bf48 100644 --- a/src/main/antigravity/hook-service.ts +++ b/src/main/antigravity/hook-service.ts @@ -6,7 +6,6 @@ import { getSharedManagedScriptPath, readHooksJson, wrapPosixHookCommand, - wrapWindowsCmdHookCommand, writeHooksJson, writeManagedScript, type HookDefinition @@ -16,6 +15,7 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' +import { wrapWindowsBatchHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' import { WINDOWS_ANTIGRAVITY_JSON_POST_SCRIPT } from './windows-hook-json-post' import { restoreManagedScript, @@ -69,7 +69,7 @@ function getPosixManagedCommand(scriptPath: string, event: AntigravityEvent): st function getManagedCommand(scriptPath: string, event: AntigravityEvent): string { if (process.platform === 'win32') { - return wrapWindowsCmdHookCommand(getWindowsWrapperScriptPath(event)) + return wrapWindowsBatchHookCommand(getWindowsWrapperScriptPath(event)) } return getPosixManagedCommand(scriptPath, event) } diff --git a/src/main/codex/windows-hook-command.test.ts b/src/main/codex/windows-hook-command.test.ts index 11fb094bff2..f28bc0db7a7 100644 --- a/src/main/codex/windows-hook-command.test.ts +++ b/src/main/codex/windows-hook-command.test.ts @@ -7,10 +7,8 @@ import { runProcess } from '@orca/process-host' import { removeTree } from '../../shared/windows-transient-lock-removal' import { getManagedCommand, CODEX_EVENTS } from './codex-hook-definition' import { getManagedScript } from './codex-hook-script' -import { - createManagedCommandMatcher, - wrapWindowsCmdHookCommand -} from '../agent-hooks/installer-utils' +import { createManagedCommandMatcher } from '../agent-hooks/installer-utils' +import { wrapWindowsBatchHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' vi.mock('electron', () => ({ app: { getPath: () => process.cwd() } })) afterEach(() => { @@ -31,7 +29,7 @@ describe('Codex Windows hook command', () => { `D:\\Windows\\System32\\cmd.exe --% /d /v:off /c @"${path.replaceAll('\\', '/')}"` ) expect(createManagedCommandMatcher('codex-hook.cmd')(command)).toBe(true) - expect(wrapWindowsCmdHookCommand(path)).toContain('-EncodedCommand') + expect(wrapWindowsBatchHookCommand(path)).toContain('-EncodedCommand') } ) diff --git a/src/main/devin/hook-service.test.ts b/src/main/devin/hook-service.test.ts index e606d54a19f..9367868d4ba 100644 --- a/src/main/devin/hook-service.test.ts +++ b/src/main/devin/hook-service.test.ts @@ -18,6 +18,7 @@ vi.mock('os', async () => { import { DevinHookService } from './hook-service' import { + applyDevinManagedHooks, getDevinConfigPath, getDevinManagedCommand, getDevinManagedScriptPath @@ -176,6 +177,40 @@ describe('DevinHookService', () => { } }) + it('registers a forward-slash cmd path on Windows and replaces the legacy backslash entry', () => { + const previous = process.platform + Object.defineProperty(process, 'platform', { value: 'win32' }) + try { + const scriptPath = 'C:\\Users\\ada\\.orca\\agent-hooks\\devin-hook.cmd' + const command = getDevinManagedCommand(scriptPath) + // Why: Git Bash strips backslashes from a bare path; forward slashes survive every host. + expect(command).toBe('C:/Users/ada/.orca/agent-hooks/devin-hook.cmd') + // A UNC home is cmd-safe but has no drive letter, so it keeps the encoded launcher. + expect( + getDevinManagedCommand('\\\\server\\share\\ada\\.orca\\agent-hooks\\devin-hook.cmd') + ).toMatch(/ -EncodedCommand \S+$/) + + const next = applyDevinManagedHooks( + { + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: scriptPath }] }, + { hooks: [{ type: 'command', command: 'C:\\tools\\user-hook.cmd' }] } + ] + } + }, + command, + 'devin-hook.cmd' + ) + expect(next.hooks?.Stop?.flatMap((definition) => definition.hooks ?? [])).toEqual([ + { type: 'command', command: 'C:\\tools\\user-hook.cmd' }, + expect.objectContaining({ command }) + ]) + } finally { + Object.defineProperty(process, 'platform', { value: previous }) + } + }) + it('reports not_installed when Devin config has no managed hooks', () => { const configPath = getDevinConfigPath() mkdirSync(dirname(configPath), { recursive: true }) diff --git a/src/main/devin/hook-settings.ts b/src/main/devin/hook-settings.ts index fe850680b05..14472029961 100644 --- a/src/main/devin/hook-settings.ts +++ b/src/main/devin/hook-settings.ts @@ -6,10 +6,10 @@ import { getSharedManagedScriptPath, removeManagedCommands, wrapPosixHookCommand, - wrapWindowsCmdHookCommand, type HookDefinition, type HooksConfig } from '../agent-hooks/installer-utils' +import { wrapWindowsBatchHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' const DEVIN_SCRIPT_BASE = 'devin-hook' @@ -52,10 +52,7 @@ export function getDevinRemoteConfigPath(remoteHome: string): string { export function getDevinManagedCommand(scriptPath: string): string { if (process.platform === 'win32') { - // Why: Devin spawns this command as argv[0], so the safe path stays a bare - // directly-spawnable .cmd; the encoded fallback protects spaced paths and - // drains stdin for a stale missing-script entry. - return wrapWindowsCmdHookCommand(scriptPath) + return wrapWindowsBatchHookCommand(scriptPath) } return wrapPosixHookCommand(scriptPath) } diff --git a/src/main/grok/grok-hook-script.ts b/src/main/grok/grok-hook-script.ts index 4a9025fcfb1..2efb0ca006b 100644 --- a/src/main/grok/grok-hook-script.ts +++ b/src/main/grok/grok-hook-script.ts @@ -1,8 +1,5 @@ -import { - getSharedManagedScriptPath, - wrapPosixHookCommand, - wrapWindowsCmdHookCommand -} from '../agent-hooks/installer-utils' +import { getSharedManagedScriptPath, wrapPosixHookCommand } from '../agent-hooks/installer-utils' +import { wrapWindowsBatchHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' import { buildPosixHookPayloadCapture, buildPosixHookSpoolLines, @@ -22,10 +19,9 @@ export function getGrokManagedScriptPath(): string { } export function getGrokManagedCommand(scriptPath: string): string { - // A cmd-safe bare path avoids two extra Windows interpreters; POSIX can gate before spawning the - // managed script because Orca injects ORCA_PANE_KEY only into panes it owns. + // POSIX can gate before spawning because Orca injects ORCA_PANE_KEY only into panes it owns. return process.platform === 'win32' - ? wrapWindowsCmdHookCommand(scriptPath) + ? wrapWindowsBatchHookCommand(scriptPath) : wrapPosixHookCommand(scriptPath, {}, { requiredEnvVar: 'ORCA_PANE_KEY' }) } diff --git a/src/main/grok/windows-hook-launcher-chain.test.ts b/src/main/grok/windows-hook-launcher-chain.test.ts index 5ea5582af78..70c9b0c78fa 100644 --- a/src/main/grok/windows-hook-launcher-chain.test.ts +++ b/src/main/grok/windows-hook-launcher-chain.test.ts @@ -22,7 +22,8 @@ vi.mock('os', async () => { }) import { getManagedCommandForTests, GrokHookService } from './hook-service' -import { wrapWindowsHookCommand } from '../agent-hooks/installer-utils' +import { wrapWindowsHookCommand, type HooksConfig } from '../agent-hooks/installer-utils' +import { buildInstalledGrokConfig } from './grok-hook-config' const GROK_EVENT_NAMES = [ 'SessionStart', @@ -80,7 +81,8 @@ describe('Windows Grok managed hook launch shape', () => { const command = withWin32(() => getManagedCommandForTests(scriptPath)) // Why (#8430): Grok spawns this as argv[0], so it must be exactly one spawnable token. - expect(command).toBe(scriptPath) + // Forward slashes because Git Bash strips the backslashes out of a bare path. + expect(command).toBe('C:/Users/dev/.orca/agent-hooks/grok-hook.cmd') // Why: whitespace is the sole trigger for Grok's `pwsh -Command` wrapper, which is the // interpreter this fix removes. Assert the trigger, not just the absence of the name. expect(command, 'a space would reintroduce the pwsh wrapper').not.toMatch(/\s/) @@ -96,6 +98,29 @@ describe('Windows Grok managed hook launch shape', () => { // Why: the raw path must never reach a cmd.exe command line unquoted. expect(command).not.toContain(spaced) }) + + // Platform-stubbed so the legacy sweep runs on every CI leg; the installed-config legs are win32-only. + it('replaces a legacy backslash entry and keeps user hooks', () => { + const config: HooksConfig = { + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: scriptPath }] }, + { hooks: [{ type: 'command', command: 'C:\\tools\\user-hook.cmd' }] } + ] + } + } + withWin32(() => + buildInstalledGrokConfig(config, getManagedCommandForTests(scriptPath), 'grok-hook.cmd') + ) + + const stop = config.hooks?.Stop?.flatMap((definition) => + (definition.hooks ?? []).map((hook) => hook.command) + ) + expect(stop).toEqual([ + 'C:\\tools\\user-hook.cmd', + 'C:/Users/dev/.orca/agent-hooks/grok-hook.cmd' + ]) + }) }) describe('installed config', () => { @@ -124,7 +149,7 @@ describe('Windows Grok managed hook launch shape', () => { expect(commands).toHaveLength(GROK_EVENT_NAMES.length) for (const command of commands) { - expect(command).toBe(scriptPath) + expect(command).toBe(scriptPath.replaceAll('\\', '/')) expect(existsSync(command), 'registered command must name a real file').toBe(true) } } @@ -163,7 +188,7 @@ describe('Windows Grok managed hook launch shape', () => { expect(config.hooks.SubagentStop).toBeUndefined() // Why: sweeping stale Orca entries must not touch hooks the user wrote. expect(all).toContain('/usr/local/bin/user-hook') - expect(all.filter((command) => command === scriptPath)).toHaveLength( + expect(all.filter((command) => command === scriptPath.replaceAll('\\', '/'))).toHaveLength( GROK_EVENT_NAMES.length ) } diff --git a/src/main/zcode/hook-service.test.ts b/src/main/zcode/hook-service.test.ts index 0c7d18f4514..1b5c626e426 100644 --- a/src/main/zcode/hook-service.test.ts +++ b/src/main/zcode/hook-service.test.ts @@ -12,7 +12,12 @@ vi.mock('node:os', async (importOriginal) => { vi.mock('electron', () => ({ app: { getPath: () => hoisted.home } })) import { zcodeHookService } from './hook-service' -import { getZCodeConfigPath, ZCODE_HOOK_EVENTS } from './hook-settings' +import { + applyZCodeManagedHooks, + getZCodeConfigPath, + getZCodeManagedCommand, + ZCODE_HOOK_EVENTS +} from './hook-settings' type ManagedHookEntry = { type: string; command: string; timeout?: number } type ZCodeConfigFile = { @@ -159,6 +164,33 @@ describe('ZCodeHookService', () => { }) }) + it('registers a forward-slash cmd path on Windows and replaces the legacy backslash entry', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const scriptPath = 'C:\\Users\\ada\\.orca\\agent-hooks\\zcode-hook.cmd' + const command = getZCodeManagedCommand(scriptPath) + // Why: Git Bash strips backslashes from a bare path; forward slashes survive every host. + expect(command).toBe('C:/Users/ada/.orca/agent-hooks/zcode-hook.cmd') + + const next = applyZCodeManagedHooks( + { + hooks: { + events: { + Stop: [ + { hooks: [{ type: 'command', command: scriptPath }] }, + { hooks: [{ type: 'command', command: 'C:\\tools\\user-hook.cmd' }] } + ] + } + } + }, + command, + 'zcode-hook.cmd' + ) + expect(next.hooks?.events?.Stop).toEqual([ + { hooks: [{ type: 'command', command: 'C:\\tools\\user-hook.cmd' }] }, + { hooks: [expect.objectContaining({ command })] } + ]) + }) + it('writes an executable managed hook script that posts to the ZCode endpoint', () => { zcodeHookService.install() const scriptPath = join( diff --git a/src/main/zcode/hook-settings.ts b/src/main/zcode/hook-settings.ts index 23d5819d114..1ce73baa1bf 100644 --- a/src/main/zcode/hook-settings.ts +++ b/src/main/zcode/hook-settings.ts @@ -8,9 +8,9 @@ import { isPlainObject, removeManagedCommands, wrapPosixHookCommand, - wrapWindowsCmdHookCommand, type HookDefinition } from '../agent-hooks/installer-utils' +import { wrapWindowsBatchHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' const ZCODE_SCRIPT_BASE = 'zcode-hook' @@ -72,9 +72,7 @@ export function getZCodeManagedScriptPath(): string { export function getZCodeManagedCommand(scriptPath: string): string { if (process.platform === 'win32') { - // Why: ZCode spawns a `type: "command"` hook through its own shell resolver, so keep the - // bare directly-spawnable .cmd on the safe path and fall back to the encoded form otherwise. - return wrapWindowsCmdHookCommand(scriptPath) + return wrapWindowsBatchHookCommand(scriptPath) } return wrapPosixHookCommand(scriptPath) }