mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(agent-hooks): register forward-slash Windows hook paths for Grok, Devin, ZCode and Antigravity (#27713)
* fix(agent-hooks): register forward-slash Windows hook paths for Grok, Devin, ZCode and Antigravity These four agents registered their native-Windows status hook as a bare backslash path. A Git Bash hook host strips the backslashes, the command exits 127, and no status event reaches Orca. Use the same direct forward-slash form Claude, Codex and Muse already register, keeping the cmd-safe gate and the encoded PowerShell fallback. Existing backslash entries still match the managed-command matcher, so the next install replaces them and keeps user hooks. * perf(hooks): register direct .cmd hooks for Gemini, DSH, Kiro, Droid and Command Code on Windows On a cmd-safe profile path these agents now register the managed .cmd path itself instead of a `powershell.exe -EncodedCommand` launcher, so each hook event no longer pays a second PowerShell start-up on top of the runner's own shell. Profiles the bare path cannot carry (spaces, non-ASCII, UNC) keep the encoded launcher. Same pattern as Claude (#18875) and Muse (#26755). Refs #20913 * test(agent-hooks): expect forward-slash Antigravity hooks after the Unicode upgrade ASCII profiles now register the direct forward-slash wrapper path, so the upgrade-delivery test expects that form and also runs it through Git Bash. Unicode profiles keep the encoded launcher. * test(agent-hooks): assert the registered hook lives under the mocked home Pins that the os mock reaches the agents' node:os imports, so the host test never installs into the real profile. * refactor(hooks): one Windows batch-hook helper and one payload-delivery table - Add wrapWindowsBatchHookCommand (bare .cmd path, encoded launcher otherwise) and use it for Muse, Gemini, DSH, Kiro, Droid and Command Code. - Fold the five agents into windows-hook-payload-delivery.test.ts as rows run under each vendor's shell, from a plain and a spaced profile, with a stale encoded registration seeded per row; drop the separate runner suite and its CI list entries. - Replace the Windows-only per-agent registration tests with ones that stub process.platform, so they run on every CI OS. - Fix stale launcher, helper and DSH comments. Refs #20913 * refactor(agent-hooks): register Grok, Devin, ZCode and Antigravity through wrapWindowsBatchHookCommand Use the shared batch-hook helper from #27717 instead of an inline direct-or-cmd fallback. A UNC home is cmd-safe but has no drive letter; the old fallback registered it as a bare backslash path that Git Bash cannot start, while the helper sends it to the encoded launcher. wrapWindowsCmdHookCommand has no production callers left, so it is removed and its tests point at the helper. Grok, Devin and ZCode join the Windows payload delivery table with a legacy backslash entry to sweep, replacing the separate host test, and the Antigravity migration test now runs on every platform. --------- Co-authored-by: Orca Worker <orca-worker@localhost>
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -115,7 +115,6 @@ export {
|
||||
buildWindowsHookPowerShellCommand,
|
||||
isSafeUnicodeWindowsBatchHookPath,
|
||||
WINDOWS_CMD_SAFE_PATH,
|
||||
wrapWindowsCmdHookCommand,
|
||||
wrapWindowsHookCommand
|
||||
} from './windows-hook-command'
|
||||
|
||||
|
||||
@@ -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<string, CommandBuilders>([
|
||||
local: (path) =>
|
||||
process.platform === 'win32'
|
||||
? ANTIGRAVITY_EVENTS.map((event) =>
|
||||
wrapWindowsCmdHookCommand(
|
||||
wrapWindowsBatchHookCommand(
|
||||
path.replace('antigravity-hook.cmd', event.windowsWrapperFileName)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string, Entry[]>,
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user