fix(cursor): emit Cursor-contract JSON from managed hooks

Merge rebased conflict repair after exact-head tests, typecheck, lint, format, and all required GitHub checks passed.
This commit is contained in:
Brennan Benson
2026-08-27 00:25:31 -07:00
committed by GitHub
parent 5631aa00dd
commit f352e3e27d
9 changed files with 323 additions and 55 deletions
+1
View File
@@ -712,6 +712,7 @@ jobs:
src/main/wsl/wsl-w1-w3-contract.test.ts
src/shared/source-scan/source-tree-scan.test.ts
src/main/cli/wsl-cli-powershell-boundary.test.ts
src/main/cursor/hook-service.test.ts
src/main/orca-profiles/profile-index-store.test.ts
src/main/runtime/repo-worktree-admin-fingerprint.test.ts
src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts
+2
View File
@@ -82,6 +82,8 @@
"../src/main/copilot/copilot-managed-script.ts",
"../src/main/copilot/copilot-remote-hook-install.ts",
"../src/main/copilot/hook-service.ts",
"../src/main/cursor/hook-events.ts",
"../src/main/cursor/hook-script.ts",
"../src/main/cursor/hook-service.ts",
"../src/main/droid/hook-service.ts",
"../src/main/gemini/hook-service.ts",
@@ -639,6 +639,17 @@ describe('wrapWindowsHookCommand', () => {
)
})
it('emits fallback stdout when the managed script is missing', () => {
const command = wrapWindowsHookCommand(
'C:\\hooks\\cursor-hook.cmd',
{},
{ fallbackStdout: '{"permission":"allow"}' }
)
expect(decodeWindowsHookCommand(command)).toContain(
'Write-Output \'{"permission":"allow"}\'; exit 0'
)
})
// Why: a user profile path like `C:\Users\Jane Doe` is the regression from
// #6078 — the raw path used to be split at the space. The wrapper must keep
// the whole path inside the encoded command so shells do not split it.
+8 -2
View File
@@ -118,14 +118,20 @@ export {
export function wrapWindowsHookCommand(
scriptPath: string,
env: Record<string, string> = {}
env: Record<string, string> = {},
// Why: POSIX wrap already answers missing-script with stdout; Windows must match so gate events cannot drift (#15462).
options: { fallbackStdout?: string } = {}
): string {
// Why: the encoded launcher protects paths across Windows shells and drains stdin when the config points at a missing script.
const quoted = quotePowerShellString(scriptPath)
const envPrefix = Object.entries(env)
.map(([key, value]) => `$env:${key} = ${quotePowerShellString(value)}; `)
.join('')
const command = `${envPrefix}if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0`
const fallback =
options.fallbackStdout === undefined
? ''
: `Write-Output ${quotePowerShellString(options.fallbackStdout)}; `
const command = `${envPrefix}if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; ${fallback}exit 0`
return wrapWindowsPowerShellEncodedCommand(command)
}
@@ -11,6 +11,7 @@ vi.mock('electron', () => ({
import { CodexHookService, codexHookService } from '../codex/hook-service'
import { DroidHookService, droidHookService } from '../droid/hook-service'
import { CursorHookService, cursorHookService } from '../cursor/hook-service'
import { CURSOR_EVENTS, type CursorEvent } from '../cursor/hook-events'
import { CommandCodeHookService, commandCodeHookService } from '../command-code/hook-service'
import { GeminiHookService, geminiHookService } from '../gemini/hook-service'
import { AntigravityHookService, antigravityHookService } from '../antigravity/hook-service'
@@ -35,6 +36,17 @@ type FakeFs = {
failRenameTo: Set<string>
}
const EXPECTED_CURSOR_HOOK_RESPONSES = {
beforeSubmitPrompt: '{"continue":true}',
stop: '{}',
preToolUse: '{"permission":"allow"}',
postToolUse: '{}',
postToolUseFailure: '{}',
beforeShellExecution: '{"permission":"allow"}',
beforeMCPExecution: '{"permission":"allow"}',
afterAgentResponse: '{}'
} satisfies Record<CursorEvent, string>
function createFakeSftp(initialFiles: Record<string, string> = {}): {
sftp: SFTPWrapper
fs: FakeFs
@@ -366,19 +378,14 @@ describe('remote hook service installers', () => {
hooks: Record<string, { command?: string; hooks?: unknown[] }[]>
}
expect(cursorConfig.version).toBe(1)
for (const eventName of [
'beforeSubmitPrompt',
'stop',
'preToolUse',
'postToolUse',
'postToolUseFailure',
'beforeShellExecution',
'beforeMCPExecution',
'afterAgentResponse'
]) {
for (const eventName of CURSOR_EVENTS) {
const definition = cursorConfig.hooks[eventName]?.[0]
expect(definition?.command).toContain('/home/dev/.orca/agent-hooks/cursor-hook.sh')
const command = definition?.command
expect(command).toContain('/home/dev/.orca/agent-hooks/cursor-hook.sh')
expect(definition?.hooks).toBeUndefined()
const response = EXPECTED_CURSOR_HOOK_RESPONSES[eventName]
expect(command).toContain(`ORCA_CURSOR_HOOK_RESPONSE='${response}'`)
expect(command).toContain(`printf '%s\\n' '${response}'`)
}
const commandCodeConfig = JSON.parse(
+29
View File
@@ -0,0 +1,29 @@
// Subscribe only to Cursor hooks needed for spinner and turn detection.
// Exclude process-boundary session hooks, which can reset the submitted-turn prompt cache.
export const CURSOR_EVENTS = [
'beforeSubmitPrompt',
'stop',
'preToolUse',
'postToolUse',
'postToolUseFailure',
'beforeShellExecution',
'beforeMCPExecution',
'afterAgentResponse'
] as const
export type CursorEvent = (typeof CURSOR_EVENTS)[number]
const CURSOR_HOOK_RESPONSES = {
beforeSubmitPrompt: '{"continue":true}',
stop: '{}',
preToolUse: '{"permission":"allow"}',
postToolUse: '{}',
postToolUseFailure: '{}',
beforeShellExecution: '{"permission":"allow"}',
beforeMCPExecution: '{"permission":"allow"}',
afterAgentResponse: '{}'
} satisfies Record<CursorEvent, string>
export function getCursorHookResponse(eventName: CursorEvent): string {
return CURSOR_HOOK_RESPONSES[eventName]
}
+87
View File
@@ -0,0 +1,87 @@
import {
buildWindowsAgentHookPostCommand,
wrapPosixHookCommand,
wrapWindowsHookCommand
} from '../agent-hooks/installer-utils'
import {
buildPosixHookPayloadCapture,
buildPosixHookSpoolLines,
buildWindowsHookEnvironmentGuardLines,
buildWindowsHookStdinDrainEpilogue
} from '../agent-hooks/hook-stdin-contract'
import { getCursorHookResponse, type CursorEvent } from './hook-events'
const CURSOR_HOOK_RESPONSE_ENV = 'ORCA_CURSOR_HOOK_RESPONSE'
export function getPosixManagedCommand(scriptPath: string, eventName: CursorEvent): string {
const response = getCursorHookResponse(eventName)
return wrapPosixHookCommand(
scriptPath,
{ [CURSOR_HOOK_RESPONSE_ENV]: response },
{ fallbackStdout: response }
)
}
export function getManagedCommand(scriptPath: string, eventName: CursorEvent): string {
const response = getCursorHookResponse(eventName)
return process.platform === 'win32'
? wrapWindowsHookCommand(
scriptPath,
{ [CURSOR_HOOK_RESPONSE_ENV]: response },
{ fallbackStdout: response }
)
: getPosixManagedCommand(scriptPath, eventName)
}
export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
if (target === 'local' && process.platform === 'win32') {
return [
'@echo off',
'setlocal',
// Why: Cursor permission hooks fail closed on empty/invalid stdout (#15462).
`if defined ${CURSOR_HOOK_RESPONSE_ENV} (echo %${CURSOR_HOOK_RESPONSE_ENV}%) else (echo {})`,
// 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(),
buildWindowsAgentHookPostCommand('cursor'),
'exit /b 0',
...buildWindowsHookStdinDrainEpilogue(),
''
].join('\r\n')
}
return [
'#!/bin/sh',
// Why: Cursor permission hooks fail closed on empty/invalid stdout (#15462).
`if [ -n "$${CURSOR_HOOK_RESPONSE_ENV}" ]; then`,
` printf '%s\\n' "$${CURSOR_HOOK_RESPONSE_ENV}"`,
'else',
' printf "{}\\n"',
'fi',
...buildPosixHookPayloadCapture(),
...buildPosixHookSpoolLines('cursor'),
// Why: refresh endpoint coordinates so surviving PTYs keep reporting.
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
' . "$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: post form fields because path-bearing worktree IDs are unsafe in hand-built JSON.
// Why: pipe payload to curl stdin to keep large output off the command line.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/cursor" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event',
'exit 0',
''
].join('\n')
}
+156 -12
View File
@@ -1,7 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { findGitBash } from '../agent-hooks/windows-git-bash-path.test-fixture'
const { homedirMock } = vi.hoisted(() => ({
homedirMock: vi.fn<() => string>()
@@ -17,22 +19,66 @@ vi.mock('os', async () => {
import { CursorHookService } from './hook-service'
import { POSIX_HOOK_STDIN_READER } from '../agent-hooks/hook-stdin-contract'
const CURSOR_EVENTS = [
'beforeSubmitPrompt',
'stop',
'preToolUse',
'postToolUse',
'postToolUseFailure',
'beforeShellExecution',
'beforeMCPExecution',
'afterAgentResponse'
]
import { CURSOR_EVENTS, type CursorEvent } from './hook-events'
const CURSOR_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'cursor-hook.cmd' : 'cursor-hook.sh'
const WINDOWS_POWERSHELL_LAUNCHER =
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/
type InstalledCursorHooks = {
hooks: Record<string, { command?: string }[]>
}
const EXPECTED_CURSOR_HOOK_STDOUT = {
beforeSubmitPrompt: { continue: true },
stop: {},
preToolUse: { permission: 'allow' },
postToolUse: {},
postToolUseFailure: {},
beforeShellExecution: { permission: 'allow' },
beforeMCPExecution: { permission: 'allow' },
afterAgentResponse: {}
} satisfies Record<CursorEvent, Record<string, unknown>>
function readInstalledCursorHooks(homeDir: string): InstalledCursorHooks {
return JSON.parse(
readFileSync(join(homeDir, '.cursor', 'hooks.json'), 'utf8')
) as InstalledCursorHooks
}
function requireRegisteredCommand(config: InstalledCursorHooks, eventName: string): string {
const command = config.hooks[eventName]?.[0]?.command
expect(command, eventName).toEqual(expect.any(String))
if (typeof command !== 'string') {
throw new Error(`missing Cursor hook command for ${eventName}`)
}
return command
}
function runRegisteredCursorHook(
command: string,
input: string,
extraEnv: NodeJS.ProcessEnv = {}
): { stdout: string; stderr: string; status: number | null } {
const executable = process.platform === 'win32' ? 'cmd.exe' : '/bin/sh'
const args = process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-c', command]
const result = spawnSync(executable, args, {
encoding: 'utf8',
input,
timeout: 15_000,
env: {
...process.env,
ORCA_AGENT_HOOK_ENDPOINT: '',
ORCA_AGENT_HOOK_PORT: '',
ORCA_AGENT_HOOK_TOKEN: '',
ORCA_PANE_KEY: '',
...extraEnv
}
})
expect(result.error, result.stderr).toBeUndefined()
return { stdout: result.stdout, stderr: result.stderr, status: result.status }
}
describe('CursorHookService', () => {
let homeDir: string
@@ -156,4 +202,102 @@ describe('CursorHookService', () => {
'/usr/local/bin/retired-user-hook'
])
})
// Why: installer-intent assertions missed empty stdout, which Cursor treats as
// invalid JSON and fails closed (#15462). This runs the registered command.
it('emits protocol-valid JSON on stdout for every managed event, including empty stdin (#15462)', () => {
expect(new CursorHookService().install().state).toBe('installed')
const config = readInstalledCursorHooks(homeDir)
const payloads = [
(eventName: string) => JSON.stringify({ hook_event_name: eventName, tool_name: 'Write' }),
() => ''
]
for (const eventName of CURSOR_EVENTS) {
const command = requireRegisteredCommand(config, eventName)
for (const payloadFor of payloads) {
const result = runRegisteredCursorHook(command, payloadFor(eventName))
expect(result.status, `${eventName} exit`).toBe(0)
expect(result.stderr, `${eventName} stderr`).toBe('')
expect(JSON.parse(result.stdout), `${eventName} stdout`).toEqual(
EXPECTED_CURSOR_HOOK_STDOUT[eventName]
)
}
}
})
it('emits protocol-valid JSON when the managed Cursor script is missing (#15462)', () => {
expect(new CursorHookService().install().state).toBe('installed')
const config = readInstalledCursorHooks(homeDir)
unlinkSync(join(homeDir, '.orca', 'agent-hooks', CURSOR_SCRIPT_FILE_NAME))
for (const eventName of CURSOR_EVENTS) {
const command = requireRegisteredCommand(config, eventName)
const result = runRegisteredCursorHook(command, '')
expect(result.status, `${eventName} missing-script exit`).toBe(0)
expect(result.stderr, `${eventName} missing-script stderr`).toBe('')
expect(JSON.parse(result.stdout), `${eventName} missing-script stdout`).toEqual(
EXPECTED_CURSOR_HOOK_STDOUT[eventName]
)
}
})
it('keeps curl failure off stdout when the listener is unreachable (#15462)', () => {
expect(new CursorHookService().install().state).toBe('installed')
const config = readInstalledCursorHooks(homeDir)
for (const eventName of ['beforeSubmitPrompt', 'preToolUse', 'stop'] as const) {
const command = requireRegisteredCommand(config, eventName)
const result = runRegisteredCursorHook(
command,
JSON.stringify({ hook_event_name: eventName, tool_name: 'Write' }),
{
ORCA_AGENT_HOOK_PORT: '59999',
ORCA_AGENT_HOOK_TOKEN: 'token',
ORCA_PANE_KEY: 'tab:leaf'
}
)
expect(result.status, `${eventName} dead-listener exit`).toBe(0)
expect(JSON.parse(result.stdout), `${eventName} dead-listener stdout`).toEqual(
EXPECTED_CURSOR_HOOK_STDOUT[eventName]
)
}
})
it.skipIf(process.platform !== 'win32')(
'emits parseable JSON through cmd.exe and Git Bash (#14825/#15462)',
() => {
expect(new CursorHookService().install().state).toBe('installed')
const config = readInstalledCursorHooks(homeDir)
const gitBash = findGitBash()
const shells = [
{ name: 'cmd.exe', executable: 'cmd.exe', args: ['/d', '/c'] },
{ name: 'Git Bash', executable: gitBash, args: ['-c'] }
]
for (const eventName of ['beforeSubmitPrompt', 'preToolUse'] as const) {
const command = requireRegisteredCommand(config, eventName)
for (const shell of shells) {
const result = spawnSync(shell.executable, [...shell.args, command], {
encoding: 'utf8',
input: JSON.stringify({ hook_event_name: eventName, tool_name: 'Write' }),
timeout: 15_000,
env: {
...process.env,
ORCA_AGENT_HOOK_ENDPOINT: '',
ORCA_AGENT_HOOK_PORT: '',
ORCA_AGENT_HOOK_TOKEN: '',
ORCA_PANE_KEY: '',
USERPROFILE: homeDir
}
})
expect(result.error, `${eventName} ${shell.name}`).toBeUndefined()
expect(result.status, `${eventName} ${shell.name} exit`).toBe(0)
expect(result.stderr, `${eventName} ${shell.name} stderr`).toBe('')
expect(JSON.parse(result.stdout), `${eventName} ${shell.name} stdout`).toEqual(
EXPECTED_CURSOR_HOOK_STDOUT[eventName]
)
}
}
}
)
})
+11 -30
View File
@@ -5,12 +5,9 @@ import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared
import {
buildManagedCommandDefinition,
createManagedCommandMatcher,
buildWindowsAgentHookPostCommand,
getSharedManagedScriptPath,
readHooksJson,
removeManagedCommands,
wrapPosixHookCommand,
wrapWindowsHookCommand,
writeHooksJson,
writeManagedScript,
type HookDefinition
@@ -21,25 +18,8 @@ import {
writeHooksJsonRemote,
writeManagedScriptRemote
} from '../agent-hooks/installer-utils-remote'
import {
buildPosixHookPayloadCapture,
buildPosixHookSpoolLines,
buildWindowsHookEnvironmentGuardLines,
buildWindowsHookStdinDrainEpilogue
} from '../agent-hooks/hook-stdin-contract'
// Subscribe only to Cursor hooks needed for spinner and turn detection.
// Exclude process-boundary session hooks, which can reset the submitted-turn prompt cache.
const CURSOR_EVENTS = [
'beforeSubmitPrompt',
'stop',
'preToolUse',
'postToolUse',
'postToolUseFailure',
'beforeShellExecution',
'beforeMCPExecution',
'afterAgentResponse'
] as const
import { CURSOR_EVENTS } from './hook-events'
import { getManagedCommand, getManagedScript, getPosixManagedCommand } from './hook-script'
function getConfigPath(): string {
return join(homedir(), '.cursor', 'hooks.json')
@@ -53,6 +33,7 @@ function getManagedScriptPath(): string {
return getSharedManagedScriptPath(getManagedScriptFileName())
}
/*
function getManagedCommand(scriptPath: string): string {
return process.platform === 'win32'
? wrapWindowsHookCommand(scriptPath)
@@ -103,7 +84,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
''
].join('\n')
}
*/
export class CursorHookService {
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
@@ -123,10 +104,10 @@ export class CursorHookService {
}
}
const command = getManagedCommand(scriptPath)
const missing: string[] = []
let presentCount = 0
for (const eventName of CURSOR_EVENTS) {
const command = getManagedCommand(scriptPath, eventName)
const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : []
// Why: Cursor puts command directly on the definition (Claude nests under `hooks`); match both shapes.
const hasCommand = definitions.some(
@@ -170,7 +151,6 @@ export class CursorHookService {
}
}
const command = getManagedCommand(scriptPath)
// Why: config.hooks is undefined on a fresh file with no prior hook install.
const nextHooks = { ...config.hooks }
const managedEvents = new Set<string>(CURSOR_EVENTS)
@@ -189,7 +169,7 @@ export class CursorHookService {
const cleaned = removeManagedCommands(definitions, isManagedCommand)
// Also strip entries with the command at the top level (Cursor schema).
const strippedCursorShape = cleaned.filter(
(definition) => !isManagedCommand(definition.command as string | undefined)
(definition) => !isManagedCommand(definition.command)
)
if (strippedCursorShape.length === 0) {
delete nextHooks[eventName]
@@ -199,10 +179,11 @@ export class CursorHookService {
}
for (const eventName of CURSOR_EVENTS) {
const command = getManagedCommand(scriptPath, eventName)
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
// Sweep Claude- and Cursor-shaped variants so installs converge on one entry.
const cleaned = removeManagedCommands(current, isManagedCommand).filter(
(definition) => !isManagedCommand(definition.command as string | undefined)
(definition) => !isManagedCommand(definition.command)
)
// Why: Cursor's schema puts `command` directly on the definition (not under `hooks`); emit that shape.
const definition: HookDefinition = buildManagedCommandDefinition(command)
@@ -235,15 +216,15 @@ export class CursorHookService {
}
}
const command = wrapPosixHookCommand(remoteScriptPath)
const nextHooks = { ...config.hooks }
const isManagedCommand = createManagedCommandMatcher('cursor-hook.sh')
for (const eventName of CURSOR_EVENTS) {
const command = getPosixManagedCommand(remoteScriptPath, eventName)
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
// Why: dual-shape sweep so repeated installs converge on a single managed entry.
const cleaned = removeManagedCommands(current, isManagedCommand).filter(
(definition) => !isManagedCommand(definition.command as string | undefined)
(definition) => !isManagedCommand(definition.command)
)
const definition: HookDefinition = buildManagedCommandDefinition(command)
nextHooks[eventName] = [...cleaned, definition]
@@ -297,7 +278,7 @@ export class CursorHookService {
continue
}
const cleaned = removeManagedCommands(definitions, isManagedCommand).filter(
(definition) => !isManagedCommand(definition.command as string | undefined)
(definition) => !isManagedCommand(definition.command)
)
if (cleaned.length === 0) {
delete nextHooks[eventName]