Files
orca/src/main/agent-hooks/runtime-home-hook-command.ts
T
Neil 15adbd9d18 fix(agent-hooks): guard every Windows missing-target fallback before it reads stdin (#19415)
A Windows hook whose target file is missing fell back to reading stdin and throwing it away. That read never returns when the caller abandons the pipe, which is what happens outside an Orca pane — one stuck process and a visible console per hook event (#11549).

The rule 'check the Orca env before you own stdin' existed once in cmd syntax and was retyped by hand elsewhere, so the PowerShell and Git Bash launchers never got it. Derive all three dialects from one list of vars and apply them wherever a missing target makes the caller the stdin owner.

- wrapWindowsHookCommand and the runtime-home PowerShell branch guard before ReadToEnd, and emit the fallback answer before the guard so a gate event outside a pane is not answered with silence.
- The runtime-home Git Bash fallback picks its rule by platform: POSIX keeps capture-first (#8110), Windows answers, guards, then drains.
- The Antigravity wrapper disables delayed expansion like its core; with a '!' in the hooks path it was missing the core on every event (#9358/#9941).

Tests drive the wrapper through the production 'cmd /d /c' chain under both delayed-expansion states, and the cross-agent ratchet covers the launchers with an abandoned pipe rather than requiring the unguarded drain.
2026-09-07 23:35:26 -07:00

53 lines
3.8 KiB
TypeScript

import {
POSIX_HOOK_STDIN_DRAIN_COMMAND,
WINDOWS_GIT_BASH_HOOK_ENVIRONMENT_GUARD,
WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD
} from './hook-stdin-contract'
import {
encodeWindowsPowerShellHookCommand,
WINDOWS_POWERSHELL_HOOK_SWITCHES
} from './windows-powershell-hook-launcher'
const MANAGED_SCRIPT_BASE_NAME = /^[A-Za-z0-9_-]+$/
const WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE = '*\\&*|*\\^*|*\\(*|*\\)*|*\\;*|*,*|*=*|*%*|*\\!*'
export function wrapRuntimeHomeHookCommand(
scriptBaseName: string,
options: { neutralJsonWhenMissing?: boolean } = {}
): string {
if (!MANAGED_SCRIPT_BASE_NAME.test(scriptBaseName)) {
throw new Error(`Invalid managed script base name: ${scriptBaseName}`)
}
// Why: default-form every var — a static hook precheck (Grok) rejects the whole command on a bare
// reference it cannot resolve, even in a branch that platform never takes.
const windowsScript = `"\${HOME-}/.orca/agent-hooks/${scriptBaseName}.cmd"`
const posixScript = `"\${HOME-}/.orca/agent-hooks/${scriptBaseName}.sh"`
const drain = POSIX_HOOK_STDIN_DRAIN_COMMAND
const neutralJson = options.neutralJsonWhenMissing ? `printf '{}\\n'` : ''
// Why two forms: the missing-script fallback owns stdin, so it follows the rule of the host
// it lands on. POSIX callers close the pipe, so capture-first is safe there and a mid-write
// exit stays visible as EPIPE (#8110). A Windows caller may abandon the pipe, so there the
// answer comes first and the drain only runs with an Orca env behind it (#11549).
const posixMissingScriptFallback = neutralJson ? `${drain}; ${neutralJson}` : drain
const windowsMissingScriptFallback = [
...(neutralJson ? [neutralJson] : []),
WINDOWS_GIT_BASH_HOOK_ENVIRONMENT_GUARD,
drain
].join('; ')
// Why platform-selected even when HOME is unset: which stdin rule applies follows the
// caller, not the reason the script could not be found.
const missingScriptFallback = `case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsMissingScriptFallback} ;; *) ${posixMissingScriptFallback} ;; esac`
const powershell = '"${SYSTEMROOT-}/System32/WindowsPowerShell/v1.0/powershell.exe"'
const powershellFallback = options.neutralJsonWhenMissing ? "; Write-Output '{}'" : ''
// Why the order: answer first, then the shared env guard, then own stdin — see wrapWindowsHookCommand.
const powershellCommand = `$homePath = $env:HOME -replace '^/([A-Za-z])/', '$1:/'; $scriptPath = Join-Path $homePath '.orca\\agent-hooks\\${scriptBaseName}.cmd'; if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }${powershellFallback}; ${WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD}; [Console]::In.ReadToEnd() | Out-Null; exit 0`
const encodedCommand = encodeWindowsPowerShellHookCommand(powershellCommand)
// Why: the Git Bash and native Windows launchers must spell the same switches — window suppression (#14815) and an AV verdict on the shape (#16003) both hit either path.
const powershellInvocation = `${powershell} ${WINDOWS_POWERSHELL_HOOK_SWITCHES} -EncodedCommand ${encodedCommand}`
const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${windowsMissingScriptFallback}; fi`
const windowsBranch = `if [ -f ${windowsScript} ]; then case "\${HOME-}" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${windowsMissingScriptFallback}; fi`
const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then /bin/sh ${posixScript}; else ${posixMissingScriptFallback}; fi`
// Why: OSTYPE is shell-owned, so platform selection adds no process to every hook invocation.
return `if [ -z "\${HOME-}" ]; then ${missingScriptFallback}; else case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsBranch} ;; *) ${posixBranch} ;; esac; fi`
}