fix(agent-hooks): make Windows cmd hook launcher directly spawnable (#8430 regression) (#8737)

Codex, Antigravity, and Devin launch their agent-hook `command` as a program
(argv[0]), not through cmd.exe. PR #8430 changed wrapWindowsCmdHookCommand to
emit an `if exist "path\." (drain) else if exist "path" (call "path") else
(drain)` compound whose argv[0] is the cmd builtin `if` — unspawnable — so every
Codex/Antigravity/Devin hook (SessionStart, UserPromptSubmit, Stop, ...) failed
with "hook exited with code 1" on Windows starting in v1.4.138.

Revert the cmd-safe fast path to the bare, directly-spawnable .cmd path (the
proven pre-#8430 form). A cmd-builtin drain and direct-spawnability are mutually
exclusive, and nesting cmd.exe /d /c breaks large-payload draining; the
missing-script stdin drain stays on the encoded-PowerShell fallback (used for
spaced/non-ASCII paths). Upgrades self-heal on first launch: startup install()
unconditionally rewrites the command and Codex trust entry, sweeping the old
compound form.

Add a platform-independent regression guard (launcher must resolve to a real
file, never a cmd-builtin fragment), update the lifecycle test + docs, and fix a
stale Devin comment.
This commit is contained in:
OrcaWin
2026-07-14 05:41:58 -07:00
parent cb41bf1bbd
commit ee709b02ed
5 changed files with 47 additions and 34 deletions
+8 -2
View File
@@ -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
+19 -14
View File
@@ -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)
}
)
+10 -10
View File
@@ -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_.:/~-]+$/
@@ -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',
+3 -2
View File
@@ -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)