From 5ee1dff2cce5eb50245298ef0319a79835f94ea5 Mon Sep 17 00:00:00 2001 From: Orca Worker Date: Sun, 11 Oct 2026 18:48:25 -0700 Subject: [PATCH 1/6] 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. --- .github/workflows/pr.yml | 1 + config/scripts/pr-code-change-scope.mjs | 1 + ...indows-direct-cmd-agent-hook-hosts.test.ts | 173 ++++++++++++++++++ src/main/antigravity/hook-service.test.ts | 42 ++++- src/main/antigravity/hook-service.ts | 5 +- src/main/devin/hook-service.test.ts | 31 ++++ src/main/devin/hook-settings.ts | 7 +- src/main/grok/grok-hook-script.ts | 7 +- .../grok/windows-hook-launcher-chain.test.ts | 39 +++- src/main/zcode/hook-service.test.ts | 34 +++- src/main/zcode/hook-settings.ts | 7 +- 11 files changed, 329 insertions(+), 18 deletions(-) create mode 100644 src/main/agent-hooks/windows-direct-cmd-agent-hook-hosts.test.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ba11ca658fd7..602f66a76ff5 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1268,6 +1268,7 @@ jobs: src/main/agent-hooks/windows-hook-payload-delivery.test.ts src/main/jcode/hook-gate-script.test.ts src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts + src/main/agent-hooks/windows-direct-cmd-agent-hook-hosts.test.ts src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts src/main/agent-hooks/windows-batch-hook-launcher.test.ts src/main/agent-hooks/windows-powershell-hook-launcher.test.ts diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 149c49a2d11c..8720f91f3b76 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -386,6 +386,7 @@ const WINDOWS_PACKAGE_TESTS = [ 'src/main/agent-hooks/windows-hook-payload-delivery.test.ts', 'src/main/jcode/hook-gate-script.test.ts', 'src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts', + 'src/main/agent-hooks/windows-direct-cmd-agent-hook-hosts.test.ts', 'src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts', 'src/main/agent-hooks/windows-batch-hook-launcher.test.ts', 'src/main/agent-hooks/windows-powershell-hook-launcher.test.ts', diff --git a/src/main/agent-hooks/windows-direct-cmd-agent-hook-hosts.test.ts b/src/main/agent-hooks/windows-direct-cmd-agent-hook-hosts.test.ts new file mode 100644 index 000000000000..e03099351bc7 --- /dev/null +++ b/src/main/agent-hooks/windows-direct-cmd-agent-hook-hosts.test.ts @@ -0,0 +1,173 @@ +// Why: Grok, Devin, ZCode and Antigravity may run hook commands through Git Bash, which strips the +// backslashes out of a bare `C:\...\x.cmd`. Run each registered command through every host. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '@orca/process-host' +import { createServer, type Server } from 'node:http' +import { mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type * as osModule from 'node:os' +import { removeTreeSync } from '../../shared/windows-transient-lock-removal' +import { WINDOWS_CMD_SAFE_PATH } from './installer-utils' +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('os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, homedir: homedirMock.mockImplementation(actual.homedir) } +}) +vi.mock('electron', () => ({ app: { getPath: () => tmpdir() } })) + +import { AntigravityHookService } from '../antigravity/hook-service' +import { DevinHookService } from '../devin/hook-service' +import { getDevinConfigPath } from '../devin/hook-settings' +import { GrokHookService } from '../grok/hook-service' +import { zcodeHookService } from '../zcode/hook-service' +import { getZCodeConfigPath } from '../zcode/hook-settings' + +type StopEntry = { command?: string; hooks?: { command?: string }[] } + +function stopCommand(entries: StopEntry[] | undefined): string { + const command = entries?.at(-1)?.hooks?.[0]?.command ?? entries?.at(-1)?.command + expect(command, 'registered Stop command').toBeDefined() + return command! +} + +type RegisteredConfig = { + hooks?: { Stop?: StopEntry[]; events?: { Stop?: StopEntry[] } } + 'orca-status'?: { Stop?: StopEntry[] } +} + +// Why no assertion: `JSON.parse` is already `any`, so the annotation narrows without a cast. +function readJson(path: string): RegisteredConfig { + return JSON.parse(readFileSync(path, 'utf8')) +} + +const AGENTS: [string, (home: string) => string][] = [ + [ + 'grok', + (home) => { + expect(new GrokHookService().install().state).toBe('installed') + const config = readJson(join(home, '.grok', 'hooks', 'orca-status.json')) + return stopCommand(config.hooks?.Stop) + } + ], + [ + 'devin', + () => { + expect(new DevinHookService().install().state).toBe('installed') + const config = readJson(getDevinConfigPath()) + return stopCommand(config.hooks?.Stop) + } + ], + [ + 'zcode', + () => { + expect(zcodeHookService.install().state).toBe('installed') + const config = readJson(getZCodeConfigPath()) + return stopCommand(config.hooks?.events?.Stop) + } + ], + [ + 'antigravity', + (home) => { + const service = new AntigravityHookService() + service.setWindowsRuntimePathProvider(() => process.execPath) + expect(service.install().state).toBe('installed') + const config = readJson(join(home, '.gemini', 'config', 'hooks.json')) + return stopCommand(config['orca-status']?.Stop) + } + ] +] + +async function startListener(): Promise<{ server: Server; port: number; paths: string[] }> { + const paths: string[] = [] + const server = createServer((req, res) => { + req.resume() + req.on('end', () => { + paths.push(req.url ?? '') + res.writeHead(200, { 'content-type': 'application/json' }) + res.end('{}') + }) + }) + const port = await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const address = server.address() + resolve(typeof address === 'object' && address ? address.port : 0) + }) + }) + return { server, port, paths } +} + +// Why: a runner whose TEMP is not cmd-safe registers the encoded launcher, not this shape. +const tempIsCmdSafe = WINDOWS_CMD_SAFE_PATH.test(join(tmpdir(), 'orca-direct-agent-hook-x')) + +describe.skipIf(process.platform !== 'win32')('direct agent hook commands in Windows hosts', () => { + let home = '' + let listener: Awaited> | null = null + + beforeEach(async () => { + home = mkdtempSync(join(tmpdir(), 'orca-direct-agent-hook-')) + homedirMock.mockReturnValue(home) + vi.stubEnv('APPDATA', join(home, 'AppData', 'Roaming')) + vi.stubEnv('GROK_HOME', '') + listener = await startListener() + }) + + afterEach(() => { + listener?.server.close() + listener = null + vi.unstubAllEnvs() + removeTreeSync(home) + }) + + async function run(file: string, args: string[]) { + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith('ORCA_')) + ) + const result = await runProcess({ + program: file, + args, + cwd: home, + env: { + ...env, + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_AGENT_HOOK_PORT: String(listener!.port), + ORCA_AGENT_HOOK_TOKEN: 'direct-agent-hook-token', + ORCA_PANE_KEY: 'tab-1:leaf-1' + }, + input: '{"hook_event_name":"Stop"}', + timeoutMs: 15_000 + }) + expect(result.timedOut, result.stderr).toBe(false) + // Why: curl returns once the POST is written; let the listener finish reading it. + await new Promise((resolve) => setTimeout(resolve, 250)) + return result + } + + it.skipIf(!tempIsCmdSafe).each(AGENTS)( + '%s posts from cmd, PowerShell and Git Bash', + async (agent, install) => { + const command = install(home) + expect(command).toMatch(/^[A-Za-z]:\/[^\\\s]+\.cmd$/) + + const hosts: [string, string[]][] = [ + ['cmd.exe', ['/d', '/c', command]], + [getWindowsPowerShellExecutablePath(), ['-NoProfile', '-Command', command]], + [findGitBash(), ['-c', command]] + ] + for (const [file, args] of hosts) { + const before = listener!.paths.length + const result = await run(file, args) + expect(result.code, `${file}: ${result.stderr}`).toBe(0) + expect(listener!.paths.slice(before), file).toEqual([`/hook/${agent}`]) + } + + // The legacy backslash entry is what Git Bash could not start. + const legacy = await run(findGitBash(), ['-c', command.replaceAll('/', '\\')]) + expect(legacy.code).toBe(127) + } + ) +}) diff --git a/src/main/antigravity/hook-service.test.ts b/src/main/antigravity/hook-service.test.ts index d682c69520dd..c0ade6538952 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,45 @@ 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('\\', '/') ) }) + + // win32-only: a cmd-safe managed path needs a real backslash-joined home. + it.skipIf(process.platform !== 'win32')( + 'registers forward-slash wrapper paths and replaces legacy backslash entries', + () => { + const configPath = join(homeDir, '.gemini', 'config', 'hooks.json') + const hooksDir = join(homeDir, '.orca', 'agent-hooks') + const legacyStop = join(hooksDir, 'antigravity-stop.cmd') + const legacyPreToolUse = join(hooksDir, '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 + } + // Why: Git Bash strips backslashes from a bare path; forward slashes survive every host. + expect(config['orca-status'].Stop).toEqual([ + userHook, + expect.objectContaining({ command: legacyStop.replaceAll('\\', '/') }) + ]) + expect(config['orca-status'].PreToolUse.flatMap((d) => d.hooks ?? [])).toEqual([ + expect.objectContaining({ command: legacyPreToolUse.replaceAll('\\', '/') }) + ]) + } + ) }) diff --git a/src/main/antigravity/hook-service.ts b/src/main/antigravity/hook-service.ts index fafd9d47b228..abf8ca4f13a2 100644 --- a/src/main/antigravity/hook-service.ts +++ b/src/main/antigravity/hook-service.ts @@ -16,6 +16,7 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' +import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' import { WINDOWS_ANTIGRAVITY_JSON_POST_SCRIPT } from './windows-hook-json-post' import { restoreManagedScript, @@ -69,7 +70,9 @@ function getPosixManagedCommand(scriptPath: string, event: AntigravityEvent): st function getManagedCommand(scriptPath: string, event: AntigravityEvent): string { if (process.platform === 'win32') { - return wrapWindowsCmdHookCommand(getWindowsWrapperScriptPath(event)) + const wrapperPath = getWindowsWrapperScriptPath(event) + // Forward slashes keep the path intact if Antigravity's host shell is Git Bash. + return wrapWindowsDirectCmdHookCommand(wrapperPath) ?? wrapWindowsCmdHookCommand(wrapperPath) } return getPosixManagedCommand(scriptPath, event) } diff --git a/src/main/devin/hook-service.test.ts b/src/main/devin/hook-service.test.ts index e606d54a19f5..0c048086d098 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,36 @@ 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') + + 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 fe850680b058..2609552c11c2 100644 --- a/src/main/devin/hook-settings.ts +++ b/src/main/devin/hook-settings.ts @@ -10,6 +10,7 @@ import { type HookDefinition, type HooksConfig } from '../agent-hooks/installer-utils' +import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' const DEVIN_SCRIPT_BASE = 'devin-hook' @@ -53,9 +54,9 @@ 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) + // directly-spawnable .cmd; forward slashes survive a Git Bash host, and the + // encoded fallback protects spaced paths and drains stdin for a missing script. + return wrapWindowsDirectCmdHookCommand(scriptPath) ?? wrapWindowsCmdHookCommand(scriptPath) } return wrapPosixHookCommand(scriptPath) } diff --git a/src/main/grok/grok-hook-script.ts b/src/main/grok/grok-hook-script.ts index 4a9025fcfb1d..fb4ca18c6da5 100644 --- a/src/main/grok/grok-hook-script.ts +++ b/src/main/grok/grok-hook-script.ts @@ -3,6 +3,7 @@ import { wrapPosixHookCommand, wrapWindowsCmdHookCommand } from '../agent-hooks/installer-utils' +import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' import { buildPosixHookPayloadCapture, buildPosixHookSpoolLines, @@ -22,10 +23,10 @@ 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. + // A cmd-safe bare path avoids two extra Windows interpreters, and forward slashes survive Grok's + // Git Bash host; POSIX can gate before spawning because Orca injects ORCA_PANE_KEY only in its panes. return process.platform === 'win32' - ? wrapWindowsCmdHookCommand(scriptPath) + ? (wrapWindowsDirectCmdHookCommand(scriptPath) ?? wrapWindowsCmdHookCommand(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 5ea5582af786..c7fc7a3d5f99 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,28 @@ 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) }) + + 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,17 +148,18 @@ 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) } } ) it.skipIf(process.platform !== 'win32')( - 'replaces a previously installed encoded-PowerShell entry on reinstall', + 'replaces previously installed encoded-PowerShell and backslash entries on reinstall', () => { const scriptPath = join(home, '.orca', 'agent-hooks', 'grok-hook.cmd') const staleCommand = wrapWindowsHookCommand(scriptPath) + const legacyBackslashCommand = scriptPath const configPath = join(home, '.grok', 'hooks', 'orca-status.json') mkdirSync(dirname(configPath), { recursive: true }) writeFileSync( @@ -142,6 +167,9 @@ describe('Windows Grok managed hook launch shape', () => { `${JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: staleCommand, timeout: 10 }] }], + PreToolUse: [ + { hooks: [{ type: 'command', command: legacyBackslashCommand, timeout: 10 }] } + ], // Why: a retired event must be swept too, or the old launcher keeps firing there. SubagentStop: [{ hooks: [{ type: 'command', command: staleCommand, timeout: 10 }] }], Notification: [{ hooks: [{ type: 'command', command: '/usr/local/bin/user-hook' }] }] @@ -159,11 +187,12 @@ describe('Windows Grok managed hook launch shape', () => { definitions.flatMap((definition) => definition.hooks.map((hook) => hook.command)) ) expect(all).not.toContain(staleCommand) + expect(all).not.toContain(legacyBackslashCommand) expect(all.filter((command) => /-EncodedCommand/i.test(command))).toEqual([]) 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 0c7d18f4514e..1b5c626e4267 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 23d5819d114e..dbe8a08def1d 100644 --- a/src/main/zcode/hook-settings.ts +++ b/src/main/zcode/hook-settings.ts @@ -11,6 +11,7 @@ import { wrapWindowsCmdHookCommand, type HookDefinition } from '../agent-hooks/installer-utils' +import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' const ZCODE_SCRIPT_BASE = 'zcode-hook' @@ -72,9 +73,9 @@ 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) + // Why: ZCode spawns a `type: "command"` hook through its own shell resolver, which may be + // Git Bash; a forward-slash .cmd survives it, else fall back to the encoded form. + return wrapWindowsDirectCmdHookCommand(scriptPath) ?? wrapWindowsCmdHookCommand(scriptPath) } return wrapPosixHookCommand(scriptPath) } From 9863537f7dfdf22312436abf732fd05b0c9fb214 Mon Sep 17 00:00:00 2001 From: Orca Worker Date: Sun, 11 Oct 2026 19:00:22 -0700 Subject: [PATCH 2/6] 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 --- .github/workflows/pr.yml | 1 + config/scripts/pr-code-change-scope.mjs | 1 + .../windows-direct-cmd-agent-runners.test.ts | 264 ++++++++++++++++++ src/main/command-code/hook-service.test.ts | 54 +++- src/main/command-code/hook-service.ts | 3 +- src/main/droid/hook-service.test.ts | 50 +++- src/main/droid/hook-service.ts | 8 +- src/main/dsh/hook-service.test.ts | 59 +++- src/main/dsh/hook-settings.ts | 3 +- src/main/gemini/hook-service.test.ts | 57 +++- src/main/gemini/hook-service.ts | 3 +- src/main/kiro/hook-service.test.ts | 70 ++++- src/main/kiro/hook-settings.ts | 3 +- 13 files changed, 540 insertions(+), 36 deletions(-) create mode 100644 src/main/agent-hooks/windows-direct-cmd-agent-runners.test.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ba11ca658fd7..9795e632cb9a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1268,6 +1268,7 @@ jobs: src/main/agent-hooks/windows-hook-payload-delivery.test.ts src/main/jcode/hook-gate-script.test.ts src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts + src/main/agent-hooks/windows-direct-cmd-agent-runners.test.ts src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts src/main/agent-hooks/windows-batch-hook-launcher.test.ts src/main/agent-hooks/windows-powershell-hook-launcher.test.ts diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 149c49a2d11c..c7f489b96fe5 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -386,6 +386,7 @@ const WINDOWS_PACKAGE_TESTS = [ 'src/main/agent-hooks/windows-hook-payload-delivery.test.ts', 'src/main/jcode/hook-gate-script.test.ts', 'src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts', + 'src/main/agent-hooks/windows-direct-cmd-agent-runners.test.ts', 'src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts', 'src/main/agent-hooks/windows-batch-hook-launcher.test.ts', 'src/main/agent-hooks/windows-powershell-hook-launcher.test.ts', diff --git a/src/main/agent-hooks/windows-direct-cmd-agent-runners.test.ts b/src/main/agent-hooks/windows-direct-cmd-agent-runners.test.ts new file mode 100644 index 000000000000..ce607eb859cc --- /dev/null +++ b/src/main/agent-hooks/windows-direct-cmd-agent-runners.test.ts @@ -0,0 +1,264 @@ +// Why: Gemini, DSH, Kiro, Droid and Command Code register the managed `.cmd` path directly on a +// cmd-safe profile. Each vendor starts that string through its own shell, so this suite installs +// into a temp profile and runs the registered command the way each runner does, following a +// non-ASCII payload to a mock listener byte-for-byte. +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '@orca/process-host' +import { createServer, type Server } from 'node:http' +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { removeTreeSync } from '../../shared/windows-transient-lock-removal' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type * as osModule from 'node:os' + +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: () => join(tmpdir(), 'orca-direct-cmd-runner-user-data') + } +})) + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: homedirMock.mockImplementation(actual.homedir) + } +}) + +import { CommandCodeHookService } from '../command-code/hook-service' +import { DroidHookService } from '../droid/hook-service' +import { DshHookService } from '../dsh/hook-service' +import { GeminiHookService } from '../gemini/hook-service' +import { KiroHookService } from '../kiro/hook-service' +import { WINDOWS_CMD_SAFE_PATH } from './installer-utils' + +const PANE_KEY = 'tab-1:leaf-1' +const HOOK_TOKEN = 'direct-cmd-runner-token' +const PAYLOAD = JSON.stringify({ + session_id: 'direct-cmd-runner', + hook_event_name: 'UserPromptSubmit', + prompt: 'café 日本語 😀 & echo %PATH% | more', + transcript: 'y'.repeat(4096) +}) + +type Runner = { + name: string + program: string + args: (command: string) => string[] + verbatim?: boolean +} + +const SYSTEM_ROOT = process.env.SystemRoot ?? 'C:\\Windows' +const CMD = join(SYSTEM_ROOT, 'System32', 'cmd.exe') +const POWERSHELL = join(SYSTEM_ROOT, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') + +// Each argv is the vendor's own spawn (pwsh-first runners fall back to Windows PowerShell 5.1). +const CMD_NODE_SHELL: Runner = { + name: 'cmd.exe (Node shell:true)', + program: CMD, + args: (command) => ['/d', '/s', '/c', `"${command}"`], + verbatim: true +} +const CMD_FACTORY: Runner = { + name: 'cmd.exe (Factory)', + program: CMD, + args: (command) => ['/d', '/s', '/c', command], + verbatim: true +} +const POWERSHELL_GEMINI: Runner = { + name: 'Windows PowerShell (Gemini)', + program: POWERSHELL, + args: (command) => [ + '-NoProfile', + '-NonInteractive', + '-Command', + `${command}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }` + ] +} +const POWERSHELL_DSH: Runner = { + name: 'Windows PowerShell (DSH)', + program: POWERSHELL, + args: (command) => [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + `[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;$OutputEncoding=[System.Text.Encoding]::UTF8;${command}` + ] +} +const POWERSHELL_PLAIN: Runner = { + name: 'Windows PowerShell', + program: POWERSHELL, + args: (command) => ['-NoProfile', '-Command', command] +} + +type JsonHooks = { hooks: Record } + +function readJson(path: string): JsonHooks { + return JSON.parse(readFileSync(path, 'utf8')) +} + +const PROVIDERS = [ + { + name: 'Droid', + script: 'droid-hook.cmd', + runners: [CMD_FACTORY], + stdout: '', + install: () => new DroidHookService().install(), + command: (home: string) => + readJson(join(home, '.factory', 'settings.json')).hooks.UserPromptSubmit[0].hooks?.[0].command + }, + { + name: 'Command Code', + script: 'command-code-hook.cmd', + runners: [CMD_NODE_SHELL], + stdout: '', + install: () => new CommandCodeHookService().install(), + command: (home: string) => + readJson(join(home, '.commandcode', 'settings.json')).hooks.Stop[0].hooks?.[0].command + }, + { + name: 'Gemini', + script: 'gemini-hook.cmd', + runners: [POWERSHELL_GEMINI], + stdout: '{}', + install: () => new GeminiHookService().install(), + command: (home: string) => + readJson(join(home, '.gemini', 'settings.json')).hooks.BeforeAgent[0].hooks?.[0].command + }, + { + name: 'DSH', + script: 'dsh-hook.cmd', + runners: [POWERSHELL_DSH], + stdout: '', + install: () => new DshHookService().install(), + command: (home: string) => + readJson(join(home, '.orca', 'agent-hooks', 'dsh-hooks.json')).hooks.UserPromptSubmit[0] + .hooks?.[0].command + }, + { + // Kiro is closed source; cover both shells it could use. + name: 'Kiro', + script: 'kiro-hook.cmd', + runners: [POWERSHELL_PLAIN, CMD_NODE_SHELL], + stdout: '', + install: (home: string) => { + mkdirSync(join(home, '.kiro', 'agents'), { recursive: true }) + writeFileSync(join(home, '.kiro', 'agents', 'mine.json'), JSON.stringify({ name: 'mine' })) + return new KiroHookService().install() + }, + command: (home: string) => + readJson(join(home, '.kiro', 'agents', 'mine.json')).hooks.userPromptSubmit[0].command + } +] + +type HookPost = { payload: string | null; paneKey: string | null; token: string | null } + +async function startHookListener(): Promise<{ server: Server; port: number; posts: HookPost[] }> { + const posts: HookPost[] = [] + const server = createServer((req, res) => { + let body = '' + req.setEncoding('utf8') + req.on('data', (chunk) => { + body += chunk + }) + req.on('end', () => { + const form = new URLSearchParams(body) + const token = req.headers['x-orca-agent-hook-token'] + posts.push({ + payload: form.get('payload'), + paneKey: form.get('paneKey'), + token: typeof token === 'string' ? token : null + }) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end('{}') + }) + }) + const port = await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const address = server.address() + resolve(typeof address === 'object' && address ? address.port : 0) + }) + }) + return { server, port, posts } +} + +// Why: strip every inherited ORCA_* so an endpoint file from a live Orca can never redirect the +// hook's POST away from this listener. +function hookEnvironment(extra: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const base = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith('ORCA_')) + ) + return { ...base, ORCA_BACKGROUND_LAUNCH: '1', ...extra } +} + +describe.skipIf(process.platform !== 'win32')('direct .cmd hooks under each agent runner', () => { + let home = '' + let server: Server | null = null + + afterEach(() => { + vi.unstubAllEnvs() + server?.close() + server = null + if (home) { + removeTreeSync(home) + home = '' + } + }) + + it.each(PROVIDERS)( + '$name delivers the payload byte-exact through its runner', + async (provider) => { + home = mkdtempSync(join(tmpdir(), 'orca-direct-cmd-runner-')) + homedirMock.mockReturnValue(home) + vi.stubEnv('USERPROFILE', home) + vi.stubEnv('HOME', home) + vi.stubEnv('KIRO_HOME', undefined) + vi.stubEnv('DSH_HOME', undefined) + // A host cmd AutoRun (HKCU) runs for the .cmd child PowerShell starts without `/d`. + writeFileSync(join(home, '.cmd_aliases.cmd'), '@echo off\r\n', 'utf8') + expect(provider.install(home).state).toBe('installed') + + const scriptPath = join(home, '.orca', 'agent-hooks', provider.script) + expect(WINDOWS_CMD_SAFE_PATH.test(scriptPath), 'test home must be cmd-safe').toBe(true) + const command = provider.command(home) + expect(command).toBe(scriptPath.replaceAll('\\', '/')) + if (!command) { + throw new Error(`Missing managed ${provider.name} command`) + } + + const listener = await startHookListener() + server = listener.server + const env = hookEnvironment({ + USERPROFILE: home, + HOME: home, + ORCA_AGENT_HOOK_PORT: String(listener.port), + ORCA_AGENT_HOOK_TOKEN: HOOK_TOKEN, + ORCA_PANE_KEY: PANE_KEY + }) + + for (const runner of provider.runners) { + const before = listener.posts.length + const result = await runProcess({ + program: runner.program, + args: runner.args(command), + windowsVerbatimArguments: runner.verbatim, + env, + input: PAYLOAD, + timeoutMs: 15_000 + }) + expect(result.timedOut, `${runner.name} timed out`).toBe(false) + expect(result.code, `${runner.name} exit code; stderr: ${result.stderr}`).toBe(0) + expect(listener.posts.slice(before), runner.name).toEqual([ + { payload: PAYLOAD, paneKey: PANE_KEY, token: HOOK_TOKEN } + ]) + expect(result.stdout.trim(), `${runner.name} stdout`).toBe(provider.stdout) + } + }, + 60_000 + ) +}) diff --git a/src/main/command-code/hook-service.test.ts b/src/main/command-code/hook-service.test.ts index 9765ef5b2f40..a5412710a3a2 100644 --- a/src/main/command-code/hook-service.test.ts +++ b/src/main/command-code/hook-service.test.ts @@ -19,6 +19,7 @@ vi.mock('os', async () => { }) import { CommandCodeHookService } from './hook-service' +import { WINDOWS_CMD_SAFE_PATH, wrapWindowsHookCommand } from '../agent-hooks/installer-utils' const WINDOWS_POWERSHELL_LAUNCHER = /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ @@ -51,9 +52,7 @@ describe('CommandCodeHookService', () => { expect(config.hooks.PreToolUse[0].matcher).toBe('.*') expect(config.hooks.PostToolUse[0].matcher).toBe('.*') expect(config.hooks.Stop[0].matcher).toBeUndefined() - expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch( - process.platform === 'win32' ? WINDOWS_POWERSHELL_LAUNCHER : /command-code-hook/ - ) + expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(/command-code-hook/) if (process.platform !== 'win32') { expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca')) } @@ -62,14 +61,51 @@ describe('CommandCodeHookService', () => { } }) - // Why: #6078 — a Windows user profile path with a space used to be written - // verbatim as the hook command, so the agent split it at the space. The - // managed command must use an encoded launcher so the path never appears raw - // on the cmd.exe command line. + // Why: Command Code already runs hooks through cmd.exe; an encoded PowerShell in between cost ~0.6s per event. + it.skipIf(process.platform !== 'win32')( + 'registers the managed .cmd path directly on a cmd-safe profile', + () => { + const scriptPath = join(homeDir, '.orca', 'agent-hooks', 'command-code-hook.cmd') + expect(WINDOWS_CMD_SAFE_PATH.test(scriptPath), 'test home must be cmd-safe').toBe(true) + expect(new CommandCodeHookService().install().state).toBe('installed') + + const config = JSON.parse( + readFileSync(join(homeDir, '.commandcode', 'settings.json'), 'utf8') + ) + for (const eventName of ['PreToolUse', 'PostToolUse', 'Stop']) { + expect(config.hooks[eventName][0].hooks[0].command).toBe(scriptPath.replaceAll('\\', '/')) + } + } + ) + it.skipIf(process.platform !== 'win32')( - 'wraps the managed hook command to survive spaces in the profile path (#6078)', + 'rewrites a stale encoded launcher registration to the direct form', () => { - const spaceHome = join(tmpdir(), 'orca command-code home with spaces') + const scriptPath = join(homeDir, '.orca', 'agent-hooks', 'command-code-hook.cmd') + const configPath = join(homeDir, '.commandcode', 'settings.json') + mkdirSync(join(homeDir, '.commandcode'), { recursive: true }) + const stale = { type: 'command', command: wrapWindowsHookCommand(scriptPath) } + writeFileSync(configPath, JSON.stringify({ hooks: { Stop: [{ hooks: [stale] }] } })) + + expect(new CommandCodeHookService().install().state).toBe('installed') + + const config = JSON.parse(readFileSync(configPath, 'utf8')) + expect(config.hooks.Stop).toHaveLength(1) + expect(config.hooks.Stop[0].hooks).toEqual([ + expect.objectContaining({ command: scriptPath.replaceAll('\\', '/') }) + ]) + } + ) + + // Why: #6078 — a Windows user profile path with a space used to be written + // verbatim as the hook command, so the agent split it at the space. A profile + // the bare path cannot carry keeps the encoded launcher. + it + .skipIf(process.platform !== 'win32') + .each(['orca command-code home with spaces', 'orca-command-code-hömé'])( + 'keeps the encoded launcher for a profile the bare path cannot carry (%s)', + (homeName) => { + const spaceHome = join(tmpdir(), homeName) mkdirSync(spaceHome, { recursive: true }) homedirMock.mockReturnValue(spaceHome) try { diff --git a/src/main/command-code/hook-service.ts b/src/main/command-code/hook-service.ts index 1e1ab9e56af8..f9d35de4ec1c 100644 --- a/src/main/command-code/hook-service.ts +++ b/src/main/command-code/hook-service.ts @@ -14,6 +14,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, @@ -48,7 +49,7 @@ function getManagedScriptPath(): string { function getManagedCommand(scriptPath: string): string { return process.platform === 'win32' - ? wrapWindowsHookCommand(scriptPath) + ? (wrapWindowsDirectCmdHookCommand(scriptPath) ?? wrapWindowsHookCommand(scriptPath)) : wrapPosixHookCommand(scriptPath) } diff --git a/src/main/droid/hook-service.test.ts b/src/main/droid/hook-service.test.ts index 7486a12b71bb..acdbee31b826 100644 --- a/src/main/droid/hook-service.test.ts +++ b/src/main/droid/hook-service.test.ts @@ -23,6 +23,7 @@ vi.mock('os', async () => { }) import { DroidHookService } from './hook-service' +import { WINDOWS_CMD_SAFE_PATH, wrapWindowsHookCommand } from '../agent-hooks/installer-utils' const WINDOWS_POWERSHELL_LAUNCHER = /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ @@ -73,23 +74,54 @@ describe('DroidHookService', () => { expect(config.hooks.PreToolUse[0].matcher).toBe('*') expect(config.hooks.PermissionRequest[0].matcher).toBe('*') expect(config.hooks.UserPromptSubmit[0].matcher).toBeUndefined() - expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch( - process.platform === 'win32' ? WINDOWS_POWERSHELL_LAUNCHER : /droid-hook/ - ) + expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(/droid-hook/) if (process.platform !== 'win32') { expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca')) } expect(config.hooks.PreToolUse[0].hooks[0].command).not.toContain(userDataDir) }) - // Why: #6078 — a Windows user profile path with a space used to be written - // verbatim as the hook command, so the agent split it at the space. The - // managed command must use an encoded launcher so the path never appears raw - // on the cmd.exe command line. + // Why: Factory already runs hooks through cmd.exe; an encoded PowerShell in between cost ~0.7s per event. it.skipIf(process.platform !== 'win32')( - 'wraps the managed hook command to survive spaces in the profile path (#6078)', + 'registers the managed .cmd path directly on a cmd-safe profile', () => { - const spaceHome = join(tmpdir(), 'orca droid home with spaces') + const scriptPath = join(homeDir, '.orca', 'agent-hooks', 'droid-hook.cmd') + expect(WINDOWS_CMD_SAFE_PATH.test(scriptPath), 'test home must be cmd-safe').toBe(true) + expect(new DroidHookService().install().state).toBe('installed') + + const config = JSON.parse(readFileSync(join(homeDir, '.factory', 'settings.json'), 'utf8')) + for (const eventName of ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'Stop']) { + expect(config.hooks[eventName][0].hooks[0].command).toBe(scriptPath.replaceAll('\\', '/')) + } + } + ) + + it.skipIf(process.platform !== 'win32')( + 'rewrites a stale encoded launcher registration to the direct form', + () => { + const scriptPath = join(homeDir, '.orca', 'agent-hooks', 'droid-hook.cmd') + const configPath = join(homeDir, '.factory', 'settings.json') + mkdirSync(dirname(configPath), { recursive: true }) + const stale = { type: 'command', command: wrapWindowsHookCommand(scriptPath) } + writeFileSync(configPath, JSON.stringify({ hooks: { Stop: [{ hooks: [stale] }] } })) + + expect(new DroidHookService().install().state).toBe('installed') + + const config = JSON.parse(readFileSync(configPath, 'utf8')) + expect(config.hooks.Stop).toHaveLength(1) + expect(config.hooks.Stop[0].hooks).toEqual([ + expect.objectContaining({ command: scriptPath.replaceAll('\\', '/') }) + ]) + } + ) + + // Why: #6078 — a Windows user profile path with a space used to be written + // verbatim as the hook command, so the agent split it at the space. A profile + // the bare path cannot carry keeps the encoded launcher. + it.skipIf(process.platform !== 'win32').each(['orca droid home with spaces', 'orca-droid-hömé'])( + 'keeps the encoded launcher for a profile the bare path cannot carry (%s)', + (homeName) => { + const spaceHome = join(tmpdir(), homeName) mkdirSync(spaceHome, { recursive: true }) homedirMock.mockReturnValue(spaceHome) try { diff --git a/src/main/droid/hook-service.ts b/src/main/droid/hook-service.ts index 3bdb715427f3..3d68666b65b4 100644 --- a/src/main/droid/hook-service.ts +++ b/src/main/droid/hook-service.ts @@ -16,6 +16,7 @@ import { type HookDefinition, type HooksConfig } from '../agent-hooks/installer-utils' +import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, @@ -70,11 +71,10 @@ function getManagedScriptPath(): string { } function getManagedCommand(scriptPath: string): string { - // Why: Factory invokes the .cmd via cmd.exe, but the raw path still splits at - // whitespace when the user profile contains a space (e.g. `C:\Users\Jane Doe`). - // The shared Windows wrapper keeps the path out of cmd.exe's raw command line. #6078. + // Why: Factory invokes the hook via cmd.exe, so a cmd-safe path needs no PowerShell hop; a + // profile with a space (e.g. `C:\Users\Jane Doe`) would split, so it keeps the encoded wrapper. #6078. return process.platform === 'win32' - ? wrapWindowsHookCommand(scriptPath) + ? (wrapWindowsDirectCmdHookCommand(scriptPath) ?? wrapWindowsHookCommand(scriptPath)) : wrapPosixHookCommand(scriptPath) } diff --git a/src/main/dsh/hook-service.test.ts b/src/main/dsh/hook-service.test.ts index 331ecd978888..53b95cc40d44 100644 --- a/src/main/dsh/hook-service.test.ts +++ b/src/main/dsh/hook-service.test.ts @@ -10,8 +10,12 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { WINDOWS_CMD_SAFE_PATH, wrapWindowsHookCommand } from '../agent-hooks/installer-utils' import { DshHookService } from './hook-service' -import { DSH_HOOK_EVENTS } from './hook-settings' +import { buildDshManagedHooksFile, DSH_HOOK_EVENTS } from './hook-settings' + +const WINDOWS_POWERSHELL_LAUNCHER = + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ // Why: getSharedManagedScriptPath() writes under homedir()/.orca and the patch layer // resolves via DSH_HOME ?? ~/.dsh. Point the home env at a temp dir and clear DSH_HOME so @@ -103,6 +107,59 @@ describe('DshHookService', () => { expect(restoreAt).toBeLessThan(guardAt) }) + // Why: DSH already runs hooks in PowerShell; a second, encoded PowerShell cost ~0.6s per event. + it.skipIf(process.platform !== 'win32')( + 'registers the managed .cmd path directly on a cmd-safe profile', + () => { + expect(WINDOWS_CMD_SAFE_PATH.test(scriptPath()), 'test home must be cmd-safe').toBe(true) + expect(new DshHookService().install().state).toBe('installed') + + for (const event of DSH_HOOK_EVENTS) { + expect(readManagedHooks().hooks[event]).toEqual([ + { hooks: [expect.objectContaining({ command: scriptPath().replaceAll('\\', '/') })] } + ]) + } + } + ) + + it.skipIf(process.platform !== 'win32')( + 'rewrites a stale encoded launcher registration to the direct form', + () => { + mkdirSync(join(home, '.orca', 'agent-hooks'), { recursive: true }) + writeFileSync( + managedHooksPath(), + buildDshManagedHooksFile(wrapWindowsHookCommand(scriptPath())) + ) + expect(new DshHookService().install().state).toBe('installed') + + expect(readManagedHooks().hooks.Stop).toEqual([ + { hooks: [expect.objectContaining({ command: scriptPath().replaceAll('\\', '/') })] } + ]) + } + ) + + it.skipIf(process.platform !== 'win32').each(['profile with spaces', 'profile-hömé'])( + 'keeps the encoded launcher for a profile the bare path cannot carry (%s)', + (profileName) => { + const profile = join(home, profileName) + mkdirSync(profile, { recursive: true }) + process.env.USERPROFILE = profile + process.env.HOME = profile + expect(new DshHookService().install().state).toBe('installed') + + const installed = JSON.parse( + readFileSync(join(profile, '.orca', 'agent-hooks', 'dsh-hooks.json'), 'utf-8') + ) + expect(installed.hooks.Stop).toEqual([ + { + hooks: [ + expect.objectContaining({ command: expect.stringMatching(WINDOWS_POWERSHELL_LAUNCHER) }) + ] + } + ]) + } + ) + it('is idempotent', () => { const service = new DshHookService() service.install() diff --git a/src/main/dsh/hook-settings.ts b/src/main/dsh/hook-settings.ts index 8ccbb8a92832..f0ba2f68a1ab 100644 --- a/src/main/dsh/hook-settings.ts +++ b/src/main/dsh/hook-settings.ts @@ -8,6 +8,7 @@ import { wrapWindowsHookCommand, type HookDefinition } from '../agent-hooks/installer-utils' +import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' import { readManagedHookEventsFromJson } from '../agent-hooks/managed-hooks-json-events' const DSH_SCRIPT_BASE = 'dsh-hook' @@ -77,7 +78,7 @@ export function getDshManagedCommand(scriptPath: string): string { // everywhere except Windows, where it binds to PowerShell — the same split these two // wrappers already encode. return process.platform === 'win32' - ? wrapWindowsHookCommand(scriptPath) + ? (wrapWindowsDirectCmdHookCommand(scriptPath) ?? wrapWindowsHookCommand(scriptPath)) : wrapPosixHookCommand(scriptPath) } diff --git a/src/main/gemini/hook-service.test.ts b/src/main/gemini/hook-service.test.ts index 551c40bb0b19..0a61a1c65434 100644 --- a/src/main/gemini/hook-service.test.ts +++ b/src/main/gemini/hook-service.test.ts @@ -24,6 +24,7 @@ vi.mock('os', async (importOriginal) => { }) import { GeminiHookService } from './hook-service' +import { WINDOWS_CMD_SAFE_PATH, wrapWindowsHookCommand } from '../agent-hooks/installer-utils' const WINDOWS_POWERSHELL_LAUNCHER = /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ @@ -105,7 +106,7 @@ describe('GeminiHookService', () => { expect(config.hooks.BeforeAgent[0].hooks[0].command).toBe('echo user-before-agent') const managedCommandPattern = process.platform === 'win32' - ? WINDOWS_POWERSHELL_LAUNCHER + ? managedHookPath.replaceAll('\\', '/') : new RegExp(managedHookPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) expect(config.hooks.BeforeAgent[1].hooks[0].command).toMatch(managedCommandPattern) expect(config.hooks.AfterAgent[0].hooks[0].command).toMatch(managedCommandPattern) @@ -113,14 +114,54 @@ describe('GeminiHookService', () => { expect(config.hooks.BeforeTool[0].hooks[0].command).toMatch(managedCommandPattern) }) - // Why: #6078 — a Windows user profile path with a space used to be written - // verbatim as the hook command, so the agent split it at the space. The - // managed command must use an encoded launcher so the path never appears raw - // on the cmd.exe command line. + // Why: Gemini already runs hooks in PowerShell; a second, encoded PowerShell cost ~0.7s per event. + it.skipIf(process.platform !== 'win32')( + 'registers the managed .cmd path directly on a cmd-safe profile', + () => { + const scriptPath = join(homeDir, '.orca', 'agent-hooks', 'gemini-hook.cmd') + expect(WINDOWS_CMD_SAFE_PATH.test(scriptPath), 'test home must be cmd-safe').toBe(true) + expect(new GeminiHookService().install().state).toBe('installed') + + const config = JSON.parse(readFileSync(join(homeDir, '.gemini', 'settings.json'), 'utf8')) + for (const eventName of ['BeforeAgent', 'AfterAgent', 'AfterTool', 'BeforeTool']) { + expect(config.hooks[eventName].at(-1).hooks[0].command).toBe( + scriptPath.replaceAll('\\', '/') + ) + } + } + ) + it.skipIf(process.platform !== 'win32')( - 'wraps the managed hook command to survive spaces in the profile path (#6078)', + 'rewrites a stale encoded launcher registration to the direct form', () => { - const spaceHome = join(tmpdir(), 'orca gemini home with spaces') + const scriptPath = join(homeDir, '.orca', 'agent-hooks', 'gemini-hook.cmd') + const configDir = join(homeDir, '.gemini') + mkdirSync(configDir, { recursive: true }) + const stale = { type: 'command', command: wrapWindowsHookCommand(scriptPath) } + writeFileSync( + join(configDir, 'settings.json'), + JSON.stringify({ hooks: { BeforeAgent: [{ hooks: [stale] }] } }) + ) + + expect(new GeminiHookService().install().state).toBe('installed') + + const config = JSON.parse(readFileSync(join(configDir, 'settings.json'), 'utf8')) + expect(config.hooks.BeforeAgent).toHaveLength(1) + expect(config.hooks.BeforeAgent[0].hooks).toEqual([ + expect.objectContaining({ command: scriptPath.replaceAll('\\', '/') }) + ]) + } + ) + + // Why: #6078 — a Windows user profile path with a space used to be written + // verbatim as the hook command, so the agent split it at the space. A profile + // the bare path cannot carry keeps the encoded launcher. + it + .skipIf(process.platform !== 'win32') + .each(['orca gemini home with spaces', 'orca-gemini-hömé'])( + 'keeps the encoded launcher for a profile the bare path cannot carry (%s)', + (homeName) => { + const spaceHome = join(tmpdir(), homeName) mkdirSync(spaceHome, { recursive: true }) homedirMock.mockReturnValue(spaceHome) try { @@ -184,7 +225,7 @@ describe('GeminiHookService', () => { expect(preToolCommands).toEqual(['echo user-authored']) expect(config.hooks.BeforeTool[0].hooks[0].command).toMatch( process.platform === 'win32' - ? WINDOWS_POWERSHELL_LAUNCHER + ? join(homeDir, '.orca', 'agent-hooks', managedHookFileName).replaceAll('\\', '/') : new RegExp(managedHookFileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) ) }) diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index 6fd529e96e46..da43be48b4dc 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -16,6 +16,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, @@ -47,7 +48,7 @@ function getManagedScriptPath(): string { function getManagedCommand(scriptPath: string): string { return process.platform === 'win32' - ? wrapWindowsHookCommand(scriptPath) + ? (wrapWindowsDirectCmdHookCommand(scriptPath) ?? wrapWindowsHookCommand(scriptPath)) : wrapPosixHookCommand(scriptPath) } diff --git a/src/main/kiro/hook-service.test.ts b/src/main/kiro/hook-service.test.ts index abfe38dbacb2..5205881a54e1 100644 --- a/src/main/kiro/hook-service.test.ts +++ b/src/main/kiro/hook-service.test.ts @@ -3,10 +3,17 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem.test-fixture' -import { isPlainObject } from '../agent-hooks/installer-utils' +import { + isPlainObject, + WINDOWS_CMD_SAFE_PATH, + wrapWindowsHookCommand +} from '../agent-hooks/installer-utils' import { KiroHookService } from './hook-service' import { getKiroManagedCommandMatcher, KIRO_HOOK_EVENTS } from './hook-settings' +const WINDOWS_POWERSHELL_LAUNCHER = + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ + // Why both names: os.homedir() reads $HOME on POSIX and %USERPROFILE% on Windows; setting // only one would let a run edit the developer's real ~/.kiro or ~/.orca. let home: string @@ -234,6 +241,67 @@ describe('KiroHookService', () => { expect(readAgent('kept').hooks).toBeDefined() }) + // Why: Kiro already runs hooks in a shell; an encoded PowerShell in between cost ~0.7s per event. + it.skipIf(process.platform !== 'win32')( + 'registers the managed .cmd path directly on a cmd-safe profile', + () => { + expect(WINDOWS_CMD_SAFE_PATH.test(scriptPath()), 'test home must be cmd-safe').toBe(true) + writeAgent('mine', { name: 'mine' }) + expect(new KiroHookService().install().state).toBe('installed') + + const hooks = readAgent('mine').hooks + if (!isPlainObject(hooks)) { + throw new Error('Expected hooks object') + } + for (const event of KIRO_HOOK_EVENTS) { + expect(hooks[event]).toEqual([ + expect.objectContaining({ command: scriptPath().replaceAll('\\', '/') }) + ]) + } + } + ) + + it.skipIf(process.platform !== 'win32')( + 'rewrites a stale encoded launcher registration to the direct form', + () => { + writeAgent('mine', { + name: 'mine', + hooks: { stop: [USER_STOP_HOOK, { command: wrapWindowsHookCommand(scriptPath()) }] } + }) + expect(new KiroHookService().install().state).toBe('installed') + + const hooks = readAgent('mine').hooks + if (!isPlainObject(hooks)) { + throw new Error('Expected hooks object') + } + expect(hooks.stop).toEqual([ + USER_STOP_HOOK, + expect.objectContaining({ command: scriptPath().replaceAll('\\', '/') }) + ]) + } + ) + + it.skipIf(process.platform !== 'win32').each(['profile with spaces', 'profile-hömé'])( + 'keeps the encoded launcher for a profile the bare path cannot carry (%s)', + (profileName) => { + const profile = join(home, profileName) + const agentFile = join(profile, '.kiro', 'agents', 'mine.json') + mkdirSync(join(profile, '.kiro', 'agents'), { recursive: true }) + writeFileSync(agentFile, JSON.stringify({ name: 'mine' })) + process.env.USERPROFILE = profile + process.env.HOME = profile + expect(new KiroHookService().install().state).toBe('installed') + + const hooks = JSON.parse(readFileSync(agentFile, 'utf-8')).hooks + if (!isPlainObject(hooks)) { + throw new Error('Expected hooks object') + } + expect(hooks.stop).toEqual([ + expect.objectContaining({ command: expect.stringMatching(WINDOWS_POWERSHELL_LAUNCHER) }) + ]) + } + ) + it('ignores non-JSON files such as the shipped example config', () => { mkdirSync(agentsDir(), { recursive: true }) writeFileSync(join(agentsDir(), 'agent_config.json.example'), '{}') diff --git a/src/main/kiro/hook-settings.ts b/src/main/kiro/hook-settings.ts index 97245f12e16a..a0f1404fc8d3 100644 --- a/src/main/kiro/hook-settings.ts +++ b/src/main/kiro/hook-settings.ts @@ -11,6 +11,7 @@ import { wrapWindowsHookCommand, type HookDefinition } from '../agent-hooks/installer-utils' +import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' const KIRO_SCRIPT_BASE = 'kiro-hook' @@ -68,7 +69,7 @@ export function getKiroManagedScriptPath(): string { export function getKiroManagedCommand(scriptPath: string): string { // Why: Kiro runs hook commands through PowerShell on Windows and sh elsewhere. return process.platform === 'win32' - ? wrapWindowsHookCommand(scriptPath) + ? (wrapWindowsDirectCmdHookCommand(scriptPath) ?? wrapWindowsHookCommand(scriptPath)) : wrapPosixHookCommand(scriptPath) } From d7e964ed650b523aa0f7be4db21950ad60a27de9 Mon Sep 17 00:00:00 2001 From: Orca Worker Date: Sun, 11 Oct 2026 20:22:56 -0700 Subject: [PATCH 3/6] 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. --- .../agent-hooks/windows-cmd-hook-command-unicode.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 f1012140933d..7d5fbea37950 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 @@ -18,6 +18,7 @@ import { wrapWindowsDirectCmdHookCommand } from './windows-direct-cmd-hook-comma 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() } })) @@ -152,7 +153,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(directCommand ?? wrapWindowsCmdHookCommand(scriptPath)) if (!WINDOWS_CMD_SAFE_PATH.test(scriptPath)) { expect(decodeCommand(command)).not.toContain('Set-ExecutionPolicy') } @@ -164,7 +167,8 @@ describe.skipIf( { program: getWindowsPowerShellExecutablePath(), args: ['-NoProfile', '-Command', command] - } + }, + ...(directCommand ? [{ program: findGitBash(), args: ['-c', command] }] : []) ] for (const host of hosts) { const before = posts.length From 0f58232bc1dc30fdd6b16c7a6c53d3984377850f Mon Sep 17 00:00:00 2001 From: Orca Worker Date: Mon, 12 Oct 2026 10:47:26 -0700 Subject: [PATCH 4/6] 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. --- .../agent-hooks/windows-direct-cmd-agent-hook-hosts.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/agent-hooks/windows-direct-cmd-agent-hook-hosts.test.ts b/src/main/agent-hooks/windows-direct-cmd-agent-hook-hosts.test.ts index e03099351bc7..21713a3ed500 100644 --- a/src/main/agent-hooks/windows-direct-cmd-agent-hook-hosts.test.ts +++ b/src/main/agent-hooks/windows-direct-cmd-agent-hook-hosts.test.ts @@ -152,6 +152,8 @@ describe.skipIf(process.platform !== 'win32')('direct agent hook commands in Win async (agent, install) => { const command = install(home) expect(command).toMatch(/^[A-Za-z]:\/[^\\\s]+\.cmd$/) + // Proves the `os` mock reached the agents' `node:os` imports (Vitest normalizes builtins). + expect(command.startsWith(`${home.replaceAll('\\', '/')}/.orca/agent-hooks/`)).toBe(true) const hosts: [string, string[]][] = [ ['cmd.exe', ['/d', '/c', command]], From 1a04860039beefb4a30158bfc8646a0dc0ee76de Mon Sep 17 00:00:00 2001 From: Orca Worker Date: Mon, 12 Oct 2026 11:03:21 -0700 Subject: [PATCH 5/6] 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 --- .github/workflows/pr.yml | 1 - config/scripts/pr-code-change-scope.mjs | 1 - .../windows-direct-cmd-agent-runners.test.ts | 264 ---------------- .../windows-direct-cmd-hook-command.test.ts | 23 +- .../windows-direct-cmd-hook-command.ts | 25 +- .../windows-hook-payload-delivery.test.ts | 296 ++++++++++++++---- .../windows-powershell-hook-launcher.ts | 5 +- src/main/command-code/hook-service.test.ts | 77 ++--- src/main/command-code/hook-service.ts | 5 +- src/main/droid/hook-service.test.ts | 75 +---- src/main/droid/hook-service.ts | 8 +- src/main/dsh/hook-service.test.ts | 64 +--- src/main/dsh/hook-settings.ts | 9 +- src/main/gemini/hook-service.test.ts | 82 +---- src/main/gemini/hook-service.ts | 5 +- src/main/kiro/hook-service.test.ts | 72 +---- src/main/kiro/hook-settings.ts | 5 +- src/main/muse/hook-settings.test.ts | 9 +- src/main/muse/hook-settings.ts | 5 +- 19 files changed, 371 insertions(+), 660 deletions(-) delete mode 100644 src/main/agent-hooks/windows-direct-cmd-agent-runners.test.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9795e632cb9a..ba11ca658fd7 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1268,7 +1268,6 @@ jobs: src/main/agent-hooks/windows-hook-payload-delivery.test.ts src/main/jcode/hook-gate-script.test.ts src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts - src/main/agent-hooks/windows-direct-cmd-agent-runners.test.ts src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts src/main/agent-hooks/windows-batch-hook-launcher.test.ts src/main/agent-hooks/windows-powershell-hook-launcher.test.ts diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index c7f489b96fe5..149c49a2d11c 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -386,7 +386,6 @@ const WINDOWS_PACKAGE_TESTS = [ 'src/main/agent-hooks/windows-hook-payload-delivery.test.ts', 'src/main/jcode/hook-gate-script.test.ts', 'src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts', - 'src/main/agent-hooks/windows-direct-cmd-agent-runners.test.ts', 'src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts', 'src/main/agent-hooks/windows-batch-hook-launcher.test.ts', 'src/main/agent-hooks/windows-powershell-hook-launcher.test.ts', diff --git a/src/main/agent-hooks/windows-direct-cmd-agent-runners.test.ts b/src/main/agent-hooks/windows-direct-cmd-agent-runners.test.ts deleted file mode 100644 index ce607eb859cc..000000000000 --- a/src/main/agent-hooks/windows-direct-cmd-agent-runners.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -// Why: Gemini, DSH, Kiro, Droid and Command Code register the managed `.cmd` path directly on a -// cmd-safe profile. Each vendor starts that string through its own shell, so this suite installs -// into a temp profile and runs the registered command the way each runner does, following a -// non-ASCII payload to a mock listener byte-for-byte. -import { afterEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '@orca/process-host' -import { createServer, type Server } from 'node:http' -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' -import { removeTreeSync } from '../../shared/windows-transient-lock-removal' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import type * as osModule from 'node:os' - -const { homedirMock } = vi.hoisted(() => ({ - homedirMock: vi.fn<() => string>() -})) - -vi.mock('electron', () => ({ - app: { - getPath: () => join(tmpdir(), 'orca-direct-cmd-runner-user-data') - } -})) - -vi.mock('os', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - homedir: homedirMock.mockImplementation(actual.homedir) - } -}) - -import { CommandCodeHookService } from '../command-code/hook-service' -import { DroidHookService } from '../droid/hook-service' -import { DshHookService } from '../dsh/hook-service' -import { GeminiHookService } from '../gemini/hook-service' -import { KiroHookService } from '../kiro/hook-service' -import { WINDOWS_CMD_SAFE_PATH } from './installer-utils' - -const PANE_KEY = 'tab-1:leaf-1' -const HOOK_TOKEN = 'direct-cmd-runner-token' -const PAYLOAD = JSON.stringify({ - session_id: 'direct-cmd-runner', - hook_event_name: 'UserPromptSubmit', - prompt: 'café 日本語 😀 & echo %PATH% | more', - transcript: 'y'.repeat(4096) -}) - -type Runner = { - name: string - program: string - args: (command: string) => string[] - verbatim?: boolean -} - -const SYSTEM_ROOT = process.env.SystemRoot ?? 'C:\\Windows' -const CMD = join(SYSTEM_ROOT, 'System32', 'cmd.exe') -const POWERSHELL = join(SYSTEM_ROOT, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') - -// Each argv is the vendor's own spawn (pwsh-first runners fall back to Windows PowerShell 5.1). -const CMD_NODE_SHELL: Runner = { - name: 'cmd.exe (Node shell:true)', - program: CMD, - args: (command) => ['/d', '/s', '/c', `"${command}"`], - verbatim: true -} -const CMD_FACTORY: Runner = { - name: 'cmd.exe (Factory)', - program: CMD, - args: (command) => ['/d', '/s', '/c', command], - verbatim: true -} -const POWERSHELL_GEMINI: Runner = { - name: 'Windows PowerShell (Gemini)', - program: POWERSHELL, - args: (command) => [ - '-NoProfile', - '-NonInteractive', - '-Command', - `${command}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }` - ] -} -const POWERSHELL_DSH: Runner = { - name: 'Windows PowerShell (DSH)', - program: POWERSHELL, - args: (command) => [ - '-NoLogo', - '-NoProfile', - '-NonInteractive', - '-Command', - `[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;$OutputEncoding=[System.Text.Encoding]::UTF8;${command}` - ] -} -const POWERSHELL_PLAIN: Runner = { - name: 'Windows PowerShell', - program: POWERSHELL, - args: (command) => ['-NoProfile', '-Command', command] -} - -type JsonHooks = { hooks: Record } - -function readJson(path: string): JsonHooks { - return JSON.parse(readFileSync(path, 'utf8')) -} - -const PROVIDERS = [ - { - name: 'Droid', - script: 'droid-hook.cmd', - runners: [CMD_FACTORY], - stdout: '', - install: () => new DroidHookService().install(), - command: (home: string) => - readJson(join(home, '.factory', 'settings.json')).hooks.UserPromptSubmit[0].hooks?.[0].command - }, - { - name: 'Command Code', - script: 'command-code-hook.cmd', - runners: [CMD_NODE_SHELL], - stdout: '', - install: () => new CommandCodeHookService().install(), - command: (home: string) => - readJson(join(home, '.commandcode', 'settings.json')).hooks.Stop[0].hooks?.[0].command - }, - { - name: 'Gemini', - script: 'gemini-hook.cmd', - runners: [POWERSHELL_GEMINI], - stdout: '{}', - install: () => new GeminiHookService().install(), - command: (home: string) => - readJson(join(home, '.gemini', 'settings.json')).hooks.BeforeAgent[0].hooks?.[0].command - }, - { - name: 'DSH', - script: 'dsh-hook.cmd', - runners: [POWERSHELL_DSH], - stdout: '', - install: () => new DshHookService().install(), - command: (home: string) => - readJson(join(home, '.orca', 'agent-hooks', 'dsh-hooks.json')).hooks.UserPromptSubmit[0] - .hooks?.[0].command - }, - { - // Kiro is closed source; cover both shells it could use. - name: 'Kiro', - script: 'kiro-hook.cmd', - runners: [POWERSHELL_PLAIN, CMD_NODE_SHELL], - stdout: '', - install: (home: string) => { - mkdirSync(join(home, '.kiro', 'agents'), { recursive: true }) - writeFileSync(join(home, '.kiro', 'agents', 'mine.json'), JSON.stringify({ name: 'mine' })) - return new KiroHookService().install() - }, - command: (home: string) => - readJson(join(home, '.kiro', 'agents', 'mine.json')).hooks.userPromptSubmit[0].command - } -] - -type HookPost = { payload: string | null; paneKey: string | null; token: string | null } - -async function startHookListener(): Promise<{ server: Server; port: number; posts: HookPost[] }> { - const posts: HookPost[] = [] - const server = createServer((req, res) => { - let body = '' - req.setEncoding('utf8') - req.on('data', (chunk) => { - body += chunk - }) - req.on('end', () => { - const form = new URLSearchParams(body) - const token = req.headers['x-orca-agent-hook-token'] - posts.push({ - payload: form.get('payload'), - paneKey: form.get('paneKey'), - token: typeof token === 'string' ? token : null - }) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end('{}') - }) - }) - const port = await new Promise((resolve) => { - server.listen(0, '127.0.0.1', () => { - const address = server.address() - resolve(typeof address === 'object' && address ? address.port : 0) - }) - }) - return { server, port, posts } -} - -// Why: strip every inherited ORCA_* so an endpoint file from a live Orca can never redirect the -// hook's POST away from this listener. -function hookEnvironment(extra: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const base = Object.fromEntries( - Object.entries(process.env).filter(([key]) => !key.startsWith('ORCA_')) - ) - return { ...base, ORCA_BACKGROUND_LAUNCH: '1', ...extra } -} - -describe.skipIf(process.platform !== 'win32')('direct .cmd hooks under each agent runner', () => { - let home = '' - let server: Server | null = null - - afterEach(() => { - vi.unstubAllEnvs() - server?.close() - server = null - if (home) { - removeTreeSync(home) - home = '' - } - }) - - it.each(PROVIDERS)( - '$name delivers the payload byte-exact through its runner', - async (provider) => { - home = mkdtempSync(join(tmpdir(), 'orca-direct-cmd-runner-')) - homedirMock.mockReturnValue(home) - vi.stubEnv('USERPROFILE', home) - vi.stubEnv('HOME', home) - vi.stubEnv('KIRO_HOME', undefined) - vi.stubEnv('DSH_HOME', undefined) - // A host cmd AutoRun (HKCU) runs for the .cmd child PowerShell starts without `/d`. - writeFileSync(join(home, '.cmd_aliases.cmd'), '@echo off\r\n', 'utf8') - expect(provider.install(home).state).toBe('installed') - - const scriptPath = join(home, '.orca', 'agent-hooks', provider.script) - expect(WINDOWS_CMD_SAFE_PATH.test(scriptPath), 'test home must be cmd-safe').toBe(true) - const command = provider.command(home) - expect(command).toBe(scriptPath.replaceAll('\\', '/')) - if (!command) { - throw new Error(`Missing managed ${provider.name} command`) - } - - const listener = await startHookListener() - server = listener.server - const env = hookEnvironment({ - USERPROFILE: home, - HOME: home, - ORCA_AGENT_HOOK_PORT: String(listener.port), - ORCA_AGENT_HOOK_TOKEN: HOOK_TOKEN, - ORCA_PANE_KEY: PANE_KEY - }) - - for (const runner of provider.runners) { - const before = listener.posts.length - const result = await runProcess({ - program: runner.program, - args: runner.args(command), - windowsVerbatimArguments: runner.verbatim, - env, - input: PAYLOAD, - timeoutMs: 15_000 - }) - expect(result.timedOut, `${runner.name} timed out`).toBe(false) - expect(result.code, `${runner.name} exit code; stderr: ${result.stderr}`).toBe(0) - expect(listener.posts.slice(before), runner.name).toEqual([ - { payload: PAYLOAD, paneKey: PANE_KEY, token: HOOK_TOKEN } - ]) - expect(result.stdout.trim(), `${runner.name} stdout`).toBe(provider.stdout) - } - }, - 60_000 - ) -}) 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 e0d8897a1b37..9b6726a43777 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 @@ -5,8 +5,11 @@ import { existsSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { removeTreeSync } from '../../shared/windows-transient-lock-removal' -import { WINDOWS_CMD_SAFE_PATH } from './installer-utils' -import { wrapWindowsDirectCmdHookCommand } from './windows-direct-cmd-hook-command' +import { WINDOWS_CMD_SAFE_PATH, wrapWindowsHookCommand } from './installer-utils' +import { + wrapWindowsBatchHookCommand, + wrapWindowsDirectCmdHookCommand +} from './windows-direct-cmd-hook-command' import { getWindowsClaudeHookEntry } from '../claude/windows-hook-files' import { getManagedScript } from '../claude/hook-script' import { getWindowsPowerShellExecutablePath } from './windows-powershell-hook-launcher' @@ -54,6 +57,22 @@ describe('wrapWindowsDirectCmdHookCommand', () => { }) }) +describe('wrapWindowsBatchHookCommand', () => { + it('registers a cmd-safe .cmd hook by its bare path', () => { + expect(wrapWindowsBatchHookCommand(SAFE_PATH)).toBe(wrapWindowsDirectCmdHookCommand(SAFE_PATH)) + }) + + 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. + const unicode = 'C:\\Users\\rené\\.orca\\agent-hooks\\gemini-hook.cmd' + expect(wrapWindowsBatchHookCommand(unicode)).toBe( + wrapWindowsHookCommand(unicode, {}, { useProcessPolicyEnvironment: true }) + ) + }) +}) + describe.skipIf(process.platform !== 'win32')( 'direct hook command, run by Windows hook hosts', () => { 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 e4fbc4d2ffce..dce62ec40a89 100644 --- a/src/main/agent-hooks/windows-direct-cmd-hook-command.ts +++ b/src/main/agent-hooks/windows-direct-cmd-hook-command.ts @@ -1,9 +1,13 @@ -import { WINDOWS_CMD_SAFE_PATH } from './installer-utils' +import { + isSafeUnicodeWindowsBatchHookPath, + WINDOWS_CMD_SAFE_PATH, + wrapWindowsHookCommand +} from './windows-hook-command' // UNC paths cannot be started consistently by all Windows hook hosts. const WINDOWS_DRIVE_LETTER_PATH = /^[A-Za-z]:\\/ -/** A bare path works without guessing whether Claude selected Bash or PowerShell. */ +/** A bare path runs the same under cmd.exe, PowerShell and Git Bash, so no host shell is guessed. */ export function wrapWindowsDirectCmdHookCommand(scriptPath: string): string | null { if (!WINDOWS_CMD_SAFE_PATH.test(scriptPath) || !WINDOWS_DRIVE_LETTER_PATH.test(scriptPath)) { return null @@ -11,3 +15,20 @@ export function wrapWindowsDirectCmdHookCommand(scriptPath: string): string | nu // The script owns failure handling; PowerShell 5.1 cannot parse a shell-level `||`. return scriptPath.replaceAll('\\', '/') } + +/** + * Registration for a managed `.cmd` hook: the bare path skips a PowerShell start-up per event + * (#18875); paths the shells cannot carry bare keep the encoded launcher. A missing script then + * prints "not recognized" until Orca reinstalls it instead of exiting silently. + */ +export function wrapWindowsBatchHookCommand(scriptPath: string): string { + return ( + wrapWindowsDirectCmdHookCommand(scriptPath) ?? + wrapWindowsHookCommand( + scriptPath, + {}, + // Same policy shortcut as wrapWindowsCmdHookCommand for guarded Unicode .cmd paths (#26382). + { 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 f242e8815cad..47eb53e26e4b 100644 --- a/src/main/agent-hooks/windows-hook-payload-delivery.test.ts +++ b/src/main/agent-hooks/windows-hook-payload-delivery.test.ts @@ -4,13 +4,14 @@ // curl POST carries no payload. Shape, exit-code and stdout assertions all stayed green // through it, because none of them ever piped a payload. This suite pipes one and follows // it to the listener, so a launcher that takes stdin away from the hook fails here (#14818). +// Every agent that registers a managed .cmd runs here under the shell its vendor uses. import { afterEach, describe, expect, it, vi } from 'vitest' import { runProcess } from '@orca/process-host' import { createServer, type Server } from 'node:http' -import { mkdtempSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { removeTreeSync } from '../../shared/windows-transient-lock-removal' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import type * as osModule from 'node:os' const { homedirMock } = vi.hoisted(() => ({ @@ -32,9 +33,14 @@ vi.mock('os', async (importOriginal) => { }) import { ClaudeHookService } from '../claude/hook-service' +import { CommandCodeHookService } from '../command-code/hook-service' +import { DroidHookService } from '../droid/hook-service' +import { DshHookService } from '../dsh/hook-service' +import { GeminiHookService } from '../gemini/hook-service' +import { KiroHookService } from '../kiro/hook-service' import { MuseHookService } from '../muse/hook-service' import { getMuseManagedHooksPath } from '../muse/hook-settings' -import { readHooksJson, WINDOWS_CMD_SAFE_PATH } from './installer-utils' +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' @@ -66,10 +72,11 @@ async function startHookListener(): Promise<{ }) req.on('end', () => { const form = new URLSearchParams(body) + const token = req.headers['x-orca-agent-hook-token'] posts.push({ payload: form.get('payload'), paneKey: form.get('paneKey'), - token: req.headers['x-orca-agent-hook-token'] as string | null + token: typeof token === 'string' ? token : null }) res.writeHead(200, { 'content-type': 'application/json' }) res.end('{}') @@ -84,18 +91,177 @@ async function startHookListener(): Promise<{ return { server, port, posts } } -type HookRun = { exitCode: number | null; stdout: string; stderr: string; timedOut: boolean } +type Runner = { + name: string + program: () => string + args: (command: string) => string[] + verbatim?: boolean +} + +const SYSTEM32 = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32') +const CMD = join(SYSTEM32, 'cmd.exe') +const POWERSHELL = join(SYSTEM32, 'WindowsPowerShell', 'v1.0', 'powershell.exe') + +const CMD_PLAIN: Runner = { + name: 'cmd.exe', + program: () => 'cmd.exe', + args: (command) => ['/d', '/c', command] +} +const GIT_BASH: Runner = { name: 'Git Bash', program: findGitBash, args: (c) => ['-c', c] } +// Vendor argv below; runners that prefer pwsh fall back to Windows PowerShell 5.1. +const CMD_NODE_SHELL: Runner = { + name: 'cmd.exe (Node shell:true)', + program: () => CMD, + args: (command) => ['/d', '/s', '/c', `"${command}"`], + verbatim: true +} +const CMD_FACTORY: Runner = { + name: 'cmd.exe (Factory)', + program: () => CMD, + args: (command) => ['/d', '/s', '/c', command], + verbatim: true +} +const POWERSHELL_GEMINI: Runner = { + name: 'Windows PowerShell (Gemini)', + program: () => POWERSHELL, + args: (command) => [ + '-NoProfile', + '-NonInteractive', + '-Command', + `${command}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }` + ] +} +const POWERSHELL_DSH: Runner = { + name: 'Windows PowerShell (DSH)', + program: () => POWERSHELL, + args: (command) => [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + `[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;$OutputEncoding=[System.Text.Encoding]::UTF8;${command}` + ] +} +const POWERSHELL_PLAIN: Runner = { + name: 'Windows PowerShell', + program: () => POWERSHELL, + args: (command) => ['-NoProfile', '-Command', command] +} -function runHookCommand( - executable: string, - args: string[], - env: NodeJS.ProcessEnv -): Promise { - return runProcess({ program: executable, args, env, input: PAYLOAD, timeoutMs: 15_000 }).then( - (result) => ({ ...result, exitCode: result.code }) +type Entry = { command?: string; hooks?: { command?: string }[] } +const commandsOf = (entries: Entry[] | undefined): (string | undefined)[] => + (entries ?? []).flatMap((entry) => + entry.hooks ? entry.hooks.map((h) => h.command) : entry.command ) + +function readEvent(path: string, event: string): (string | undefined)[] { + return commandsOf(JSON.parse(readFileSync(path, 'utf8')).hooks?.[event]) +} + +function writeHooks( + path: string, + hooks: Record, + extra: { name?: string } = {} +): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, JSON.stringify({ ...extra, hooks })) } +type Provider = { + name: string + script: string + stdout: string + install: () => { state: string } + /** Managed commands registered for the event the row runs; exactly one is expected. */ + commands: (home: string) => (string | undefined)[] + /** Seeds the pre-#27717 encoded registration so install must replace it, not add to it. */ + seedStale?: (home: string, stale: string) => void + runners?: Runner[] +} + +const PROVIDERS: Provider[] = [ + { + name: 'Claude', + script: 'claude-hook.cmd', + stdout: '{}', + install: () => new ClaudeHookService().install(), + commands: () => commandsOf(readHooksJson(getConfigPath())?.hooks?.PreToolUse) + }, + { + name: 'Muse', + script: 'muse-hook.cmd', + stdout: '', + install: () => new MuseHookService().install(), + commands: () => commandsOf(readHooksJson(getMuseManagedHooksPath())?.hooks?.PreToolUse) + }, + { + name: 'Droid', + script: 'droid-hook.cmd', + stdout: '', + install: () => new DroidHookService().install(), + commands: (home) => readEvent(join(home, '.factory', 'settings.json'), 'UserPromptSubmit'), + seedStale: (home, stale) => + writeHooks(join(home, '.factory', 'settings.json'), { + UserPromptSubmit: [{ hooks: [{ command: stale }] }] + }), + runners: [CMD_FACTORY] + }, + { + name: 'Command Code', + script: 'command-code-hook.cmd', + stdout: '', + install: () => new CommandCodeHookService().install(), + commands: (home) => readEvent(join(home, '.commandcode', 'settings.json'), 'Stop'), + seedStale: (home, stale) => + writeHooks(join(home, '.commandcode', 'settings.json'), { + Stop: [{ hooks: [{ command: stale }] }] + }), + runners: [CMD_NODE_SHELL] + }, + { + name: 'Gemini', + script: 'gemini-hook.cmd', + stdout: '{}', + install: () => new GeminiHookService().install(), + commands: (home) => readEvent(join(home, '.gemini', 'settings.json'), 'BeforeAgent'), + seedStale: (home, stale) => + writeHooks(join(home, '.gemini', 'settings.json'), { + BeforeAgent: [{ hooks: [{ command: stale }] }] + }), + runners: [POWERSHELL_GEMINI] + }, + { + // dsh-hooks.json is Orca-owned and rewritten whole, so there is no stale entry to sweep. + name: 'DSH', + script: 'dsh-hook.cmd', + stdout: '', + install: () => new DshHookService().install(), + commands: (home) => + readEvent(join(home, '.orca', 'agent-hooks', 'dsh-hooks.json'), 'UserPromptSubmit'), + runners: [POWERSHELL_DSH] + }, + { + // Kiro is closed source; cover both shells it could use. + name: 'Kiro', + script: 'kiro-hook.cmd', + stdout: '', + install: () => new KiroHookService().install(), + commands: (home) => readEvent(join(home, '.kiro', 'agents', 'mine.json'), 'userPromptSubmit'), + seedStale: (home, stale) => + writeHooks( + join(home, '.kiro', 'agents', 'mine.json'), + { userPromptSubmit: [{ command: stale }] }, + { name: 'mine' } + ), + runners: [POWERSHELL_PLAIN, CMD_NODE_SHELL] + } +] + +// A space keeps the bare path off the command line, so that profile takes the encoded launcher. +const CASES = PROVIDERS.flatMap((provider) => + ['user', 'user name'].map((homeName) => ({ ...provider, homeName })) +) + // Why: a developer box may set HKCU\...\Command Processor\AutoRun, which cmd.exe runs before // any .cmd — and MSYS spawns a .cmd without `/d`, so it fires on the Git Bash leg. Redirecting // USERPROFILE to a temp home makes the usual `%USERPROFILE%\.cmd_aliases.cmd` target vanish, and @@ -105,9 +271,11 @@ function seedCmdAutoRunTarget(home: string): void { writeFileSync(join(home, '.cmd_aliases.cmd'), '@echo off\r\n', 'utf8') } +// Why: strip every inherited ORCA_* so an endpoint file from a live Orca can never redirect the +// hook's POST away from this listener. function hookEnvironment(extra: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const base = Object.fromEntries( - Object.entries(process.env).filter(([key]) => !key.startsWith('ORCA_')) + Object.entries(process.env).filter(([key]) => !key.toUpperCase().startsWith('ORCA_')) ) return { ...base, ORCA_BACKGROUND_LAUNCH: '1', ...extra } } @@ -125,7 +293,7 @@ describe('Windows managed hook launcher', () => { }) describe.skipIf(process.platform !== 'win32')('Windows managed hook payload delivery', () => { - let home = '' + let root = '' let server: Server | null = null afterEach(() => { @@ -133,47 +301,44 @@ describe.skipIf(process.platform !== 'win32')('Windows managed hook payload deli server?.close() server = null homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) - if (home) { - removeTreeSync(home) - home = '' + if (root) { + removeTreeSync(root) + root = '' } }) - it.each([ - { - name: 'Claude', - install: () => new ClaudeHookService().install(), - configPath: getConfigPath, - script: 'claude-hook.cmd', - stdout: '{}' - }, - { - name: 'Muse', - install: () => new MuseHookService().install(), - configPath: getMuseManagedHooksPath, - script: 'muse-hook.cmd', - stdout: '' - } - ])( - '$name delivers the piped payload through cmd.exe and Git Bash', + it.each(CASES)( + '$name delivers the piped payload through its runners (home "$homeName")', async (provider) => { - home = mkdtempSync(join(tmpdir(), 'orca-hook-payload-')) - vi.stubEnv('XDG_CONFIG_HOME', undefined) + root = mkdtempSync(join(tmpdir(), 'orca-hook-payload-')) + const home = join(root, provider.homeName) + mkdirSync(home) homedirMock.mockReturnValue(home) + for (const name of ['USERPROFILE', 'HOME']) { + vi.stubEnv(name, home) + } + for (const name of ['XDG_CONFIG_HOME', 'KIRO_HOME', 'DSH_HOME']) { + vi.stubEnv(name, undefined) + } seedCmdAutoRunTarget(home) + const scriptPath = join(home, '.orca', 'agent-hooks', provider.script) + provider.seedStale?.(home, wrapWindowsHookCommand(scriptPath)) expect(provider.install().state).toBe('installed') - const settings = readHooksJson(provider.configPath()) // Why: assert nothing about the launcher's shape here — this test's whole value is // that it fails for any launcher that loses the payload, named conhost or not. - const registeredCommand = settings?.hooks?.PreToolUse[0]?.hooks?.[0]?.command + const commands = provider.commands(home) + expect(commands, 'exactly one managed registration').toHaveLength(1) + const registeredCommand = commands[0] if (!registeredCommand) { - throw new Error('Missing managed PreToolUse command') + throw new Error(`Missing managed ${provider.name} command`) } // ...with one exception: a cmd-safe profile must reach the script with no interpreter in // front of it, or #18875's per-event PowerShell start-up has quietly come back. - if (WINDOWS_CMD_SAFE_PATH.test(join(home, '.orca', 'agent-hooks', provider.script))) { + if (WINDOWS_CMD_SAFE_PATH.test(scriptPath)) { expect(registeredCommand).not.toMatch(/powershell|-EncodedCommand/i) + } else { + expect(registeredCommand).toMatch(/-EncodedCommand/) } const listener = await startHookListener() @@ -185,41 +350,40 @@ describe.skipIf(process.platform !== 'win32')('Windows managed hook payload deli ORCA_AGENT_HOOK_TOKEN: HOOK_TOKEN, ORCA_PANE_KEY: PANE_KEY }) + const run = (runner: Runner, runEnv: NodeJS.ProcessEnv) => + runProcess({ + program: runner.program(), + args: runner.args(registeredCommand), + windowsVerbatimArguments: runner.verbatim, + env: runEnv, + input: PAYLOAD, + timeoutMs: 15_000 + }) - const shells = [ - { name: 'cmd.exe', executable: 'cmd.exe', args: ['/d', '/c', registeredCommand] }, - { name: 'Git Bash', executable: findGitBash(), args: ['-c', registeredCommand] } - ] - for (const shell of shells) { + for (const runner of provider.runners ?? [CMD_PLAIN, GIT_BASH]) { const before = listener.posts.length - const result = await runHookCommand(shell.executable, shell.args, env) - expect(result.timedOut, `${shell.name} timed out`).toBe(false) - expect(result.exitCode, `${shell.name} exit code`).toBe(0) + const result = await run(runner, env) + expect(result.timedOut, `${runner.name} timed out`).toBe(false) + expect(result.code, `${runner.name} exit code; stderr: ${result.stderr}`).toBe(0) // Why: assert delivery before stdout so a launcher that swallows the payload fails // on the symptom users report, not on some downstream difference in what it printed. - const posts = listener.posts.slice(before) - expect(posts, `${shell.name} posted exactly one hook`).toHaveLength(1) - // Why: byte-exact, not "non-empty" — a pseudoconsole host delivers nothing, and a + // Byte-exact, not "non-empty" — a pseudoconsole host delivers nothing, and a // code-page-translating launcher delivers a corrupted body that still looks posted. - expect(posts[0].payload, `${shell.name} payload`).toBe(PAYLOAD) - expect(posts[0].paneKey, `${shell.name} paneKey`).toBe(PANE_KEY) - expect(posts[0].token, `${shell.name} token`).toBe(HOOK_TOKEN) - - expect(result.stderr, `${shell.name} stderr`).toBe('') + expect(listener.posts.slice(before), `${runner.name} posts`).toEqual([ + { payload: PAYLOAD, paneKey: PANE_KEY, token: HOOK_TOKEN } + ]) + expect(result.stderr, `${runner.name} stderr`).toBe('') // Why: compat consumers gate the tool call on parseable stdout (#14818). - expect(result.stdout.trim(), `${shell.name} stdout`).toBe(provider.stdout) - - const outsideOrca = await runHookCommand( - shell.executable, - shell.args, - hookEnvironment({ USERPROFILE: home, HOME: home }) - ) - expect(outsideOrca.timedOut, `${shell.name} outside Orca timed out`).toBe(false) - expect(outsideOrca.exitCode, `${shell.name} outside Orca exit code`).toBe(0) + expect(result.stdout.trim(), `${runner.name} stdout`).toBe(provider.stdout) + + // Outside an Orca pane the hook exits without posting or waiting on stdin (#11549). + const outsideOrca = await run(runner, hookEnvironment({ USERPROFILE: home, HOME: home })) + expect(outsideOrca.timedOut, `${runner.name} outside Orca timed out`).toBe(false) + expect(outsideOrca.code, `${runner.name} outside Orca exit code`).toBe(0) expect(listener.posts).toHaveLength(before + 1) } - // Why: two shell launches plus a real install can overrun the default under load. + // Why: several shell launches plus a real install can overrun the default under load. }, 60_000 ) diff --git a/src/main/agent-hooks/windows-powershell-hook-launcher.ts b/src/main/agent-hooks/windows-powershell-hook-launcher.ts index c2a81d679cfd..b2e419834eba 100644 --- a/src/main/agent-hooks/windows-powershell-hook-launcher.ts +++ b/src/main/agent-hooks/windows-powershell-hook-launcher.ts @@ -40,9 +40,8 @@ export function getWindowsPowerShellExecutablePath(): string { * AV host for a flicker. The answer is to shorten the interpreter chain — the * shipped doctrine of #15520 and #15595 — or a launcher that owns no console. * - * #18875 took that answer for the Claude lifecycle hook, which now registers the - * managed `.cmd` path directly (`windows-direct-cmd-hook-command.ts`) and reaches - * this launcher only when the profile path is not cmd-safe. Every other caller still comes through here on every event. + * #18875 took that answer for the Claude lifecycle hook, and `wrapWindowsBatchHookCommand` + * extends it to other `.cmd` hooks: they reach this launcher only when the profile path is not cmd-safe. */ export const WINDOWS_POWERSHELL_HOOK_SWITCHES = '-NoProfile' diff --git a/src/main/command-code/hook-service.test.ts b/src/main/command-code/hook-service.test.ts index a5412710a3a2..41402ae03af5 100644 --- a/src/main/command-code/hook-service.test.ts +++ b/src/main/command-code/hook-service.test.ts @@ -19,10 +19,8 @@ vi.mock('os', async () => { }) import { CommandCodeHookService } from './hook-service' -import { WINDOWS_CMD_SAFE_PATH, wrapWindowsHookCommand } from '../agent-hooks/installer-utils' -const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ +import { wrapWindowsBatchHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' describe('CommandCodeHookService', () => { let homeDir: string @@ -52,7 +50,13 @@ describe('CommandCodeHookService', () => { expect(config.hooks.PreToolUse[0].matcher).toBe('.*') expect(config.hooks.PostToolUse[0].matcher).toBe('.*') expect(config.hooks.Stop[0].matcher).toBeUndefined() - expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(/command-code-hook/) + expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch( + process.platform === 'win32' + ? wrapWindowsBatchHookCommand( + join(homeDir, '.orca', 'agent-hooks', 'command-code-hook.cmd') + ) + : /command-code-hook/ + ) if (process.platform !== 'win32') { expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca')) } @@ -61,67 +65,24 @@ describe('CommandCodeHookService', () => { } }) - // Why: Command Code already runs hooks through cmd.exe; an encoded PowerShell in between cost ~0.6s per event. - it.skipIf(process.platform !== 'win32')( - 'registers the managed .cmd path directly on a cmd-safe profile', - () => { - const scriptPath = join(homeDir, '.orca', 'agent-hooks', 'command-code-hook.cmd') - expect(WINDOWS_CMD_SAFE_PATH.test(scriptPath), 'test home must be cmd-safe').toBe(true) + // Why: runs on every OS; windows-hook-payload-delivery.test.ts runs each form under Command Code's shell. + it('registers the Windows batch hook command for every event', () => { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + try { expect(new CommandCodeHookService().install().state).toBe('installed') - + const command = wrapWindowsBatchHookCommand( + join(homeDir, '.orca', 'agent-hooks', 'command-code-hook.cmd') + ) const config = JSON.parse( readFileSync(join(homeDir, '.commandcode', 'settings.json'), 'utf8') ) for (const eventName of ['PreToolUse', 'PostToolUse', 'Stop']) { - expect(config.hooks[eventName][0].hooks[0].command).toBe(scriptPath.replaceAll('\\', '/')) - } - } - ) - - it.skipIf(process.platform !== 'win32')( - 'rewrites a stale encoded launcher registration to the direct form', - () => { - const scriptPath = join(homeDir, '.orca', 'agent-hooks', 'command-code-hook.cmd') - const configPath = join(homeDir, '.commandcode', 'settings.json') - mkdirSync(join(homeDir, '.commandcode'), { recursive: true }) - const stale = { type: 'command', command: wrapWindowsHookCommand(scriptPath) } - writeFileSync(configPath, JSON.stringify({ hooks: { Stop: [{ hooks: [stale] }] } })) - - expect(new CommandCodeHookService().install().state).toBe('installed') - - const config = JSON.parse(readFileSync(configPath, 'utf8')) - expect(config.hooks.Stop).toHaveLength(1) - expect(config.hooks.Stop[0].hooks).toEqual([ - expect.objectContaining({ command: scriptPath.replaceAll('\\', '/') }) - ]) - } - ) - - // Why: #6078 — a Windows user profile path with a space used to be written - // verbatim as the hook command, so the agent split it at the space. A profile - // the bare path cannot carry keeps the encoded launcher. - it - .skipIf(process.platform !== 'win32') - .each(['orca command-code home with spaces', 'orca-command-code-hömé'])( - 'keeps the encoded launcher for a profile the bare path cannot carry (%s)', - (homeName) => { - const spaceHome = join(tmpdir(), homeName) - mkdirSync(spaceHome, { recursive: true }) - homedirMock.mockReturnValue(spaceHome) - try { - expect(new CommandCodeHookService().install().state).toBe('installed') - - const config = JSON.parse( - readFileSync(join(spaceHome, '.commandcode', 'settings.json'), 'utf8') - ) as { hooks: Record } - - const command = config.hooks.PreToolUse[0].hooks[0].command - expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER) - } finally { - rmSync(spaceHome, { recursive: true, force: true }) + expect(config.hooks[eventName][0].hooks[0].command).toBe(command) } + } finally { + platform.mockRestore() } - ) + }) it('installs a hook script that can recover the endpoint when Command Code strips token env', () => { new CommandCodeHookService().install() diff --git a/src/main/command-code/hook-service.ts b/src/main/command-code/hook-service.ts index f9d35de4ec1c..0ac48d2d9d6d 100644 --- a/src/main/command-code/hook-service.ts +++ b/src/main/command-code/hook-service.ts @@ -9,12 +9,11 @@ import { readHooksJson, removeManagedCommands, wrapPosixHookCommand, - wrapWindowsHookCommand, writeHooksJson, writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' -import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' +import { wrapWindowsBatchHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, @@ -49,7 +48,7 @@ function getManagedScriptPath(): string { function getManagedCommand(scriptPath: string): string { return process.platform === 'win32' - ? (wrapWindowsDirectCmdHookCommand(scriptPath) ?? wrapWindowsHookCommand(scriptPath)) + ? wrapWindowsBatchHookCommand(scriptPath) : wrapPosixHookCommand(scriptPath) } diff --git a/src/main/droid/hook-service.test.ts b/src/main/droid/hook-service.test.ts index acdbee31b826..5d399a8be805 100644 --- a/src/main/droid/hook-service.test.ts +++ b/src/main/droid/hook-service.test.ts @@ -23,10 +23,8 @@ vi.mock('os', async () => { }) import { DroidHookService } from './hook-service' -import { WINDOWS_CMD_SAFE_PATH, wrapWindowsHookCommand } from '../agent-hooks/installer-utils' -const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ +import { wrapWindowsBatchHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' describe('DroidHookService', () => { let homeDir: string @@ -74,72 +72,33 @@ describe('DroidHookService', () => { expect(config.hooks.PreToolUse[0].matcher).toBe('*') expect(config.hooks.PermissionRequest[0].matcher).toBe('*') expect(config.hooks.UserPromptSubmit[0].matcher).toBeUndefined() - expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(/droid-hook/) + expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch( + process.platform === 'win32' + ? wrapWindowsBatchHookCommand(join(homeDir, '.orca', 'agent-hooks', 'droid-hook.cmd')) + : /droid-hook/ + ) if (process.platform !== 'win32') { expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca')) } expect(config.hooks.PreToolUse[0].hooks[0].command).not.toContain(userDataDir) }) - // Why: Factory already runs hooks through cmd.exe; an encoded PowerShell in between cost ~0.7s per event. - it.skipIf(process.platform !== 'win32')( - 'registers the managed .cmd path directly on a cmd-safe profile', - () => { - const scriptPath = join(homeDir, '.orca', 'agent-hooks', 'droid-hook.cmd') - expect(WINDOWS_CMD_SAFE_PATH.test(scriptPath), 'test home must be cmd-safe').toBe(true) + // Why: runs on every OS; windows-hook-payload-delivery.test.ts runs each form under Droid's shell. + it('registers the Windows batch hook command for every event', () => { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + try { expect(new DroidHookService().install().state).toBe('installed') - + const command = wrapWindowsBatchHookCommand( + join(homeDir, '.orca', 'agent-hooks', 'droid-hook.cmd') + ) const config = JSON.parse(readFileSync(join(homeDir, '.factory', 'settings.json'), 'utf8')) for (const eventName of ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'Stop']) { - expect(config.hooks[eventName][0].hooks[0].command).toBe(scriptPath.replaceAll('\\', '/')) + expect(config.hooks[eventName][0].hooks[0].command).toBe(command) } + } finally { + platform.mockRestore() } - ) - - it.skipIf(process.platform !== 'win32')( - 'rewrites a stale encoded launcher registration to the direct form', - () => { - const scriptPath = join(homeDir, '.orca', 'agent-hooks', 'droid-hook.cmd') - const configPath = join(homeDir, '.factory', 'settings.json') - mkdirSync(dirname(configPath), { recursive: true }) - const stale = { type: 'command', command: wrapWindowsHookCommand(scriptPath) } - writeFileSync(configPath, JSON.stringify({ hooks: { Stop: [{ hooks: [stale] }] } })) - - expect(new DroidHookService().install().state).toBe('installed') - - const config = JSON.parse(readFileSync(configPath, 'utf8')) - expect(config.hooks.Stop).toHaveLength(1) - expect(config.hooks.Stop[0].hooks).toEqual([ - expect.objectContaining({ command: scriptPath.replaceAll('\\', '/') }) - ]) - } - ) - - // Why: #6078 — a Windows user profile path with a space used to be written - // verbatim as the hook command, so the agent split it at the space. A profile - // the bare path cannot carry keeps the encoded launcher. - it.skipIf(process.platform !== 'win32').each(['orca droid home with spaces', 'orca-droid-hömé'])( - 'keeps the encoded launcher for a profile the bare path cannot carry (%s)', - (homeName) => { - const spaceHome = join(tmpdir(), homeName) - mkdirSync(spaceHome, { recursive: true }) - homedirMock.mockReturnValue(spaceHome) - try { - expect(new DroidHookService().install().state).toBe('installed') - - const config = JSON.parse( - readFileSync(join(spaceHome, '.factory', 'settings.json'), 'utf8') - ) as { hooks: Record } - - for (const eventName of ['SessionStart', 'UserPromptSubmit', 'Stop']) { - const command = config.hooks[eventName]?.[0]?.hooks?.[0]?.command - expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER) - } - } finally { - rmSync(spaceHome, { recursive: true, force: true }) - } - } - ) + }) it('reports partial when Factory has hooks disabled globally', () => { const configPath = join(homeDir, '.factory', 'settings.json') diff --git a/src/main/droid/hook-service.ts b/src/main/droid/hook-service.ts index 3d68666b65b4..a8b864046ca0 100644 --- a/src/main/droid/hook-service.ts +++ b/src/main/droid/hook-service.ts @@ -10,13 +10,12 @@ import { readHooksJson, removeManagedCommands, wrapPosixHookCommand, - wrapWindowsHookCommand, writeHooksJson, writeManagedScript, type HookDefinition, type HooksConfig } from '../agent-hooks/installer-utils' -import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' +import { wrapWindowsBatchHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, @@ -71,10 +70,9 @@ function getManagedScriptPath(): string { } function getManagedCommand(scriptPath: string): string { - // Why: Factory invokes the hook via cmd.exe, so a cmd-safe path needs no PowerShell hop; a - // profile with a space (e.g. `C:\Users\Jane Doe`) would split, so it keeps the encoded wrapper. #6078. + // Why: Factory runs hooks via cmd.exe, which splits a bare path at a profile space (#6078). return process.platform === 'win32' - ? (wrapWindowsDirectCmdHookCommand(scriptPath) ?? wrapWindowsHookCommand(scriptPath)) + ? wrapWindowsBatchHookCommand(scriptPath) : wrapPosixHookCommand(scriptPath) } diff --git a/src/main/dsh/hook-service.test.ts b/src/main/dsh/hook-service.test.ts index 53b95cc40d44..91644106107c 100644 --- a/src/main/dsh/hook-service.test.ts +++ b/src/main/dsh/hook-service.test.ts @@ -9,13 +9,10 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { WINDOWS_CMD_SAFE_PATH, wrapWindowsHookCommand } from '../agent-hooks/installer-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { wrapWindowsBatchHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' import { DshHookService } from './hook-service' -import { buildDshManagedHooksFile, DSH_HOOK_EVENTS } from './hook-settings' - -const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ +import { DSH_HOOK_EVENTS } from './hook-settings' // Why: getSharedManagedScriptPath() writes under homedir()/.orca and the patch layer // resolves via DSH_HOME ?? ~/.dsh. Point the home env at a temp dir and clear DSH_HOME so @@ -107,58 +104,21 @@ describe('DshHookService', () => { expect(restoreAt).toBeLessThan(guardAt) }) - // Why: DSH already runs hooks in PowerShell; a second, encoded PowerShell cost ~0.6s per event. - it.skipIf(process.platform !== 'win32')( - 'registers the managed .cmd path directly on a cmd-safe profile', - () => { - expect(WINDOWS_CMD_SAFE_PATH.test(scriptPath()), 'test home must be cmd-safe').toBe(true) + // Why: runs on every OS; windows-hook-payload-delivery.test.ts runs each form under DSH's shell. + it('registers the Windows batch hook command for every event', () => { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + try { expect(new DshHookService().install().state).toBe('installed') - + const command = wrapWindowsBatchHookCommand(scriptPath()) for (const event of DSH_HOOK_EVENTS) { expect(readManagedHooks().hooks[event]).toEqual([ - { hooks: [expect.objectContaining({ command: scriptPath().replaceAll('\\', '/') })] } + { hooks: [expect.objectContaining({ command })] } ]) } + } finally { + platform.mockRestore() } - ) - - it.skipIf(process.platform !== 'win32')( - 'rewrites a stale encoded launcher registration to the direct form', - () => { - mkdirSync(join(home, '.orca', 'agent-hooks'), { recursive: true }) - writeFileSync( - managedHooksPath(), - buildDshManagedHooksFile(wrapWindowsHookCommand(scriptPath())) - ) - expect(new DshHookService().install().state).toBe('installed') - - expect(readManagedHooks().hooks.Stop).toEqual([ - { hooks: [expect.objectContaining({ command: scriptPath().replaceAll('\\', '/') })] } - ]) - } - ) - - it.skipIf(process.platform !== 'win32').each(['profile with spaces', 'profile-hömé'])( - 'keeps the encoded launcher for a profile the bare path cannot carry (%s)', - (profileName) => { - const profile = join(home, profileName) - mkdirSync(profile, { recursive: true }) - process.env.USERPROFILE = profile - process.env.HOME = profile - expect(new DshHookService().install().state).toBe('installed') - - const installed = JSON.parse( - readFileSync(join(profile, '.orca', 'agent-hooks', 'dsh-hooks.json'), 'utf-8') - ) - expect(installed.hooks.Stop).toEqual([ - { - hooks: [ - expect.objectContaining({ command: expect.stringMatching(WINDOWS_POWERSHELL_LAUNCHER) }) - ] - } - ]) - } - ) + }) it('is idempotent', () => { const service = new DshHookService() diff --git a/src/main/dsh/hook-settings.ts b/src/main/dsh/hook-settings.ts index f0ba2f68a1ab..5fcd486af6b4 100644 --- a/src/main/dsh/hook-settings.ts +++ b/src/main/dsh/hook-settings.ts @@ -5,10 +5,9 @@ import { createManagedCommandMatcher, getSharedManagedScriptPath, wrapPosixHookCommand, - wrapWindowsHookCommand, type HookDefinition } from '../agent-hooks/installer-utils' -import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' +import { wrapWindowsBatchHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' import { readManagedHookEventsFromJson } from '../agent-hooks/managed-hooks-json-events' const DSH_SCRIPT_BASE = 'dsh-hook' @@ -74,11 +73,9 @@ export function getDshRemoteManagedHooksPath(remoteHome: string): string { } export function getDshManagedCommand(scriptPath: string): string { - // Why: DSH runs hooks through `ctx.shell`, which the base profile binds to bash - // everywhere except Windows, where it binds to PowerShell — the same split these two - // wrappers already encode. + // Why: DSH runs hooks through `ctx.shell`: bash off Windows, PowerShell on it, which runs a bare .cmd too. return process.platform === 'win32' - ? (wrapWindowsDirectCmdHookCommand(scriptPath) ?? wrapWindowsHookCommand(scriptPath)) + ? wrapWindowsBatchHookCommand(scriptPath) : wrapPosixHookCommand(scriptPath) } diff --git a/src/main/gemini/hook-service.test.ts b/src/main/gemini/hook-service.test.ts index 0a61a1c65434..099403d35e24 100644 --- a/src/main/gemini/hook-service.test.ts +++ b/src/main/gemini/hook-service.test.ts @@ -24,10 +24,7 @@ vi.mock('os', async (importOriginal) => { }) import { GeminiHookService } from './hook-service' -import { WINDOWS_CMD_SAFE_PATH, wrapWindowsHookCommand } from '../agent-hooks/installer-utils' - -const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ +import { wrapWindowsBatchHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' describe('GeminiHookService', () => { let homeDir: string @@ -106,7 +103,7 @@ describe('GeminiHookService', () => { expect(config.hooks.BeforeAgent[0].hooks[0].command).toBe('echo user-before-agent') const managedCommandPattern = process.platform === 'win32' - ? managedHookPath.replaceAll('\\', '/') + ? wrapWindowsBatchHookCommand(managedHookPath) : new RegExp(managedHookPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) expect(config.hooks.BeforeAgent[1].hooks[0].command).toMatch(managedCommandPattern) expect(config.hooks.AfterAgent[0].hooks[0].command).toMatch(managedCommandPattern) @@ -114,73 +111,22 @@ describe('GeminiHookService', () => { expect(config.hooks.BeforeTool[0].hooks[0].command).toMatch(managedCommandPattern) }) - // Why: Gemini already runs hooks in PowerShell; a second, encoded PowerShell cost ~0.7s per event. - it.skipIf(process.platform !== 'win32')( - 'registers the managed .cmd path directly on a cmd-safe profile', - () => { - const scriptPath = join(homeDir, '.orca', 'agent-hooks', 'gemini-hook.cmd') - expect(WINDOWS_CMD_SAFE_PATH.test(scriptPath), 'test home must be cmd-safe').toBe(true) + // Why: runs on every OS; windows-hook-payload-delivery.test.ts runs each form under Gemini's shell. + it('registers the Windows batch hook command for every event', () => { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + try { expect(new GeminiHookService().install().state).toBe('installed') - + const command = wrapWindowsBatchHookCommand( + join(homeDir, '.orca', 'agent-hooks', 'gemini-hook.cmd') + ) const config = JSON.parse(readFileSync(join(homeDir, '.gemini', 'settings.json'), 'utf8')) for (const eventName of ['BeforeAgent', 'AfterAgent', 'AfterTool', 'BeforeTool']) { - expect(config.hooks[eventName].at(-1).hooks[0].command).toBe( - scriptPath.replaceAll('\\', '/') - ) + expect(config.hooks[eventName].at(-1).hooks[0].command).toBe(command) } + } finally { + platform.mockRestore() } - ) - - it.skipIf(process.platform !== 'win32')( - 'rewrites a stale encoded launcher registration to the direct form', - () => { - const scriptPath = join(homeDir, '.orca', 'agent-hooks', 'gemini-hook.cmd') - const configDir = join(homeDir, '.gemini') - mkdirSync(configDir, { recursive: true }) - const stale = { type: 'command', command: wrapWindowsHookCommand(scriptPath) } - writeFileSync( - join(configDir, 'settings.json'), - JSON.stringify({ hooks: { BeforeAgent: [{ hooks: [stale] }] } }) - ) - - expect(new GeminiHookService().install().state).toBe('installed') - - const config = JSON.parse(readFileSync(join(configDir, 'settings.json'), 'utf8')) - expect(config.hooks.BeforeAgent).toHaveLength(1) - expect(config.hooks.BeforeAgent[0].hooks).toEqual([ - expect.objectContaining({ command: scriptPath.replaceAll('\\', '/') }) - ]) - } - ) - - // Why: #6078 — a Windows user profile path with a space used to be written - // verbatim as the hook command, so the agent split it at the space. A profile - // the bare path cannot carry keeps the encoded launcher. - it - .skipIf(process.platform !== 'win32') - .each(['orca gemini home with spaces', 'orca-gemini-hömé'])( - 'keeps the encoded launcher for a profile the bare path cannot carry (%s)', - (homeName) => { - const spaceHome = join(tmpdir(), homeName) - mkdirSync(spaceHome, { recursive: true }) - homedirMock.mockReturnValue(spaceHome) - try { - expect(new GeminiHookService().install().state).toBe('installed') - - const config = JSON.parse( - readFileSync(join(spaceHome, '.gemini', 'settings.json'), 'utf8') - ) as { hooks: Record } - - for (const eventName of ['BeforeAgent', 'AfterAgent', 'AfterTool']) { - const command = config.hooks[eventName]?.[0]?.hooks?.[0]?.command - expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER) - } - } finally { - rmSync(spaceHome, { recursive: true, force: true }) - homedirMock.mockReturnValue(homeDir) - } - } - ) + }) it('preserves user-authored PreToolUse hooks while sweeping stale managed Gemini hooks', () => { const managedHookFileName = process.platform === 'win32' ? 'gemini-hook.cmd' : 'gemini-hook.sh' @@ -225,7 +171,7 @@ describe('GeminiHookService', () => { expect(preToolCommands).toEqual(['echo user-authored']) expect(config.hooks.BeforeTool[0].hooks[0].command).toMatch( process.platform === 'win32' - ? join(homeDir, '.orca', 'agent-hooks', managedHookFileName).replaceAll('\\', '/') + ? wrapWindowsBatchHookCommand(join(homeDir, '.orca', 'agent-hooks', managedHookFileName)) : new RegExp(managedHookFileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) ) }) diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index da43be48b4dc..df640661a66a 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -11,12 +11,11 @@ import { readHooksJson, removeManagedCommands, wrapPosixHookCommand, - wrapWindowsHookCommand, writeHooksJson, writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' -import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' +import { wrapWindowsBatchHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, @@ -48,7 +47,7 @@ function getManagedScriptPath(): string { function getManagedCommand(scriptPath: string): string { return process.platform === 'win32' - ? (wrapWindowsDirectCmdHookCommand(scriptPath) ?? wrapWindowsHookCommand(scriptPath)) + ? wrapWindowsBatchHookCommand(scriptPath) : wrapPosixHookCommand(scriptPath) } diff --git a/src/main/kiro/hook-service.test.ts b/src/main/kiro/hook-service.test.ts index 5205881a54e1..5af123a0e09b 100644 --- a/src/main/kiro/hook-service.test.ts +++ b/src/main/kiro/hook-service.test.ts @@ -1,19 +1,13 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem.test-fixture' -import { - isPlainObject, - WINDOWS_CMD_SAFE_PATH, - wrapWindowsHookCommand -} from '../agent-hooks/installer-utils' +import { isPlainObject } from '../agent-hooks/installer-utils' +import { wrapWindowsBatchHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' import { KiroHookService } from './hook-service' import { getKiroManagedCommandMatcher, KIRO_HOOK_EVENTS } from './hook-settings' -const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ - // Why both names: os.homedir() reads $HOME on POSIX and %USERPROFILE% on Windows; setting // only one would let a run edit the developer's real ~/.kiro or ~/.orca. let home: string @@ -241,66 +235,24 @@ describe('KiroHookService', () => { expect(readAgent('kept').hooks).toBeDefined() }) - // Why: Kiro already runs hooks in a shell; an encoded PowerShell in between cost ~0.7s per event. - it.skipIf(process.platform !== 'win32')( - 'registers the managed .cmd path directly on a cmd-safe profile', - () => { - expect(WINDOWS_CMD_SAFE_PATH.test(scriptPath()), 'test home must be cmd-safe').toBe(true) + // Why: runs on every OS; windows-hook-payload-delivery.test.ts runs each form under Kiro's shells. + it('registers the Windows batch hook command for every event', () => { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + try { writeAgent('mine', { name: 'mine' }) expect(new KiroHookService().install().state).toBe('installed') - const hooks = readAgent('mine').hooks if (!isPlainObject(hooks)) { throw new Error('Expected hooks object') } + const command = wrapWindowsBatchHookCommand(scriptPath()) for (const event of KIRO_HOOK_EVENTS) { - expect(hooks[event]).toEqual([ - expect.objectContaining({ command: scriptPath().replaceAll('\\', '/') }) - ]) - } - } - ) - - it.skipIf(process.platform !== 'win32')( - 'rewrites a stale encoded launcher registration to the direct form', - () => { - writeAgent('mine', { - name: 'mine', - hooks: { stop: [USER_STOP_HOOK, { command: wrapWindowsHookCommand(scriptPath()) }] } - }) - expect(new KiroHookService().install().state).toBe('installed') - - const hooks = readAgent('mine').hooks - if (!isPlainObject(hooks)) { - throw new Error('Expected hooks object') + expect(hooks[event]).toEqual([expect.objectContaining({ command })]) } - expect(hooks.stop).toEqual([ - USER_STOP_HOOK, - expect.objectContaining({ command: scriptPath().replaceAll('\\', '/') }) - ]) + } finally { + platform.mockRestore() } - ) - - it.skipIf(process.platform !== 'win32').each(['profile with spaces', 'profile-hömé'])( - 'keeps the encoded launcher for a profile the bare path cannot carry (%s)', - (profileName) => { - const profile = join(home, profileName) - const agentFile = join(profile, '.kiro', 'agents', 'mine.json') - mkdirSync(join(profile, '.kiro', 'agents'), { recursive: true }) - writeFileSync(agentFile, JSON.stringify({ name: 'mine' })) - process.env.USERPROFILE = profile - process.env.HOME = profile - expect(new KiroHookService().install().state).toBe('installed') - - const hooks = JSON.parse(readFileSync(agentFile, 'utf-8')).hooks - if (!isPlainObject(hooks)) { - throw new Error('Expected hooks object') - } - expect(hooks.stop).toEqual([ - expect.objectContaining({ command: expect.stringMatching(WINDOWS_POWERSHELL_LAUNCHER) }) - ]) - } - ) + }) it('ignores non-JSON files such as the shipped example config', () => { mkdirSync(agentsDir(), { recursive: true }) diff --git a/src/main/kiro/hook-settings.ts b/src/main/kiro/hook-settings.ts index a0f1404fc8d3..da2e8906bd9d 100644 --- a/src/main/kiro/hook-settings.ts +++ b/src/main/kiro/hook-settings.ts @@ -8,10 +8,9 @@ import { MANAGED_HOOK_TIMEOUT_MILLISECONDS, removeManagedCommands, wrapPosixHookCommand, - wrapWindowsHookCommand, type HookDefinition } from '../agent-hooks/installer-utils' -import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' +import { wrapWindowsBatchHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' const KIRO_SCRIPT_BASE = 'kiro-hook' @@ -69,7 +68,7 @@ export function getKiroManagedScriptPath(): string { export function getKiroManagedCommand(scriptPath: string): string { // Why: Kiro runs hook commands through PowerShell on Windows and sh elsewhere. return process.platform === 'win32' - ? (wrapWindowsDirectCmdHookCommand(scriptPath) ?? wrapWindowsHookCommand(scriptPath)) + ? wrapWindowsBatchHookCommand(scriptPath) : wrapPosixHookCommand(scriptPath) } diff --git a/src/main/muse/hook-settings.test.ts b/src/main/muse/hook-settings.test.ts index 157a5ba28d9f..40066423462c 100644 --- a/src/main/muse/hook-settings.test.ts +++ b/src/main/muse/hook-settings.test.ts @@ -1,5 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { wrapWindowsHookCommand } from '../agent-hooks/windows-hook-command' +import { + isSafeUnicodeWindowsBatchHookPath, + wrapWindowsHookCommand +} from '../agent-hooks/windows-hook-command' import { wrapPosixHookCommand } from '../agent-hooks/posix-hook-command' import { getMuseManagedCommand, getMuseRemoteManagedCommand } from './hook-settings' @@ -22,7 +25,9 @@ describe('Muse hook commands', () => { '\\\\server\\share\\.orca\\agent-hooks\\muse-hook.cmd' ])('retains the established quoting fallback for %s', (path) => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') - expect(getMuseManagedCommand(path)).toBe(wrapWindowsHookCommand(path)) + // Guarded Unicode .cmd paths take the cheaper policy switch (#26382). + const policy = { useProcessPolicyEnvironment: isSafeUnicodeWindowsBatchHookPath(path) } + expect(getMuseManagedCommand(path)).toBe(wrapWindowsHookCommand(path, {}, policy)) }) it.each(['darwin', 'linux'] as const)('keeps local %s commands POSIX', (platform) => { diff --git a/src/main/muse/hook-settings.ts b/src/main/muse/hook-settings.ts index 98d208bd9350..392b5f048ff8 100644 --- a/src/main/muse/hook-settings.ts +++ b/src/main/muse/hook-settings.ts @@ -6,11 +6,10 @@ import { createManagedCommandMatcher, getSharedManagedScriptPath, wrapPosixHookCommand, - wrapWindowsHookCommand, type HookDefinition } from '../agent-hooks/installer-utils' import { readManagedHookEventsFromJson } from '../agent-hooks/managed-hooks-json-events' -import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' +import { wrapWindowsBatchHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { parseMuseSettingsText } from './hook-config-json' @@ -68,7 +67,7 @@ export function getMuseRemoteManagedHooksPath(remoteHome: string): string { export function getMuseManagedCommand(scriptPath: string): string { return process.platform === 'win32' - ? (wrapWindowsDirectCmdHookCommand(scriptPath) ?? wrapWindowsHookCommand(scriptPath)) + ? wrapWindowsBatchHookCommand(scriptPath) : wrapPosixHookCommand(scriptPath) } From afda99d5f9bb57709a59737494e70dcc67e1a9d4 Mon Sep 17 00:00:00 2001 From: Orca Worker Date: Mon, 12 Oct 2026 11:39:54 -0700 Subject: [PATCH 6/6] 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. --- .github/workflows/pr.yml | 1 - config/scripts/pr-code-change-scope.mjs | 1 - src/main/agent-hooks/installer-utils.test.ts | 31 ---- src/main/agent-hooks/installer-utils.ts | 1 - .../managed-hook-command-contract.test.ts | 9 +- .../windows-batch-hook-launcher.test.ts | 11 +- .../windows-cmd-hook-command-unicode.test.ts | 15 +- ...indows-direct-cmd-agent-hook-hosts.test.ts | 175 ------------------ .../windows-direct-cmd-hook-command.test.ts | 2 +- .../windows-direct-cmd-hook-command.ts | 2 +- src/main/agent-hooks/windows-hook-command.ts | 15 -- .../windows-hook-payload-delivery.test.ts | 55 +++++- src/main/antigravity/hook-service.test.ts | 32 ++-- src/main/antigravity/hook-service.ts | 7 +- src/main/codex/windows-hook-command.test.ts | 8 +- src/main/devin/hook-service.test.ts | 4 + src/main/devin/hook-settings.ts | 8 +- src/main/grok/grok-hook-script.ts | 13 +- .../grok/windows-hook-launcher-chain.test.ts | 8 +- src/main/zcode/hook-settings.ts | 7 +- 20 files changed, 107 insertions(+), 298 deletions(-) delete mode 100644 src/main/agent-hooks/windows-direct-cmd-agent-hook-hosts.test.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 602f66a76ff5..ba11ca658fd7 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1268,7 +1268,6 @@ jobs: src/main/agent-hooks/windows-hook-payload-delivery.test.ts src/main/jcode/hook-gate-script.test.ts src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts - src/main/agent-hooks/windows-direct-cmd-agent-hook-hosts.test.ts src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts src/main/agent-hooks/windows-batch-hook-launcher.test.ts src/main/agent-hooks/windows-powershell-hook-launcher.test.ts diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 8720f91f3b76..149c49a2d11c 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -386,7 +386,6 @@ const WINDOWS_PACKAGE_TESTS = [ 'src/main/agent-hooks/windows-hook-payload-delivery.test.ts', 'src/main/jcode/hook-gate-script.test.ts', 'src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts', - 'src/main/agent-hooks/windows-direct-cmd-agent-hook-hosts.test.ts', 'src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts', 'src/main/agent-hooks/windows-batch-hook-launcher.test.ts', 'src/main/agent-hooks/windows-powershell-hook-launcher.test.ts', diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 36044633ec1a..c3ae247b410d 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 244fb59fe25b..41454024e0fb 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 7e22508c10f7..3f28cad0e240 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 { @@ -153,7 +150,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 1d3ecb094ff2..6f171b4c3ba3 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 7d5fbea37950..77d4267d3c35 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,11 +10,13 @@ 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' @@ -41,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') @@ -73,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() }) }) @@ -155,7 +156,7 @@ describe.skipIf( const scriptPath = join(home, '.orca', 'agent-hooks', event.windowsWrapperFileName) const directCommand = wrapWindowsDirectCmdHookCommand(scriptPath) // Why: an ASCII profile registers the forward-slash path Git Bash can start too. - expect(command).toBe(directCommand ?? wrapWindowsCmdHookCommand(scriptPath)) + expect(command).toBe(wrapWindowsBatchHookCommand(scriptPath)) if (!WINDOWS_CMD_SAFE_PATH.test(scriptPath)) { expect(decodeCommand(command)).not.toContain('Set-ExecutionPolicy') } diff --git a/src/main/agent-hooks/windows-direct-cmd-agent-hook-hosts.test.ts b/src/main/agent-hooks/windows-direct-cmd-agent-hook-hosts.test.ts deleted file mode 100644 index 21713a3ed500..000000000000 --- a/src/main/agent-hooks/windows-direct-cmd-agent-hook-hosts.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -// Why: Grok, Devin, ZCode and Antigravity may run hook commands through Git Bash, which strips the -// backslashes out of a bare `C:\...\x.cmd`. Run each registered command through every host. -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '@orca/process-host' -import { createServer, type Server } from 'node:http' -import { mkdtempSync, readFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import type * as osModule from 'node:os' -import { removeTreeSync } from '../../shared/windows-transient-lock-removal' -import { WINDOWS_CMD_SAFE_PATH } from './installer-utils' -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('os', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, homedir: homedirMock.mockImplementation(actual.homedir) } -}) -vi.mock('electron', () => ({ app: { getPath: () => tmpdir() } })) - -import { AntigravityHookService } from '../antigravity/hook-service' -import { DevinHookService } from '../devin/hook-service' -import { getDevinConfigPath } from '../devin/hook-settings' -import { GrokHookService } from '../grok/hook-service' -import { zcodeHookService } from '../zcode/hook-service' -import { getZCodeConfigPath } from '../zcode/hook-settings' - -type StopEntry = { command?: string; hooks?: { command?: string }[] } - -function stopCommand(entries: StopEntry[] | undefined): string { - const command = entries?.at(-1)?.hooks?.[0]?.command ?? entries?.at(-1)?.command - expect(command, 'registered Stop command').toBeDefined() - return command! -} - -type RegisteredConfig = { - hooks?: { Stop?: StopEntry[]; events?: { Stop?: StopEntry[] } } - 'orca-status'?: { Stop?: StopEntry[] } -} - -// Why no assertion: `JSON.parse` is already `any`, so the annotation narrows without a cast. -function readJson(path: string): RegisteredConfig { - return JSON.parse(readFileSync(path, 'utf8')) -} - -const AGENTS: [string, (home: string) => string][] = [ - [ - 'grok', - (home) => { - expect(new GrokHookService().install().state).toBe('installed') - const config = readJson(join(home, '.grok', 'hooks', 'orca-status.json')) - return stopCommand(config.hooks?.Stop) - } - ], - [ - 'devin', - () => { - expect(new DevinHookService().install().state).toBe('installed') - const config = readJson(getDevinConfigPath()) - return stopCommand(config.hooks?.Stop) - } - ], - [ - 'zcode', - () => { - expect(zcodeHookService.install().state).toBe('installed') - const config = readJson(getZCodeConfigPath()) - return stopCommand(config.hooks?.events?.Stop) - } - ], - [ - 'antigravity', - (home) => { - const service = new AntigravityHookService() - service.setWindowsRuntimePathProvider(() => process.execPath) - expect(service.install().state).toBe('installed') - const config = readJson(join(home, '.gemini', 'config', 'hooks.json')) - return stopCommand(config['orca-status']?.Stop) - } - ] -] - -async function startListener(): Promise<{ server: Server; port: number; paths: string[] }> { - const paths: string[] = [] - const server = createServer((req, res) => { - req.resume() - req.on('end', () => { - paths.push(req.url ?? '') - res.writeHead(200, { 'content-type': 'application/json' }) - res.end('{}') - }) - }) - const port = await new Promise((resolve) => { - server.listen(0, '127.0.0.1', () => { - const address = server.address() - resolve(typeof address === 'object' && address ? address.port : 0) - }) - }) - return { server, port, paths } -} - -// Why: a runner whose TEMP is not cmd-safe registers the encoded launcher, not this shape. -const tempIsCmdSafe = WINDOWS_CMD_SAFE_PATH.test(join(tmpdir(), 'orca-direct-agent-hook-x')) - -describe.skipIf(process.platform !== 'win32')('direct agent hook commands in Windows hosts', () => { - let home = '' - let listener: Awaited> | null = null - - beforeEach(async () => { - home = mkdtempSync(join(tmpdir(), 'orca-direct-agent-hook-')) - homedirMock.mockReturnValue(home) - vi.stubEnv('APPDATA', join(home, 'AppData', 'Roaming')) - vi.stubEnv('GROK_HOME', '') - listener = await startListener() - }) - - afterEach(() => { - listener?.server.close() - listener = null - vi.unstubAllEnvs() - removeTreeSync(home) - }) - - async function run(file: string, args: string[]) { - const env = Object.fromEntries( - Object.entries(process.env).filter(([key]) => !key.startsWith('ORCA_')) - ) - const result = await runProcess({ - program: file, - args, - cwd: home, - env: { - ...env, - ORCA_BACKGROUND_LAUNCH: '1', - ORCA_AGENT_HOOK_PORT: String(listener!.port), - ORCA_AGENT_HOOK_TOKEN: 'direct-agent-hook-token', - ORCA_PANE_KEY: 'tab-1:leaf-1' - }, - input: '{"hook_event_name":"Stop"}', - timeoutMs: 15_000 - }) - expect(result.timedOut, result.stderr).toBe(false) - // Why: curl returns once the POST is written; let the listener finish reading it. - await new Promise((resolve) => setTimeout(resolve, 250)) - return result - } - - it.skipIf(!tempIsCmdSafe).each(AGENTS)( - '%s posts from cmd, PowerShell and Git Bash', - async (agent, install) => { - const command = install(home) - expect(command).toMatch(/^[A-Za-z]:\/[^\\\s]+\.cmd$/) - // Proves the `os` mock reached the agents' `node:os` imports (Vitest normalizes builtins). - expect(command.startsWith(`${home.replaceAll('\\', '/')}/.orca/agent-hooks/`)).toBe(true) - - const hosts: [string, string[]][] = [ - ['cmd.exe', ['/d', '/c', command]], - [getWindowsPowerShellExecutablePath(), ['-NoProfile', '-Command', command]], - [findGitBash(), ['-c', command]] - ] - for (const [file, args] of hosts) { - const before = listener!.paths.length - const result = await run(file, args) - expect(result.code, `${file}: ${result.stderr}`).toBe(0) - expect(listener!.paths.slice(before), file).toEqual([`/hook/${agent}`]) - } - - // The legacy backslash entry is what Git Bash could not start. - const legacy = await run(findGitBash(), ['-c', command.replaceAll('/', '\\')]) - expect(legacy.code).toBe(127) - } - ) -}) 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 9b6726a43777..647663658732 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 dce62ec40a89..ebb3560a47c4 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 42d435fd7000..287e37eec294 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 47eb53e26e4b..e23b4db2e8ff 100644 --- a/src/main/agent-hooks/windows-hook-payload-delivery.test.ts +++ b/src/main/agent-hooks/windows-hook-payload-delivery.test.ts @@ -35,11 +35,14 @@ vi.mock('os', async (importOriginal) => { import { ClaudeHookService } from '../claude/hook-service' import { CommandCodeHookService } from '../command-code/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 { 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' @@ -158,6 +161,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, @@ -254,6 +262,50 @@ const PROVIDERS: Provider[] = [ { name: 'mine' } ), runners: [POWERSHELL_PLAIN, CMD_NODE_SHELL] + }, + { + // 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] } ] @@ -317,7 +369,8 @@ describe.skipIf(process.platform !== 'win32')('Windows managed hook payload deli for (const name of ['USERPROFILE', 'HOME']) { vi.stubEnv(name, home) } - for (const name of ['XDG_CONFIG_HOME', 'KIRO_HOME', 'DSH_HOME']) { + vi.stubEnv('APPDATA', join(home, 'AppData', 'Roaming')) + for (const name of ['XDG_CONFIG_HOME', 'KIRO_HOME', 'DSH_HOME', 'GROK_HOME']) { vi.stubEnv(name, undefined) } seedCmdAutoRunTarget(home) diff --git a/src/main/antigravity/hook-service.test.ts b/src/main/antigravity/hook-service.test.ts index c0ade6538952..ae46f34b3634 100644 --- a/src/main/antigravity/hook-service.test.ts +++ b/src/main/antigravity/hook-service.test.ts @@ -417,14 +417,12 @@ describe('AntigravityHookService', () => { ) }) - // win32-only: a cmd-safe managed path needs a real backslash-joined home. - it.skipIf(process.platform !== 'win32')( - 'registers forward-slash wrapper paths and replaces legacy backslash entries', - () => { + it('replaces legacy backslash Windows entries and keeps user hooks', () => { + withPlatform('win32', () => { const configPath = join(homeDir, '.gemini', 'config', 'hooks.json') - const hooksDir = join(homeDir, '.orca', 'agent-hooks') - const legacyStop = join(hooksDir, 'antigravity-stop.cmd') - const legacyPreToolUse = join(hooksDir, 'antigravity-pre-tool-use.cmd') + // 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( @@ -444,14 +442,14 @@ describe('AntigravityHookService', () => { const config = JSON.parse(readFileSync(configPath, 'utf8')) as { 'orca-status': Record } - // Why: Git Bash strips backslashes from a bare path; forward slashes survive every host. - expect(config['orca-status'].Stop).toEqual([ - userHook, - expect.objectContaining({ command: legacyStop.replaceAll('\\', '/') }) - ]) - expect(config['orca-status'].PreToolUse.flatMap((d) => d.hooks ?? [])).toEqual([ - expect.objectContaining({ command: legacyPreToolUse.replaceAll('\\', '/') }) - ]) - } - ) + 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 abf8ca4f13a2..071ca59bf484 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,7 +15,7 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' -import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' +import { wrapWindowsBatchHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' import { WINDOWS_ANTIGRAVITY_JSON_POST_SCRIPT } from './windows-hook-json-post' import { restoreManagedScript, @@ -70,9 +69,7 @@ function getPosixManagedCommand(scriptPath: string, event: AntigravityEvent): st function getManagedCommand(scriptPath: string, event: AntigravityEvent): string { if (process.platform === 'win32') { - const wrapperPath = getWindowsWrapperScriptPath(event) - // Forward slashes keep the path intact if Antigravity's host shell is Git Bash. - return wrapWindowsDirectCmdHookCommand(wrapperPath) ?? wrapWindowsCmdHookCommand(wrapperPath) + 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 11fb094bff25..f28bc0db7a77 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 0c048086d098..9367868d4ba2 100644 --- a/src/main/devin/hook-service.test.ts +++ b/src/main/devin/hook-service.test.ts @@ -185,6 +185,10 @@ describe('DevinHookService', () => { 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( { diff --git a/src/main/devin/hook-settings.ts b/src/main/devin/hook-settings.ts index 2609552c11c2..144720299614 100644 --- a/src/main/devin/hook-settings.ts +++ b/src/main/devin/hook-settings.ts @@ -6,11 +6,10 @@ import { getSharedManagedScriptPath, removeManagedCommands, wrapPosixHookCommand, - wrapWindowsCmdHookCommand, type HookDefinition, type HooksConfig } from '../agent-hooks/installer-utils' -import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' +import { wrapWindowsBatchHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' const DEVIN_SCRIPT_BASE = 'devin-hook' @@ -53,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; forward slashes survive a Git Bash host, and the - // encoded fallback protects spaced paths and drains stdin for a missing script. - return wrapWindowsDirectCmdHookCommand(scriptPath) ?? 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 fb4ca18c6da5..2efb0ca006b0 100644 --- a/src/main/grok/grok-hook-script.ts +++ b/src/main/grok/grok-hook-script.ts @@ -1,9 +1,5 @@ -import { - getSharedManagedScriptPath, - wrapPosixHookCommand, - wrapWindowsCmdHookCommand -} from '../agent-hooks/installer-utils' -import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' +import { getSharedManagedScriptPath, wrapPosixHookCommand } from '../agent-hooks/installer-utils' +import { wrapWindowsBatchHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' import { buildPosixHookPayloadCapture, buildPosixHookSpoolLines, @@ -23,10 +19,9 @@ export function getGrokManagedScriptPath(): string { } export function getGrokManagedCommand(scriptPath: string): string { - // A cmd-safe bare path avoids two extra Windows interpreters, and forward slashes survive Grok's - // Git Bash host; POSIX can gate before spawning because Orca injects ORCA_PANE_KEY only in its panes. + // POSIX can gate before spawning because Orca injects ORCA_PANE_KEY only into panes it owns. return process.platform === 'win32' - ? (wrapWindowsDirectCmdHookCommand(scriptPath) ?? 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 c7fc7a3d5f99..70c9b0c78fae 100644 --- a/src/main/grok/windows-hook-launcher-chain.test.ts +++ b/src/main/grok/windows-hook-launcher-chain.test.ts @@ -99,6 +99,7 @@ describe('Windows Grok managed hook launch shape', () => { 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: { @@ -155,11 +156,10 @@ describe('Windows Grok managed hook launch shape', () => { ) it.skipIf(process.platform !== 'win32')( - 'replaces previously installed encoded-PowerShell and backslash entries on reinstall', + 'replaces a previously installed encoded-PowerShell entry on reinstall', () => { const scriptPath = join(home, '.orca', 'agent-hooks', 'grok-hook.cmd') const staleCommand = wrapWindowsHookCommand(scriptPath) - const legacyBackslashCommand = scriptPath const configPath = join(home, '.grok', 'hooks', 'orca-status.json') mkdirSync(dirname(configPath), { recursive: true }) writeFileSync( @@ -167,9 +167,6 @@ describe('Windows Grok managed hook launch shape', () => { `${JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: staleCommand, timeout: 10 }] }], - PreToolUse: [ - { hooks: [{ type: 'command', command: legacyBackslashCommand, timeout: 10 }] } - ], // Why: a retired event must be swept too, or the old launcher keeps firing there. SubagentStop: [{ hooks: [{ type: 'command', command: staleCommand, timeout: 10 }] }], Notification: [{ hooks: [{ type: 'command', command: '/usr/local/bin/user-hook' }] }] @@ -187,7 +184,6 @@ describe('Windows Grok managed hook launch shape', () => { definitions.flatMap((definition) => definition.hooks.map((hook) => hook.command)) ) expect(all).not.toContain(staleCommand) - expect(all).not.toContain(legacyBackslashCommand) expect(all.filter((command) => /-EncodedCommand/i.test(command))).toEqual([]) expect(config.hooks.SubagentStop).toBeUndefined() // Why: sweeping stale Orca entries must not touch hooks the user wrote. diff --git a/src/main/zcode/hook-settings.ts b/src/main/zcode/hook-settings.ts index dbe8a08def1d..1ce73baa1bf3 100644 --- a/src/main/zcode/hook-settings.ts +++ b/src/main/zcode/hook-settings.ts @@ -8,10 +8,9 @@ import { isPlainObject, removeManagedCommands, wrapPosixHookCommand, - wrapWindowsCmdHookCommand, type HookDefinition } from '../agent-hooks/installer-utils' -import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' +import { wrapWindowsBatchHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' const ZCODE_SCRIPT_BASE = 'zcode-hook' @@ -73,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, which may be - // Git Bash; a forward-slash .cmd survives it, else fall back to the encoded form. - return wrapWindowsDirectCmdHookCommand(scriptPath) ?? wrapWindowsCmdHookCommand(scriptPath) + return wrapWindowsBatchHookCommand(scriptPath) } return wrapPosixHookCommand(scriptPath) }