Skip to content
Merged
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
2 changes: 2 additions & 0 deletions packages/control-plane/src/http/dto/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2334,6 +2334,7 @@ export const SessionDto = z.object({
model: z.string().nullable(),
effort: z.string().nullable(),
fastMode: z.boolean().nullable(),
// Effective session permission preset reported by the daemon; Codex Auto may be composite.
permissionMode: z.string().nullable(),
outputMode: z.string().nullable(),
daemonId: z.string().nullable(),
Expand Down Expand Up @@ -2411,6 +2412,7 @@ export const SessionDetailDto = z.object({
model: z.string().nullable(),
effort: z.string().nullable(),
fastMode: z.boolean().nullable(),
// Effective session permission preset reported by the daemon; Codex Auto may be composite.
permissionMode: z.string().nullable(),
outputMode: z.string().nullable(),
daemonId: z.string().nullable(),
Expand Down
86 changes: 81 additions & 5 deletions packages/daemon/src/acp/acp-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
import { sandboxWrap, type SandboxMechanism } from './sandbox.js'
import type { Logger } from '../log.js'
import { accountAppIsolation } from './account-apps.js'
import { permissionPresetSettings, type SessionApprovalsReviewer } from './permission-modes.js'

// The raw session config-option shapes (from the ACP SDK), re-exported so
// sessionConfigOptions() consumers can type the option tree without importing
Expand Down Expand Up @@ -80,6 +81,12 @@ export interface PermissionModeOptions {
modes: string[]
}

/** A runtime's advertised approval-reviewer selector. Independent from the mode. */
export interface ApprovalsReviewerOptions {
current?: SessionApprovalsReviewer
reviewers: SessionApprovalsReviewer[]
}

export type AcpPermissionPolicyEvent =
| { kind: 'requested' }
| {
Expand Down Expand Up @@ -443,6 +450,25 @@ export function permissionModeOptionsFrom(
return { current: opt.currentValue, modes }
}

/** Extract codex-acp's approval-reviewer selector, retaining only values AgentConnect
* can represent. Older runtimes omit it and therefore expose no Auto preset. */
export function approvalsReviewerOptionsFrom(
configOptions: SessionConfigOption[] | null | undefined
): ApprovalsReviewerOptions | null {
const opt = configOptions?.find((o) => o.category === APPROVALS_REVIEWER_CATEGORY && o.type === 'select')
if (!opt || opt.type !== 'select') return null
const isReviewer = (value: string): value is SessionApprovalsReviewer => value === 'user' || value === 'auto_review'
const reviewers = opt.options
.flatMap((o) => ('group' in o ? o.options : [o]))
.map((o) => o.value)
.filter(isReviewer)
if (reviewers.length === 0) return null
return {
...(isReviewer(opt.currentValue) ? { current: opt.currentValue } : {}),
reviewers
}
}

/**
* Extract the fast-mode toggle from a session's `configOptions` (ACP `category:
* "model_config"`, `type: "select"` with on/off values). Returns null when the current
Expand Down Expand Up @@ -476,6 +502,7 @@ export class AcpHost {
private lastModelOptions: ModelOptions | null = null
private lastEffortOptions: EffortOptions | null = null
private lastPermissionModeOptions: PermissionModeOptions | null = null
private lastApprovalsReviewerOptions: ApprovalsReviewerOptions | null = null
private lastFastOption: FastModeOption | null = null
// The most recent reconciled config-option set per live session, cached so a
// mid-session `set_config_option` (e.g. setSessionModel) can plan against the
Expand Down Expand Up @@ -848,10 +875,24 @@ export class AcpHost {
// ultracode already forces effort to xhigh).
const ultracode = this.isClaudeRuntime() && this.opts.configPrefs?.reasoningEffort === ULTRACODE_EFFORT
const fastMode = this.opts.configPrefs?.fastMode
const permissionMode = this.opts.configPrefs?.permissionMode
const approvalsReviewer = this.opts.configPrefs?.approvalsReviewer
// Clear Auto-review before widening permissions; when enabling it, establish
// Agent mode first. This also protects restored sessions whose persisted
// selector state differs from the Agent's current configuration.
const permissionPrefs: Array<[category: string, desired: string | undefined]> =
approvalsReviewer === 'user'
? [
[APPROVALS_REVIEWER_CATEGORY, approvalsReviewer],
['mode', permissionMode]
]
: [
['mode', permissionMode],
[APPROVALS_REVIEWER_CATEGORY, approvalsReviewer]
]
const prefs: Array<[category: string, desired: string | undefined]> = [
['model', this.opts.configPrefs?.model],
['mode', this.opts.configPrefs?.permissionMode],
[APPROVALS_REVIEWER_CATEGORY, this.opts.configPrefs?.approvalsReviewer],
...permissionPrefs,
['thought_level', ultracode ? undefined : this.opts.configPrefs?.reasoningEffort],
// Fast mode comes AFTER model: the option is only advertised (and the
// reconciled option set only carries it) once a fast-capable model is set.
Expand Down Expand Up @@ -884,6 +925,7 @@ export class AcpHost {
this.lastModelOptions = modelOptionsFrom(configOptions)
this.lastEffortOptions = effortOptionsFrom(configOptions, this.isClaudeRuntime())
this.lastPermissionModeOptions = permissionModeOptionsFrom(configOptions)
this.lastApprovalsReviewerOptions = approvalsReviewerOptionsFrom(configOptions)
this.lastFastOption = fastOptionFrom(configOptions)
}

Expand All @@ -908,12 +950,20 @@ export class AcpHost {
return this.lastEffortOptions
}

/** The permission/approval mode selector from the most recent session/new|load, or
* null if none is offered. */
permissionModeOptions(): PermissionModeOptions | null {
/** The permission/approval mode selector for one live session, or the most recently
* reconciled selector when no session is supplied. */
permissionModeOptions(sessionId?: string): PermissionModeOptions | null {
if (sessionId !== undefined) return permissionModeOptionsFrom(this.sessionConfigs.get(sessionId))
return this.lastPermissionModeOptions
}

/** The approval-reviewer selector for one live session, or the most recently
* reconciled selector when no session is supplied. */
approvalsReviewerOptions(sessionId?: string): ApprovalsReviewerOptions | null {
if (sessionId !== undefined) return approvalsReviewerOptionsFrom(this.sessionConfigs.get(sessionId))
return this.lastApprovalsReviewerOptions
}

/** The fast-mode toggle from the most recent session/new|load, or null if the current
* model offers no fast mode. */
fastModeOption(): FastModeOption | null {
Expand Down Expand Up @@ -965,6 +1015,32 @@ export class AcpHost {
return this.setSessionConfig(sessionId, 'mode', mode)
}

/** Switch who reviews eligible approval requests on an already-running Codex
* session. False when the runtime does not advertise the selector. */
async setSessionApprovalsReviewer(sessionId: string, reviewer: SessionApprovalsReviewer): Promise<boolean> {
return this.setSessionConfig(sessionId, APPROVALS_REVIEWER_CATEGORY, reviewer)
}

/** Apply the composite value shared by every AgentConnect session selector. The
* raw ACP mode is validated before reviewer state can change. */
async setSessionPermissionPreset(sessionId: string, preset: string): Promise<boolean> {
const settings = permissionPresetSettings(preset)
const offeredModes = permissionModeOptionsFrom(this.sessionConfigs.get(sessionId))?.modes ?? []
if (!offeredModes.includes(settings.permissionMode)) return false
const reviewerAvailable = this.approvalsReviewerOptions(sessionId) !== null
// Disable Auto-review before changing to an ordinary mode (especially Full
// Access); enable it only after the requested Agent mode is in force.
let reviewerApplied = false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ordering only protects live preset changes. applySessionConfig() still orders mode before _approvals_reviewer for session/load, so loading a session that currently has Auto and restoring Full Access + user review first creates Full Access + Auto-review, then clears it. Route configured permissions through the same conditional ordering and cover the load case.

if (reviewerAvailable && settings.approvalsReviewer === 'user') {
reviewerApplied = await this.setSessionApprovalsReviewer(sessionId, 'user')
}
const modeApplied = await this.setSessionPermissionMode(sessionId, settings.permissionMode)
if (reviewerAvailable && settings.approvalsReviewer === 'auto_review') {
reviewerApplied = await this.setSessionApprovalsReviewer(sessionId, 'auto_review')
}
return modeApplied || reviewerApplied
}

/** Toggle fast mode on an already-running session (ACP `model_config` on/off select).
* False when the current model offers no fast toggle. */
async setSessionFastMode(sessionId: string, on: boolean): Promise<boolean> {
Expand Down
64 changes: 56 additions & 8 deletions packages/daemon/src/acp/permission-modes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,75 @@
* Display labels for ACP permission/approval mode values shown to users (Slack
* status modal, Telegram/Discord select cards, `/permission` text lists).
*
* The wire and ACP `session/set_config_option` calls always carry the raw,
* runtime-owned value (`read-only` / `agent` / `agent-full-access` on codex-acp);
* these labels are a presentation layer only. Values are mapped to the name Codex's
* own UI ("Update Model Permissions", v0.144.x) gives that same approval+sandbox
* preset — matched by policy, NOT by menu position — so we can't misrepresent them:
* ACP `session/set_config_option` calls always carry the raw, runtime-owned mode
* (`read-only` / `agent` / `agent-full-access` on codex-acp). AgentConnect's
* session-control surfaces additionally use one synthetic preset for Auto-review;
* it is decomposed back into the raw mode + reviewer at the AcpHost boundary.
* Values are mapped to the name Codex's own UI ("Update Model Permissions",
* v0.144.x) gives that same approval+sandbox preset — matched by policy, NOT by
* menu position — so we can't misrepresent them:
*
* read-only → "Read Only" (approval on-request, read-only sandbox)
* agent → "Ask for approval" (on-request + workspace-write; Codex's default,
* its literal label for this preset)
* agent-full-access → "Full Access" (danger-full-access: out-of-workspace + network)
*
* NB: Codex's CLI menu also offers "Approve for me" (on-failure — ask only on unsafe
* actions), but codex-acp does not advertise a matching session mode, so it never
* appears here. Unknown values (e.g. Claude's `default` / `plan`) fall through verbatim.
* Auto is not a fourth ACP mode: it is `agent` plus `_approvals_reviewer=auto_review`.
* Unknown values (e.g. Claude's `default` / `plan`) fall through verbatim.
*/
export const AUTO_REVIEW_PERMISSION_PRESET = 'agent:auto-review'

export type SessionApprovalsReviewer = 'user' | 'auto_review'

const CODEX_MODE_LABELS: Record<string, string> = {
'read-only': 'Read Only',
agent: 'Ask for approval',
[AUTO_REVIEW_PERMISSION_PRESET]: 'Auto',
'agent-full-access': 'Full Access'
}

export function permissionModeDisplayLabel(value: string): string {
return CODEX_MODE_LABELS[value] ?? value
}

/** Compose the two independent Codex settings into the value session selectors use. */
export function selectedPermissionPreset(
permissionMode: string,
approvalsReviewer: SessionApprovalsReviewer | undefined
): string {
return permissionMode === 'agent' && approvalsReviewer === 'auto_review'
? AUTO_REVIEW_PERMISSION_PRESET
: permissionMode
}

/** Decompose a session selector value before it reaches ACP. Selecting any ordinary
* mode restores user review, so switching away from Auto cannot leave it enabled. */
export function permissionPresetSettings(preset: string): {
permissionMode: string
approvalsReviewer: SessionApprovalsReviewer
} {
return preset === AUTO_REVIEW_PERMISSION_PRESET
? { permissionMode: 'agent', approvalsReviewer: 'auto_review' }
: { permissionMode: preset, approvalsReviewer: 'user' }
}

/** Insert Auto after the ordinary Agent mode only when the live runtime advertises
* both ingredients. Already-composed lists pass through unchanged. */
export function permissionPresetValues(
permissionModes: readonly string[],
approvalsReviewers: readonly string[]
): string[] {
if (
!permissionModes.includes('agent') ||
permissionModes.includes(AUTO_REVIEW_PERMISSION_PRESET) ||
!approvalsReviewers.includes('auto_review')
) {
return [...permissionModes]
}
const agentIndex = permissionModes.indexOf('agent')
return [
...permissionModes.slice(0, agentIndex + 1),
AUTO_REVIEW_PERMISSION_PRESET,
...permissionModes.slice(agentIndex + 1)
]
}
Loading