mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user