diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index e59b4bbdc76..e42da745315 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -473,7 +473,7 @@ describe('Codex hook normalization', () => { // Why: Codex's PreToolUse is NOT an approval prompt — it fires for every // tool call. We map it to `working` (never `waiting`) and use it only to // give the dashboard a live readout during the gap between prompt and - // Stop. Real approval signals flow through Codex's `notify` callback. + // Stop. Real approval signals flow through PermissionRequest. const result = _internals.normalizeHookPayload( 'codex', buildBody({ @@ -488,6 +488,26 @@ describe('Codex hook normalization', () => { expect(result?.payload.toolInput).toBe('git status') }) + it('PermissionRequest maps to waiting and surfaces the pending tool input', () => { + // Why: Codex asks for user attention through PermissionRequest. Orca's + // sidebar red dot depends on this becoming `waiting`; treating it like + // PreToolUse would leave the pane looking busy while it is blocked on the + // user. + const result = _internals.normalizeHookPayload( + 'codex', + buildBody({ + hook_event_name: 'PermissionRequest', + tool_name: 'exec_command', + tool_input: { cmd: 'rm -rf build', workdir: '/tmp' } + }), + 'production' + ) + expect(result?.payload.state).toBe('waiting') + expect(result?.payload.agentType).toBe('codex') + expect(result?.payload.toolName).toBe('exec_command') + expect(result?.payload.toolInput).toBe('rm -rf build') + }) + it('UserPromptSubmit does not extract tool fields even when the payload carries them', () => { // Why: UserPromptSubmit is a turn-boundary event; any tool_name on it // would be leftover noise and should not leak into the working-state diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts new file mode 100644 index 00000000000..a431c17fff9 --- /dev/null +++ b/src/main/codex/hook-service.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import type * as Os from 'os' +import { join } from 'path' + +const { getPathMock, homedirMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: homedirMock + } +}) + +import { CodexHookService } from './hook-service' + +let tmpHome: string +let userDataDir: string + +beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'orca-codex-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-user-data-')) + homedirMock.mockReturnValue(tmpHome) + getPathMock.mockImplementation((name: string) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + }) +}) + +afterEach(() => { + rmSync(tmpHome, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + vi.clearAllMocks() +}) + +describe('CodexHookService', () => { + it('installs PermissionRequest with trust so Codex approval prompts reach Orca', () => { + const status = new CodexHookService().install() + + expect(status.state).toBe('installed') + + const hooksConfig = JSON.parse( + readFileSync(join(tmpHome, '.codex', 'hooks.json'), 'utf-8') + ) as { + hooks: Record + } + + expect(Object.keys(hooksConfig.hooks).sort()).toEqual( + [ + 'PermissionRequest', + 'PostToolUse', + 'PreToolUse', + 'SessionStart', + 'Stop', + 'UserPromptSubmit' + ].sort() + ) + expect(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('agent-hooks') + expect(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('codex-hook') + + const trustConfig = readFileSync(join(tmpHome, '.codex', 'config.toml'), 'utf-8') + expect(trustConfig).toContain(':permission_request:0:0') + }) +}) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 35627d36420..b2df60c0a49 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -26,15 +26,14 @@ import { // Why: PreToolUse/PostToolUse give the dashboard a live readout of the // in-flight tool (name + input preview) between UserPromptSubmit and Stop. -// Without them, a long-running Codex turn looks like a silent gap after the -// prompt is submitted. We keep both events mapped to `working` (see -// normalizeCodexEvent); they do NOT mean "approval needed" — Codex's real -// approval signal flows through its separate `notify` callback (different -// install surface, different payload shape) and is deferred as a follow-up. +// PermissionRequest is the human-input boundary: the managed script exits +// without a decision so Codex still shows its normal approval UI, while Orca +// can flip the pane to the red waiting state. const CODEX_EVENTS = [ 'SessionStart', 'UserPromptSubmit', 'PreToolUse', + 'PermissionRequest', 'PostToolUse', 'Stop' ] as const @@ -55,6 +54,7 @@ const CODEX_EVENT_LABEL: Record<(typeof CODEX_EVENTS)[number], CodexEventLabel> SessionStart: 'session_start', UserPromptSubmit: 'user_prompt_submit', PreToolUse: 'pre_tool_use', + PermissionRequest: 'permission_request', PostToolUse: 'post_tool_use', Stop: 'stop' } diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 6193ce76102..f39abb4b34a 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -481,7 +481,11 @@ function extractCodexToolFields( eventName: unknown, hookPayload: Record ): ToolSnapshot { - if (eventName === 'PreToolUse' || eventName === 'PostToolUse') { + if ( + eventName === 'PreToolUse' || + eventName === 'PermissionRequest' || + eventName === 'PostToolUse' + ) { const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') const toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) ?? @@ -759,9 +763,11 @@ function normalizeCodexEvent( eventName === 'PreToolUse' || eventName === 'PostToolUse' ? 'working' - : eventName === 'Stop' - ? 'done' - : null + : eventName === 'PermissionRequest' + ? 'waiting' + : eventName === 'Stop' + ? 'done' + : null if (!stateName) { return null