diff --git a/docs/reference/agent-hook-stdin-lifecycle.md b/docs/reference/agent-hook-stdin-lifecycle.md index 9f875b3d06c..98b404c97ac 100644 --- a/docs/reference/agent-hook-stdin-lifecycle.md +++ b/docs/reference/agent-hook-stdin-lifecycle.md @@ -112,8 +112,14 @@ path. This mirrors POSIX ownership without starting an additional process. - Encoded PowerShell launchers use `Test-Path -PathType Leaf`. A missing script calls `[Console]::In.ReadToEnd()` and exits zero; an existing script preserves `$LASTEXITCODE`. -- Codex's cmd.exe fast path remains PowerShell-free. It rejects missing paths - and directories, using the same qualified `more.com` drain for both. +- Codex's (and Antigravity's/Devin's) cmd fast path is the bare, directly + spawnable `.cmd` path. These agents launch the hook command as a program + (argv[0]), not through cmd.exe, so the launcher cannot lead with a cmd builtin + such as `if` — that argv[0] is unspawnable and fails every hook. A missing + script therefore surfaces a normal launch failure on this fast path; only + launchers that already require a real interpreter (encoded PowerShell, Git + Bash) drain a missing script. Paths cmd.exe cannot invoke verbatim fall back to + the encoded PowerShell launcher, which owns stdin for the missing case. - Claude's Git Bash fast path uses a POSIX file guard and drain while continuing to execute the `.cmd` directly rather than interpreting it as shell source. - Agent-specific direct launchers, including Antigravity event wrappers and diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 66d4134f004..6883308be6f 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -454,27 +454,32 @@ describe('wrapWindowsHookCommand', () => { }) describe('wrapWindowsCmdHookCommand', () => { - it('keeps the safe-path launcher PowerShell-free and drains non-file paths', () => { + it('returns the bare, directly-spawnable path for a cmd-safe managed script', () => { + // Why: Codex/Antigravity/Devin launch the command as a program (argv[0]), + // not via cmd.exe, so the launcher must be a single spawnable token — a bare + // .cmd path. A cmd-builtin `if …` launcher has argv[0] = `if`, which is + // unspawnable and fails every hook with exit 1 (#8430 regression). const scriptPath = 'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd' const command = wrapWindowsCmdHookCommand(scriptPath) - expect(command).toContain(`if exist "${scriptPath}\\."`) - expect(command).toContain(`if exist "${scriptPath}" (call "${scriptPath}")`) - expect(command).toContain('"%SystemRoot%\\System32\\more.com" >nul 2>nul') + expect(command).toBe(scriptPath) + expect(command).not.toMatch(/^if\b/) expect(command).not.toMatch(/powershell/i) }) it.skipIf(process.platform !== 'win32')( - 'drains stdin when a directory occupies the managed script path', + 'resolves the launcher to a real executable file, not a shell fragment', () => { - const scriptPath = 'directory-hook.cmd' - mkdirSync(join(tmpDir, scriptPath)) - const result = spawnSync('cmd.exe', ['/d', '/c', wrapWindowsCmdHookCommand(scriptPath)], { - cwd: tmpDir, - input: Buffer.alloc(1_000_000, 'x') - }) - - expect(result.error).toBeUndefined() - expect(result.status).toBe(0) + // Regression guard for #8430: Codex/Antigravity/Devin spawn the launcher as + // a program (argv[0]), so it must be an existing, launchable file. The broken + // `if exist … (call …)` form had argv[0] = `if` — a cmd builtin, not a file — + // which is unspawnable and failed every hook. The bare path is the file. + // win32-only: the real temp path is cmd-safe only with backslashes; a POSIX + // tmpDir has `/`, which routes to the encoded fallback by design. + 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) } ) diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index 6b9e93edad5..893cb53d465 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -13,10 +13,7 @@ import { dirname, join } from 'node:path' import { randomUUID } from 'node:crypto' import type { AgentHookSource } from '../../shared/agent-hook-relay' import { grantDirAcl, isPermissionError } from '../win32-utils' -import { - POSIX_HOOK_STDIN_DRAIN_COMMAND, - WINDOWS_HOOK_STDIN_DRAIN_COMMAND -} from './hook-stdin-contract' +import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract' export type HookCommandConfig = { type: 'command' @@ -179,12 +176,15 @@ export function wrapWindowsHookCommand( export const WINDOWS_CMD_SAFE_PATH = /^[A-Za-z0-9_.:\\~-]+$/ export function wrapWindowsCmdHookCommand(scriptPath: string): string { - // Why: keep the safe-path fast path PowerShell-free while making a stale - // config entry own stdin; the `path\.` sentinel also rejects a directory - // that happens to occupy the managed .cmd path, including an empty one. - return WINDOWS_CMD_SAFE_PATH.test(scriptPath) - ? `if exist "${scriptPath}\\." (${WINDOWS_HOOK_STDIN_DRAIN_COMMAND}) else if exist "${scriptPath}" (call "${scriptPath}") else (${WINDOWS_HOOK_STDIN_DRAIN_COMMAND})` - : wrapWindowsHookCommand(scriptPath) + // Why: Codex/Antigravity/Devin launch the hook command as a program (argv[0]), + // NOT through cmd.exe, so the launcher must be a single directly-spawnable + // token. The bare .cmd path is exactly that. A cmd-builtin `if exist …` + // launcher is unspawnable — its argv[0] is `if` — so it fails every hook with + // exit 1 (#8430 regression). A stale/missing script therefore surfaces a normal + // launch failure here; the missing-script stdin drain lives on the encoded + // fallback below, which needs a real interpreter for spaces/metacharacters + // anyway and cannot be reached by a cmd-builtin drain without breaking spawn. + return WINDOWS_CMD_SAFE_PATH.test(scriptPath) ? scriptPath : wrapWindowsHookCommand(scriptPath) } export const WINDOWS_GIT_BASH_SAFE_PATH = /^[A-Za-z0-9_.:/~-]+$/ diff --git a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts index 6a627227f7a..39639a36b49 100644 --- a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts +++ b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts @@ -40,7 +40,6 @@ import { KimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' import { wrapPosixHookCommand, - wrapWindowsCmdHookCommand, wrapWindowsGitBashHookCommand, wrapWindowsHookCommand } from './installer-utils' @@ -292,17 +291,19 @@ describe('Windows managed hook stdin structure', () => { } const missingScript = 'C:\\missing\\orca-hook.cmd' + // Why: the cmd fast path is intentionally a bare, directly-spawnable .cmd + // path (Codex/Antigravity/Devin launch it as argv[0], not via cmd.exe), so + // it cannot own stdin for a missing script — a cmd-builtin drain would make + // argv[0] unspawnable and fail every hook (#8430 regression). Only launchers + // that already require a real interpreter (encoded PowerShell, Git Bash) + // drain a missing script; the bare path's missing-script behavior is a + // normal launch failure, covered in installer-utils.test.ts. const launcherCases = [ { name: 'encoded PowerShell', executable: 'cmd.exe', args: ['/d', '/c', wrapWindowsHookCommand(missingScript)] }, - { - name: 'cmd fast path', - executable: 'cmd.exe', - args: ['/d', '/c', wrapWindowsCmdHookCommand(missingScript)] - }, { name: 'Git Bash fast path', executable: process.env.KIMI_SHELL_PATH || 'bash.exe', diff --git a/src/main/devin/hook-settings.ts b/src/main/devin/hook-settings.ts index 674ddb6c233..fe850680b05 100644 --- a/src/main/devin/hook-settings.ts +++ b/src/main/devin/hook-settings.ts @@ -52,8 +52,9 @@ export function getDevinRemoteConfigPath(remoteHome: string): string { export function getDevinManagedCommand(scriptPath: string): string { if (process.platform === 'win32') { - // Why: keep safe paths on cmd.exe's fast path; the fallback protects spaced - // paths, and both forms drain stdin for a stale missing-script entry. + // Why: Devin spawns this command as argv[0], so the safe path stays a bare + // directly-spawnable .cmd; the encoded fallback protects spaced paths and + // drains stdin for a stale missing-script entry. return wrapWindowsCmdHookCommand(scriptPath) } return wrapPosixHookCommand(scriptPath)