diff --git a/packages/control-plane/src/http/dto/index.ts b/packages/control-plane/src/http/dto/index.ts index 648ffa780..7210cd6b9 100644 --- a/packages/control-plane/src/http/dto/index.ts +++ b/packages/control-plane/src/http/dto/index.ts @@ -9,6 +9,7 @@ import { Cron } from 'croner' import { RESERVED_AGENT_SLUGS } from '../../domain/reserved-agent-slugs.js' import { AgentMemoryBinding, + ApprovalsReviewer, AgentPermissionRequestRecord, CanonicalMemoryRecord, FeishuRegion, @@ -506,6 +507,7 @@ export const CreateAgentBody = z.object({ showStatusBar: z.boolean().optional(), // persistent Slack session status row (absent ⇒ default true) fastMode: z.boolean().optional(), // runtime fast mode toggle permissionMode: z.string().min(1).optional(), // runtime permission/approval mode + approvalsReviewer: ApprovalsReviewer.optional(), // who reviews eligible Codex approval requests allowRuntimeChangesInChat: z.boolean().optional(), // explicit opt-in; absent ⇒ false pause: z.boolean().optional(), // operational message-processing toggle (#288) introduceOnJoin: z.boolean().optional(), // #536: self-introduce to peers on a genuine channel join @@ -554,6 +556,7 @@ export const UpdateAgentBody = z showStatusBar: z.boolean().optional(), fastMode: z.boolean().nullable().optional(), permissionMode: z.string().min(1).nullable().optional(), + approvalsReviewer: ApprovalsReviewer.nullable().optional(), allowRuntimeChangesInChat: z.boolean().optional(), pause: z.boolean().nullable().optional(), // operational toggle (#288); null clears introduceOnJoin: z.boolean().optional(), // #536: self-introduce to peers on a genuine channel join @@ -619,6 +622,7 @@ export const AgentDto = z.object({ showStatusBar: z.boolean(), fastMode: z.boolean().nullable(), // null ⇒ never set (runtime default) permissionMode: z.string().nullable(), // null ⇒ never set (runtime default) + approvalsReviewer: ApprovalsReviewer.nullable(), // null ⇒ runtime default (`user`) allowRuntimeChangesInChat: z.boolean(), pause: z.boolean().nullable(), // null ⇒ not paused (#288) env: z.record(z.string(), z.string()), diff --git a/packages/control-plane/src/http/mcp/tools.ts b/packages/control-plane/src/http/mcp/tools.ts index 01a4e0d5a..47be95bb1 100644 --- a/packages/control-plane/src/http/mcp/tools.ts +++ b/packages/control-plane/src/http/mcp/tools.ts @@ -17,6 +17,7 @@ */ import { randomUUID } from 'node:crypto' import { z } from 'zod' +import { ApprovalsReviewer } from '@agentconnect.md/protocol' /** What a tool needs to execute: the caller's org and credentialed requests * against the versioned REST surface (`/api/v1`-relative paths). */ @@ -275,6 +276,7 @@ export const MCP_TOOLS: McpToolDef[] = [ outputMode: OutputMode.optional(), fastMode: z.boolean().optional(), permissionMode: z.string().min(1).optional(), + approvalsReviewer: ApprovalsReviewer.optional(), daemonId: z.string().min(1).optional().describe('Pin to a daemon (from listDaemons); omit to leave unplaced'), pause: z.boolean().optional() }) @@ -297,6 +299,7 @@ export const MCP_TOOLS: McpToolDef[] = [ outputMode: OutputMode.nullable().optional(), fastMode: z.boolean().nullable().optional(), permissionMode: z.string().min(1).nullable().optional(), + approvalsReviewer: ApprovalsReviewer.nullable().optional(), pause: z.boolean().optional().describe('true pauses the agent; false resumes it') }) .strict(), diff --git a/packages/control-plane/src/http/routes/agents.ts b/packages/control-plane/src/http/routes/agents.ts index ec5d12293..c411de822 100644 --- a/packages/control-plane/src/http/routes/agents.ts +++ b/packages/control-plane/src/http/routes/agents.ts @@ -215,6 +215,7 @@ function toDto( showStatusBar: a.showStatusBar, fastMode: a.fastMode, permissionMode: a.permissionMode, + approvalsReviewer: a.approvalsReviewer ?? null, allowRuntimeChangesInChat: a.allowRuntimeChangesInChat, pause: a.pause, env: a.env, @@ -1047,6 +1048,9 @@ export function agentRoutes(deps: HttpDeps) { ...(req.body.showStatusBar !== undefined ? { showStatusBar: req.body.showStatusBar } : {}), ...(req.body.fastMode !== undefined ? { fastMode: req.body.fastMode } : {}), ...(req.body.permissionMode !== undefined ? { permissionMode: req.body.permissionMode } : {}), + ...(req.body.approvalsReviewer !== undefined + ? { approvalsReviewer: req.body.approvalsReviewer } + : {}), ...(req.body.allowRuntimeChangesInChat !== undefined ? { allowRuntimeChangesInChat: req.body.allowRuntimeChangesInChat } : {}), diff --git a/packages/control-plane/src/orchestrator/agentSpecAssembler.ts b/packages/control-plane/src/orchestrator/agentSpecAssembler.ts index d87ce3a63..805d82981 100644 --- a/packages/control-plane/src/orchestrator/agentSpecAssembler.ts +++ b/packages/control-plane/src/orchestrator/agentSpecAssembler.ts @@ -190,6 +190,7 @@ export function agentRecordToSpec( model: a.model, reasoningEffort: a.reasoningEffort, permissionMode: a.permissionMode, + approvalsReviewer: a.approvalsReviewer ?? null, showFooter: a.showFooter, showStatusBar: a.showStatusBar, allowRuntimeChangesInChat: a.allowRuntimeChangesInChat, diff --git a/packages/control-plane/src/persistence/ports.ts b/packages/control-plane/src/persistence/ports.ts index ee6b3d096..a6affd145 100644 --- a/packages/control-plane/src/persistence/ports.ts +++ b/packages/control-plane/src/persistence/ports.ts @@ -23,6 +23,7 @@ import type { BindRule, AgentIcon, AgentMemoryBinding, + ApprovalsReviewer, OrganizationSuggestionInfo } from '@agentconnect.md/protocol' import type { @@ -521,6 +522,7 @@ export interface CreateAgentInput { showStatusBar?: boolean // render Slack's persistent session status row (default true) fastMode?: boolean // runtime fast mode toggle permissionMode?: string // runtime permission/approval mode + approvalsReviewer?: ApprovalsReviewer // who reviews eligible Codex approval requests allowRuntimeChangesInChat?: boolean // explicit opt-in; default false pause?: boolean // operational message-processing toggle (#288); true ⇒ daemon skips all turns introduceOnJoin?: boolean // #536: self-introduce to peers on a genuine channel join (absent ⇒ DB default false) @@ -570,6 +572,7 @@ export interface UpdateAgentInput { showStatusBar?: boolean fastMode?: boolean | null permissionMode?: string | null + approvalsReviewer?: ApprovalsReviewer | null allowRuntimeChangesInChat?: boolean pause?: boolean | null // operational message-processing toggle (#288); null clears introduceOnJoin?: boolean // #536: self-introduce to peers on a genuine channel join @@ -618,6 +621,7 @@ export interface AgentRecord { showStatusBar: boolean // from runtimeOverrides.showStatusBar (default true) fastMode: boolean | null // from runtimeOverrides.fastMode (null ⇒ runtime default) permissionMode: string | null // from runtimeOverrides.permissionMode (null ⇒ runtime default) + approvalsReviewer: ApprovalsReviewer | null // from runtimeOverrides.approvalsReviewer allowRuntimeChangesInChat: boolean // from runtimeOverrides (default false) pause: boolean | null // from runtimeOverrides.pause (null ⇒ not paused) (#288) env: Record // from runtimeOverrides.env ({} when unset) diff --git a/packages/control-plane/src/persistence/repositories/agent.repo.ts b/packages/control-plane/src/persistence/repositories/agent.repo.ts index c352239ec..83c38f5d1 100644 --- a/packages/control-plane/src/persistence/repositories/agent.repo.ts +++ b/packages/control-plane/src/persistence/repositories/agent.repo.ts @@ -3,7 +3,7 @@ */ import { Prisma } from '../../generated/prisma/client.js' import type { Agent, PrismaClient, User } from '../../generated/prisma/client.js' -import { redactGitUrlSecrets, type AgentMemoryBinding } from '@agentconnect.md/protocol' +import { redactGitUrlSecrets, type AgentMemoryBinding, type ApprovalsReviewer } from '@agentconnect.md/protocol' import type { PrismaLike } from '../prisma.js' import type { AgentCallPolicy, @@ -130,6 +130,7 @@ type RuntimeOverrides = { showStatusBar?: boolean fastMode?: boolean permissionMode?: string + approvalsReviewer?: ApprovalsReviewer allowRuntimeChangesInChat?: boolean // Operational message-processing toggle (#288). Stored in the overrides bag for // consistency with the sibling boolean toggles; the daemon skips all turn dispatch @@ -225,6 +226,7 @@ function toRecord(a: AgentWithUsers): AgentRecord { showStatusBar: ov.showStatusBar ?? true, fastMode: ov.fastMode ?? null, permissionMode: ov.permissionMode ?? null, + approvalsReviewer: ov.approvalsReviewer ?? null, allowRuntimeChangesInChat: ov.allowRuntimeChangesInChat ?? false, pause: ov.pause ?? null, env: ov.env ?? {}, @@ -318,6 +320,7 @@ export class PgAgentRepo implements AgentRepo { input.showStatusBar !== undefined || input.fastMode !== undefined || input.permissionMode || + input.approvalsReviewer || input.allowRuntimeChangesInChat !== undefined || input.pause !== undefined || input.env || @@ -333,6 +336,7 @@ export class PgAgentRepo implements AgentRepo { ...(input.showStatusBar !== undefined ? { showStatusBar: input.showStatusBar } : {}), ...(input.fastMode !== undefined ? { fastMode: input.fastMode } : {}), ...(input.permissionMode ? { permissionMode: input.permissionMode } : {}), + ...(input.approvalsReviewer ? { approvalsReviewer: input.approvalsReviewer } : {}), ...(input.allowRuntimeChangesInChat !== undefined ? { allowRuntimeChangesInChat: input.allowRuntimeChangesInChat } : {}), @@ -411,6 +415,7 @@ export class PgAgentRepo implements AgentRepo { patch.showStatusBar !== undefined || patch.fastMode !== undefined || patch.permissionMode !== undefined || + patch.approvalsReviewer !== undefined || patch.allowRuntimeChangesInChat !== undefined || patch.pause !== undefined || patch.env !== undefined || @@ -473,6 +478,10 @@ export class PgAgentRepo implements AgentRepo { if (patch.permissionMode === null) delete next.permissionMode else next.permissionMode = patch.permissionMode } + if (patch.approvalsReviewer !== undefined) { + if (patch.approvalsReviewer === null) delete next.approvalsReviewer + else next.approvalsReviewer = patch.approvalsReviewer + } if (patch.allowRuntimeChangesInChat !== undefined) { next.allowRuntimeChangesInChat = patch.allowRuntimeChangesInChat } diff --git a/packages/control-plane/test/integration/agents.replication.route.test.ts b/packages/control-plane/test/integration/agents.replication.route.test.ts index 0d4999bbd..69e969424 100644 --- a/packages/control-plane/test/integration/agents.replication.route.test.ts +++ b/packages/control-plane/test/integration/agents.replication.route.test.ts @@ -164,6 +164,7 @@ describe('agent config replication CP→daemon (REST → agent/upsert·remove)', // Always shipped as null when unset (per-runtime override): a runtime switch // must be able to CLEAR it, so the spec carries the clear rather than omitting it. permissionMode: null, + approvalsReviewer: null, outputMode: 'medium', showFooter: true, showStatusBar: true, diff --git a/packages/control-plane/test/integration/agents.route.test.ts b/packages/control-plane/test/integration/agents.route.test.ts index 88beb7023..56df99275 100644 --- a/packages/control-plane/test/integration/agents.route.test.ts +++ b/packages/control-plane/test/integration/agents.route.test.ts @@ -987,18 +987,19 @@ describe('C2 BFF REST — agents/daemons/workspaces/crons over app.inject', () = expect(badKey.statusCode).toBe(400) }) - it('POST + PATCH /agents carries output and chat runtime controls in runtimeOverrides', async () => { + it('POST + PATCH /agents carries output, reviewer, and chat runtime controls in runtimeOverrides', async () => { const app = build() const create = await app.app.inject({ method: 'POST', url: `${ORG}/agents`, payload: { name: 'verbose', - runtime: 'claude', + runtime: 'codex', outputMode: 'high', showFooter: false, showStatusBar: false, fastMode: true, + approvalsReviewer: 'auto_review', allowRuntimeChangesInChat: true } }) @@ -1009,18 +1010,21 @@ describe('C2 BFF REST — agents/daemons/workspaces/crons over app.inject', () = showFooter: boolean showStatusBar: boolean fastMode: boolean | null + approvalsReviewer: 'user' | 'auto_review' | null allowRuntimeChangesInChat: boolean } expect(created.outputMode).toBe('high') expect(created.showFooter).toBe(false) expect(created.showStatusBar).toBe(false) expect(created.fastMode).toBe(true) + expect(created.approvalsReviewer).toBe('auto_review') expect(created.allowRuntimeChangesInChat).toBe(true) expect((await prisma.agent.findUnique({ where: { id: created.id } }))?.runtimeOverrides).toEqual({ outputMode: 'high', showFooter: false, showStatusBar: false, fastMode: true, + approvalsReviewer: 'auto_review', allowRuntimeChangesInChat: true }) @@ -1033,6 +1037,7 @@ describe('C2 BFF REST — agents/daemons/workspaces/crons over app.inject', () = showFooter: true, showStatusBar: true, fastMode: false, + approvalsReviewer: 'user', allowRuntimeChangesInChat: false } }) @@ -1042,21 +1047,24 @@ describe('C2 BFF REST — agents/daemons/workspaces/crons over app.inject', () = showFooter: boolean showStatusBar: boolean fastMode: boolean | null + approvalsReviewer: 'user' | 'auto_review' | null allowRuntimeChangesInChat: boolean } expect(patched.outputMode).toBe('low') expect(patched.showFooter).toBe(true) expect(patched.showStatusBar).toBe(true) expect(patched.fastMode).toBe(false) + expect(patched.approvalsReviewer).toBe('user') expect(patched.allowRuntimeChangesInChat).toBe(false) const cleared = await app.app.inject({ method: 'PATCH', url: `${ORG}/agents/${created.id}`, - payload: { outputMode: null, fastMode: null } + payload: { outputMode: null, fastMode: null, approvalsReviewer: null } }) expect((cleared.json() as { outputMode: string | null }).outputMode).toBeNull() expect((cleared.json() as { fastMode: boolean | null }).fastMode).toBeNull() + expect((cleared.json() as { approvalsReviewer: string | null }).approvalsReviewer).toBeNull() expect((await prisma.agent.findUnique({ where: { id: created.id } }))?.runtimeOverrides).toEqual({ showFooter: true, showStatusBar: true, diff --git a/packages/control-plane/test/protocol/register.handler.test.ts b/packages/control-plane/test/protocol/register.handler.test.ts index 58fe24b2f..6f6f9a654 100644 --- a/packages/control-plane/test/protocol/register.handler.test.ts +++ b/packages/control-plane/test/protocol/register.handler.test.ts @@ -208,6 +208,7 @@ describe('register handler — authoritative reconcile snapshot + idempotency + model: null, reasoningEffort: null, permissionMode: null, + approvalsReviewer: null, showFooter: true, showStatusBar: true, allowRuntimeChangesInChat: false, diff --git a/packages/daemon/src/acp/acp-host.ts b/packages/daemon/src/acp/acp-host.ts index 04a385347..9a1e9862c 100644 --- a/packages/daemon/src/acp/acp-host.ts +++ b/packages/daemon/src/acp/acp-host.ts @@ -42,6 +42,7 @@ import { accountAppIsolation } from './account-apps.js' export type { SessionConfigOption, SessionConfigSelectGroup, SessionConfigSelectOption } from '@agentclientprotocol/sdk' const PROTOCOL_VERSION = 1 +export const APPROVALS_REVIEWER_CATEGORY = '_approvals_reviewer' /** A session/load may replay the historical conversation stream. Keep that off * platform transports, but preserve latest-wins metadata needed to converge the @@ -135,6 +136,9 @@ export interface SessionConfigPrefs { * `category: "mode"`. Values are runtime-owned (`default` / `plan` on * claude-acp, `agent` / `read-only` on codex-acp, etc.). */ permissionMode?: string + /** Who reviews eligible approval requests, matched against codex-acp's + * `_approvals_reviewer` select. Independent from permissionMode. */ + approvalsReviewer?: 'user' | 'auto_review' /** Optional host-level system-prompt seed, layered ahead of any per-session append * on Claude runtimes (see {@link claudeSessionMeta}). Left unset by default: the * agent's identity + description now travel per-session in the agent meta object @@ -826,13 +830,12 @@ export class AcpHost { } /** - * Apply the desired model / reasoning effort / fast mode to a fresh session - * via ACP `session/set_config_option`. Model first — the effort and fast-mode - * vocabularies depend on the selected model, and each response returns the - * reconciled option set the next step plans against. Best-effort by design: a - * runtime without the selector, an unoffered value, or a failed request logs - * and moves on — the session still runs on the runtime's defaults. Returns the - * final option set. + * Apply the desired session preferences to a fresh or restored session via ACP + * `session/set_config_option`. Model first — the effort and fast-mode vocabularies + * depend on the selected model, and each response returns the reconciled option set + * the next step plans against. Best-effort by design: a runtime without the selector, + * an unoffered value, or a failed request logs and moves on — the session still runs + * on the runtime's defaults. Returns the final option set. */ private async applySessionConfig( sessionId: string, @@ -848,6 +851,7 @@ export class AcpHost { 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], ['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. @@ -875,7 +879,7 @@ export class AcpHost { return options } - /** Re-derive the model / effort / fast selector caches from a reconciled option set. */ + /** Re-derive the model / effort / mode / fast selector caches from a reconciled option set. */ private refreshOptionCaches(configOptions: SessionConfigOption[] | null | undefined): void { this.lastModelOptions = modelOptionsFrom(configOptions) this.lastEffortOptions = effortOptionsFrom(configOptions, this.isClaudeRuntime()) diff --git a/packages/daemon/src/agents/agent-schema.ts b/packages/daemon/src/agents/agent-schema.ts index 2aeca3dcd..e37fb537c 100644 --- a/packages/daemon/src/agents/agent-schema.ts +++ b/packages/daemon/src/agents/agent-schema.ts @@ -190,6 +190,10 @@ export const AgentSchema = z.object({ // runtime-owned strings: claude-acp uses default/acceptEdits/auto/dontAsk/plan, // codex-acp uses read-only/agent/agent-full-access. permissionMode: z.string().default('default'), + // Who reviews eligible Codex approval requests. This stays independent from + // permissionMode: Auto-review does not widen the active sandbox or policy. + // Absent leaves the runtime's own default (`user`) untouched. + approvalsReviewer: z.enum(['user', 'auto_review']).optional(), // Conversation participants are not authorization principals by default. // Editors may explicitly opt this agent back into chat-side runtime setting // changes (model, effort, permission mode, fast mode) and approval controls. diff --git a/packages/daemon/src/agents/write-agent.ts b/packages/daemon/src/agents/write-agent.ts index 76e868a00..03ef61e6e 100644 --- a/packages/daemon/src/agents/write-agent.ts +++ b/packages/daemon/src/agents/write-agent.ts @@ -733,7 +733,7 @@ function applySpecFields( // runtime is CP-owned — a PATCH may switch it (e.g. claude → codex). Apply it on // merge too, not only on create; absent ⇒ leave the on-disk runtime as-is. if (spec.runtime !== undefined) raw.runtime = spec.runtime - // model/reasoningEffort/permissionMode are per-runtime override vocabularies: + // model/reasoningEffort/permissionMode/approvalsReviewer are per-runtime override vocabularies: // null ⇒ clear (revert to runtime default), a value ⇒ set, absent ⇒ leave alone. // Clearing must delete the key so a runtime switch drops the old runtime's override // instead of leaving it stale (model handled below, inside runtimeOverrides). @@ -747,6 +747,10 @@ function applySpecFields( if (spec.permissionMode === null) delete raw.permissionMode else raw.permissionMode = spec.permissionMode } + if (spec.approvalsReviewer !== undefined) { + if (spec.approvalsReviewer === null) delete raw.approvalsReviewer + else raw.approvalsReviewer = spec.approvalsReviewer + } if (spec.allowRuntimeChangesInChat !== undefined) { raw.allowRuntimeChangesInChat = spec.allowRuntimeChangesInChat } diff --git a/packages/daemon/src/cli/chat.ts b/packages/daemon/src/cli/chat.ts index 1600c55d3..9bcf43a33 100644 --- a/packages/daemon/src/cli/chat.ts +++ b/packages/daemon/src/cli/chat.ts @@ -153,6 +153,7 @@ export async function runChat(opts: RunChatOpts): Promise { configPrefs: { model: agent.runtimeOverrides?.model, permissionMode: agent.permissionMode, + approvalsReviewer: agent.approvalsReviewer, reasoningEffort: agent.reasoningEffort, fastMode: agent.fastMode } diff --git a/packages/daemon/src/daemon.ts b/packages/daemon/src/daemon.ts index 66f210fe7..08c353004 100644 --- a/packages/daemon/src/daemon.ts +++ b/packages/daemon/src/daemon.ts @@ -4246,6 +4246,7 @@ export class Daemon { configPrefs: { model: agent.runtimeOverrides?.model, permissionMode: agent.permissionMode, + approvalsReviewer: agent.approvalsReviewer, reasoningEffort: agent.reasoningEffort, fastMode: agent.fastMode }, diff --git a/packages/daemon/src/evaluation/runner.ts b/packages/daemon/src/evaluation/runner.ts index 40172cc2a..5de7ee54e 100644 --- a/packages/daemon/src/evaluation/runner.ts +++ b/packages/daemon/src/evaluation/runner.ts @@ -466,6 +466,7 @@ function prepareSubject(options: EvaluationRunnerOptions, agentIds: readonly str ...(typeof agent.runtimeOverrides?.model === 'string' ? { model: agent.runtimeOverrides.model } : {}), settings: { ...(typeof agent.permissionMode === 'string' ? { permissionMode: agent.permissionMode } : {}), + ...(typeof agent.approvalsReviewer === 'string' ? { approvalsReviewer: agent.approvalsReviewer } : {}), ...(typeof agent.reasoningEffort === 'string' ? { reasoningEffort: agent.reasoningEffort } : {}), ...(typeof agent.fastMode === 'boolean' ? { fastMode: agent.fastMode } : {}), ...(typeof agent.runInSandbox === 'boolean' ? { runInSandbox: agent.runInSandbox } : {}), @@ -861,6 +862,7 @@ export class RawAcpEvaluationRunner { configPrefs: { model: agent.runtimeOverrides?.model, permissionMode: agent.permissionMode, + approvalsReviewer: agent.approvalsReviewer, reasoningEffort: agent.reasoningEffort, fastMode: agent.fastMode } diff --git a/packages/daemon/src/reconciler/reconciler.ts b/packages/daemon/src/reconciler/reconciler.ts index d972488a3..1a2c41c0d 100644 --- a/packages/daemon/src/reconciler/reconciler.ts +++ b/packages/daemon/src/reconciler/reconciler.ts @@ -16,9 +16,9 @@ function signature(a: Agent): string { * must be materialized before it starts — the spawn binary (`runtime`), workspace * skills, and the knobs baked into the host at construction: child env / * system-prompt seed (agentChildEnv + cpRuntimeEnv), session config prefs (model / - * reasoningEffort / fastMode, applied per session via ACP set_config_option), and - * the OS sandbox wrapper. A change here means the cached host must be evicted so - * the next session respawns it fresh. */ + * reasoningEffort / fastMode / permissionMode / approvalsReviewer, applied per + * session via ACP set_config_option), and the OS sandbox wrapper. A change here + * means the cached host must be evicted so the next session respawns it fresh. */ function hostSpawnSig(a: Agent): string { return JSON.stringify({ runtime: a.runtime, @@ -29,6 +29,7 @@ function hostSpawnSig(a: Agent): string { executionMode: a.executionMode, fastMode: a.fastMode, permissionMode: a.permissionMode, + approvalsReviewer: a.approvalsReviewer, // Memory backend bakes env into the child (disable vs redirect the runtime's own // memory — see memoryProviderFor), so a provider change must respawn the host. // An external connection switch also changes the trusted scope/client captured diff --git a/packages/daemon/test/acp-host.test.ts b/packages/daemon/test/acp-host.test.ts index 7dc49a141..74bf03177 100644 --- a/packages/daemon/test/acp-host.test.ts +++ b/packages/daemon/test/acp-host.test.ts @@ -34,6 +34,22 @@ describe('AcpHost (against a fake ACP agent)', () => { expect(updates).toContainEqual({ sessionId, text: 'echo:hi' }) await host.stop() }, 15_000) + + it('applies Auto-review through the independent approvals reviewer selector', async () => { + const host = new AcpHost( + { command: process.execPath, args: [fakeAgent], env: [] }, + { + onUpdate: () => {}, + env: { AC_APPROVALS_REVIEWER: '1' }, + 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') + await host.stop() + }, 15_000) }) describe('AcpHost.mcpCapabilities (MCP transports from initialize)', () => { diff --git a/packages/daemon/test/evaluation-runner.test.ts b/packages/daemon/test/evaluation-runner.test.ts index 2bc9c5f56..a29bd961d 100644 --- a/packages/daemon/test/evaluation-runner.test.ts +++ b/packages/daemon/test/evaluation-runner.test.ts @@ -169,6 +169,7 @@ describe('EvaluationRunner', () => { scenarioPath, JSON.stringify({ prompt: { + echoConfigOptions: true, requestPermission: { options: [ { optionId: 'allow', name: 'Allow', kind: 'allow_once' }, @@ -176,7 +177,19 @@ describe('EvaluationRunner', () => { ] }, updates: [{ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: '$INPUT' } }] - } + }, + configOptions: [ + { + id: 'approvals_reviewer', + category: '_approvals_reviewer', + type: 'select', + currentValue: 'user', + options: [ + { value: 'user', name: 'User' }, + { value: 'auto_review', name: 'Auto-review' } + ] + } + ] }) ) writeFileSync( @@ -204,7 +217,8 @@ describe('EvaluationRunner', () => { runtime: 'codex-acp', workspace: { mode: 'from-scratch', path: join(agentDir, 'workspace') }, integrations: [], - output: { mode: 'medium' } + output: { mode: 'medium' }, + approvalsReviewer: 'auto_review' }) ) const runner = new RawAcpEvaluationRunner({ @@ -223,12 +237,14 @@ describe('EvaluationRunner', () => { return } expect(result.status).toBe('passed') + expect(result.output).toContain('"category":"_approvals_reviewer"') + expect(result.output).toContain('"currentValue":"auto_review"') expect(result.output).toContain('perm:{"outcome":"selected","optionId":"allow"}') expect(result.output).toContain('echo:hello') const manifest = EvaluationRunManifestSchema.parse(JSON.parse(readFileSync(result.manifestPath, 'utf8'))) expect(manifest).toMatchObject({ treatment: { name: 'raw-acp', memory: 'off', collaboration: 'off' }, - subject: { settings: { execution: 'raw-acp' } } + subject: { settings: { execution: 'raw-acp', approvalsReviewer: 'auto_review' } } }) const events = readFileSync(result.eventsPath, 'utf8') .trim() @@ -242,6 +258,7 @@ describe('EvaluationRunner', () => { 'permission.resolved', 'acp.update', 'acp.update', + 'acp.update', 'turn.completed' ]) expect(JSON.stringify(events)).not.toContain('# Agent') diff --git a/packages/daemon/test/fixtures/fake-acp-agent.mjs b/packages/daemon/test/fixtures/fake-acp-agent.mjs index 926dc7ee8..912284db7 100644 --- a/packages/daemon/test/fixtures/fake-acp-agent.mjs +++ b/packages/daemon/test/fixtures/fake-acp-agent.mjs @@ -21,6 +21,8 @@ const modelList = (process.env.AC_MODELS ?? '') .map((s) => s.trim()) .filter(Boolean) const sessionModels = new Map() +const reviewerEnabled = process.env.AC_APPROVALS_REVIEWER === '1' +const sessionReviewers = new Map() // Optional MCP transport capabilities advertised at initialize. `AC_MCP_CAPS` // (comma list) turns them on; unset ⇒ no mcpCapabilities key at all. @@ -39,18 +41,31 @@ const agentCapabilities = () => ({ : {}), ...(process.env.AC_LOAD_UPDATES ? { loadSession: true } : {}) }) -const configOptions = (sessionId) => - modelList.length - ? [ - { - id: 'model', - category: 'model', - type: 'select', - currentValue: sessionModels.get(sessionId) ?? modelList[0], - options: modelList.map((value) => ({ value, name: value })) - } +const configOptions = (sessionId) => { + const options = [] + if (modelList.length) { + options.push({ + id: 'model', + category: 'model', + type: 'select', + currentValue: sessionModels.get(sessionId) ?? modelList[0], + options: modelList.map((value) => ({ value, name: value })) + }) + } + if (reviewerEnabled) { + options.push({ + id: 'approvals_reviewer', + category: '_approvals_reviewer', + type: 'select', + currentValue: sessionReviewers.get(sessionId) ?? 'user', + options: [ + { value: 'user', name: 'User' }, + { value: 'auto_review', name: 'Auto-review' } ] - : undefined + }) + } + return options.length ? options : undefined +} rl.on('line', async (line) => { if (!line.trim()) return @@ -61,10 +76,13 @@ rl.on('line', async (line) => { } else if (method === 'session/new') { const sessionId = `s${++sessionCounter}` sessionModels.set(sessionId, modelList[0]) + sessionReviewers.set(sessionId, 'user') send({ jsonrpc: '2.0', id, result: { sessionId, configOptions: configOptions(sessionId) } }) } else if (method === 'session/set_config_option') { if (params.configId === 'model' && modelList.includes(params.value)) sessionModels.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_UPDATES) { diff --git a/packages/daemon/test/fixtures/scriptable-acp-agent.mjs b/packages/daemon/test/fixtures/scriptable-acp-agent.mjs index ebf1045d1..d9460b32d 100644 --- a/packages/daemon/test/fixtures/scriptable-acp-agent.mjs +++ b/packages/daemon/test/fixtures/scriptable-acp-agent.mjs @@ -87,6 +87,9 @@ async function handlePrompt(id, params) { const append = sessionMeta.get(params.sessionId)?.systemPrompt?.append ?? null update(params.sessionId, textChunk(`sysmeta:${JSON.stringify(append)}`)) } + if (p.echoConfigOptions) { + update(params.sessionId, textChunk(`config:${JSON.stringify(sessionOptions.get(params.sessionId) ?? [])}`)) + } // Scripted stream. Default = a single echo chunk (keeps simple profiles terse). const updates = p.updates ?? [textChunk(`echo:${input}`)] diff --git a/packages/daemon/test/reconciler.test.ts b/packages/daemon/test/reconciler.test.ts index 8737830fd..2f9e4ee02 100644 --- a/packages/daemon/test/reconciler.test.ts +++ b/packages/daemon/test/reconciler.test.ts @@ -65,7 +65,7 @@ describe('diffAgents', () => { }) }) - it('classifies model / description / reasoningEffort / executionMode / fastMode / env as host-spawn changes', () => { + it('classifies runtime session preferences and child inputs as host-spawn changes', () => { const cases: Array> = [ { runtimeOverrides: { model: 'opus', env: [] } }, { description: 'be terse' }, @@ -74,6 +74,7 @@ describe('diffAgents', () => { // fastMode is baked into the host's configPrefs at construction, so an edit // must evict the host (unlike output.mode, which is read live per dispatch). { fastMode: true }, + { approvalsReviewer: 'auto_review' }, { runtimeOverrides: { model: undefined as any, env: [{ name: 'FOO', value: 'bar' }] } }, // Secrets are baked into the child env (and materialized as config files) // at spawn — a value rotation must evict the host or the child keeps the diff --git a/packages/daemon/test/write-agent.test.ts b/packages/daemon/test/write-agent.test.ts index 366671696..d9a144a70 100644 --- a/packages/daemon/test/write-agent.test.ts +++ b/packages/daemon/test/write-agent.test.ts @@ -383,6 +383,7 @@ describe('writeAgentSpec — merge (agent.json exists)', () => { runtimeOverrides: { model: 'opus', env: [{ name: 'FOO', value: 'bar' }] }, reasoningEffort: 'high', permissionMode: 'plan', + approvalsReviewer: 'auto_review', workspace: { mode: 'from-scratch', path: './workspace' } }) @@ -391,7 +392,14 @@ describe('writeAgentSpec — merge (agent.json exists)', () => { writeAgentSpec( dir, 'bot-a', - baseSpec({ runtime: 'codex', model: null, reasoningEffort: null, permissionMode: null, env: { FOO: 'bar' } }), + baseSpec({ + runtime: 'codex', + model: null, + reasoningEffort: null, + permissionMode: null, + approvalsReviewer: null, + env: { FOO: 'bar' } + }), deps ) @@ -402,6 +410,7 @@ describe('writeAgentSpec — merge (agent.json exists)', () => { expect((raw.runtimeOverrides as { env?: unknown }).env).toEqual([{ name: 'FOO', value: 'bar' }]) expect(raw.reasoningEffort).toBeUndefined() expect(raw.permissionMode).toBeUndefined() + expect(raw.approvalsReviewer).toBeUndefined() }) it('leaves runtimeOverrides.model alone when the spec omits model (absent ≠ clear)', () => { diff --git a/packages/protocol/src/frames/agent.ts b/packages/protocol/src/frames/agent.ts index 3eb61694a..6f531fa4c 100644 --- a/packages/protocol/src/frames/agent.ts +++ b/packages/protocol/src/frames/agent.ts @@ -46,6 +46,12 @@ export const AgentWorkspace = z.discriminatedUnion('mode', [ ]) export type AgentWorkspace = z.infer +/** Who reviews Codex approval requests that cross the active sandbox boundary. + * This is deliberately independent from the ACP permission mode: Auto-review + * changes the reviewer, not the sandbox or approval policy. */ +export const ApprovalsReviewer = z.enum(['user', 'auto_review']) +export type ApprovalsReviewer = z.infer + /** * MCP-server name reserved for the daemon's own injected stdio bridge (its * platform tools). A config-defined or agent-enabled server under this name @@ -277,6 +283,7 @@ export const AgentSpec = z.object({ showStatusBar: z.boolean().optional(), // render Slack's persistent session status row; absent ⇒ leave agent.json unchanged fastMode: z.boolean().optional(), // runtime fast mode (ACP `model_config` toggle); absent ⇒ leave runtime default permissionMode: z.string().nullable().optional(), // runtime permission/approval mode (ACP `mode` selector); absent ⇒ leave alone, null ⇒ clear + approvalsReviewer: ApprovalsReviewer.nullable().optional(), // Codex approval reviewer; independent from permissionMode // Explicit opt-in: when false, conversation participants cannot change runtime // settings (model, effort, permission mode, fast mode) or answer approval // requests. Agent editors decide pending requests from the console instead. diff --git a/packages/web/src/components/console/modals/AddAgentModal.tsx b/packages/web/src/components/console/modals/AddAgentModal.tsx index b56c19b34..2f940e341 100644 --- a/packages/web/src/components/console/modals/AddAgentModal.tsx +++ b/packages/web/src/components/console/modals/AddAgentModal.tsx @@ -8,6 +8,8 @@ import { useProfile } from '@/lib/profile' import { useOrgs } from '@/lib/org-context' import { FALLBACK_RUNTIME_IDS, + approvalsReviewerDefault, + approvalsReviewerOptions, loginRequiredRuntimeIds, agentSlugFinalize, agentSlugSanitize, @@ -23,6 +25,9 @@ import { resolveEffortForModel, permissionModeChoicesFor, permissionModeDefault, + supportsApprovalsReviewer, + type AgentCallPolicy, + type ApprovalsReviewer, supportsModes } from '@/lib/data' import { @@ -46,7 +51,6 @@ import { DEFAULT_AGENT_OUTPUT_MODE, type OutputMode } from '@/lib/output-mode' import { Button, Icon } from '@/components/ui' import { VisibilityField, type SharingValue } from '@/components/console/VisibilityField' import { AgentCallVisibility } from '@/components/console/AgentCallVisibility' -import type { AgentCallPolicy } from '@/lib/data' import { OutputModeField } from '@/components/console/OutputModeField' import { RuntimeChatField } from '@/components/console/RuntimeChatField' import { SandboxField } from '@/components/console/SandboxField' @@ -199,6 +203,7 @@ export default function AddAgentModal({ onClose }: { onClose: () => void }) { const [memoryProvider, setMemoryProvider] = useState('managed') const [externalMemory, setExternalMemory] = useState(DEFAULT_EXTERNAL_MEMORY_BINDING) const [permissionMode, setPermissionMode] = useState(permissionModeDefault(FALLBACK_RUNTIME_IDS[0]!)) + const [approvalsReviewer, setApprovalsReviewer] = useState('') const [allowRuntimeChangesInChat, setAllowRuntimeChangesInChat] = useState(false) const [description, setDescription] = useState('') const [daemonId, setDaemonId] = useState('') @@ -387,6 +392,9 @@ export default function AddAgentModal({ onClose }: { onClose: () => void }) { // Edit, nothing in this modal is stored yet. const selectedPermissionMode = resolvedPermissionMode(permissionMode, permissionChoices, modelCatalog) const permissionOptions = permissionChoices + const showApprovalsReviewer = supportsApprovalsReviewer(effectiveRuntime) + const selectedApprovalsReviewer = approvalsReviewer || approvalsReviewerDefault(effectiveRuntime) + const approvalsReviewerChoices = approvalsReviewerOptions(effectiveRuntime) // A daemon selection defines the product default: optional means off; required // means on and immutable. Capability refreshes converge the same way. @@ -805,6 +813,7 @@ export default function AddAgentModal({ onClose }: { onClose: () => void }) { ...(Object.keys(envRecord).length ? { env: envRecord } : {}), ...(Object.keys(secretsRecord).length ? { secrets: secretsRecord } : {}), permissionMode: selectedPermissionMode, + ...(showApprovalsReviewer && selectedApprovalsReviewer ? { approvalsReviewer: selectedApprovalsReviewer } : {}), allowRuntimeChangesInChat, workspace, // Atomic restricted-create: the CP intersects sharedWith with org members. @@ -979,6 +988,7 @@ export default function AddAgentModal({ onClose }: { onClose: () => void }) { setRuntime(nextRuntime) setEffort('') setPermissionMode(permissionModeDefault(nextRuntime)) + setApprovalsReviewer(approvalsReviewerDefault(nextRuntime)) }} /> @@ -1031,7 +1041,7 @@ export default function AddAgentModal({ onClose }: { onClose: () => void }) {
Runtime
- {(showEffort || fastModeAvailable || showPermission) && ( + {(showEffort || fastModeAvailable || showPermission || showApprovalsReviewer) && (
{showEffort && ( @@ -1103,6 +1113,28 @@ export default function AddAgentModal({ onClose }: { onClose: () => void }) {
)} + {showApprovalsReviewer && ( +
+ Approval reviewer +
+ {approvalsReviewerChoices.map((o) => ( + + ))} +
+
+ )}
)} diff --git a/packages/web/src/components/console/modals/EditAgentModal.tsx b/packages/web/src/components/console/modals/EditAgentModal.tsx index 59e747b9e..6b0534224 100644 --- a/packages/web/src/components/console/modals/EditAgentModal.tsx +++ b/packages/web/src/components/console/modals/EditAgentModal.tsx @@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { + approvalsReviewerDefault, + approvalsReviewerOptions, effortChoicesFor, effortField, effortLabel, @@ -17,10 +19,12 @@ import { permissionModeDefault, permissionModeLabel, runtimeLabel, + supportsApprovalsReviewer, supportsModes, agentLabel, type Agent, - type AgentCallPolicy + type AgentCallPolicy, + type ApprovalsReviewer } from '@/lib/data' import { acpRuntime, useAcpRegistry } from '@/lib/acp-registry' import { fetchAgentDto, type AgentCallPolicyInput, type UpdateAgentInput } from '@/lib/api' @@ -110,6 +114,12 @@ export default function EditAgentModal({ const initialFastMode = useRef(agent.fastMode) const [permissionMode, setPermissionMode] = useState(permissionModeDefault(agent.runtime)) const initialPermissionMode = useRef(permissionModeDefault(agent.runtime)) + const [approvalsReviewer, setApprovalsReviewer] = useState( + agent.approvalsReviewer ?? approvalsReviewerDefault(agent.runtime) + ) + const initialApprovalsReviewer = useRef( + agent.approvalsReviewer ?? approvalsReviewerDefault(agent.runtime) + ) const [allowRuntimeChangesInChat, setAllowRuntimeChangesInChat] = useState(agent.allowRuntimeChangesInChat) const initialAllowRuntimeChangesInChat = useRef(agent.allowRuntimeChangesInChat) const [introduceOnJoin, setIntroduceOnJoin] = useState(agent.introduceOnJoin) @@ -182,6 +192,9 @@ export default function EditAgentModal({ initialFastMode.current = dto.fastMode ?? false setPermissionMode(dto.permissionMode ?? permissionModeDefault(dto.runtime ?? '')) initialPermissionMode.current = dto.permissionMode ?? permissionModeDefault(dto.runtime ?? '') + const nextApprovalsReviewer = dto.approvalsReviewer ?? approvalsReviewerDefault(dto.runtime ?? '') + setApprovalsReviewer(nextApprovalsReviewer) + initialApprovalsReviewer.current = nextApprovalsReviewer setAllowRuntimeChangesInChat(dto.allowRuntimeChangesInChat ?? false) initialAllowRuntimeChangesInChat.current = dto.allowRuntimeChangesInChat ?? false setIntroduceOnJoin(dto.introduceOnJoin ?? false) @@ -363,6 +376,8 @@ export default function EditAgentModal({ { v: permissionMode, l: `${permissionModeLabel(runtime, permissionMode)} (unavailable)` } ] : permissionChoices + const showApprovalsReviewer = supportsApprovalsReviewer(runtime) + const approvalsReviewerChoices = approvalsReviewerOptions(runtime) const runtimeUnavailable = daemonChanged && reportedRuntimeIds.length > 0 && !reportedRuntimeIds.includes(runtime) const modelUnavailable = daemonChanged && !!selectedModel && reportedModels.length > 0 && !reportedModels.includes(selectedModel) @@ -399,6 +414,7 @@ export default function EditAgentModal({ setModel('') setEffort('') setPermissionMode(permissionModeDefault(nextRuntime)) + setApprovalsReviewer(approvalsReviewerDefault(nextRuntime)) } const normalizedDisplayName = displayName.trim() ? displayName.trim() : null @@ -422,6 +438,7 @@ export default function EditAgentModal({ ...(showStatusBar !== initialShowStatusBar.current ? { showStatusBar } : {}), ...(fastMode !== initialFastMode.current ? { fastMode } : {}), ...(permissionMode !== initialPermissionMode.current ? { permissionMode } : {}), + ...(approvalsReviewer !== initialApprovalsReviewer.current ? { approvalsReviewer: approvalsReviewer || null } : {}), ...(allowRuntimeChangesInChat !== initialAllowRuntimeChangesInChat.current ? { allowRuntimeChangesInChat } : {}), ...(introduceOnJoin !== initialIntroduceOnJoin.current ? { introduceOnJoin } : {}), ...(runInSandbox !== initialRunInSandbox.current ? { runInSandbox } : {}), @@ -772,7 +789,7 @@ export default function EditAgentModal({
Runtime
- {(showEffort || fastModeAvailable || showPermission) && ( + {(showEffort || fastModeAvailable || showPermission || showApprovalsReviewer) && (
{showEffort && ( @@ -844,6 +861,28 @@ export default function EditAgentModal({
)} + {showApprovalsReviewer && ( +
+ Approval reviewer +
+ {approvalsReviewerChoices.map((o) => ( + + ))} +
+
+ )}
)} diff --git a/packages/web/src/components/console/views/AgentDetailView.tsx b/packages/web/src/components/console/views/AgentDetailView.tsx index 29812491c..b1493ee90 100644 --- a/packages/web/src/components/console/views/AgentDetailView.tsx +++ b/packages/web/src/components/console/views/AgentDetailView.tsx @@ -5,6 +5,7 @@ import useSWR from 'swr' import Link from 'next/link' import { useParams, useRouter, useSearchParams } from 'next/navigation' import { + approvalsReviewerLabel, agentEffortDisplay, agentLabel, agentModelDisplay, @@ -17,6 +18,7 @@ import { MOCK_PREFIX, runtimeLabel, status, + supportsApprovalsReviewer, supportsModes, workspaceStatus } from '@/lib/data' @@ -896,6 +898,16 @@ export default function AgentDetailView() { {agentPermissionDisplay(owningDaemon, da.runtime, da.permissionMode)} + {supportsApprovalsReviewer(da.runtime) && ( +
+ + Approval reviewer + + + {approvalsReviewerLabel(da.approvalsReviewer)} + +
+ )}
{effortField(da.runtime).label} diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index 803b19723..d5e86ad8a 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -7,6 +7,7 @@ import type { Agent, AgentCallPolicy, + ApprovalsReviewer, ConnectionStatusKey, DaemonRow, ResourceVisibility, @@ -265,6 +266,7 @@ export interface AgentDto { showStatusBar: boolean // render Slack's persistent session status row; defaults true fastMode: boolean | null // runtime fast mode; null when never set (runtime default) permissionMode: string | null // runtime permission/approval mode; null when never set + approvalsReviewer: ApprovalsReviewer | null // null when never set (runtime default) allowRuntimeChangesInChat: boolean // explicit opt-in; defaults false pause: boolean | null // operational message-processing toggle; true ⇒ agent skips all messages; null ⇒ not paused env: Record // extra env injected into the runtime @@ -797,6 +799,7 @@ export interface UpdateAgentInput { showStatusBar?: boolean fastMode?: boolean | null permissionMode?: string | null + approvalsReviewer?: ApprovalsReviewer | null allowRuntimeChangesInChat?: boolean /** Operational message-processing toggle; true ⇒ agent skips all messages; null clears. */ pause?: boolean | null @@ -955,6 +958,7 @@ export interface CreateAgentInput { showStatusBar?: boolean fastMode?: boolean // runtime fast mode toggle permissionMode?: string // runtime permission/approval mode + approvalsReviewer?: ApprovalsReviewer // who reviews eligible Codex approval requests allowRuntimeChangesInChat?: boolean pause?: boolean // operational message-processing toggle; true ⇒ agent skips all messages env?: Record @@ -1579,6 +1583,7 @@ export function agentFromDto(d: AgentDto): Agent { } : {}), permissionMode: d.permissionMode ?? '', + ...(d.approvalsReviewer ? { approvalsReviewer: d.approvalsReviewer } : {}), allowRuntimeChangesInChat: d.allowRuntimeChangesInChat ?? false, env: Object.entries(d.env ?? {}).map(([k, v]) => ({ k, v })), secretKeys: d.secretKeys ?? [], diff --git a/packages/web/src/lib/data.test.ts b/packages/web/src/lib/data.test.ts index 8ffdcb919..f22c44e0d 100644 --- a/packages/web/src/lib/data.test.ts +++ b/packages/web/src/lib/data.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' import { + approvalsReviewerDefault, + approvalsReviewerLabel, + approvalsReviewerOptions, agentEffortDisplay, agentModelDisplay, agentPermissionDisplay, @@ -273,6 +276,20 @@ describe('permissionModeChoicesFor', () => { }) }) +describe('Auto-review options', () => { + it('keeps the reviewer separate from Codex permission modes', () => { + expect(permissionModeOptions('codex').map((option) => option.v)).toEqual([ + 'read-only', + 'agent', + 'agent-full-access' + ]) + expect(approvalsReviewerOptions('codex').map((option) => option.v)).toEqual(['user', 'auto_review']) + expect(approvalsReviewerDefault('codex')).toBe('user') + expect(approvalsReviewerDefault('claude')).toBe('') + expect(approvalsReviewerLabel('auto_review')).toBe('Auto-review') + }) +}) + describe('resolveEffortForModel', () => { const cap = (efforts: string[], defaultEffort?: string) => ({ id: 'm', diff --git a/packages/web/src/lib/data.ts b/packages/web/src/lib/data.ts index a699fa4d2..20cdb5e62 100644 --- a/packages/web/src/lib/data.ts +++ b/packages/web/src/lib/data.ts @@ -7,6 +7,7 @@ import type { MemoryDreamingConfig } from '@/lib/api' export type LifecycleStatusKey = 'upgrading' | 'restarting' export type ConnectionStatusKey = 'online' | 'paused' | 'offline' export type StatusKey = ConnectionStatusKey | LifecycleStatusKey +export type ApprovalsReviewer = 'user' | 'auto_review' export interface StatusInfo { dot: string @@ -270,6 +271,8 @@ export interface Agent { memoryCaptureMode?: 'turn' | 'manual' /** Runtime permission/approval mode; 'default' means the runtime default. */ permissionMode: string + /** Who reviews eligible Codex approval requests; absent means the runtime default (`user`). */ + approvalsReviewer?: ApprovalsReviewer /** Explicit opt-in for chat-side runtime changes and approval controls. */ allowRuntimeChangesInChat: boolean /** Extra env injected into the runtime, in display order. */ @@ -448,8 +451,8 @@ 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 "Approve for me" preset has no codex-acp mode, - // so it isn't offered.) + // out-of-workspace + network. Codex's "Approve for me" behavior is exposed + // separately through approvalsReviewer; it is not a fourth permission mode. return [ { v: 'read-only', l: 'Read Only' }, { v: 'agent', l: 'Ask for approval' }, @@ -479,6 +482,30 @@ export function permissionModeLabel(runtime: string, v: string): string { return o?.l ?? mode } +export function supportsApprovalsReviewer(runtime: string): boolean { + return runtimeLabel(runtime) === 'Codex' +} + +export function approvalsReviewerOptions(runtime: string): Array<{ + v: ApprovalsReviewer + l: string + description: string +}> { + if (!supportsApprovalsReviewer(runtime)) return [] + return [ + { v: 'user', l: 'User', description: 'Ask an agent editor to approve or deny requests' }, + { v: 'auto_review', l: 'Auto-review', description: 'Use a reviewer agent for eligible requests' } + ] +} + +export function approvalsReviewerDefault(runtime: string): ApprovalsReviewer | '' { + return supportsApprovalsReviewer(runtime) ? 'user' : '' +} + +export function approvalsReviewerLabel(value: ApprovalsReviewer | null | undefined): string { + return value === 'auto_review' ? 'Auto-review' : 'User' +} + // ── dynamic model catalog (runtime-model-catalog.md §7) ───────────────────── // The daemon-discovered per-model capability matrix, carried on each // (daemon, runtime) profile. The static tables above are demoted to the