diff --git a/packages/control-plane/src/http/dto/index.ts b/packages/control-plane/src/http/dto/index.ts index 86ce4d160..78dd7651b 100644 --- a/packages/control-plane/src/http/dto/index.ts +++ b/packages/control-plane/src/http/dto/index.ts @@ -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(), @@ -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(), diff --git a/packages/daemon/src/acp/acp-host.ts b/packages/daemon/src/acp/acp-host.ts index 4e65af7a6..4ccafbaac 100644 --- a/packages/daemon/src/acp/acp-host.ts +++ b/packages/daemon/src/acp/acp-host.ts @@ -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 @@ -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' } | { @@ -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 @@ -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 @@ -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. @@ -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) } @@ -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 { @@ -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 { + 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 { + 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 + 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 { diff --git a/packages/daemon/src/acp/permission-modes.ts b/packages/daemon/src/acp/permission-modes.ts index a6d323b20..f7a138a29 100644 --- a/packages/daemon/src/acp/permission-modes.ts +++ b/packages/daemon/src/acp/permission-modes.ts @@ -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 = { '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) + ] +} diff --git a/packages/daemon/src/daemon.ts b/packages/daemon/src/daemon.ts index f5ec4f3e6..f610bb78e 100644 --- a/packages/daemon/src/daemon.ts +++ b/packages/daemon/src/daemon.ts @@ -35,7 +35,12 @@ import { import { AcpHost, turnFailureCode, turnFailureReason, type AcpPermissionPolicyEvent } from './acp/acp-host.js' import { detectSandbox, sandboxBoundary, SandboxError, type SandboxMechanism } from './acp/sandbox.js' import { effectiveRunInSandbox, prepareRuntimeLaunch } from './acp/runtime-launch.js' -import { permissionModeDisplayLabel } from './acp/permission-modes.js' +import { + permissionModeDisplayLabel, + permissionPresetSettings, + permissionPresetValues, + selectedPermissionPreset +} from './acp/permission-modes.js' import { SessionManager, transcriptCoords, @@ -5540,23 +5545,40 @@ export class Daemon { return true } - /** Every chat-side runtime change funnels through the same Agent-level guard before - * a sticky override can be written, including stale callbacks and relay frames. */ - private setPermissionModeByKey(key: string, permissionMode: string): boolean { + /** Apply one composite session permission value to a warm host. Older injected host + * fakes retain the two-setter fallback; real AcpHosts own the validation/decomposition. */ + private async applySessionPermissionPreset(host: AcpHost, sessionId: string, preset: string): Promise { + if (typeof host.setSessionPermissionPreset === 'function') { + await host.setSessionPermissionPreset(sessionId, preset) + return + } + const settings = permissionPresetSettings(preset) + if (settings.approvalsReviewer === 'user' && typeof host.setSessionApprovalsReviewer === 'function') { + await host.setSessionApprovalsReviewer(sessionId, 'user') + } + await host.setSessionPermissionMode(sessionId, settings.permissionMode) + if (settings.approvalsReviewer === 'auto_review' && typeof host.setSessionApprovalsReviewer === 'function') { + await host.setSessionApprovalsReviewer(sessionId, 'auto_review') + } + } + + /** Every chat-side permission-preset change funnels through the same Agent-level + * guard before a sticky override can be written, including stale callbacks and + * relay frames. */ + private setPermissionModeByKey(key: string, permissionPreset: string): boolean { const rec = this.chatRuntimeSession(key) if (!rec) return false - this.store.setPermissionModeOverride(key, permissionMode) - this.log.info(`session ${key} permission-mode override → "${permissionMode}"`) + this.store.setPermissionModeOverride(key, permissionPreset) + this.log.info(`session ${key} permission preset override → "${permissionPreset}"`) const acpSessionId = rec.acpSessionId const host = this.hosts.get(rec.agentId) if (!acpSessionId || !host?.hasSession(acpSessionId)) { this.refreshStatusBarForKey(key) return true } - void host - .setSessionPermissionMode(acpSessionId, permissionMode) + void this.applySessionPermissionPreset(host, acpSessionId, permissionPreset) .then(() => this.refreshStatusBarForKey(key)) - .catch((err) => this.log.warn(`set-permission-mode failed: ${(err as Error).message}`)) + .catch((err) => this.log.warn(`set-permission-preset failed: ${(err as Error).message}`)) return true } @@ -5580,8 +5602,12 @@ export class Daemon { const fastMode = agent.fastMode ?? false if (model) await host.setSessionModel(sessionId, model) if (effort) await host.setSessionEffort(sessionId, effort) - if (permissionMode && typeof host.setSessionPermissionMode === 'function') { - await host.setSessionPermissionMode(sessionId, permissionMode) + if (permissionMode) { + await this.applySessionPermissionPreset( + host, + sessionId, + selectedPermissionPreset(permissionMode, agent.approvalsReviewer ?? 'user') + ) } await host.setSessionFastMode(sessionId, fastMode) } @@ -8871,9 +8897,8 @@ export class Daemon { return this.setPermissionModeByKey(key, value) } - /** Display alias for a select value: permission-mode ids read as their Codex - * desktop-app names; model/effort values render verbatim. The underlying value - * (state, wire, resolution) is unchanged — this is presentation only. */ + /** Display alias for a select value: raw modes and AgentConnect's composite Auto + * preset read as their Codex names; model/effort values render verbatim. */ private selectDisplay(kind: SelectKind, value: string): string { return kind === 'permission' ? permissionModeDisplayLabel(value) : value } @@ -10602,20 +10627,19 @@ export class Daemon { .setSessionEffort(sessionId, effortOverride) .catch((err) => this.log.debug(`effort override "${effortOverride}" not applied: ${(err as Error).message}`)) } - const permissionModeOverride = allowRuntimeChangesInChat ? this.store.getPermissionModeOverride(key) : undefined - const effectivePermissionMode = - permissionModeOverride ?? + const permissionPresetOverride = allowRuntimeChangesInChat ? this.store.getPermissionModeOverride(key) : undefined + const configuredPermissionMode = runtimeAgent?.permissionMode ?? this.runtimeCatalogs.get(runtimeAgent?.runtime ?? agent.runtime)?.defaultPermissionMode - // AcpHost provides this method; older injected/embedded hosts may not. - // Treat the selector as an advertised capability, matching the other - // optional runtime controls, while real hosts still restore the Agent policy. - if (effectivePermissionMode && typeof host.setSessionPermissionMode === 'function') { - await host - .setSessionPermissionMode(sessionId, effectivePermissionMode) - .catch((err) => - this.log.debug(`permission-mode "${effectivePermissionMode}" not applied: ${(err as Error).message}`) - ) + const effectivePermissionPreset = + permissionPresetOverride ?? + (configuredPermissionMode + ? selectedPermissionPreset(configuredPermissionMode, runtimeAgent?.approvalsReviewer ?? 'user') + : undefined) + if (effectivePermissionPreset) { + await this.applySessionPermissionPreset(host, sessionId, effectivePermissionPreset).catch((err) => + this.log.debug(`permission preset "${effectivePermissionPreset}" not applied: ${(err as Error).message}`) + ) } const fastOverride = allowRuntimeChangesInChat ? this.store.getFastModeOverride(key) : undefined if (fastOverride !== undefined) { @@ -12380,7 +12404,9 @@ export class Daemon { if (fastMode !== undefined) event.fastMode = fastMode const permissionMode = (allowRuntimeChangesInChat && storeKey ? this.store.getPermissionModeOverride(storeKey) : undefined) ?? - agent?.permissionMode + (agent?.permissionMode + ? selectedPermissionPreset(agent.permissionMode, agent.approvalsReviewer ?? 'user') + : undefined) if (input.permissionMode !== undefined) event.permissionMode = input.permissionMode else if (permissionMode !== undefined) event.permissionMode = permissionMode const outputMode = (storeKey ? this.store.getOutputModeOverride(storeKey) : undefined) ?? agent?.output?.mode @@ -12439,7 +12465,8 @@ export class Daemon { : models[0] : undefined const effort = host?.effortOptions?.() - const permissionMode = host?.permissionModeOptions?.() + const permissionMode = host?.permissionModeOptions?.(acpSessionId) + const approvalsReviewer = host?.approvalsReviewerOptions?.(acpSessionId) const fast = host?.fastModeOption?.() const allowRuntimeChangesInChat = agent?.allowRuntimeChangesInChat === true // Current model: live selector, then sticky/session default, then the runtime's @@ -12447,16 +12474,22 @@ export class Daemon { // only way an `ultracode` value (which never appears in the live select) is reflected. const effortOverride = allowRuntimeChangesInChat ? this.store.getEffortOverride(sessionKey) : undefined const modelOverride = allowRuntimeChangesInChat ? this.store.getModelOverride(sessionKey) : undefined - const permissionModeOverride = allowRuntimeChangesInChat + const permissionPresetOverride = allowRuntimeChangesInChat ? this.store.getPermissionModeOverride(sessionKey) : undefined const fastOverride = allowRuntimeChangesInChat ? this.store.getFastModeOverride(sessionKey) : undefined + const currentPermissionMode = allowRuntimeChangesInChat + ? (permissionMode?.current ?? agent?.permissionMode) + : (agent?.permissionMode ?? permissionMode?.current) + const currentApprovalsReviewer = allowRuntimeChangesInChat + ? (approvalsReviewer?.current ?? agent?.approvalsReviewer ?? 'user') + : (agent?.approvalsReviewer ?? approvalsReviewer?.current ?? 'user') return { model: model?.current ?? modelOverride ?? agent?.runtimeOverrides?.model ?? fallbackModel, effort: effortOverride ?? effort?.current ?? agent?.reasoningEffort, - permissionMode: allowRuntimeChangesInChat - ? (permissionModeOverride ?? permissionMode?.current ?? agent?.permissionMode) - : (agent?.permissionMode ?? permissionMode?.current), + permissionMode: + permissionPresetOverride ?? + (currentPermissionMode ? selectedPermissionPreset(currentPermissionMode, currentApprovalsReviewer) : undefined), fastMode: fastOverride ?? fast?.current ?? agent?.fastMode, contextUsed: usage.contextUsed, contextSize: usage.contextSize, @@ -12483,7 +12516,10 @@ export class Daemon { ...(allowRuntimeChangesInChat && effort?.efforts?.length ? { efforts: effort.efforts } : {}), ...(agent ? { - permissionModes: allowRuntimeChangesInChat && permissionMode?.modes?.length ? permissionMode.modes : [] + permissionModes: + allowRuntimeChangesInChat && permissionMode?.modes?.length + ? permissionPresetValues(permissionMode.modes, approvalsReviewer?.reviewers ?? []) + : [] } : {}), ...(allowRuntimeChangesInChat && fast ? { fastModeAvailable: true } : {}), diff --git a/packages/daemon/src/slack/render.ts b/packages/daemon/src/slack/render.ts index f87332f61..b4a3fd1fa 100644 --- a/packages/daemon/src/slack/render.ts +++ b/packages/daemon/src/slack/render.ts @@ -251,6 +251,7 @@ function renderReasoning(buf: string): string { export interface StatusBarInfo { model?: string effort?: string + /** Effective session permission preset. Codex Auto is a composite value, not a raw ACP mode. */ permissionMode?: string fastMode?: boolean contextUsed?: number @@ -263,6 +264,7 @@ export interface StatusBarInfo { // ignores these; only buildStatusModal / the web bar consume them. models?: string[] efforts?: string[] + /** Selectable session presets; may include the synthetic Codex Auto value. */ permissionModes?: string[] fastModeAvailable?: boolean // Current Slack output verbosity (daemon-side minimal/low/medium/high). Modal-only selector; @@ -649,8 +651,9 @@ export function buildStatusModal( }) } - // Permission mode follows the model / effort / fast controls, matching the agent - // configuration surfaces. Values remain runtime-owned and travel verbatim. + // Permission follows the model / effort / fast controls, matching the Agent and + // console session surfaces. Codex Auto travels as one AgentConnect session preset; + // the daemon decomposes it before calling ACP. const permissionModes = info.permissionModes ?? [] if (permissionModes.length > 0) { const opts = diff --git a/packages/daemon/src/store/local-store.ts b/packages/daemon/src/store/local-store.ts index ff6cad993..c8cefd0ed 100644 --- a/packages/daemon/src/store/local-store.ts +++ b/packages/daemon/src/store/local-store.ts @@ -2006,17 +2006,19 @@ export class LocalStore { this.db.prepare('UPDATE sessions SET effortOverride = ? WHERE key = ?').run(effort, key) } - /** The session-scoped permission-mode override (set via status bars), or undefined if - * the session runs on the agent's default. Sticky across turns and restarts. */ + /** The session-scoped permission preset (set via status bars), or undefined if the + * session runs on the agent's default. Codex Auto is stored as AgentConnect's + * composite preset and decomposed only when applied to ACP. Sticky across turns + * and restarts. */ getPermissionModeOverride(key: string): string | undefined { const row = this.db.prepare('SELECT permissionModeOverride FROM sessions WHERE key = ?').get(key) as { permissionModeOverride: string | null } | undefined return row?.permissionModeOverride ?? undefined } - /** Persist the session-scoped permission-mode override. No-op on an unknown key. */ - setPermissionModeOverride(key: string, mode: string): void { - this.db.prepare('UPDATE sessions SET permissionModeOverride = ? WHERE key = ?').run(mode, key) + /** Persist the session-scoped permission preset. No-op on an unknown key. */ + setPermissionModeOverride(key: string, preset: string): void { + this.db.prepare('UPDATE sessions SET permissionModeOverride = ? WHERE key = ?').run(preset, key) } /** Revoke every chat-authored runtime override for an Agent. Output mode is diff --git a/packages/daemon/test/acp-config.test.ts b/packages/daemon/test/acp-config.test.ts index ff03542fb..9a4179f3f 100644 --- a/packages/daemon/test/acp-config.test.ts +++ b/packages/daemon/test/acp-config.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' import type { SessionConfigOption } from '@agentclientprotocol/sdk' import { + approvalsReviewerOptionsFrom, claudeSessionMeta, effortOptionsFrom, fastOptionFrom, @@ -9,6 +10,11 @@ import { planConfigSelection, SDK_LIFECYCLE_FILTERS } from '../src/acp/acp-host.js' +import { + permissionPresetSettings, + permissionPresetValues, + selectedPermissionPreset +} from '../src/acp/permission-modes.js' /** configOptions as claude-acp advertises them: mode + model + effort selects. * The effort values are the runtime's real `thought_level` enum, verified against @@ -170,6 +176,47 @@ describe('permissionModeOptionsFrom', () => { }) }) +describe('Auto-review permission presets', () => { + const reviewerSelect: SessionConfigOption[] = [ + { + id: 'approvals_reviewer', + name: 'Approval reviewer', + category: '_approvals_reviewer', + type: 'select', + currentValue: 'auto_review', + options: [ + { value: 'user', name: 'User' }, + { value: 'auto_review', name: 'Auto-review' } + ] + } + ] + + it('extracts the independent reviewer selector', () => { + expect(approvalsReviewerOptionsFrom(reviewerSelect)).toEqual({ + current: 'auto_review', + reviewers: ['user', 'auto_review'] + }) + }) + + it('round-trips Auto while keeping raw ACP modes unchanged', () => { + expect(permissionPresetValues(['read-only', 'agent', 'agent-full-access'], ['user', 'auto_review'])).toEqual([ + 'read-only', + 'agent', + 'agent:auto-review', + 'agent-full-access' + ]) + expect(selectedPermissionPreset('agent', 'auto_review')).toBe('agent:auto-review') + expect(permissionPresetSettings('agent:auto-review')).toEqual({ + permissionMode: 'agent', + approvalsReviewer: 'auto_review' + }) + expect(permissionPresetSettings('agent-full-access')).toEqual({ + permissionMode: 'agent-full-access', + approvalsReviewer: 'user' + }) + }) +}) + describe('effortOptionsFrom', () => { it('reflects the current effort + advertised levels, appending synthetic ultracode on Claude', () => { expect(effortOptionsFrom(claudeLike({ effort: 'high' }), true)).toEqual({ diff --git a/packages/daemon/test/acp-host.test.ts b/packages/daemon/test/acp-host.test.ts index 74bf03177..a3be1f102 100644 --- a/packages/daemon/test/acp-host.test.ts +++ b/packages/daemon/test/acp-host.test.ts @@ -35,19 +35,33 @@ describe('AcpHost (against a fake ACP agent)', () => { await host.stop() }, 15_000) - it('applies Auto-review through the independent approvals reviewer selector', async () => { + it('applies and switches the composite Auto permission preset through independent selectors', async () => { const host = new AcpHost( { command: process.execPath, args: [fakeAgent], env: [] }, { onUpdate: () => {}, - env: { AC_APPROVALS_REVIEWER: '1' }, + env: { + AC_APPROVALS_REVIEWER: '1', + AC_PERMISSION_MODES: 'read-only,agent,agent-full-access' + }, configPrefs: { permissionMode: 'agent', approvalsReviewer: 'auto_review' } } ) await host.start() const sessionId = await host.newSession('/tmp') - const reviewer = host.sessionConfigOptions(sessionId)?.find((option) => option.category === '_approvals_reviewer') - expect(reviewer?.currentValue).toBe('auto_review') + const mode = () => host.sessionConfigOptions(sessionId)?.find((option) => option.category === 'mode') + const reviewer = () => + host.sessionConfigOptions(sessionId)?.find((option) => option.category === '_approvals_reviewer') + expect(mode()?.currentValue).toBe('agent') + expect(reviewer()?.currentValue).toBe('auto_review') + + await host.setSessionPermissionPreset(sessionId, 'agent-full-access') + expect(mode()?.currentValue).toBe('agent-full-access') + expect(reviewer()?.currentValue).toBe('user') + + await host.setSessionPermissionPreset(sessionId, 'agent:auto-review') + expect(mode()?.currentValue).toBe('agent') + expect(reviewer()?.currentValue).toBe('auto_review') await host.stop() }, 15_000) }) @@ -89,6 +103,30 @@ describe('AcpHost session/load update filtering', () => { await host.stop() }, 15_000) + it('disables Auto-review before widening permissions on a restored session', async () => { + const host = new AcpHost( + { command: process.execPath, args: [fakeAgent], env: [] }, + { + onUpdate: () => {}, + env: { + AC_APPROVALS_REVIEWER: '1', + AC_LOAD_APPROVALS_REVIEWER: 'auto_review', + AC_LOAD_PERMISSION_MODE: 'agent', + AC_LOAD_UPDATES: '1', + AC_PERMISSION_MODES: 'read-only,agent,agent-full-access', + AC_REJECT_AUTO_FULL_ACCESS: '1' + }, + configPrefs: { permissionMode: 'agent-full-access', approvalsReviewer: 'user' } + } + ) + await host.start() + await host.loadSession('persisted-auto-session', '/tmp') + const options = host.sessionConfigOptions('persisted-auto-session') + expect(options?.find((option) => option.category === 'mode')?.currentValue).toBe('agent-full-access') + expect(options?.find((option) => option.category === '_approvals_reviewer')?.currentValue).toBe('user') + await host.stop() + }, 15_000) + it('allows only latest-wins metadata through during load', () => { expect(shouldForwardUpdateDuringLoad({ sessionUpdate: 'session_info_update', title: 'Restored' })).toBe(true) expect(shouldForwardUpdateDuringLoad({ sessionUpdate: 'usage_update', used: 1, size: 10 } as any)).toBe(true) diff --git a/packages/daemon/test/daemon-commands.test.ts b/packages/daemon/test/daemon-commands.test.ts index afc599029..c5e65afa0 100644 --- a/packages/daemon/test/daemon-commands.test.ts +++ b/packages/daemon/test/daemon-commands.test.ts @@ -894,7 +894,12 @@ describe('Daemon in-conversation commands', () => { permissionModeOptions: vi.fn(() => ({ current: 'agent', modes: ['read-only', 'agent', 'agent-full-access'] - })) + })), + approvalsReviewerOptions: vi.fn(() => ({ + current: 'user', + reviewers: ['user', 'auto_review'] + })), + setSessionApprovalsReviewer: vi.fn(async () => true) } const daemon = new Daemon({ root: scaffold(), hostFactory: () => host as any }) await daemon.start() @@ -923,13 +928,16 @@ describe('Daemon in-conversation commands', () => { const listed = conn.postMessage.mock.calls.at(-1)![1] as string expect(listed).toContain('Read Only') expect(listed).toContain('Ask for approval') + expect(listed).toContain('Auto') expect(listed).toContain('Full Access') expect(listed).not.toContain('agent-full-access') // choose by label ("full access") → resolves to the raw wire id, applied live ;(daemon as any).onInbound(dm('210', '/permission full access')) expect(store.getPermissionModeOverride(key)).toBe('agent-full-access') - expect(host.setSessionPermissionMode).toHaveBeenCalledWith('acp-1', 'agent-full-access') + await vi.waitFor(() => { + expect(host.setSessionPermissionMode).toHaveBeenCalledWith('acp-1', 'agent-full-access') + }) expect(conn.postMessage).toHaveBeenCalledWith( 'C1', expect.stringContaining('Permission mode set to Full Access'), @@ -939,6 +947,20 @@ describe('Daemon in-conversation commands', () => { // the default preset resolves from its Codex label too ("ask for approval" → agent) ;(daemon as any).onInbound(dm('220', '/permission ask for approval')) expect(store.getPermissionModeOverride(key)).toBe('agent') + await vi.waitFor(() => { + expect(host.setSessionPermissionMode).toHaveBeenCalledWith('acp-1', 'agent') + }) + host.setSessionPermissionMode.mockClear() + host.setSessionApprovalsReviewer.mockClear() + + // Auto is one session preset in every chat surface, but reaches ACP as two + // independent config selections. + ;(daemon as any).onInbound(dm('230', '/permission auto')) + expect(store.getPermissionModeOverride(key)).toBe('agent:auto-review') + await vi.waitFor(() => { + expect(host.setSessionPermissionMode).toHaveBeenCalledWith('acp-1', 'agent') + expect(host.setSessionApprovalsReviewer).toHaveBeenCalledWith('acp-1', 'auto_review') + }) await daemon.stop() }, 15_000) diff --git a/packages/daemon/test/fixtures/fake-acp-agent.mjs b/packages/daemon/test/fixtures/fake-acp-agent.mjs index 912284db7..bf2b64334 100644 --- a/packages/daemon/test/fixtures/fake-acp-agent.mjs +++ b/packages/daemon/test/fixtures/fake-acp-agent.mjs @@ -21,6 +21,11 @@ const modelList = (process.env.AC_MODELS ?? '') .map((s) => s.trim()) .filter(Boolean) const sessionModels = new Map() +const permissionModeList = (process.env.AC_PERMISSION_MODES ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean) +const sessionPermissionModes = new Map() const reviewerEnabled = process.env.AC_APPROVALS_REVIEWER === '1' const sessionReviewers = new Map() @@ -52,6 +57,15 @@ const configOptions = (sessionId) => { options: modelList.map((value) => ({ value, name: value })) }) } + if (permissionModeList.length) { + options.push({ + id: 'mode', + category: 'mode', + type: 'select', + currentValue: sessionPermissionModes.get(sessionId) ?? permissionModeList[0], + options: permissionModeList.map((value) => ({ value, name: value })) + }) + } if (reviewerEnabled) { options.push({ id: 'approvals_reviewer', @@ -76,15 +90,31 @@ rl.on('line', async (line) => { } else if (method === 'session/new') { const sessionId = `s${++sessionCounter}` sessionModels.set(sessionId, modelList[0]) + sessionPermissionModes.set(sessionId, permissionModeList[0]) sessionReviewers.set(sessionId, 'user') send({ jsonrpc: '2.0', id, result: { sessionId, configOptions: configOptions(sessionId) } }) } else if (method === 'session/set_config_option') { + if ( + process.env.AC_REJECT_AUTO_FULL_ACCESS === '1' && + params.configId === 'mode' && + params.value === 'agent-full-access' && + sessionReviewers.get(params.sessionId) === 'auto_review' + ) { + send({ jsonrpc: '2.0', id, error: { code: -32000, message: 'Auto-review must be disabled first' } }) + return + } if (params.configId === 'model' && modelList.includes(params.value)) sessionModels.set(params.sessionId, params.value) + if (params.configId === 'mode' && permissionModeList.includes(params.value)) + sessionPermissionModes.set(params.sessionId, params.value) if (params.configId === 'approvals_reviewer' && ['user', 'auto_review'].includes(params.value)) sessionReviewers.set(params.sessionId, params.value) send({ jsonrpc: '2.0', id, result: { configOptions: configOptions(params.sessionId) } }) } else if (method === 'session/load') { + if (process.env.AC_LOAD_PERMISSION_MODE) + sessionPermissionModes.set(params.sessionId, process.env.AC_LOAD_PERMISSION_MODE) + if (process.env.AC_LOAD_APPROVALS_REVIEWER) + sessionReviewers.set(params.sessionId, process.env.AC_LOAD_APPROVALS_REVIEWER) if (process.env.AC_LOAD_UPDATES) { send({ jsonrpc: '2.0', diff --git a/packages/daemon/test/render.test.ts b/packages/daemon/test/render.test.ts index bbab794ec..ae36ce6c1 100644 --- a/packages/daemon/test/render.test.ts +++ b/packages/daemon/test/render.test.ts @@ -1154,6 +1154,19 @@ describe('buildStatusModal (Configure controls modal)', () => { expect(select.initial_option.text.text).toBe('Permission · Full Access') }) + it('renders Auto as one Slack session permission option', () => { + const view = buildStatusModal( + { + permissionMode: 'agent:auto-review', + permissionModes: ['read-only', 'agent', 'agent:auto-review', 'agent-full-access'] + }, + KEY + ) + const select = accessoryById(view, 'ac_set_permission_mode') + expect(select.initial_option).toMatchObject({ value: 'agent:auto-review', text: { text: 'Permission · Auto' } }) + expect(select.options.map((o: any) => o.text.text)).toContain('Permission · Auto') + }) + it('prepends a current effort the advertised list omits (e.g. a pending ultracode override)', () => { const select = accessoryById( buildStatusModal({ effort: 'ultracode', efforts: ['low', 'high'] }, KEY), diff --git a/packages/protocol/src/frames/telemetry.ts b/packages/protocol/src/frames/telemetry.ts index f001d5e7d..37ad4ef04 100644 --- a/packages/protocol/src/frames/telemetry.ts +++ b/packages/protocol/src/frames/telemetry.ts @@ -113,7 +113,9 @@ export const EventSession = z.object({ model: z.string().optional(), effort: z.string().optional(), // reasoning effort level (runtime-owned vocabulary) fastMode: z.boolean().optional(), - permissionMode: z.string().optional(), // runtime permission/approval mode + // Effective session permission preset. Usually the runtime-owned mode; Codex Auto + // is the AgentConnect composite value that the daemon decomposes before ACP. + permissionMode: z.string().optional(), outputMode: z.string().optional(), // daemon-side output verbosity (none/minimal/low/medium/high) ts: z.string().datetime() }) diff --git a/packages/protocol/src/frames/webchat.ts b/packages/protocol/src/frames/webchat.ts index bfd21eee8..5f7a5498f 100644 --- a/packages/protocol/src/frames/webchat.ts +++ b/packages/protocol/src/frames/webchat.ts @@ -100,6 +100,7 @@ export type WebchatEvent = z.infer export const WebchatStatus = z.object({ model: z.string().optional(), effort: z.string().optional(), + // Effective session permission preset; Codex Auto is composite rather than a raw mode. permissionMode: z.string().optional(), fastMode: z.boolean().optional(), contextUsed: z.number().int().optional(), @@ -114,8 +115,9 @@ export const WebchatStatus = z.object({ // `thought_level` config option, plus the synthetic `ultracode`/`max` entries on // Claude runtimes) — populates the console's effort dropdown. Absent ⇒ no selector. efforts: z.array(z.string()).optional(), - // The runtime's selectable permission/approval modes (ACP `mode` config option). - // Values are runtime-owned and omitted when the Agent disables chat-side changes. + // Selectable session permission presets. Most values come from ACP `mode`; Codex Auto + // is composed from `mode=agent` + `_approvals_reviewer=auto_review`. Omitted when the + // Agent disables chat-side changes. permissionModes: z.array(z.string()).optional(), // Whether the selected model advertises a fast-mode toggle (the ACP `model_config` // config option only appears once a fast-capable model is selected). Absent/false ⇒ diff --git a/packages/web/src/components/console/PlaygroundProvider.tsx b/packages/web/src/components/console/PlaygroundProvider.tsx index 99e16c9d6..dc58fdad1 100644 --- a/packages/web/src/components/console/PlaygroundProvider.tsx +++ b/packages/web/src/components/console/PlaygroundProvider.tsx @@ -9,7 +9,14 @@ // view. import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' -import { agentLabel, type Agent, type Session, type SessionImage, type SessionStep } from '@/lib/data' +import { + agentLabel, + selectedPermissionPreset, + type Agent, + type Session, + type SessionImage, + type SessionStep +} from '@/lib/data' import { useConsoleData } from '@/lib/data-context' import { webchatWsUrl, @@ -55,8 +62,8 @@ interface PlaygroundData { pgSetModel: (id: string, agentId: string, model: string, conversationId?: string) => void /** Switch the session's reasoning effort (in-session, sticky). */ pgSetEffort: (id: string, agentId: string, effort: string, conversationId?: string) => void - /** Switch the session's permission mode when the Agent explicitly allows chat changes. */ - pgSetPermissionMode: (id: string, agentId: string, permissionMode: string, conversationId?: string) => void + /** Switch the session's composite permission preset when chat changes are allowed. */ + pgSetPermissionPreset: (id: string, agentId: string, permissionPreset: string, conversationId?: string) => void /** Toggle the session's fast mode (in-session, sticky). */ pgSetFast: (id: string, agentId: string, fastMode: boolean, conversationId?: string) => void /** Interrupt the running turn without ending the session. */ @@ -780,7 +787,7 @@ export function PlaygroundProvider({ children }: { children: ReactNode }) { : {}), model: da.model, runtime: da.runtime, - permissionMode: da.permissionMode, + permissionMode: selectedPermissionPreset(da.runtime, da.permissionMode, da.approvalsReviewer ?? 'user'), duration: 'live', tokens: '0', cost: '—', @@ -965,13 +972,14 @@ export function PlaygroundProvider({ children }: { children: ReactNode }) { [connect, stageRuntimeChange] ) - /** Switch the session's permission/approval mode (fire-and-forget), optimistic like pgSetEffort. */ - const pgSetPermissionMode = useCallback( - (id: string, agentForId: string, permissionMode: string, conversationId?: string) => { - setPgSessions((cur) => (cur[id] ? { ...cur, [id]: { ...cur[id]!, permissionMode } } : cur)) - stageRuntimeChange(id, { permissionMode }) + /** Switch the session's composite permission preset (fire-and-forget), optimistic + * like pgSetEffort. The daemon decomposes Auto before calling ACP. */ + const pgSetPermissionPreset = useCallback( + (id: string, agentForId: string, permissionPreset: string, conversationId?: string) => { + setPgSessions((cur) => (cur[id] ? { ...cur, [id]: { ...cur[id]!, permissionMode: permissionPreset } } : cur)) + stageRuntimeChange(id, { permissionMode: permissionPreset }) connect(id, agentForId, conversationId) - .ready.then((ws) => ws.send(JSON.stringify({ type: 'set_permission_mode', permissionMode }))) + .ready.then((ws) => ws.send(JSON.stringify({ type: 'set_permission_mode', permissionMode: permissionPreset }))) .catch(() => {}) }, [connect, stageRuntimeChange] @@ -1037,7 +1045,7 @@ export function PlaygroundProvider({ children }: { children: ReactNode }) { pgSend, pgSetModel, pgSetEffort, - pgSetPermissionMode, + pgSetPermissionPreset, pgSetFast, pgCancel, getPgSession, @@ -1056,7 +1064,7 @@ export function PlaygroundProvider({ children }: { children: ReactNode }) { pgSend, pgSetModel, pgSetEffort, - pgSetPermissionMode, + pgSetPermissionPreset, pgSetFast, pgCancel, getPgSession, diff --git a/packages/web/src/components/console/views/HomeView.test.tsx b/packages/web/src/components/console/views/HomeView.test.tsx index 2a9881747..0d156206f 100644 --- a/packages/web/src/components/console/views/HomeView.test.tsx +++ b/packages/web/src/components/console/views/HomeView.test.tsx @@ -15,7 +15,7 @@ const mocks = vi.hoisted(() => ({ pgSend: vi.fn(), pgSetModel: vi.fn(), pgSetEffort: vi.fn(), - pgSetPermissionMode: vi.fn(), + pgSetPermissionPreset: vi.fn(), push: vi.fn() })) @@ -43,7 +43,7 @@ vi.mock('@/components/console/PlaygroundProvider', () => ({ pgSend: mocks.pgSend, pgSetModel: mocks.pgSetModel, pgSetEffort: mocks.pgSetEffort, - pgSetPermissionMode: mocks.pgSetPermissionMode + pgSetPermissionPreset: mocks.pgSetPermissionPreset }) })) vi.mock('@/components/console/ComposerMenu', () => ({ @@ -122,7 +122,7 @@ beforeEach(() => { mocks.pgSend.mockClear() mocks.pgSetModel.mockClear() mocks.pgSetEffort.mockClear() - mocks.pgSetPermissionMode.mockClear() + mocks.pgSetPermissionPreset.mockClear() mocks.push.mockClear() }) afterEach(() => { @@ -238,6 +238,51 @@ describe('HomeView run-selectors (catalog-aware)', () => { expect(menu('Effort')).toBeUndefined() expect(menu('Permission')).toBeUndefined() }) + + it('shows and stages Auto as one Codex permission preset', async () => { + const codexCatalog = { + models: [{ id: 'gpt-5.6-sol' }], + permissionModes: [ + { value: 'read-only', name: 'Read-only' }, + { value: 'agent', name: 'Agent' }, + { value: 'agent-full-access', name: 'Agent (full access)' } + ], + defaultModel: 'gpt-5.6-sol', + defaultPermissionMode: 'agent', + source: 'acp', + observedAt: '' + } + mocks.agents = [ + agent({ + runtime: 'codex', + model: 'gpt-5.6-sol', + permissionMode: 'agent', + approvalsReviewer: 'auto_review' + }) + ] + mocks.daemons = [ + daemon({ + runtimeModels: [{ runtime: 'codex', models: ['gpt-5.6-sol'], modelCatalog: codexCatalog, authRequired: false }] + }) + ] + await render() + expect(menu('Permission')).toMatchObject({ + value: 'agent:auto-review', + options: ['read-only', 'agent', 'agent:auto-review', 'agent-full-access'] + }) + + const ta = host.querySelector('textarea')! + const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set! + await act(async () => { + setValue.call(ta, 'hi') + ta.dispatchEvent(new Event('input', { bubbles: true })) + }) + await act(async () => { + host.querySelector('button.sendbtn')!.click() + await new Promise((r) => setTimeout(r, 0)) + }) + expect(mocks.pgSetPermissionPreset).toHaveBeenCalledWith('pg_1', 'a1', 'agent:auto-review') + }) }) describe('HomeView readiness gate', () => { diff --git a/packages/web/src/components/console/views/HomeView.tsx b/packages/web/src/components/console/views/HomeView.tsx index ed8d10bd2..65f2142cb 100644 --- a/packages/web/src/components/console/views/HomeView.tsx +++ b/packages/web/src/components/console/views/HomeView.tsx @@ -38,7 +38,9 @@ import { displayedEffort, resolveEffortForModel, permissionModeChoicesFor, + permissionModePresets, resolvedPermissionMode, + selectedPermissionPreset, supportsModes, type Agent } from '@/lib/data' @@ -110,7 +112,7 @@ export default function HomeView() { const firstName = user.name.trim().split(/\s+/)[0] ?? '' const { orgPath } = useOrgs() const { agents, daemons, crons, allSessions, usage24h, getAgent, loading } = useConsoleData() - const { openPlayground, pgSend, pgSetModel, pgSetEffort, pgSetPermissionMode } = usePlayground() + const { openPlayground, pgSend, pgSetModel, pgSetEffort, pgSetPermissionPreset } = usePlayground() // Home is the default landing, so it owns the fresh-org bounce to /onboarding. const holdForOnboarding = useOnboardingRedirect() @@ -159,7 +161,7 @@ export default function HomeView() { const [input, setInput] = useState('') // Which selector menu is open (only one at a time), and the run-runtime overrides. const [menu, setMenu] = useState<'agent' | 'model' | 'effort' | 'permission' | 'add' | null>(null) - const [runtime, setRuntime] = useState<{ model?: string; effort?: string; permission?: string }>({}) + const [runtime, setRuntime] = useState<{ model?: string; effort?: string; permissionPreset?: string }>({}) // Overrides are per-agent; drop them when the agent changes so the new agent's // own defaults show through. @@ -209,10 +211,17 @@ export default function HomeView() { const permissionList = agent ? permissionModeChoicesFor(agent.runtime, modelCatalog) : [] const showPermission = agent ? !!modelCatalog?.permissionModes?.length || supportsModes(agent.runtime) : false - const permission = showPermission - ? resolvedPermissionMode(runtime.permission ?? agent?.permissionMode ?? '', permissionList, modelCatalog) + const permissionMode = showPermission + ? resolvedPermissionMode(agent?.permissionMode ?? '', permissionList, modelCatalog) : '' - const permissionChoices = permissionList.map((o) => ({ value: o.v, label: o.l, description: o.description })) + const permissionPreset = + runtime.permissionPreset ?? + selectedPermissionPreset(agent?.runtime ?? '', permissionMode, agent?.approvalsReviewer ?? 'user') + const permissionChoices = permissionModePresets(agent?.runtime ?? '', permissionList).map((o) => ({ + value: o.v, + label: o.l, + description: o.description + })) // Why the composer can't start a session for the selected agent (null ⇒ it can). const blocked: 'offline' | 'auth' | null = !agent @@ -239,7 +248,7 @@ export default function HomeView() { if (!multi) { if (model) pgSetModel(id, agent.id, model) if (effort) pgSetEffort(id, agent.id, effort) - if (permission) pgSetPermissionMode(id, agent.id, permission) + if (permissionPreset) pgSetPermissionPreset(id, agent.id, permissionPreset) } pgSend(id, agent.id, text) setInput('') @@ -475,7 +484,7 @@ export default function HomeView() { {!multi && showPermission && permissionChoices.length > 0 && ( setMenu(o ? 'permission' : null)} - onChange={(v) => setRuntime((r) => ({ ...r, permission: v }))} + onChange={(v) => setRuntime((r) => ({ ...r, permissionPreset: v }))} /> )} diff --git a/packages/web/src/components/console/views/SessionDetailView.tsx b/packages/web/src/components/console/views/SessionDetailView.tsx index bf6641b24..0270890e5 100644 --- a/packages/web/src/components/console/views/SessionDetailView.tsx +++ b/packages/web/src/components/console/views/SessionDetailView.tsx @@ -709,7 +709,7 @@ export default function SessionDetailView() { pgAddAgent, pgSetModel, pgSetEffort, - pgSetPermissionMode, + pgSetPermissionPreset, pgSetFast, pgCancel } = usePlayground() @@ -741,7 +741,7 @@ export default function SessionDetailView() { const [attachMenuOpen, setAttachMenuOpen] = useState(false) const [composerMenuOpen, setComposerMenuOpen] = useState(null) const [runtimeSelections, setRuntimeSelections] = useState< - Record + Record >({}) // A rail row already carries enough metadata to paint the next session while // its detail/transcript requests catch up. Keeping it here also holds the @@ -1474,7 +1474,7 @@ export default function SessionDetailView() { const allowRuntimeChangesInChat = owner?.allowRuntimeChangesInChat === true const runtimeChangesEnabled = sessionRuntimeChangesEnabled(allowRuntimeChangesInChat, session) const runtimeSelection = runtimeSelections[session.id] - const setRuntimeSelection = (patch: { model?: string; effort?: string; permissionMode?: string; fast?: boolean }) => + const setRuntimeSelection = (patch: { model?: string; effort?: string; permissionPreset?: string; fast?: boolean }) => setRuntimeSelections((current) => ({ ...current, [session.id]: { ...current[session.id], ...patch } @@ -1505,17 +1505,21 @@ export default function SessionDetailView() { : pgEffortChoices const livePermissionModes = session.availablePermissionModes const selectablePermissionModes = sessionPermissionChoices(agentRuntime, runtimeCatalog, livePermissionModes) - const pgPermissionMode = sessionPermissionSelection( + const pgPermissionPreset = sessionPermissionSelection( agentRuntime, runtimeCatalog, livePermissionModes, - runtimeSelection?.permissionMode ?? session.permissionMode ?? owner?.permissionMode ?? '' + runtimeSelection?.permissionPreset ?? session.permissionMode ?? owner?.permissionMode ?? '', + owner?.approvalsReviewer ?? 'user' ) - const pgPermissionModes = + const pgPermissionPresets = livePermissionModes === undefined && - pgPermissionMode && - !selectablePermissionModes.some((choice) => choice.v === pgPermissionMode) - ? [{ v: pgPermissionMode, l: permissionModeLabel(agentRuntime, pgPermissionMode) }, ...selectablePermissionModes] + pgPermissionPreset && + !selectablePermissionModes.some((choice) => choice.v === pgPermissionPreset) + ? [ + { v: pgPermissionPreset, l: permissionModeLabel(agentRuntime, pgPermissionPreset) }, + ...selectablePermissionModes + ] : selectablePermissionModes // Stage the fast selection locally like model/effort/permission: an adopted // (persisted webchat) session has no synthetic provider entry for pgSetFast to @@ -2296,11 +2300,11 @@ export default function SessionDetailView() { ) ))} {!multiLive && - (runtimeChangesEnabled && pgPermissionModes.length > 0 ? ( + (runtimeChangesEnabled && pgPermissionPresets.length > 0 ? ( ({ + value={pgPermissionPreset} + options={pgPermissionPresets.map((mode) => ({ value: mode.v, label: mode.l, description: mode.description @@ -2312,20 +2316,20 @@ export default function SessionDetailView() { setAttachMenuOpen(false) setComposerMenuOpen(open ? 'permission' : null) }} - onChange={(permissionMode) => { - setRuntimeSelection({ permissionMode }) - pgSetPermissionMode( + onChange={(permissionPreset) => { + setRuntimeSelection({ permissionPreset }) + pgSetPermissionPreset( session.id, session.agentId ?? '', - permissionMode, + permissionPreset, webchatConversationId ) }} /> ) : ( - pgPermissionMode && ( + pgPermissionPreset && ( - {agentPermissionDisplay(owningDaemon, agentRuntime, pgPermissionMode)} + {agentPermissionDisplay(owningDaemon, agentRuntime, pgPermissionPreset)} ) ))} diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index d977d34ca..ae8dcbf1f 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -369,7 +369,7 @@ export interface SessionDto { model: string | null effort: string | null fastMode: boolean | null - permissionMode: string | null + permissionMode: string | null // effective session preset; Codex Auto is composite outputMode: string | null daemonId: string | null } @@ -462,7 +462,7 @@ export interface SessionDetailDto { model: string | null effort: string | null fastMode: boolean | null - permissionMode: string | null + permissionMode: string | null // effective session preset; Codex Auto is composite outputMode: string | null daemonId: string | null // Session visibility (docs/designs/session-visibility.md §5/§6). All three are diff --git a/packages/web/src/lib/data.ts b/packages/web/src/lib/data.ts index de102602d..121ca7916 100644 --- a/packages/web/src/lib/data.ts +++ b/packages/web/src/lib/data.ts @@ -451,9 +451,9 @@ export function permissionModeOptions(runtime: string): { v: string; l: string } // console can't misrepresent them. Values are codex-acp's runtime-owned ids (probed // from session/new): `agent` is Codex's default, labeled "Ask for approval" // (on-request + workspace-write); `agent-full-access` is danger-full-access — - // out-of-workspace + network. Codex's Auto preset is composed in the Agent - // editor from `agent` + approvalsReviewer; it is not a runtime mode and must - // stay out of this raw list (session controls can only stage the mode itself). + // out-of-workspace + network. Codex's Auto preset is composed by the UI from + // `agent` + approvalsReviewer; it is not a runtime mode and stays out of this + // raw list. return [ { v: 'read-only', l: 'Read Only' }, { v: 'agent', l: 'Ask for approval' }, @@ -479,6 +479,7 @@ export function permissionModeDefault(runtime: string): string { export function permissionModeLabel(runtime: string, v: string): string { const mode = v || permissionModeDefault(runtime) + if (supportsApprovalsReviewer(runtime) && mode === CODEX_AUTO_REVIEW_PRESET) return 'Auto' const o = permissionModeOptions(runtime).find((x) => x.v === mode) return o?.l ?? mode } @@ -493,14 +494,20 @@ export function approvalsReviewerDefault(runtime: string): ApprovalsReviewer | ' const CODEX_AUTO_REVIEW_PRESET = 'agent:auto-review' -/** Agent-editor presets compose Codex's independent mode + reviewer selectors - * into the single permissions control users see in Codex. Other runtimes keep - * their advertised mode list unchanged. */ +/** Agent and session presets compose Codex's independent mode + reviewer selectors + * into the single permissions control users see in Codex. Other runtimes keep their + * advertised mode list unchanged. */ export function permissionModePresets( runtime: string, modes: Array<{ v: string; l: string; description?: string }> ): Array<{ v: string; l: string; description?: string }> { - if (!supportsApprovalsReviewer(runtime) || !modes.some((mode) => mode.v === 'agent')) return modes + if ( + !supportsApprovalsReviewer(runtime) || + !modes.some((mode) => mode.v === 'agent') || + modes.some((mode) => mode.v === CODEX_AUTO_REVIEW_PRESET) + ) { + return modes + } return modes.flatMap((mode) => mode.v === 'agent' ? [ @@ -899,8 +906,9 @@ export interface Session { * (webchat status frame). `availableEfforts` empty/absent ⇒ no effort selector. */ effort?: string availableEfforts?: string[] - /** Current permission/approval mode + selectable modes for the in-session switch - * (webchat status frame). `availablePermissionModes` empty/absent ⇒ no selector. */ + /** Current permission preset + selectable presets for the in-session switch + * (webchat status frame). Codex Auto is composite; an empty/absent + * `availablePermissionModes` means no selector. */ permissionMode?: string availablePermissionModes?: string[] /** Current fast-mode state + whether the selected model offers a fast toggle (webchat diff --git a/packages/web/src/lib/session-runtime-controls.test.ts b/packages/web/src/lib/session-runtime-controls.test.ts index 21d6ad27f..97b27507d 100644 --- a/packages/web/src/lib/session-runtime-controls.test.ts +++ b/packages/web/src/lib/session-runtime-controls.test.ts @@ -68,6 +68,49 @@ describe('session runtime controls', () => { expect(sessionPermissionSelection('codex', undefined, undefined, '')).toBe('agent') }) + it('composes Auto into live and fallback Codex session selectors', () => { + const codexCatalog: RuntimeModelCatalog = { + ...catalog, + permissionModes: [ + { value: 'read-only', name: 'Read-only' }, + { value: 'agent', name: 'Agent' }, + { value: 'agent-full-access', name: 'Agent (full access)' } + ], + defaultPermissionMode: 'agent' + } + expect(sessionPermissionChoices('codex', codexCatalog, undefined).map((choice) => choice.v)).toEqual([ + 'read-only', + 'agent', + 'agent:auto-review', + 'agent-full-access' + ]) + expect(sessionPermissionSelection('codex', codexCatalog, undefined, 'agent', 'auto_review')).toBe( + 'agent:auto-review' + ) + expect( + sessionPermissionChoices('codex', codexCatalog, [ + 'read-only', + 'agent', + 'agent:auto-review', + 'agent-full-access' + ]).filter((choice) => choice.v === 'agent:auto-review') + ).toHaveLength(1) + expect(sessionPermissionChoices('codex', codexCatalog, ['read-only', 'agent', 'agent-full-access'])).toEqual([ + { v: 'read-only', l: 'Read-only' }, + { v: 'agent', l: 'Agent' }, + { v: 'agent-full-access', l: 'Agent (full access)' } + ]) + expect( + sessionPermissionSelection( + 'codex', + codexCatalog, + ['read-only', 'agent', 'agent-full-access'], + 'agent', + 'auto_review' + ) + ).toBe('agent') + }) + it('moves an unavailable effort to the selected model default', () => { expect(sessionEffortAfterModelChange('codex', daemon, 'terra', 'max')).toBe('high') }) diff --git a/packages/web/src/lib/session-runtime-controls.ts b/packages/web/src/lib/session-runtime-controls.ts index 3bb0d757b..ec9030e9a 100644 --- a/packages/web/src/lib/session-runtime-controls.ts +++ b/packages/web/src/lib/session-runtime-controls.ts @@ -5,8 +5,11 @@ import { permissionModeChoicesFor, permissionModeDefault, permissionModeLabel, + permissionModePresets, resolvedPermissionMode, resolveEffortForModel, + selectedPermissionPreset, + type ApprovalsReviewer, type DaemonRow, type EffortChoice, type RuntimeModelCatalog, @@ -56,9 +59,15 @@ export function sessionPermissionChoices( liveValues: string[] | undefined ): PermissionChoice[] { const discovered = permissionModeChoicesFor(runtime, catalog) - if (liveValues === undefined) return discovered + if (liveValues === undefined) return permissionModePresets(runtime, discovered) + // A live daemon has already composed capability-backed presets. Preserve its + // exact list so an older runtime without the reviewer selector cannot expose Auto. return liveValues.map( - (value) => discovered.find((choice) => choice.v === value) ?? { v: value, l: permissionModeLabel(runtime, value) } + (value) => + discovered.find((choice) => choice.v === value) ?? { + v: value, + l: permissionModeLabel(runtime, value) + } ) } @@ -67,17 +76,24 @@ export function sessionPermissionSelection( runtime: string, catalog: RuntimeModelCatalog | undefined, liveValues: string[] | undefined, - current: string + current: string, + approvalsReviewer: ApprovalsReviewer | '' = '' ): string { const choices = sessionPermissionChoices(runtime, catalog, liveValues) const resolvedCurrent = current || catalog?.defaultPermissionMode || permissionModeDefault(runtime) + const selectedCurrent = selectedPermissionPreset(runtime, resolvedCurrent, approvalsReviewer) + if (choices.some((choice) => choice.v === selectedCurrent)) return selectedCurrent if (liveValues === undefined) { - return resolvedPermissionMode(resolvedCurrent, choices, catalog) + const resolvedMode = resolvedPermissionMode(resolvedCurrent, choices, catalog) + return selectedPermissionPreset(runtime, resolvedMode, approvalsReviewer) } - if (choices.length === 0) return resolvedCurrent - if (choices.some((choice) => choice.v === current)) return current + if (choices.length === 0) return selectedCurrent const defaultMode = catalog?.defaultPermissionMode - if (defaultMode && choices.some((choice) => choice.v === defaultMode)) return defaultMode + if (defaultMode) { + const selectedDefault = selectedPermissionPreset(runtime, defaultMode, approvalsReviewer) + if (choices.some((choice) => choice.v === selectedDefault)) return selectedDefault + if (choices.some((choice) => choice.v === defaultMode)) return defaultMode + } return choices[0]?.v ?? '' }