fix(grok): stop replayed Claude/Cursor hooks reporting Grok panes as Claude (#20507)

* fix(grok): stop replayed Claude/Cursor hooks reporting Grok panes as Claude

Grok's hook discovery reads ~/.claude/settings.json (and the Cursor equivalent)
for vendor compatibility, and that is on by default. So inside every Grok pane
Orca's managed Claude hook fires in addition to Orca's managed Grok hook, and
both POST the same Grok envelope. The Claude-routed copy lands last and wins, so
the pane's agent type is resolved from the POST route as "claude" and no
Grok-specific normalization runs for it.

Guard the managed Claude and Cursor scripts on GROK_HOOK_EVENT, which Grok's hook
runner stamps into every hook subprocess it spawns — including replayed vendor
configs — after any user-supplied environment, so a hook cannot spoof it. This
mirrors the existing DEVIN_PROJECT_DIR guard in the same script, which solves the
identical problem for another agent that imports Claude hooks.

Placement is load-bearing: the guard sits after the stdin capture, so Grok's
writer never blocks, and before both the spool write and the HTTP POST, so a
replayed event cannot leave a spool entry that replays later. The Windows
variants jump to the stdin-drain label rather than exiting, because abandoning
stdin there hangs the writer.

The guard is scoped to agent === 'claude'; OpenClaude reuses ClaudeHookService
with its own settings file, which Grok does not replay, so it is unaffected.

Verified live against Grok 1.0.25 in a dev instance: the pane's reported agent
type goes from "claude" to "grok" on every turn-end, including the hidden
follow-up turns Grok runs when background work finishes.

The guard pushed hook-service.ts past the 300-line cap, so the script builder
moves to a sibling hook-script.ts. That mirrors the existing split under
src/main/cursor/, where the service owns install/status and the script module
owns script text.

* fix(agent-hooks): preserve Windows background worker stdin contract
This commit is contained in:
Brennan Benson
2026-09-13 15:55:41 -07:00
committed by GitHub
parent 09187fcad8
commit e944e76537
8 changed files with 274 additions and 86 deletions
+2
View File
@@ -4,6 +4,8 @@
"../src/cli/**/*",
"../src/shared/**/*",
"../src/main/agent-state-file-reader.ts",
"../src/main/agent-hooks/grok-replay-guard.ts",
"../src/main/claude/hook-script.ts",
"../src/main/agent-hooks/hook-stdin-contract.ts",
"../src/main/agent-hooks/hook-post-command.ts",
"../src/main/agent-hooks/hook-config-write-path.ts",
@@ -0,0 +1,136 @@
import { spawnSync } from 'node:child_process'
import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/userData'
}
}))
import { getManagedScript as getClaudeManagedScript } from '../claude/hook-service'
import { getManagedScript as getCursorManagedScript } from '../cursor/hook-script'
const POSIX_GROK_GUARD = 'if [ -n "$GROK_HOOK_EVENT" ]; then'
const WINDOWS_GROK_GUARD = 'if not "%GROK_HOOK_EVENT%"=="" goto :orca_agent_hook_drain_stdin'
const CLAUDE_SCRIPT_OPTIONS = {
skipWhenDevinImportsClaude: true,
skipWhenGrokImportsClaude: true
}
function withPlatform<T>(platform: NodeJS.Platform, run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')!
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
try {
return run()
} finally {
Object.defineProperty(process, 'platform', descriptor)
}
}
function expectGuardBeforeTransport(
script: string,
guard: string,
response: string,
spool?: string
): void {
const guardIndex = script.indexOf(guard)
expect(guardIndex).toBeGreaterThan(script.indexOf(response))
expect(guardIndex).toBeLessThan(script.indexOf('curl'))
if (spool) {
expect(guardIndex).toBeLessThan(script.indexOf(spool))
}
}
function runPosixHook(
script: string,
grokHookEvent: string
): {
curlCalled: boolean
stdout: string
} {
const dir = mkdtempSync(join(tmpdir(), 'orca-grok-replay-'))
const scriptPath = join(dir, 'hook.sh')
const curlPath = join(dir, 'curl')
const curlLog = join(dir, 'curl.log')
try {
writeFileSync(scriptPath, script)
writeFileSync(
curlPath,
'#!/bin/sh\n{ command -p cat 2>/dev/null || cat; } >/dev/null\nprintf "called\\n" >> "$CURL_LOG"\n'
)
chmodSync(scriptPath, 0o755)
chmodSync(curlPath, 0o755)
const result = spawnSync('/bin/sh', [scriptPath], {
encoding: 'utf8',
input: '{"hook_event_name":"Stop"}',
env: {
...process.env,
PATH: `${dir}:${process.env.PATH ?? ''}`,
CURL_LOG: curlLog,
GROK_HOOK_EVENT: grokHookEvent,
ORCA_AGENT_HOOK_ENDPOINT: '',
ORCA_AGENT_HOOK_PORT: '1234',
ORCA_AGENT_HOOK_TOKEN: 'token',
ORCA_PANE_KEY: 'tab:leaf'
}
})
expect(result.error).toBeUndefined()
expect(result.status).toBe(0)
return { curlCalled: existsSync(curlLog), stdout: result.stdout }
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
describe('Grok vendor hook replay guard', () => {
it('precedes spooling and HTTP in the generated POSIX Claude and Cursor scripts', () => {
const claude = getClaudeManagedScript('posix', CLAUDE_SCRIPT_OPTIONS)
const cursor = getCursorManagedScript('posix')
expectGuardBeforeTransport(claude, POSIX_GROK_GUARD, 'printf "{}\\n"', 'spool_hook_event')
expectGuardBeforeTransport(cursor, POSIX_GROK_GUARD, 'printf "{}\\n"', 'spool_hook_event')
})
it('precedes HTTP while preserving fail-open output in generated Windows scripts', () => {
const { claude, cursor } = withPlatform('win32', () => ({
claude: getClaudeManagedScript('local', CLAUDE_SCRIPT_OPTIONS),
cursor: getCursorManagedScript('local')
}))
expectGuardBeforeTransport(claude, WINDOWS_GROK_GUARD, 'echo {}')
expectGuardBeforeTransport(cursor, WINDOWS_GROK_GUARD, '(echo {})')
const backgroundWorkerGuardIndex = claude.indexOf('CLAUDE_JOB_DIR')
expect(backgroundWorkerGuardIndex).toBeGreaterThan(-1)
expect(backgroundWorkerGuardIndex).toBeLessThan(claude.indexOf(WINDOWS_GROK_GUARD))
})
it.skipIf(process.platform === 'win32')(
'drops Grok-replayed hooks without suppressing their protocol response',
() => {
for (const script of [
getClaudeManagedScript('posix', CLAUDE_SCRIPT_OPTIONS),
getCursorManagedScript('posix')
]) {
const result = runPosixHook(script, 'Stop')
expect(result.curlCalled).toBe(false)
expect(result.stdout).toBe('{}\n')
}
}
)
it.skipIf(process.platform === 'win32')('leaves non-Grok hook delivery unchanged', () => {
for (const script of [
getClaudeManagedScript('posix', CLAUDE_SCRIPT_OPTIONS),
getCursorManagedScript('posix')
]) {
const result = runPosixHook(script, '')
expect(result.curlCalled).toBe(true)
expect(result.stdout).toBe('{}\n')
}
})
})
+14
View File
@@ -0,0 +1,14 @@
import { WINDOWS_HOOK_STDIN_DRAIN_LABEL } from './hook-stdin-contract'
export function buildPosixGrokReplayGuardLines(): string[] {
return [
// Why: Grok imports vendor hooks; only its native hook may report the event as Grok.
'if [ -n "$GROK_HOOK_EVENT" ]; then',
' exit 0',
'fi'
]
}
export function buildWindowsGrokReplayGuardLines(): string[] {
return [`if not "%GROK_HOOK_EVENT%"=="" goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`]
}
+93
View File
@@ -0,0 +1,93 @@
/** The managed Claude-compatible hook script, built for local, POSIX-remote and Windows targets.
* Split from hook-service.ts so the service owns install/status and this owns script text,
* mirroring the same split under src/main/cursor/. */
import { buildWindowsAgentHookCurlPostCommand } from '../agent-hooks/installer-utils'
import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command'
import {
buildPosixGrokReplayGuardLines,
buildWindowsGrokReplayGuardLines
} from '../agent-hooks/grok-replay-guard'
import {
WINDOWS_HOOK_STDIN_DRAIN_LABEL,
buildPosixHookPayloadCapture,
buildPosixHookSpoolLines,
buildWindowsHookEnvironmentGuardLines,
buildWindowsHookStdinDrainEpilogue
} from '../agent-hooks/hook-stdin-contract'
export function getManagedScript(
target: 'local' | 'posix' = 'local',
options: {
skipWhenDevinImportsClaude?: boolean
skipWhenGrokImportsClaude?: boolean
} = {}
): string {
if (target === 'local' && process.platform === 'win32') {
return [
'@echo off',
'setlocal',
// Why: Claude-compatible permission hooks fail closed on empty stdout (#14818).
'echo {}',
// Why: refresh endpoint coordinates for PTYs surviving an Orca restart.
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
// Why (#11549): the env guards must outrank the Devin skip — the Devin skip parks in more.com,
// and outside an Orca pane the caller can abandon stdin, so more.com never returns.
...buildWindowsHookEnvironmentGuardLines(),
// Why: a backgrounded session runs in a daemon worker that inherited the dispatching
// pane's env, so ORCA_PANE_KEY names a pane this session does not run in (#9236).
// Why exit, not the drain label: the drain parks in more.com and a worker is outside
// an Orca pane — the abandoned-stdin hang #11549 guards against.
'if not "%CLAUDE_JOB_DIR%"=="" exit /b 0',
...(options.skipWhenGrokImportsClaude ? buildWindowsGrokReplayGuardLines() : []),
...(options.skipWhenDevinImportsClaude
? [
// Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin.
`if not "%DEVIN_PROJECT_DIR%"=="" goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`
]
: []),
// Why: use curl.exe to avoid an extra PowerShell startup per hook.
buildWindowsAgentHookCurlPostCommand('claude'),
'exit /b 0',
...buildWindowsHookStdinDrainEpilogue(),
''
].join('\r\n')
}
return [
'#!/bin/sh',
// Why: Claude-compatible permission hooks fail closed on empty stdout (#14818).
'printf "{}\\n"',
...buildPosixHookPayloadCapture(),
...(options.skipWhenGrokImportsClaude ? buildPosixGrokReplayGuardLines() : []),
...buildPosixHookSpoolLines('claude'),
...(options.skipWhenDevinImportsClaude
? [
// Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin.
'if [ -n "$DEVIN_PROJECT_DIR" ]; then',
' exit 0',
'fi'
]
: []),
// Why: a backgrounded session runs in a daemon worker that inherited the dispatching
// pane's env, so ORCA_PANE_KEY names a pane this session does not run in (#9236).
'if [ -n "$CLAUDE_JOB_DIR" ]; then',
' exit 0',
'fi',
// Why: refresh endpoint coordinates for PTYs surviving an Orca restart.
// Why: suppress parse errors so they neither leak nor trip outer set -e.
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
' unset ORCA_AGENT_HOOK_TRANSPORT',
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' spool_hook_event',
' exit 0',
'fi',
// Why: keep full hook JSON off the command line and avoid IDS-friendly URL-encoded paths.
...buildPosixAgentHookPostCommand('claude').map((line, index, lines) =>
index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line
),
'exit 0',
''
].join('\n')
}
+5
View File
@@ -262,6 +262,7 @@ describe('ClaudeHookService.install', () => {
'utf-8'
)
expect(managedScript).toContain('DEVIN_PROJECT_DIR')
expect(managedScript).toContain('GROK_HOOK_EVENT')
// Why: guard and Devin-skip paths must still return neutral JSON (#14818).
expect(managedScript).toMatch(
process.platform === 'win32'
@@ -711,6 +712,7 @@ describe('ClaudeHookService.installRemote', () => {
const script = fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh')
expect(script).toContain('#!/bin/sh')
expect(script).toContain('DEVIN_PROJECT_DIR')
expect(script).toContain('GROK_HOOK_EVENT')
// Why: remote guard paths must still return neutral JSON (#14818).
expect(script!.indexOf('printf "{}\\n"')).toBe(
script!.indexOf('#!/bin/sh') + '#!/bin/sh\n'.length
@@ -813,6 +815,9 @@ describe('OpenClaudeHookService-compatible install', () => {
expect(
readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8')
).not.toContain('DEVIN_PROJECT_DIR')
expect(
readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8')
).not.toContain('GROK_HOOK_EVENT')
// Why: the statusline usage feed is Claude-only; OpenClaude installs must not set statusLine.
expect(parsed.statusLine).toBeUndefined()
expect(existsSync(join(tmpHome, '.claude', 'settings.json'))).toBe(false)
+17 -86
View File
@@ -3,26 +3,20 @@ import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
buildManagedCommandHook,
buildWindowsAgentHookCurlPostCommand,
readHooksJson,
writeHooksJson,
writeManagedScript,
type HooksConfig
type HooksConfig,
writeManagedScript
} from '../agent-hooks/installer-utils'
import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command'
import {
readHooksJsonRemote,
writeHooksJsonRemote,
writeManagedScriptRemote
} from '../agent-hooks/installer-utils-remote'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
buildPosixHookPayloadCapture,
buildPosixHookSpoolLines,
buildWindowsHookEnvironmentGuardLines,
buildWindowsHookStdinDrainEpilogue,
WINDOWS_HOOK_STDIN_DRAIN_LABEL
} from '../agent-hooks/hook-stdin-contract'
import { getManagedScript } from './hook-script'
export { getManagedScript }
import { getManagedStatusLineScript } from './statusline-script'
import {
applyManagedHooks,
@@ -59,78 +53,6 @@ const DEFAULT_CLAUDE_HOOK_SERVICE_OPTIONS: ClaudeHookServiceOptions = {
settings: CLAUDE_HOOK_SETTINGS
}
function getManagedScript(
target: 'local' | 'posix' = 'local',
options: { skipWhenDevinImportsClaude?: boolean } = {}
): string {
if (target === 'local' && process.platform === 'win32') {
return [
'@echo off',
'setlocal',
// Why: Claude-compatible permission hooks fail closed on empty stdout (#14818).
'echo {}',
// Why: refresh endpoint coordinates for PTYs surviving an Orca restart.
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
// Why (#11549): the env guards must outrank the Devin skip — the Devin skip parks in more.com,
// and outside an Orca pane the caller can abandon stdin, so more.com never returns.
...buildWindowsHookEnvironmentGuardLines(),
// Why: a backgrounded session runs in a daemon worker that inherited the dispatching
// pane's env, so ORCA_PANE_KEY names a pane this session does not run in (#9236).
// Why exit, not the drain label: the drain parks in more.com and a worker is outside
// an Orca pane — the abandoned-stdin hang #11549 guards against.
'if not "%CLAUDE_JOB_DIR%"=="" exit /b 0',
...(options.skipWhenDevinImportsClaude
? [
// Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin.
`if not "%DEVIN_PROJECT_DIR%"=="" goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`
]
: []),
// Why: use curl.exe to avoid an extra PowerShell startup per hook.
buildWindowsAgentHookCurlPostCommand('claude'),
'exit /b 0',
...buildWindowsHookStdinDrainEpilogue(),
''
].join('\r\n')
}
return [
'#!/bin/sh',
// Why: Claude-compatible permission hooks fail closed on empty stdout (#14818).
'printf "{}\\n"',
...buildPosixHookPayloadCapture(),
...buildPosixHookSpoolLines('claude'),
...(options.skipWhenDevinImportsClaude
? [
// Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin.
'if [ -n "$DEVIN_PROJECT_DIR" ]; then',
' exit 0',
'fi'
]
: []),
// Why: a backgrounded session runs in a daemon worker that inherited the dispatching
// pane's env, so ORCA_PANE_KEY names a pane this session does not run in (#9236).
'if [ -n "$CLAUDE_JOB_DIR" ]; then',
' exit 0',
'fi',
// Why: refresh endpoint coordinates for PTYs surviving an Orca restart.
// Why: suppress parse errors so they neither leak nor trip outer set -e.
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
' unset ORCA_AGENT_HOOK_TRANSPORT',
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' spool_hook_event',
' exit 0',
'fi',
// Why: keep full hook JSON off the command line and avoid IDS-friendly URL-encoded paths.
...buildPosixAgentHookPostCommand('claude').map((line, index, lines) =>
index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line
),
'exit 0',
''
].join('\n')
}
export class ClaudeHookService {
private readonly options: ClaudeHookServiceOptions
@@ -188,7 +110,10 @@ export class ClaudeHookService {
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(
getManagedScriptPath(this.options.settings),
getManagedScript('local', { skipWhenDevinImportsClaude: this.options.agent === 'claude' })
getManagedScript('local', {
skipWhenDevinImportsClaude: this.options.agent === 'claude',
skipWhenGrokImportsClaude: this.options.agent === 'claude'
})
)
// Why: no agent gate — the statusline script only ever exists for claude, so presence is the gate.
await refreshManagedScriptIfPresent(
@@ -219,7 +144,10 @@ export class ClaudeHookService {
)
writeManagedScript(
scriptPath,
getManagedScript('local', { skipWhenDevinImportsClaude: this.options.agent === 'claude' })
getManagedScript('local', {
skipWhenDevinImportsClaude: this.options.agent === 'claude',
skipWhenGrokImportsClaude: this.options.agent === 'claude'
})
)
// Why: the statusline usage feed is Claude-only — OpenClaude data would be misattributed to the Claude provider.
if (this.options.agent === 'claude') {
@@ -281,7 +209,10 @@ export class ClaudeHookService {
await writeManagedScriptRemote(
sftp,
remoteScriptPath,
getManagedScript('posix', { skipWhenDevinImportsClaude: this.options.agent === 'claude' })
getManagedScript('posix', {
skipWhenDevinImportsClaude: this.options.agent === 'claude',
skipWhenGrokImportsClaude: this.options.agent === 'claude'
})
)
// Why: no statusline install here — this path serves SSH remotes and WSL guests, whose relay hook
// listener doesn't route /statusline/claude, and an SSH box's Claude login can be a different
+6
View File
@@ -9,6 +9,10 @@ import {
buildWindowsHookEnvironmentGuardLines,
buildWindowsHookStdinDrainEpilogue
} from '../agent-hooks/hook-stdin-contract'
import {
buildPosixGrokReplayGuardLines,
buildWindowsGrokReplayGuardLines
} from '../agent-hooks/grok-replay-guard'
import { getCursorHookResponse, type CursorEvent } from './hook-events'
const CURSOR_HOOK_RESPONSE_ENV = 'ORCA_CURSOR_HOOK_RESPONSE'
@@ -43,6 +47,7 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
// Why: source current endpoint coordinates for PTYs surviving an Orca restart.
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
...buildWindowsHookEnvironmentGuardLines(),
...buildWindowsGrokReplayGuardLines(),
buildWindowsAgentHookPostCommand('cursor'),
'exit /b 0',
...buildWindowsHookStdinDrainEpilogue(),
@@ -59,6 +64,7 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' printf "{}\\n"',
'fi',
...buildPosixHookPayloadCapture(),
...buildPosixGrokReplayGuardLines(),
...buildPosixHookSpoolLines('cursor'),
// Why: refresh endpoint coordinates so surviving PTYs keep reporting.
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
+1
View File
@@ -131,6 +131,7 @@ describe('CursorHookService', () => {
'utf8'
)
expect(script).toContain('/hook/cursor')
expect(script).toContain('GROK_HOOK_EVENT')
if (process.platform === 'win32') {
expect(script).toContain('%SystemRoot%\\System32\\curl.exe')
} else {