diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 18b7bc9190..bf234eb521 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2011,6 +2011,7 @@ dependencies = [ "codex-thread-store", "codex-tools", "codex-utils-absolute-path", + "codex-utils-approval-presets", "codex-utils-cargo-bin", "codex-utils-cli", "codex-utils-json-to-toml", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index b2be46f1f3..0df2583ef7 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -12990,6 +12990,39 @@ } ] }, + "PermissionPreset": { + "properties": { + "approvalPolicy": { + "$ref": "#/definitions/v2/AskForApproval" + }, + "approvalsReviewer": { + "$ref": "#/definitions/v2/ApprovalsReviewer" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "permissionProfileId": { + "type": "string" + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "id", + "isDefault", + "permissionProfileId" + ], + "type": "object" + }, "PermissionProfileListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 98b0d42976..7b13da044e 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -9394,6 +9394,39 @@ } ] }, + "PermissionPreset": { + "properties": { + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "$ref": "#/definitions/ApprovalsReviewer" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "permissionProfileId": { + "type": "string" + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "id", + "isDefault", + "permissionProfileId" + ], + "type": "object" + }, "PermissionProfileListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionPreset.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionPreset.ts new file mode 100644 index 0000000000..ae96d094a8 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionPreset.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ApprovalsReviewer } from "./ApprovalsReviewer"; +import type { AskForApproval } from "./AskForApproval"; + +export type PermissionPreset = { id: string, permissionProfileId: string, description: string | null, approvalPolicy: AskForApproval, approvalsReviewer: ApprovalsReviewer, isDefault: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 3e769d3fab..4e0cf39bf6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -279,6 +279,7 @@ export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; export type { PatchChangeKind } from "./PatchChangeKind"; export type { PermissionGrantScope } from "./PermissionGrantScope"; +export type { PermissionPreset } from "./PermissionPreset"; export type { PermissionProfileListParams } from "./PermissionProfileListParams"; export type { PermissionProfileListResponse } from "./PermissionProfileListResponse"; export type { PermissionProfileSummary } from "./PermissionProfileSummary"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 71773f2fc8..540b547278 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -875,6 +875,12 @@ client_request_definitions! { serialization: global_shared_read("config"), response: v2::PermissionProfileListResponse, }, + #[experimental("permissionPreset/list")] + PermissionPresetList => "permissionPreset/list" { + params: v2::PermissionPresetListParams, + serialization: global_shared_read("config"), + response: v2::PermissionPresetListResponse, + }, ExperimentalFeatureEnablementSet => "experimentalFeature/enablement/set" { params: v2::ExperimentalFeatureEnablementSetParams, serialization: global("config"), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index 932e44e267..6382379209 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -1,3 +1,5 @@ +use super::ApprovalsReviewer; +use super::AskForApproval; use super::shared::v2_enum_from_core; use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; use codex_protocol::approvals::NetworkApprovalContext as CoreNetworkApprovalContext; @@ -392,6 +394,38 @@ pub struct PermissionProfileListResponse { pub next_cursor: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionPresetListParams { + #[ts(optional = nullable)] + pub cursor: Option, + #[ts(optional = nullable)] + pub limit: Option, + #[ts(optional = nullable)] + pub cwd: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionPreset { + pub id: String, + pub permission_profile_id: String, + pub description: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub is_default: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionPresetListResponse { + pub data: Vec, + pub next_cursor: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 3d08059a63..7e66696828 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -75,6 +75,7 @@ codex-state = { workspace = true } codex-thread-store = { workspace = true } codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } +codex-utils-approval-presets = { workspace = true } codex-utils-json-to-toml = { workspace = true } codex-utils-path-uri = { workspace = true } chrono = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 287b4f89ce..81eaa99da4 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -203,6 +203,7 @@ Example with notification opt-out: - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text and an `allowed` flag reflecting effective requirements, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.]` entries to be included in the current catalog view. +- `permissionPreset/list` — experimental; list resolved, selectable permission presets for an optional `cwd`, using cursor pagination. Each preset includes an opaque `id`, `permissionProfileId`, `approvalPolicy`, `approvalsReviewer`, optional custom-profile `description`, and `isDefault`. The server applies managed requirements, omits unavailable profiles, and removes presets that resolve to the same settings; clients should preserve response order and apply the returned settings without reconstructing preset policy. - `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. Invalid keys will be ignored. - `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; optional `connectTimeoutMs` overrides the WebSocket connection timeout; returns `{}` and does not change the default environment. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 264c28e098..0dac5fcd56 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -102,6 +102,7 @@ mod message_processor; mod models; mod models_refresh_worker; mod outgoing_message; +mod permission_presets; mod request_processors; mod request_serialization; mod server_request_error; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 33cf142fe8..02bed2ac02 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1309,6 +1309,9 @@ impl MessageProcessor { ClientRequest::PermissionProfileList { params, .. } => { self.catalog_processor.permission_profile_list(params).await } + ClientRequest::PermissionPresetList { params, .. } => { + self.catalog_processor.permission_preset_list(params).await + } ClientRequest::CollaborationModeList { params, .. } => { self.catalog_processor.collaboration_mode_list(params).await } diff --git a/codex-rs/app-server/src/permission_presets.rs b/codex-rs/app-server/src/permission_presets.rs new file mode 100644 index 0000000000..908afe7c7f --- /dev/null +++ b/codex-rs/app-server/src/permission_presets.rs @@ -0,0 +1,143 @@ +use crate::config_manager::ConfigManager; +use codex_app_server_protocol::PermissionPreset; +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; +use codex_features::Feature; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::GranularApprovalConfig; +use codex_utils_approval_presets::builtin_approval_presets; +use futures::StreamExt; +use std::path::PathBuf; + +const PRESET_RESOLUTION_CONCURRENCY: usize = 5; + +struct PermissionPresetCandidate { + id: String, + permission_profile_id: String, + description: Option, + approval_policy: AskForApproval, + approvals_reviewer: ApprovalsReviewer, +} + +pub(crate) async fn permission_presets( + config_manager: &ConfigManager, + cwd: Option, +) -> std::io::Result> { + let config = config_manager + .load_for_cwd(None, ConfigOverrides::default(), cwd.clone()) + .await?; + let mut candidates = Vec::new(); + + for preset in builtin_approval_presets() { + candidates.push(PermissionPresetCandidate { + id: preset.id.to_string(), + permission_profile_id: preset.active_permission_profile.id.clone(), + description: None, + approval_policy: preset.approval, + approvals_reviewer: ApprovalsReviewer::User, + }); + if preset.id == "auto" { + candidates.push(PermissionPresetCandidate { + id: "granular".to_string(), + permission_profile_id: preset.active_permission_profile.id.clone(), + description: None, + approval_policy: AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: false, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: true, + }), + approvals_reviewer: ApprovalsReviewer::User, + }); + if config.features.enabled(Feature::GuardianApproval) { + candidates.push(PermissionPresetCandidate { + id: "guardian-approvals".to_string(), + permission_profile_id: preset.active_permission_profile.id, + description: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::AutoReview, + }); + } + } + } + + let mut custom_profiles = config.custom_permission_profiles.clone(); + custom_profiles.sort_by(|left, right| left.id.cmp(&right.id)); + candidates.extend( + custom_profiles + .into_iter() + .map(|profile| PermissionPresetCandidate { + id: format!("permission-profile:{}", profile.id), + permission_profile_id: profile.id, + description: profile.description, + approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: config.approvals_reviewer, + }), + ); + + let resolved_presets = futures::stream::iter(candidates) + .map(|candidate| resolve_candidate(config_manager, cwd.clone(), &config, candidate)) + .buffered(PRESET_RESOLUTION_CONCURRENCY) + .collect::>() + .await; + let mut presets = Vec::new(); + for preset in resolved_presets { + let Some(preset) = preset else { + continue; + }; + if presets.iter().any(|existing: &PermissionPreset| { + existing.permission_profile_id == preset.permission_profile_id + && existing.approval_policy == preset.approval_policy + && existing.approvals_reviewer == preset.approvals_reviewer + }) { + continue; + } + presets.push(preset); + } + + Ok(presets) +} + +async fn resolve_candidate( + config_manager: &ConfigManager, + cwd: Option, + default_config: &Config, + candidate: PermissionPresetCandidate, +) -> Option { + let config = config_manager + .load_for_cwd( + None, + ConfigOverrides { + approval_policy: Some(candidate.approval_policy), + approvals_reviewer: Some(candidate.approvals_reviewer), + default_permissions: Some(candidate.permission_profile_id.clone()), + ..Default::default() + }, + cwd, + ) + .await + .ok()?; + let active_profile = config.permissions.active_permission_profile()?; + if active_profile.id != candidate.permission_profile_id { + return None; + } + let approval_policy = config.permissions.approval_policy.value(); + let approvals_reviewer = config.approvals_reviewer; + let is_default = default_config + .permissions + .active_permission_profile() + .is_some_and(|profile| profile.id == candidate.permission_profile_id) + && default_config.permissions.approval_policy.value() == approval_policy + && default_config.approvals_reviewer == approvals_reviewer; + + Some(PermissionPreset { + id: candidate.id, + permission_profile_id: candidate.permission_profile_id, + description: candidate.description, + approval_policy: approval_policy.into(), + approvals_reviewer: approvals_reviewer.into(), + is_default, + }) +} diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 35df59d5cc..5fd795a5ce 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -115,6 +115,8 @@ use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::MockExperimentalMethodResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; +use codex_app_server_protocol::PermissionPresetListParams; +use codex_app_server_protocol::PermissionPresetListResponse; use codex_app_server_protocol::PermissionProfileListParams; use codex_app_server_protocol::PermissionProfileListResponse; use codex_app_server_protocol::PermissionProfileSummary; diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs index 9542340200..8e09810af3 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -1,5 +1,6 @@ -use super::*; +use crate::permission_presets::permission_presets; use codex_core::config::permission_profile_catalog; +use super::*; use futures::StreamExt; #[derive(Clone)] @@ -180,6 +181,15 @@ impl CatalogRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn permission_preset_list( + &self, + params: PermissionPresetListParams, + ) -> Result, JSONRPCErrorError> { + self.permission_preset_list_response(params) + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn collaboration_mode_list( &self, params: CollaborationModeListParams, @@ -466,6 +476,39 @@ impl CatalogRequestProcessor { Ok(PermissionProfileListResponse { data, next_cursor }) } + async fn permission_preset_list_response( + &self, + params: PermissionPresetListParams, + ) -> Result { + let PermissionPresetListParams { cursor, limit, cwd } = params; + let presets = permission_presets(&self.config_manager, cwd.map(PathBuf::from)) + .await + .map_err(|err| { + internal_error(format!("failed to resolve permission presets: {err}")) + })?; + let total = presets.len(); + let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; + let effective_limit = effective_limit.min(total); + let start = match cursor { + Some(cursor) => cursor + .parse::() + .map_err(|_| invalid_request(format!("invalid cursor: {cursor}")))?, + None => 0, + }; + + if start > total { + return Err(invalid_request(format!( + "cursor {start} exceeds total permission presets {total}" + ))); + } + + let end = start.saturating_add(effective_limit).min(total); + let data = presets[start..end].to_vec(); + let next_cursor = (end < total).then_some(end.to_string()); + + Ok(PermissionPresetListResponse { data, next_cursor }) + } + async fn mock_experimental_method_inner( &self, params: MockExperimentalMethodParams, diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index e940d9a42b..e0ea381dd2 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -58,6 +58,7 @@ use codex_app_server_protocol::McpServerToolCallParams; use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelProviderCapabilitiesReadParams; +use codex_app_server_protocol::PermissionPresetListParams; use codex_app_server_protocol::PermissionProfileListParams; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstalledParams; @@ -648,6 +649,15 @@ impl TestAppServer { self.send_request("permissionProfile/list", params).await } + /// Send a `permissionPreset/list` JSON-RPC request. + pub async fn send_permission_preset_list_request( + &mut self, + params: PermissionPresetListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("permissionPreset/list", params).await + } + /// Send an `experimentalFeature/enablement/set` JSON-RPC request. pub async fn send_experimental_feature_enablement_set_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index f4400da468..7a59fc6859 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -35,6 +35,7 @@ mod memory_reset; mod model_list; mod model_provider_capabilities_read; mod output_schema; +mod permission_preset_list; mod permission_profile_list; mod plan_item; mod plugin_install; diff --git a/codex-rs/app-server/tests/suite/v2/permission_preset_list.rs b/codex-rs/app-server/tests/suite/v2/permission_preset_list.rs new file mode 100644 index 0000000000..55927d0359 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/permission_preset_list.rs @@ -0,0 +1,252 @@ +use std::time::Duration; + +use anyhow::Result; +use app_test_support::TestAppServer; +use app_test_support::to_response; +use codex_app_server_protocol::ApprovalsReviewer; +use codex_app_server_protocol::AskForApproval; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::PermissionPreset; +use codex_app_server_protocol::PermissionPresetListParams; +use codex_app_server_protocol::PermissionPresetListResponse; +use codex_app_server_protocol::RequestId; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +#[tokio::test] +async fn permission_preset_list_returns_resolved_builtin_presets() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#" +default_permissions = ":workspace" +approvals_reviewer = "auto_review" + +[features] +guardian_approval = true +"#, + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_permission_preset_list_request(PermissionPresetListParams { + cursor: None, + limit: None, + cwd: None, + }) + .await?; + let actual = read_response::(&mut mcp, request_id).await?; + + assert_eq!( + actual, + PermissionPresetListResponse { + data: vec![ + preset( + "read-only", + BUILT_IN_PERMISSION_PROFILE_READ_ONLY, + AskForApproval::OnRequest, + ApprovalsReviewer::User, + false, + ), + preset( + "auto", + BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + AskForApproval::OnRequest, + ApprovalsReviewer::User, + false, + ), + preset( + "granular", + BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + AskForApproval::Granular { + sandbox_approval: false, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: true, + }, + ApprovalsReviewer::User, + false, + ), + preset( + "guardian-approvals", + BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + AskForApproval::OnRequest, + ApprovalsReviewer::AutoReview, + true, + ), + preset( + "full-access", + BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS, + AskForApproval::Never, + ApprovalsReviewer::User, + false, + ), + ], + next_cursor: None, + } + ); + Ok(()) +} + +#[tokio::test] +async fn permission_preset_list_applies_managed_profile_and_policy_fallbacks() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#" +default_permissions = "dev" +approval_policy = "never" +approvals_reviewer = "auto_review" + +[permissions.dev] +description = "Day-to-day coding work." + +[permissions.dev.filesystem] +":workspace_roots" = "write" + +[permissions.audit] +description = "Inspect without writes." + +[permissions.audit.filesystem] +":workspace_roots" = "read" +"#, + )?; + std::fs::write( + codex_home.path().join("requirements.toml"), + r#" +allowed_approval_policies = ["on-request"] +allowed_approvals_reviewers = ["user"] +default_permissions = "audit" + +[allowed_permission_profiles] +":read-only" = true +audit = true +"#, + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_permission_preset_list_request(PermissionPresetListParams { + cursor: None, + limit: None, + cwd: None, + }) + .await?; + let actual = read_response::(&mut mcp, request_id).await?; + + assert_eq!( + actual, + PermissionPresetListResponse { + data: vec![ + preset( + "read-only", + BUILT_IN_PERMISSION_PROFILE_READ_ONLY, + AskForApproval::OnRequest, + ApprovalsReviewer::User, + false, + ), + PermissionPreset { + id: "permission-profile:audit".to_string(), + permission_profile_id: "audit".to_string(), + description: Some("Inspect without writes.".to_string()), + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + is_default: true, + }, + ], + next_cursor: None, + } + ); + Ok(()) +} + +#[tokio::test] +async fn permission_preset_list_removes_presets_with_identical_resolved_settings() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#" +default_permissions = ":workspace" +approval_policy = "never" +approvals_reviewer = "auto_review" +"#, + )?; + std::fs::write( + codex_home.path().join("requirements.toml"), + r#" +allowed_approval_policies = ["on-request"] +allowed_approvals_reviewers = ["user"] +default_permissions = ":workspace" + +[allowed_permission_profiles] +":workspace" = true +"#, + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_permission_preset_list_request(PermissionPresetListParams { + cursor: None, + limit: None, + cwd: None, + }) + .await?; + let actual = read_response::(&mut mcp, request_id).await?; + + assert_eq!( + actual, + PermissionPresetListResponse { + data: vec![preset( + "auto", + BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + AskForApproval::OnRequest, + ApprovalsReviewer::User, + true, + )], + next_cursor: None, + } + ); + Ok(()) +} + +fn preset( + id: &str, + permission_profile_id: &str, + approval_policy: AskForApproval, + approvals_reviewer: ApprovalsReviewer, + is_default: bool, +) -> PermissionPreset { + PermissionPreset { + id: id.to_string(), + permission_profile_id: permission_profile_id.to_string(), + description: None, + approval_policy, + approvals_reviewer, + is_default, + } +} + +async fn read_response( + mcp: &mut TestAppServer, + request_id: i64, +) -> Result { + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +}