From aac81f4d4a08e0c23e9e4568d7e935f317a4e301 Mon Sep 17 00:00:00 2001 From: andrew12-forge Date: Tue, 15 Sep 2026 09:32:24 -0700 Subject: [PATCH 1/2] fix(codex): honor live noninteractive permission hooks --- ...r-codex-noninteractive-permissions.test.ts | 157 ++++++++++++++++++ .../server/server-ingest-remote.ts | 9 + .../agent-hooks/server/server-persistence.ts | 1 + .../server/server-status-update.ts | 3 +- src/main/agent-hooks/server/server-types.ts | 1 + src/relay/agent-hook-envelope-build.ts | 1 + ...-completion-codex-live-permissions.test.ts | 104 ++++++++++++ ...nt-hook-listener-codex-permissions.test.ts | 96 +++++++++++ src/shared/agent-hook-listener.ts | 5 + .../agent-hook-listener/listener-event.ts | 2 + .../providers/codex-events.ts | 2 + .../providers/codex-permission-request.ts | 14 ++ .../providers/codex-state.ts | 19 ++- .../providers/codex-tool-fields.ts | 5 +- src/shared/agent-hook-relay.ts | 2 + 15 files changed, 415 insertions(+), 6 deletions(-) create mode 100644 src/main/agent-hooks/server-codex-noninteractive-permissions.test.ts create mode 100644 src/renderer/src/components/terminal-pane/agent-completion-codex-live-permissions.test.ts create mode 100644 src/shared/agent-hook-listener-codex-permissions.test.ts create mode 100644 src/shared/agent-hook-listener/providers/codex-permission-request.ts diff --git a/src/main/agent-hooks/server-codex-noninteractive-permissions.test.ts b/src/main/agent-hooks/server-codex-noninteractive-permissions.test.ts new file mode 100644 index 000000000000..b5a2bb2a0705 --- /dev/null +++ b/src/main/agent-hooks/server-codex-noninteractive-permissions.test.ts @@ -0,0 +1,157 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { normalizeHookPayload } from '../../shared/agent-hook-listener' +import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state' +import { PANE_KEY } from '../../shared/agent-hook-listener-test-harness' +import { buildRelayHookEnvelope } from '../../relay/agent-hook-envelope-build' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) + +const servers: AgentHookServer[] = [] +afterEach(() => { + for (const server of servers) { + server.stop() + } + servers.length = 0 +}) + +function setup() { + const server = new AgentHookServer() + servers.push(server) + const relayState = createHookListenerState() + function envelope(payload: Record) { + const event = normalizeHookPayload( + relayState, + 'codex', + { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'folder-workspace', + payload + }, + 'production' + ) + if (!event) { + throw new Error('Expected a Codex hook') + } + return buildRelayHookEnvelope(event, 'codex') + } + function ingest(payload: Record) { + server.ingestRemote(envelope(payload), 'synthetic-connection') + return server.getStatusSnapshot()[0] + } + return { server, envelope, ingest } +} + +const automaticPermission = { + hook_event_name: 'PermissionRequest', + permission_mode: 'bypassPermissions', + tool_name: 'Bash', + tool_input: { command: 'sleep 10' } +} + +describe('Codex noninteractive permissions across SSH normalization', () => { + it('stores the noninteractive status from an authenticated local hook POST', async () => { + const { server } = setup() + await server.start({ env: 'production' }) + const env = server.buildPtyEnv() + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify({ paneKey: PANE_KEY, payload: automaticPermission }) + }) + expect(response.status).toBe(204) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', toolName: 'Bash' }) + expect(server.getStatusSnapshot()[0]?.interactivePrompt).toBeUndefined() + }) + + it('preserves host normalization in the authoritative store for a folder workspace', () => { + const { ingest } = setup() + expect(ingest(automaticPermission)).toMatchObject({ + state: 'working', + worktreeId: 'folder-workspace', + toolName: 'Bash' + }) + }) + + it('keeps a child human wait visible without stranding the noninteractive parent', () => { + const { ingest } = setup() + ingest({ hook_event_name: 'SessionStart' }) + ingest({ hook_event_name: 'SubagentStart', agent_id: 'synthetic-child' }) + expect( + ingest({ + ...automaticPermission, + agent_id: 'synthetic-child', + permission_mode: 'default' + })?.state + ).toBe('waiting') + expect(ingest(automaticPermission)?.state).toBe('waiting') + expect( + ingest({ + hook_event_name: 'SubagentStop', + agent_id: 'synthetic-child' + })?.state + ).toBe('working') + }) + + it('does not make a child auto-review hide the parent human approval', () => { + const { ingest } = setup() + ingest({ hook_event_name: 'SessionStart' }) + ingest({ ...automaticPermission, permission_mode: 'default' }) + expect(ingest({ ...automaticPermission, agent_id: 'synthetic-child' })?.state).toBe('waiting') + }) + + it('retains attention with an old relay that has no noninteractive proof', () => { + const { server, envelope } = setup() + const legacy = envelope({ ...automaticPermission, permission_mode: undefined }) + expect(legacy.codexNonInteractivePermission).toBeUndefined() + server.ingestRemote(JSON.parse(JSON.stringify(legacy)), 'synthetic-connection') + expect(server.getStatusSnapshot()[0]?.state).toBe('waiting') + }) + + it('fails open if an older transport strips the optional proof', () => { + const { server, envelope } = setup() + const current = envelope(automaticPermission) + const { codexNonInteractivePermission: _proof, ...legacy } = current + server.ingestRemote(legacy, 'synthetic-connection') + expect(server.getStatusSnapshot()[0]?.state).toBe('waiting') + }) + + it.each([false, 'true', 1])('rejects malformed remote proof (%s)', (proof) => { + const { server, envelope } = setup() + server.ingestRemote( + { + ...envelope({ ...automaticPermission, permission_mode: 'default' }), + codexNonInteractivePermission: proof + }, + 'synthetic-connection' + ) + expect(server.getStatusSnapshot()[0]?.state).toBe('waiting') + }) + + it('preserves the normal question PreToolUse across the relay', () => { + const { ingest } = setup() + expect( + ingest({ + ...automaticPermission, + hook_event_name: 'PreToolUse', + tool_name: 'request_user_input' + })?.state + ).toBe('waiting') + }) + + it('never applies remote permission proof to a human question', () => { + const { server, envelope } = setup() + server.ingestRemote( + { + ...envelope({ ...automaticPermission, tool_name: 'request_user_input' }), + codexNonInteractivePermission: true + }, + 'synthetic-connection' + ) + expect(server.getStatusSnapshot()[0]?.state).toBe('waiting') + }) +}) diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts index 7f2008114f18..e0d4cd6e46df 100644 --- a/src/main/agent-hooks/server/server-ingest-remote.ts +++ b/src/main/agent-hooks/server/server-ingest-remote.ts @@ -1,3 +1,4 @@ +import { isAskUserQuestionTool } from '../../../shared/agent-question-answered-intent' import { track } from '../../telemetry/client' import { normalizeAgentStatusPayload } from '../../../shared/agent-status-types' import { normalizeAgentProviderSession } from '../../../shared/agent-session-resume' @@ -37,6 +38,7 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS providerPromptId?: unknown grokPromptBoundary?: unknown compactTrigger?: unknown + codexNonInteractivePermission?: unknown toolUseId?: string toolAgentId?: string teammateName?: string @@ -261,6 +263,13 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS providerPromptId, grokPromptBoundary, compactTrigger, + codexNonInteractivePermission: + source === 'codex' && + hookEventName === 'PermissionRequest' && + envelope.codexNonInteractivePermission === true && + !isAskUserQuestionTool(normalizedPayload.toolName) + ? true + : undefined, toolUseId, toolAgentId, teammateName, diff --git a/src/main/agent-hooks/server/server-persistence.ts b/src/main/agent-hooks/server/server-persistence.ts index 6d811216f9f0..39da89abe9db 100644 --- a/src/main/agent-hooks/server/server-persistence.ts +++ b/src/main/agent-hooks/server/server-persistence.ts @@ -35,6 +35,7 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio const childOnlyBoundary = enrichedPayload.claudeLeadBoundaryChildOnly === true const { claudeRunningNonAgentTask: _claudeRunningNonAgentTask, + codexNonInteractivePermission: _codexNonInteractivePermission, promptInteractionKey: _promptInteractionKey, // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. restoredUnconfirmed: _restoredUnconfirmed, diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index fb165ce1c837..6e878f8cd8a1 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -89,7 +89,8 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA terminalOwnedPayload.hookEventName, terminalOwnedPayload.toolAgentId, terminalOwnedPayload.payload, - previous?.payload + previous?.payload, + terminalOwnedPayload.codexNonInteractivePermission ) } : terminalOwnedPayload diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index b4cf176b176c..9f05f57c919e 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -30,6 +30,7 @@ export type EnrichedAgentHookEventPayload = AgentHookEventPayload & { export type PersistedAgentHookEventPayload = Omit< EnrichedAgentHookEventPayload, | 'claudeRunningNonAgentTask' + | 'codexNonInteractivePermission' | 'launchToken' | 'promptInteractionKey' | 'restoredUnconfirmed' diff --git a/src/relay/agent-hook-envelope-build.ts b/src/relay/agent-hook-envelope-build.ts index 6b1b45432d8b..8736f1f0fad2 100644 --- a/src/relay/agent-hook-envelope-build.ts +++ b/src/relay/agent-hook-envelope-build.ts @@ -27,6 +27,7 @@ export function buildRelayHookEnvelope( providerPromptId: event.providerPromptId, grokPromptBoundary: event.grokPromptBoundary, compactTrigger: event.compactTrigger, + codexNonInteractivePermission: event.codexNonInteractivePermission, toolUseId: event.toolUseId, toolAgentId: event.toolAgentId, teammateName: event.teammateName, diff --git a/src/renderer/src/components/terminal-pane/agent-completion-codex-live-permissions.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-codex-live-permissions.test.ts new file mode 100644 index 000000000000..6a27176b4a0d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-completion-codex-live-permissions.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest' +import { normalizeHookPayload } from '../../../../shared/agent-hook-listener' +import { createHookListenerState } from '../../../../shared/agent-hook-listener/listener-state' +import { PANE_KEY } from '../../../../shared/agent-hook-listener-test-harness' +import { createAgentCompletionCoordinator } from './agent-completion-coordinator' +import { useAgentCompletionCoordinatorLifecycle } from './agent-completion-coordinator-test-harness' + +function setup() { + const dispatchAttention = vi.fn() + const dispatchCompletion = vi.fn() + const dispatchHookLifecycle = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: PANE_KEY, + getPtyId: () => 'synthetic-pty', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchAttention, + dispatchCompletion, + dispatchHookLifecycle, + isLive: () => true + }) + const state = createHookListenerState() + function hook(eventName: string, permissionMode = 'bypassPermissions', toolName = 'Bash') { + const event = normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: eventName, + permission_mode: permissionMode, + tool_name: toolName, + tool_input: + toolName === 'Bash' + ? { command: 'sleep 10' } + : { questions: [{ id: 'choice', header: 'Choice', question: 'Which option?' }] } + } + }, + 'production' + ) + if (!event) { + throw new Error('Expected a normalized Codex event') + } + coordinator.observeHookStatus(event.payload) + return event.payload + } + return { coordinator, hook, dispatchAttention, dispatchCompletion, dispatchHookLifecycle } +} + +describe('Codex live permission hooks through attention dispatch', () => { + useAgentCompletionCoordinatorLifecycle() + + it('does not notify during auto-review or an approved command running longer than the debounce', () => { + const { coordinator, hook, dispatchAttention, dispatchCompletion, dispatchHookLifecycle } = + setup() + try { + hook('PreToolUse') + const permission = hook('PermissionRequest') + // No further hook arrives while review and the approved ten-second command run. + for (let sample = 0; sample < 5; sample++) { + vi.advanceTimersByTime(2_000) + expect(dispatchAttention).not.toHaveBeenCalled() + expect(permission.state).toBe('working') + expect(permission.interactivePrompt).toBeUndefined() + expect(dispatchCompletion).not.toHaveBeenCalled() + expect(dispatchHookLifecycle).not.toHaveBeenCalledWith( + expect.objectContaining({ state: 'waiting' }) + ) + } + hook('PostToolUse') + vi.advanceTimersByTime(2_000) + expect(dispatchAttention).not.toHaveBeenCalled() + } finally { + coordinator.dispose() + } + }) + + it.each([ + ['PermissionRequest', 'default', 'Bash'], + ['PermissionRequest', 'unknown', 'Bash'], + ['PreToolUse', 'bypassPermissions', 'request_user_input'], + ['PermissionRequest', 'bypassPermissions', 'request_user_input'] + ])('notifies for an unanswered human %s in %s (%s)', (eventName, mode, toolName) => { + const { coordinator, hook, dispatchAttention, dispatchCompletion } = setup() + try { + hook('PreToolUse') + hook(eventName, mode, toolName) + vi.advanceTimersByTime(1_499) + expect(dispatchAttention).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + expect(dispatchAttention).toHaveBeenCalledExactlyOnceWith( + 'codex', + expect.objectContaining({ + agentStatus: expect.objectContaining({ state: 'waiting', toolName }) + }) + ) + vi.advanceTimersByTime(8_500) + expect(dispatchAttention).toHaveBeenCalledTimes(1) + expect(dispatchCompletion).not.toHaveBeenCalled() + } finally { + coordinator.dispose() + } + }) +}) diff --git a/src/shared/agent-hook-listener-codex-permissions.test.ts b/src/shared/agent-hook-listener-codex-permissions.test.ts new file mode 100644 index 000000000000..980346cac8be --- /dev/null +++ b/src/shared/agent-hook-listener-codex-permissions.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { normalizeHookPayload } from './agent-hook-listener' +import { createHookListenerState } from './agent-hook-listener/listener-state' +import { PANE_KEY } from './agent-hook-listener-test-harness' + +function permissionEvent(permissionMode: unknown, toolName = 'Bash') { + return { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PermissionRequest', + permission_mode: permissionMode, + tool_name: toolName, + tool_input: { command: 'sleep 10' } + } + } +} + +describe('Codex live permission mode', () => { + it('keeps sandbox auto-review working without creating a human approval card', () => { + const state = createHookListenerState() + const result = normalizeHookPayload( + state, + 'codex', + permissionEvent('bypassPermissions'), + 'production' + ) + expect(result?.payload).toMatchObject({ + state: 'working', + toolName: 'Bash', + toolInput: 'sleep 10' + }) + expect(result?.payload.interactivePrompt).toBeUndefined() + expect(result?.codexNonInteractivePermission).toBe(true) + }) + + it.each([undefined, null, 'default', 'acceptEdits', 'plan', 'dontAsk', 'unknown', true])( + 'preserves human attention without explicit noninteractive proof (%s)', + (mode) => { + const result = normalizeHookPayload( + createHookListenerState(), + 'codex', + permissionEvent(mode), + 'production' + ) + expect(result?.payload.state).toBe('waiting') + expect(result?.payload.interactivePrompt).toContain('approval') + expect(result?.codexNonInteractivePermission).toBeUndefined() + } + ) + + it.each(['PreToolUse', 'PermissionRequest'])( + 'preserves a question in bypass mode on %s', + (eventName) => { + const event = permissionEvent('bypassPermissions', 'request_user_input') + event.payload.hook_event_name = eventName + const result = normalizeHookPayload(createHookListenerState(), 'codex', event, 'production') + expect(result?.payload.state).toBe('waiting') + expect(result?.payload.interactivePrompt).toBeDefined() + expect(result?.codexNonInteractivePermission).toBeUndefined() + } + ) + + it('uses each hook mode, including an in-session switch back to human approval', () => { + const state = createHookListenerState() + for (const mode of ['default', 'bypassPermissions', 'default']) { + const result = normalizeHookPayload(state, 'codex', permissionEvent(mode), 'production') + expect(result?.payload.state).toBe(mode === 'default' ? 'waiting' : 'working') + expect(Boolean(result?.payload.interactivePrompt)).toBe(mode === 'default') + } + }) + + it('ignores envelope proof that was not derived from the live hook mode', () => { + const result = normalizeHookPayload( + createHookListenerState(), + 'codex', + { + ...permissionEvent('default'), + codexNonInteractivePermission: true + }, + 'production' + ) + expect(result?.payload.state).toBe('waiting') + expect(result?.codexNonInteractivePermission).toBeUndefined() + }) + + it('does not apply Codex permission semantics to another provider', () => { + const result = normalizeHookPayload( + createHookListenerState(), + 'claude', + permissionEvent('bypassPermissions'), + 'production' + ) + expect(result?.payload.state).toBe('waiting') + expect(result?.codexNonInteractivePermission).toBeUndefined() + }) +}) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 7df46e74cbc0..8f8a86dc89e7 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -18,6 +18,7 @@ import { extractPromptText } from './agent-hook-listener/prompt-fields' import { normalizeProviderEvent } from './agent-hook-listener/provider-dispatch' import { hasExplicitUserPrompt } from './agent-hook-listener/provider-event-routing' import { hasExplicitAmpPrompt } from './agent-hook-listener/providers/amp-events' +import { isCodexNonInteractivePermissionRequest } from './agent-hook-listener/providers/codex-permission-request' import { readString } from './agent-hook-listener/tool-input-preview' /** Canonical transport-agnostic normalization entry shared by main and relay listeners. */ export function normalizeHookPayload( @@ -162,6 +163,10 @@ export function normalizeHookPayload( source === 'grok' ? (grokActiveTurn?.promptId ?? providerPromptId) : providerPromptId, grokPromptBoundary: grokActiveTurn ? true : undefined, compactTrigger, + codexNonInteractivePermission: + source === 'codex' && isCodexNonInteractivePermissionRequest(eventName, hookPayloadRecord) + ? true + : undefined, toolUseId: readFirstString(hookPayloadRecord, ['tool_use_id', 'toolUseId']), toolAgentId: readFirstString(hookPayloadRecord, ['agent_id', 'agentId']), teammateName: diff --git a/src/shared/agent-hook-listener/listener-event.ts b/src/shared/agent-hook-listener/listener-event.ts index 48797d09d8f9..f238df02de1e 100644 --- a/src/shared/agent-hook-listener/listener-event.ts +++ b/src/shared/agent-hook-listener/listener-event.ts @@ -27,6 +27,8 @@ export type AgentHookEventPayload = { grokPromptBoundary?: true /** Active Claude compact generation, keyed by provider prompt identity. */ compactTrigger?: 'manual' | 'auto' + /** Execution-host proof that this Codex permission hook cannot ask a human. */ + codexNonInteractivePermission?: true /** Claude tool-use identifier when the hook source exposes one. */ toolUseId?: string /** Claude agent/subagent identifier when the hook source exposes one. */ diff --git a/src/shared/agent-hook-listener/providers/codex-events.ts b/src/shared/agent-hook-listener/providers/codex-events.ts index bcd0474b9400..5ac3e6ee2406 100644 --- a/src/shared/agent-hook-listener/providers/codex-events.ts +++ b/src/shared/agent-hook-listener/providers/codex-events.ts @@ -17,6 +17,7 @@ import type { HookListenerState } from '../listener-state' import { resolvePrompt, resolveToolState } from '../prompt-fields' import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' import { readString } from '../tool-input-preview' +import { isCodexNonInteractivePermissionRequest } from './codex-permission-request' import { getOrCreateCodexSubagentRoster, getOrCreateCodexSubagentTranscriptState, @@ -117,6 +118,7 @@ export function normalizeCodexEvent( const stateName = eventName === 'SessionStart' || eventName === 'UserPromptSubmit' || + isCodexNonInteractivePermissionRequest(eventName, hookPayload) || (eventName === 'PreToolUse' && !isUserInputPreTool) || eventName === 'PostToolUse' ? 'working' diff --git a/src/shared/agent-hook-listener/providers/codex-permission-request.ts b/src/shared/agent-hook-listener/providers/codex-permission-request.ts new file mode 100644 index 000000000000..15b4d6126ddd --- /dev/null +++ b/src/shared/agent-hook-listener/providers/codex-permission-request.ts @@ -0,0 +1,14 @@ +import { isAskUserQuestionTool } from '../../agent-question-answered-intent' +import { readString } from '../tool-input-preview' + +export function isCodexNonInteractivePermissionRequest( + eventName: unknown, + hookPayload: Record +): boolean { + // Codex maps approval_policy=never to bypassPermissions, including sandbox auto-review. + return ( + eventName === 'PermissionRequest' && + hookPayload.permission_mode === 'bypassPermissions' && + !isAskUserQuestionTool(readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name')) + ) +} diff --git a/src/shared/agent-hook-listener/providers/codex-state.ts b/src/shared/agent-hook-listener/providers/codex-state.ts index 1131008ca7a7..52baf233441f 100644 --- a/src/shared/agent-hook-listener/providers/codex-state.ts +++ b/src/shared/agent-hook-listener/providers/codex-state.ts @@ -1,3 +1,4 @@ +import { isAskUserQuestionTool } from '../../agent-question-answered-intent' import type { ParsedAgentStatusPayload } from '../../agent-status-types' import { codexRosterEffectiveState, @@ -73,14 +74,19 @@ export function markCodexLeadTurnInterrupted(state: HookListenerState, paneKey: } export function codexLeadStateForHookEvent( - eventName: string | undefined + eventName: string | undefined, + nonInteractivePermission = false, + toolName?: string ): CodexLeadTurnState['state'] | undefined { if (eventName === 'Stop') { return 'done' } - if (eventName === 'PermissionRequest') { + if (eventName === 'PreToolUse' && isAskUserQuestionTool(toolName)) { return 'waiting' } + if (eventName === 'PermissionRequest') { + return nonInteractivePermission ? 'working' : 'waiting' + } if ( eventName === 'SessionStart' || eventName === 'UserPromptSubmit' || @@ -99,7 +105,8 @@ export function reconcileRemoteCodexState( eventName: string | undefined, agentId: string | undefined, payload: ParsedAgentStatusPayload, - previous: ParsedAgentStatusPayload | undefined + previous: ParsedAgentStatusPayload | undefined, + nonInteractivePermission = false ): ParsedAgentStatusPayload { if (previous?.agentType === 'codex') { seedCodexStateFromSnapshot(state, paneKey, previous) @@ -120,7 +127,11 @@ export function reconcileRemoteCodexState( finishCodexSubagent(roster, agentId) } } else { - const leadState = codexLeadStateForHookEvent(eventName) + const leadState = codexLeadStateForHookEvent( + eventName, + nonInteractivePermission, + payload.toolName + ) if (eventName === 'SessionStart' || (eventName === 'Stop' && !payload.subagents)) { roster.clear() } diff --git a/src/shared/agent-hook-listener/providers/codex-tool-fields.ts b/src/shared/agent-hook-listener/providers/codex-tool-fields.ts index 09b87c794a50..1e17dcaf8450 100644 --- a/src/shared/agent-hook-listener/providers/codex-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/codex-tool-fields.ts @@ -6,6 +6,7 @@ import { toolUpdate } from '../tool-input-preview' import { deriveInteractivePrompt } from '../interactive-tool' +import { isCodexNonInteractivePermissionRequest } from './codex-permission-request' export function extractCodexToolFields( eventName: unknown, @@ -26,7 +27,9 @@ export function extractCodexToolFields( { toolName, toolInput, - interactivePrompt: deriveInteractivePrompt(toolName, rawInput, eventName) + interactivePrompt: isCodexNonInteractivePermissionRequest(eventName, hookPayload) + ? undefined + : deriveInteractivePrompt(toolName, rawInput, eventName) }, { hasToolInputField: hasAnyOwnField(hookPayload, ['tool_input', 'input', 'arguments']) } ) diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index dadfa590a4e7..03ee0763fed7 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -91,6 +91,8 @@ export type AgentHookRelayEnvelope = { grokPromptBoundary?: true /** Active Claude compact generation, keyed by provider prompt identity. */ compactTrigger?: 'manual' | 'auto' + /** Optional execution-host proof; older relays retain ordinary permission attention. */ + codexNonInteractivePermission?: true /** Claude tool execution id, when the source hook provides one. */ toolUseId?: string /** Claude subagent identity, when the source hook provides one. */ From e1ac6aff3448a243c97c8a1a9ef7cdc3aca50c0b Mon Sep 17 00:00:00 2001 From: andrew12-forge Date: Tue, 15 Sep 2026 10:13:47 -0700 Subject: [PATCH 2/2] fix(codex): preserve permission proof through SSH ingress --- ...r-codex-noninteractive-permissions.test.ts | 2 +- ...ay-session-agent-hooks.integration.test.ts | 56 +++++++++++++++++++ src/main/ssh/ssh-relay-session.ts | 2 + ...-completion-codex-live-permissions.test.ts | 4 +- ...nt-hook-listener-codex-permissions.test.ts | 2 +- .../providers/codex-permission-request.ts | 2 +- 6 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/main/agent-hooks/server-codex-noninteractive-permissions.test.ts b/src/main/agent-hooks/server-codex-noninteractive-permissions.test.ts index b5a2bb2a0705..8156aede5961 100644 --- a/src/main/agent-hooks/server-codex-noninteractive-permissions.test.ts +++ b/src/main/agent-hooks/server-codex-noninteractive-permissions.test.ts @@ -97,7 +97,7 @@ describe('Codex noninteractive permissions across SSH normalization', () => { ).toBe('working') }) - it('does not make a child auto-review hide the parent human approval', () => { + it('does not make a child noninteractive permission hook hide the parent human approval', () => { const { ingest } = setup() ingest({ hook_event_name: 'SessionStart' }) ingest({ ...automaticPermission, permission_mode: 'default' }) diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index 87d5c77f2050..264ed9ee23eb 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -6,6 +6,9 @@ import type { SshConnection } from './ssh-connection' import type { MultiplexerTransport } from './ssh-channel-multiplexer' import type { AgentHookRelayEnvelope } from '../../shared/agent-hook-relay' import { RelayDispatcher } from '../../relay/dispatcher' +import { buildRelayHookEnvelope } from '../../relay/agent-hook-envelope-build' +import { normalizeHookPayload } from '../../shared/agent-hook-listener' +import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state' import { AGENT_HOOK_NOTIFICATION_METHOD, AGENT_HOOK_REQUEST_REPLAY_METHOD, @@ -636,6 +639,59 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { ingestSpy.mockRestore() }) + it.each([ + ['bypassPermissions', 'PermissionRequest', 'Bash', 'working'], + ['default', 'PermissionRequest', 'Bash', 'waiting'], + [undefined, 'PermissionRequest', 'Bash', 'waiting'], + ['bypassPermissions', 'PermissionRequest', 'request_user_input', 'waiting'], + ['bypassPermissions', 'PreToolUse', 'request_user_input', 'waiting'] + ])( + 'carries Codex permission mode %s through SSH (%s, %s)', + async (mode, eventName, toolName, state) => { + relay = createFakeRelay() + vi.mocked(deployAndLaunchRelay).mockResolvedValue({ + transport: relay.transport, + serverBuildId: 'test-relay-build', + platform: 'linux-x64' + }) + session = createSession('conn-codex-permission') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked deployment never reads the SSH connection. + await session.establish({} as SshConnection) + const event = normalizeHookPayload( + createHookListenerState(), + 'codex', + { + paneKey: `tab-ssh:${SSH_LEAF_ID}`, + worktreeId: 'synthetic-folder', + payload: { + hook_event_name: eventName, + permission_mode: mode, + tool_name: toolName, + tool_input: + toolName === 'Bash' + ? { command: 'sleep 10' } + : { questions: [{ id: 'choice', header: 'Choice', question: 'Which option?' }] } + } + }, + REMOTE_AGENT_HOOK_ENV + ) + if (!event) { + throw new Error('Expected a normalized Codex hook') + } + relay.notifyAgentHook(buildRelayHookEnvelope(event, 'codex', REMOTE_AGENT_HOOK_ENV)) + await vi.waitFor(() => + expect(agentHookServer.getStatusSnapshot()[0]).toMatchObject({ + state, + toolName, + worktreeId: 'synthetic-folder' + }) + ) + expect(Boolean(agentHookServer.getStatusSnapshot()[0]?.interactivePrompt)).toBe( + state === 'waiting' + ) + } + ) + it('tracks prompt sent from live SSH agent hooks but not replayed hooks', async () => { relay = createFakeRelay() vi.mocked(deployAndLaunchRelay).mockResolvedValue({ diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index ef33f2ecadd8..89b06517bc35 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1591,6 +1591,8 @@ export class SshRelaySession { providerPromptId: envelope.providerPromptId, grokPromptBoundary: envelope.grokPromptBoundary === true ? true : undefined, compactTrigger: envelope.compactTrigger, + codexNonInteractivePermission: + envelope.codexNonInteractivePermission === true ? true : undefined, toolUseId: typeof envelope.toolUseId === 'string' ? envelope.toolUseId : undefined, toolAgentId: typeof envelope.toolAgentId === 'string' ? envelope.toolAgentId : undefined, teammateName: diff --git a/src/renderer/src/components/terminal-pane/agent-completion-codex-live-permissions.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-codex-live-permissions.test.ts index 6a27176b4a0d..8687198ce624 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-codex-live-permissions.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-codex-live-permissions.test.ts @@ -50,13 +50,13 @@ function setup() { describe('Codex live permission hooks through attention dispatch', () => { useAgentCompletionCoordinatorLifecycle() - it('does not notify during auto-review or an approved command running longer than the debounce', () => { + it('does not notify for a noninteractive command running longer than the debounce', () => { const { coordinator, hook, dispatchAttention, dispatchCompletion, dispatchHookLifecycle } = setup() try { hook('PreToolUse') const permission = hook('PermissionRequest') - // No further hook arrives while review and the approved ten-second command run. + // No further hook arrives while the noninteractive ten-second command runs. for (let sample = 0; sample < 5; sample++) { vi.advanceTimersByTime(2_000) expect(dispatchAttention).not.toHaveBeenCalled() diff --git a/src/shared/agent-hook-listener-codex-permissions.test.ts b/src/shared/agent-hook-listener-codex-permissions.test.ts index 980346cac8be..f7e3c9fdb1c2 100644 --- a/src/shared/agent-hook-listener-codex-permissions.test.ts +++ b/src/shared/agent-hook-listener-codex-permissions.test.ts @@ -16,7 +16,7 @@ function permissionEvent(permissionMode: unknown, toolName = 'Bash') { } describe('Codex live permission mode', () => { - it('keeps sandbox auto-review working without creating a human approval card', () => { + it('keeps noninteractive permission hooks working without creating a human approval card', () => { const state = createHookListenerState() const result = normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener/providers/codex-permission-request.ts b/src/shared/agent-hook-listener/providers/codex-permission-request.ts index 15b4d6126ddd..657bc305dbc3 100644 --- a/src/shared/agent-hook-listener/providers/codex-permission-request.ts +++ b/src/shared/agent-hook-listener/providers/codex-permission-request.ts @@ -5,7 +5,7 @@ export function isCodexNonInteractivePermissionRequest( eventName: unknown, hookPayload: Record ): boolean { - // Codex maps approval_policy=never to bypassPermissions, including sandbox auto-review. + // Codex maps approval_policy=never to bypassPermissions; this hook cannot ask a human. return ( eventName === 'PermissionRequest' && hookPayload.permission_mode === 'bypassPermissions' &&