Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 0 additions & 31 deletions src/main/agent-hooks/installer-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import {
hookDefinitionHasManagedCommand,
removeManagedCommands,
wrapPosixHookCommand,
wrapWindowsCmdHookCommand,
readHooksJsonWithRaw,
wrapWindowsHookCommand,
writeManagedScript,
Expand Down Expand Up @@ -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')
Expand Down
1 change: 0 additions & 1 deletion src/main/agent-hooks/installer-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,6 @@ export {
buildWindowsHookPowerShellCommand,
isSafeUnicodeWindowsBatchHookPath,
WINDOWS_CMD_SAFE_PATH,
wrapWindowsCmdHookCommand,
wrapWindowsHookCommand
} from './windows-hook-command'

Expand Down
9 changes: 3 additions & 6 deletions src/main/agent-hooks/managed-hook-command-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -152,7 +149,7 @@ const buildersByAgent = new Map<string, CommandBuilders>([
local: (path) =>
process.platform === 'win32'
? ANTIGRAVITY_EVENTS.map((event) =>
wrapWindowsCmdHookCommand(
wrapWindowsBatchHookCommand(
path.replace('antigravity-hook.cmd', event.windowsWrapperFileName)
)
)
Expand Down
11 changes: 6 additions & 5 deletions src/main/agent-hooks/windows-batch-hook-launcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 13 additions & 8 deletions src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() } }))
Expand All @@ -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')
Expand All @@ -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()
})
})
Expand Down Expand Up @@ -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')
}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
2 changes: 1 addition & 1 deletion src/main/agent-hooks/windows-direct-cmd-hook-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
)
)
Expand Down
15 changes: 0 additions & 15 deletions src/main/agent-hooks/windows-hook-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
)
}
56 changes: 55 additions & 1 deletion src/main/agent-hooks/windows-hook-payload-delivery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, Entry[]>,
Expand Down Expand Up @@ -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]
}
]

Expand Down Expand Up @@ -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)
}
Expand Down
40 changes: 38 additions & 2 deletions src/main/antigravity/hook-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,15 +405,51 @@ 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) =>
(definition.hooks ?? []).map((hook) => hook.command)
)
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<string, { command?: string; hooks?: { command: string }[] }[]>
}
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)
})
})
})
Loading
Loading