Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions src/main/agent-hooks/server-codex-noninteractive-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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<string, unknown>) {
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 noninteractive permission hook 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')
})
})
9 changes: 9 additions & 0 deletions src/main/agent-hooks/server/server-ingest-remote.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -37,6 +38,7 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS
providerPromptId?: unknown
grokPromptBoundary?: unknown
compactTrigger?: unknown
codexNonInteractivePermission?: unknown
toolUseId?: string
toolAgentId?: string
teammateName?: string
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/main/agent-hooks/server/server-persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/main/agent-hooks/server/server-status-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
terminalOwnedPayload.hookEventName,
terminalOwnedPayload.toolAgentId,
terminalOwnedPayload.payload,
previous?.payload
previous?.payload,
terminalOwnedPayload.codexNonInteractivePermission
)
}
: terminalOwnedPayload
Expand Down
1 change: 1 addition & 0 deletions src/main/agent-hooks/server/server-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
export type PersistedAgentHookEventPayload = Omit<
EnrichedAgentHookEventPayload,
| 'claudeRunningNonAgentTask'
| 'codexNonInteractivePermission'
| 'launchToken'
| 'promptInteractionKey'
| 'restoredUnconfirmed'
Expand Down
56 changes: 56 additions & 0 deletions src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions src/main/ssh/ssh-relay-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/relay/agent-hook-envelope-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading