diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 70d52e0802c..9cf3779c03c 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -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", diff --git a/src/main/agent-hooks/grok-replay-guard.test.ts b/src/main/agent-hooks/grok-replay-guard.test.ts new file mode 100644 index 00000000000..1da54f287d2 --- /dev/null +++ b/src/main/agent-hooks/grok-replay-guard.test.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(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') + } + }) +}) diff --git a/src/main/agent-hooks/grok-replay-guard.ts b/src/main/agent-hooks/grok-replay-guard.ts new file mode 100644 index 00000000000..6309ed6e584 --- /dev/null +++ b/src/main/agent-hooks/grok-replay-guard.ts @@ -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}`] +} diff --git a/src/main/claude/hook-script.ts b/src/main/claude/hook-script.ts new file mode 100644 index 00000000000..efbe71fc9e3 --- /dev/null +++ b/src/main/claude/hook-script.ts @@ -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') +} diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index a4e48c98120..2a07aff4c8a 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -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) diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index b3ae8d01136..571531aa2d9 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -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 { 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 diff --git a/src/main/cursor/hook-script.ts b/src/main/cursor/hook-script.ts index 94563337131..739026aa6a8 100644 --- a/src/main/cursor/hook-script.ts +++ b/src/main/cursor/hook-script.ts @@ -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', diff --git a/src/main/cursor/hook-service.test.ts b/src/main/cursor/hook-service.test.ts index f5f25295a60..3f93e91f7fe 100644 --- a/src/main/cursor/hook-service.test.ts +++ b/src/main/cursor/hook-service.test.ts @@ -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 {