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 f89d3eac44..e8e7d35692 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 @@ -13285,6 +13285,13 @@ "model": { "type": "string" }, + "modelSpecialty": { + "default": null, + "type": [ + "string", + "null" + ] + }, "serviceTiers": { "default": [], "items": { 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 f53fe8228b..3e9ea97932 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 @@ -9627,6 +9627,13 @@ "model": { "type": "string" }, + "modelSpecialty": { + "default": null, + "type": [ + "string", + "null" + ] + }, "serviceTiers": { "default": [], "items": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json index c50a6bdfa0..46a0d255b1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json @@ -86,6 +86,13 @@ "model": { "type": "string" }, + "modelSpecialty": { + "default": null, + "type": [ + "string", + "null" + ] + }, "serviceTiers": { "default": [], "items": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts index e9dbf15b0b..cb15add8dc 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts @@ -8,7 +8,7 @@ import type { ModelServiceTier } from "./ModelServiceTier"; import type { ModelUpgradeInfo } from "./ModelUpgradeInfo"; import type { ReasoningEffortOption } from "./ReasoningEffortOption"; -export type Model = { id: string, model: string, upgrade: string | null, upgradeInfo: ModelUpgradeInfo | null, availabilityNux: ModelAvailabilityNux | null, displayName: string, description: string, hidden: boolean, supportedReasoningEfforts: Array, defaultReasoningEffort: ReasoningEffort, inputModalities: Array, supportsPersonality: boolean, +export type Model = { id: string, model: string, upgrade: string | null, upgradeInfo: ModelUpgradeInfo | null, availabilityNux: ModelAvailabilityNux | null, displayName: string, description: string, modelSpecialty: string | null, hidden: boolean, supportedReasoningEfforts: Array, defaultReasoningEffort: ReasoningEffort, inputModalities: Array, supportsPersonality: boolean, /** * Deprecated: use `serviceTiers` instead. */ diff --git a/codex-rs/app-server-protocol/src/protocol/v2/model.rs b/codex-rs/app-server-protocol/src/protocol/v2/model.rs index bb6c411124..3fac812891 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/model.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/model.rs @@ -87,6 +87,8 @@ pub struct Model { pub availability_nux: Option, pub display_name: String, pub description: String, + #[serde(default)] + pub model_specialty: Option, pub hidden: bool, pub supported_reasoning_efforts: Vec, pub default_reasoning_effort: ReasoningEffort, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 81406c1aaf..8284c69612 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -202,7 +202,7 @@ Example with notification opt-out: - `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path and caller-provided `watchId`; returns the canonicalized `path`. - `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`. - `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`. -- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with model-advertised string reasoning effort options in the catalog's intended progression order, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. Clients should preserve the `supportedReasoningEfforts` array order rather than deriving order from the effort names. +- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with model-advertised string reasoning effort options in the catalog's intended progression order, optional `modelSpecialty`, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. Clients should preserve the `supportedReasoningEfforts` array order rather than deriving order from the effort names. - `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. diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index 36219e886f..30cebbf5ce 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -38,6 +38,7 @@ fn model_from_preset(preset: ModelPreset) -> Model { availability_nux: preset.availability_nux.map(Into::into), display_name: preset.display_name.to_string(), description: preset.description.to_string(), + model_specialty: preset.model_specialty, hidden: !preset.show_in_picker, supported_reasoning_efforts: reasoning_efforts_from_preset( preset.supported_reasoning_efforts, diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index f409b403d3..dfbdeb110f 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -56,6 +56,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { supports_search_tool: false, use_responses_lite: false, auto_review_model_override: None, + model_specialty: None, tool_mode: None, multi_agent_version: None, } diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index 4c029b55c9..d060a097e2 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -43,6 +43,7 @@ fn model_from_preset(preset: &ModelPreset) -> Model { availability_nux: preset.availability_nux.clone().map(Into::into), display_name: preset.display_name.clone(), description: preset.description.clone(), + model_specialty: preset.model_specialty.clone(), hidden: !preset.show_in_picker, supported_reasoning_efforts: preset .supported_reasoning_efforts @@ -156,6 +157,7 @@ async fn list_models_uses_chatgpt_remote_catalog_as_source_of_truth() -> Result< "slug": "chatgpt-remote-only", "display_name": "ChatGPT Remote Only", "description": "Remote-only model for app-server model/list coverage", + "model_specialty": "cyber", "default_reasoning_level": "max", "supported_reasoning_levels": [ {"effort": "max", "description": "Maximum"}, diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 57d1c4234d..7fa3752899 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -102,6 +102,7 @@ async fn models_client_hits_models_endpoint() { supports_search_tool: false, use_responses_lite: false, auto_review_model_override: None, + model_specialty: None, tool_mode: None, multi_agent_version: None, }], diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs index 96a6212bb6..cd526a9e01 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs @@ -14,6 +14,7 @@ fn model_preset(id: &str, show_in_picker: bool) -> ModelPreset { model: format!("{id}-model"), display_name: format!("{id} display"), description: format!("{id} description"), + model_specialty: None, default_reasoning_effort: ReasoningEffort::Medium, supported_reasoning_efforts: vec![ReasoningEffortPreset { effort: ReasoningEffort::Medium, diff --git a/codex-rs/core/tests/suite/auto_review.rs b/codex-rs/core/tests/suite/auto_review.rs index 317ae835fc..afacfd3674 100644 --- a/codex-rs/core/tests/suite/auto_review.rs +++ b/codex-rs/core/tests/suite/auto_review.rs @@ -243,6 +243,7 @@ fn remote_model_with_auto_review_override(slug: &str, review_model: &str) -> Mod supports_search_tool: false, use_responses_lite: false, auto_review_model_override: Some(review_model.to_string()), + model_specialty: None, tool_mode: None, multi_agent_version: None, priority: 1, diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 00f2370819..d1e5f2a6d1 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -90,6 +90,7 @@ fn test_model_info( supports_search_tool: false, use_responses_lite: false, auto_review_model_override: None, + model_specialty: None, tool_mode: None, multi_agent_version: None, priority: 1, @@ -954,6 +955,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< supports_search_tool: false, use_responses_lite: false, auto_review_model_override: None, + model_specialty: None, tool_mode: None, multi_agent_version: None, priority: 1, diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 05a7407c8b..b6340c57be 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -389,6 +389,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { supports_search_tool: false, use_responses_lite: false, auto_review_model_override: None, + model_specialty: None, tool_mode: None, multi_agent_version: None, } diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index aa71da4e80..547ea54986 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -665,6 +665,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow supports_search_tool: false, use_responses_lite: false, auto_review_model_override: None, + model_specialty: None, tool_mode: None, multi_agent_version: None, }; @@ -784,6 +785,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - supports_search_tool: false, use_responses_lite: false, auto_review_model_override: None, + model_specialty: None, tool_mode: None, multi_agent_version: None, }; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index d6d58a4cab..1c211fe738 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -480,6 +480,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { supports_search_tool: false, use_responses_lite: false, auto_review_model_override: None, + model_specialty: None, tool_mode: None, multi_agent_version: None, priority: 1, @@ -734,6 +735,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { supports_search_tool: false, use_responses_lite: false, auto_review_model_override: None, + model_specialty: None, tool_mode: None, multi_agent_version: None, priority: 1, @@ -1261,6 +1263,7 @@ fn test_remote_model_with_policy( supports_search_tool: false, use_responses_lite: false, auto_review_model_override: None, + model_specialty: None, tool_mode: None, multi_agent_version: None, priority, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index addaa54c70..7eb188ef48 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -1981,6 +1981,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re supports_search_tool: false, use_responses_lite: false, auto_review_model_override: None, + model_specialty: None, tool_mode: None, multi_agent_version: None, }], diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index 6478829050..ec93e47dfa 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -71,6 +71,7 @@ fn test_model_info( supports_search_tool: false, use_responses_lite: false, auto_review_model_override: None, + model_specialty: None, tool_mode: None, multi_agent_version: None, priority: 1, diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 5181847ade..518edcfea5 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -1363,6 +1363,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an supports_search_tool: false, use_responses_lite: false, auto_review_model_override: None, + model_specialty: None, tool_mode: None, multi_agent_version: None, priority: 1, diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index f8c2ee3ecc..13c89fe7e7 100644 --- a/codex-rs/models-manager/src/model_info.rs +++ b/codex-rs/models-manager/src/model_info.rs @@ -162,6 +162,7 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo { supports_search_tool: false, use_responses_lite: false, auto_review_model_override: None, + model_specialty: None, tool_mode: None, multi_agent_version: None, } diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 5f53bab037..bf0cddb2ba 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -210,6 +210,8 @@ pub struct ModelPreset { pub display_name: String, /// Short human description shown in UIs. pub description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_specialty: Option, /// Reasoning effort applied when none is explicitly chosen. pub default_reasoning_effort: ReasoningEffort, /// Supported reasoning effort options. @@ -437,6 +439,8 @@ pub struct ModelInfo { pub use_responses_lite: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub auto_review_model_override: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_specialty: Option, #[serde( default, skip_serializing_if = "Option::is_none", @@ -612,6 +616,7 @@ impl From for ModelPreset { model: info.slug.clone(), display_name: info.display_name, description: info.description.unwrap_or_default(), + model_specialty: info.model_specialty, default_reasoning_effort: info .default_reasoning_level .unwrap_or(ReasoningEffort::None), @@ -737,6 +742,7 @@ mod tests { supports_search_tool: false, use_responses_lite: false, auto_review_model_override: None, + model_specialty: None, tool_mode: None, multi_agent_version: None, } diff --git a/codex-rs/tools/src/tool_config_tests.rs b/codex-rs/tools/src/tool_config_tests.rs index 9b176b0c3e..a0d64272f3 100644 --- a/codex-rs/tools/src/tool_config_tests.rs +++ b/codex-rs/tools/src/tool_config_tests.rs @@ -48,6 +48,7 @@ fn model_with_shell_type(shell_type: ConfigShellToolType) -> ModelInfo { supports_search_tool: false, use_responses_lite: false, auto_review_model_override: None, + model_specialty: None, tool_mode: None, multi_agent_version: None, } diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index b8b1bce2b4..7009c74651 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1087,11 +1087,15 @@ impl App { .await; } AppEvent::UpdateModel(model) => { - self.chat_widget.set_model(&model); - self.sync_active_thread_model_setting(app_server, model) - .await; - self.sync_active_thread_service_tier_to_cached_session() - .await; + let model_changed = self.chat_widget.current_model() != model + || self.chat_widget.current_collaboration_mode().model() != model; + if model_changed { + self.chat_widget.set_model(&model); + self.sync_active_thread_model_setting(app_server, model, /*effort*/ None) + .await; + self.sync_active_thread_service_tier_to_cached_session() + .await; + } } AppEvent::UpdatePersonality(personality) => { self.on_update_personality(personality); @@ -1115,12 +1119,22 @@ impl App { self.chat_widget.open_advanced_reasoning_popup(model); } AppEvent::ApplyAdvancedReasoning { model, effort } => { + let model_changed = self.chat_widget.current_model() != model + || self.chat_widget.current_collaboration_mode().model() != model; let default_effort = self.on_apply_advanced_reasoning(model.as_str(), effort.clone()); - if let Some(mut params) = - self.active_thread_model_setting_update_params(model.clone()) + if model_changed { + self.sync_active_thread_model_setting( + app_server, + model.clone(), + Some(effort.clone()), + ) + .await; + } else if let Some(mut params) = + self.active_thread_reasoning_setting_update_params(Some(effort.clone())) { - params.effort = Some(effort.clone()); + params.collaboration_mode = + Some(self.chat_widget.effective_collaboration_mode()); self.send_thread_settings_update(app_server, params).await; } self.sync_active_thread_service_tier_to_cached_session() @@ -1693,6 +1707,11 @@ impl App { } } } + AppEvent::CyberModelAutoReviewNotice => { + self.chat_widget.add_warning_message( + "Cyber models default to \"Approve for me\" for safety reasons.".to_string(), + ); + } AppEvent::PluginUninstallLoaded { cwd, plugin_id: _plugin_id, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 970300b891..50a6d1e273 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -259,6 +259,30 @@ fn bypass_hook_trust_startup_warning_snapshot() { assert_app_snapshot!("bypass_hook_trust_startup_warning", rendered); } + +#[tokio::test] +async fn cyber_model_auto_review_notice_snapshot() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::CyberModelAutoReviewNotice, + ) + .await?; + + let cell = match app_event_rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => cell, + other => panic!("expected InsertHistoryCell event, got {other:?}"), + }; + let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); + assert_app_snapshot!("cyber_model_auto_review_notice", rendered); + Ok(()) +} + #[tokio::test] async fn enqueue_primary_thread_session_replays_buffered_approval_after_attach() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; @@ -7108,6 +7132,291 @@ async fn override_turn_context_sends_thread_settings_update() { .await; } +#[tokio::test] +async fn selecting_cyber_model_defaults_active_thread_to_auto_review() { + Box::pin(async { + let mut app = make_test_app().await; + app.config + .permissions + .approval_policy + .set(AskForApproval::UnlessTrusted.to_core()) + .expect("set approval policy"); + app.chat_widget + .set_approval_policy(AskForApproval::UnlessTrusted); + let mut model = app + .model_catalog + .try_list_models() + .expect("model catalog") + .into_iter() + .find(|model| model.model == "gpt-5.4") + .expect("gpt-5.4 model"); + model.model_specialty = Some("cyber".to_string()); + app.model_catalog = Arc::new(ModelCatalog::new(vec![model])); + + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("thread/start should succeed"); + assert_eq!( + started.session.approval_policy, + AskForApproval::UnlessTrusted + ); + assert_eq!(started.session.approvals_reviewer, ApprovalsReviewer::User); + let thread_id = started.session.thread_id; + app.enqueue_primary_thread_session(started.session, started.turns) + .await + .expect("primary thread should be registered"); + + let mut tui = crate::tui::test_support::make_test_tui().expect("test tui"); + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::UpdateModel("gpt-5.4".to_string()), + ) + .await + .expect("model selection should succeed"); + + let notification = next_thread_settings_updated(&mut app_server, thread_id).await; + assert_eq!( + notification.thread_settings.approval_policy, + AskForApproval::OnRequest + ); + assert_eq!( + notification.thread_settings.approvals_reviewer.to_core(), + ApprovalsReviewer::AutoReview + ); + assert_eq!( + notification + .thread_settings + .active_permission_profile + .expect("active permission profile") + .id, + codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE + ); + }) + .await; +} + +#[tokio::test] +async fn changing_cyber_model_reasoning_preserves_selected_permissions() { + Box::pin(async { + let mut app = make_test_app().await; + let model_name = app.chat_widget.current_model().to_string(); + let mut model = app + .model_catalog + .try_list_models() + .expect("model catalog") + .into_iter() + .find(|model| model.model == model_name) + .expect("current model"); + model.model_specialty = Some("cyber".to_string()); + app.model_catalog = Arc::new(ModelCatalog::new(vec![model])); + + assert!( + app.apply_permission_profile_selection(PermissionProfileSelection { + profile_id: codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY + .to_string(), + approval_policy: Some(AskForApproval::OnRequest), + approvals_reviewer: Some(ApprovalsReviewer::User), + display_label: "Read Only".to_string(), + }) + .await + ); + + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("thread/start should succeed"); + let thread_id = started.session.thread_id; + app.enqueue_primary_thread_session(started.session, started.turns) + .await + .expect("primary thread should be registered"); + + let mut tui = crate::tui::test_support::make_test_tui().expect("test tui"); + for effort in [ReasoningEffortConfig::High, ReasoningEffortConfig::Ultra] { + if effort == ReasoningEffortConfig::Ultra { + app.chat_widget + .set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); + app.chat_widget + .set_collaboration_mask(CollaborationModeMask { + name: "Plan".to_string(), + mode: Some(ModeKind::Plan), + model: Some(model_name.clone()), + reasoning_effort: Some(Some(effort.clone())), + developer_instructions: None, + }); + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::ApplyAdvancedReasoning { + model: model_name.clone(), + effort: effort.clone(), + }, + ) + .await + .expect("advanced reasoning selection should succeed"); + } else { + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::UpdateModel(model_name.clone()), + ) + .await + .expect("same-model selection should succeed"); + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::UpdateReasoningEffort(Some(effort.clone())), + ) + .await + .expect("reasoning selection should succeed"); + } + + let settings = next_thread_settings_updated(&mut app_server, thread_id) + .await + .thread_settings; + assert_eq!(settings.effort, Some(effort)); + assert_eq!(settings.approval_policy, AskForApproval::OnRequest); + assert_eq!( + settings.approvals_reviewer.to_core(), + ApprovalsReviewer::User + ); + assert_eq!( + settings + .active_permission_profile + .expect("active permission profile") + .id, + codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY + ); + assert_eq!( + settings.collaboration_mode.mode, + app.chat_widget.effective_collaboration_mode().mode + ); + assert_eq!(settings.collaboration_mode.settings.model, model_name); + } + }) + .await; +} + +#[tokio::test] +async fn selecting_cyber_model_falls_back_to_user_when_auto_review_is_unavailable() { + let mut app = make_test_app().await; + let mut model = app + .model_catalog + .try_list_models() + .expect("model catalog") + .into_iter() + .find(|model| model.model == "gpt-5.4") + .expect("gpt-5.4 model"); + model.model_specialty = Some("cyber".to_string()); + app.model_catalog = Arc::new(ModelCatalog::new(vec![model])); + let _ = app.config.features.disable(Feature::GuardianApproval); + app.chat_widget + .set_feature_enabled(Feature::GuardianApproval, /*enabled*/ false); + app.active_thread_id = Some(ThreadId::new()); + + let params = app + .active_thread_model_setting_update_params("gpt-5.4".to_string()) + .expect("active thread should produce update params"); + + assert_eq!( + params.approval_policy, + Some(codex_app_server_protocol::AskForApproval::OnRequest) + ); + assert_eq!( + params.approvals_reviewer, + Some(codex_app_server_protocol::ApprovalsReviewer::User) + ); +} + +#[tokio::test] +async fn selecting_cyber_model_respects_auto_review_requirements() { + Box::pin(async { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let requirements_toml = codex_config::ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::UnlessTrusted.to_core()]), + ..Default::default() + }; + let mut requirements_with_sources = codex_config::ConfigRequirementsWithSources::default(); + requirements_with_sources.merge_unset_fields( + codex_config::RequirementSource::Unknown, + requirements_toml.clone(), + ); + let requirements = codex_config::ConfigRequirements::try_from(requirements_with_sources) + .expect("reviewer requirements"); + app.config.config_layer_stack = + codex_config::ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) + .expect("auto-review requirements stack"); + app.config + .permissions + .approval_policy + .set(AskForApproval::UnlessTrusted.to_core()) + .expect("set approval policy"); + app.chat_widget + .set_approval_policy(AskForApproval::UnlessTrusted); + app.chat_widget.sync_plugin_mentions_config(&app.config); + + let mut model = app + .model_catalog + .try_list_models() + .expect("model catalog") + .into_iter() + .find(|model| model.model == "gpt-5.4") + .expect("gpt-5.4 model"); + model.model_specialty = Some("cyber".to_string()); + app.model_catalog = Arc::new(ModelCatalog::new(vec![model])); + + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("thread/start should succeed"); + assert_eq!( + started.session.approval_policy, + AskForApproval::UnlessTrusted + ); + let thread_id = started.session.thread_id; + app.enqueue_primary_thread_session(started.session, started.turns) + .await + .expect("primary thread should be registered"); + + let mut tui = crate::tui::test_support::make_test_tui().expect("test tui"); + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::UpdateModel("gpt-5.4".to_string()), + ) + .await + .expect("model selection should succeed"); + + let notification = next_thread_settings_updated(&mut app_server, thread_id).await; + assert_eq!( + notification.thread_settings.approval_policy, + AskForApproval::UnlessTrusted + ); + assert_eq!( + notification.thread_settings.approvals_reviewer.to_core(), + ApprovalsReviewer::User + ); + assert!( + std::iter::from_fn(|| app_event_rx.try_recv().ok()) + .all(|event| !matches!(event, AppEvent::CyberModelAutoReviewNotice)) + ); + }) + .await; +} + #[tokio::test] async fn thread_setting_update_params_sync_model_and_default_reasoning() { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app/thread_settings.rs b/codex-rs/tui/src/app/thread_settings.rs index 1f96b1750e..8d3383880e 100644 --- a/codex-rs/tui/src/app/thread_settings.rs +++ b/codex-rs/tui/src/app/thread_settings.rs @@ -2,13 +2,18 @@ use super::App; use crate::app_command::AppCommand; +use crate::app_event::AppEvent; use crate::app_server_session::AppServerSession; +use crate::chatwidget::cyber_model_approval_reviewer; use crate::session_state::ThreadSessionState; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; +use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ThreadSettings; use codex_app_server_protocol::ThreadSettingsUpdateParams; +use codex_config::types::ApprovalsReviewer; use codex_protocol::ThreadId; use codex_protocol::config_types::ModeKind; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::PermissionProfile; impl App { @@ -16,11 +21,26 @@ impl App { &mut self, app_server: &mut AppServerSession, model: String, + effort: Option, ) { - let Some(params) = self.active_thread_model_setting_update_params(model) else { + let Some(mut params) = self.active_thread_model_setting_update_params(model) else { return; }; - self.send_thread_settings_update(app_server, params).await; + params.effort = effort; + let defaulted_to_auto_review = params.approvals_reviewer + == Some(AppServerApprovalsReviewer::AutoReview) + && (self.chat_widget.config_ref().approvals_reviewer != ApprovalsReviewer::AutoReview + || AppServerAskForApproval::from( + self.chat_widget + .config_ref() + .permissions + .approval_policy + .value(), + ) != AppServerAskForApproval::OnRequest); + let settings_updated = self.send_thread_settings_update(app_server, params).await; + if defaulted_to_auto_review && settings_updated { + self.app_event_tx.send(AppEvent::CyberModelAutoReviewNotice); + } } pub(super) fn active_thread_model_setting_update_params( @@ -28,12 +48,40 @@ impl App { model: String, ) -> Option { let thread_id = self.active_thread_id?; - Some(ThreadSettingsUpdateParams { + let is_cyber_model = self.model_catalog.try_list_models().is_ok_and(|models| { + models.iter().any(|preset| { + preset.model == model && preset.model_specialty.as_deref() == Some("cyber") + }) + }); + + let mut params = ThreadSettingsUpdateParams { thread_id: thread_id.to_string(), model: Some(model), collaboration_mode: Some(self.chat_widget.effective_collaboration_mode()), ..ThreadSettingsUpdateParams::default() - }) + }; + + if is_cyber_model { + let workspace_profile = PermissionProfile::workspace_write(); + let workspace_allowed = self + .config + .permissions + .can_set_permission_profile(&workspace_profile) + .is_ok() + && self.config.is_permission_profile_allowed( + BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + &workspace_profile, + ); + + if workspace_allowed && let Some(reviewer) = cyber_model_approval_reviewer(&self.config) + { + params.permissions = Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()); + params.approval_policy = Some(AppServerAskForApproval::OnRequest); + params.approvals_reviewer = Some(reviewer.into()); + } + } + + Some(params) } pub(super) async fn sync_active_thread_reasoning_setting( @@ -157,14 +205,18 @@ impl App { &mut self, app_server: &mut AppServerSession, params: ThreadSettingsUpdateParams, - ) { + ) -> bool { if !thread_settings_update_has_changes(¶ms) { - return; + return false; } - if let Err(err) = app_server.thread_settings_update(params).await { - tracing::warn!("failed to update app-server thread settings from TUI: {err}"); - self.chat_widget - .add_error_message(format!("Failed to update thread settings: {err}")); + match app_server.thread_settings_update(params).await { + Ok(settings_updated) => settings_updated, + Err(err) => { + tracing::warn!("failed to update app-server thread settings from TUI: {err}"); + self.chat_widget + .add_error_message(format!("Failed to update thread settings: {err}")); + false + } } } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 4f731958c3..0b178a8f37 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -775,6 +775,9 @@ pub(crate) enum AppEvent { effort: Option, }, + /// Show the cyber auto-review notice after the model selection confirmation. + CyberModelAutoReviewNotice, + /// Persist the selected personality to the appropriate config. PersistPersonalitySelection { personality: Personality, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 81b38697da..56ad03059a 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -838,9 +838,9 @@ impl AppServerSession { pub(crate) async fn thread_settings_update( &mut self, params: ThreadSettingsUpdateParams, - ) -> Result<()> { + ) -> Result { if !self.thread_settings_update_supported { - return Ok(()); + return Ok(false); } let request_id = self.next_request_id(); match self @@ -851,7 +851,7 @@ impl AppServerSession { }) .await { - Ok(_) => Ok(()), + Ok(_) => Ok(true), Err(TypedRequestError::Server { source, .. }) if is_thread_settings_update_unsupported(&source) => { @@ -862,7 +862,7 @@ impl AppServerSession { // of showing an error every time the user changes model, effort, // personality, or mode. self.thread_settings_update_supported = false; - Ok(()) + Ok(false) } Err(err) => Err(err).wrap_err("thread/settings/update failed in TUI"), } @@ -1338,6 +1338,7 @@ fn model_preset_from_api_model(model: ApiModel) -> ModelPreset { model: model.model, display_name: model.display_name, description: model.description, + model_specialty: model.model_specialty, default_reasoning_effort: model.default_reasoning_effort, supported_reasoning_efforts: model .supported_reasoning_efforts diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a3d539e54c..f7b352eef5 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -169,7 +169,6 @@ use crossterm::event::KeyModifiers; use rand::Rng; use ratatui::buffer::Buffer; use ratatui::layout::Rect; -use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Stylize; @@ -380,6 +379,8 @@ mod notifications; use self::notifications::Notification; mod permission_popups; mod permissions_menu; +pub(crate) use self::permissions_menu::auto_review_available; +pub(crate) use self::permissions_menu::cyber_model_approval_reviewer; mod protocol; mod protocol_requests; mod rate_limits; diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src/chatwidget/permission_popups.rs index 6c667864d3..9bec7aa697 100644 --- a/codex-rs/tui/src/chatwidget/permission_popups.rs +++ b/codex-rs/tui/src/chatwidget/permission_popups.rs @@ -407,19 +407,45 @@ impl ChatWidget { ) { let selected_name = preset.label.to_string(); let approval = AskForApproval::from(preset.approval); - let mut header_children: Vec> = Vec::new(); + let is_cyber_model = self.model_catalog.try_list_models().is_ok_and(|models| { + models.iter().any(|model| { + model.model == self.current_model() + && model.model_specialty.as_deref() == Some("cyber") + }) + }); let title_line = Line::from("Enable full access?").bold(); - let info_line = Line::from(vec![ - "When Codex runs with full access, it can edit any file on your computer and run commands with network, without your approval. " - .into(), - "Exercise caution when enabling full access. This significantly increases the risk of data loss, leaks, or unexpected behavior." - .fg(Color::Red), - ]); - header_children.push(Box::new(title_line)); - header_children.push(Box::new( - Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }), - )); - let header = ColumnRenderable::with(header_children); + let info_lines = if is_cyber_model { + let recommendation = if auto_review_available(&self.config) { + "We strongly recommend selecting \"Approve for me\" instead, and customizing the reviewer policy for your use case." + } else { + "We strongly recommend selecting \"Ask for approval\" instead." + }; + vec![ + Line::default(), + Line::from( + "When Codex runs with full access, it can edit any file on your computer and run commands with network, without your approval.", + ), + Line::default(), + Line::from(vec![ + "Cyber models carry a higher risk of dangerous actions.".red(), + " Ensure proper safeguards are in place before granting full access. ".into(), + recommendation.into(), + ]), + ] + } else { + vec![Line::from(vec![ + "When Codex runs with full access, it can edit any file on your computer and run commands with network, without your approval. " + .into(), + "Exercise caution when enabling full access. This significantly increases the risk of data loss, leaks, or unexpected behavior." + .red(), + ])] + }; + let header = Paragraph::new( + std::iter::once(title_line) + .chain(info_lines) + .collect::>(), + ) + .wrap(Wrap { trim: false }); let accept_actions = profile_selection.map_or_else( || { diff --git a/codex-rs/tui/src/chatwidget/permissions_menu.rs b/codex-rs/tui/src/chatwidget/permissions_menu.rs index c97721819e..2bf0bf733e 100644 --- a/codex-rs/tui/src/chatwidget/permissions_menu.rs +++ b/codex-rs/tui/src/chatwidget/permissions_menu.rs @@ -1,6 +1,37 @@ use super::*; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; +pub(crate) fn auto_review_available(config: &Config) -> bool { + cyber_model_approval_reviewer(config) == Some(ApprovalsReviewer::AutoReview) +} + +pub(crate) fn cyber_model_approval_reviewer(config: &Config) -> Option { + let requirements = config.config_layer_stack.requirements(); + if requirements + .approval_policy + .can_set(&AskForApproval::OnRequest.to_core()) + .is_err() + { + return None; + } + + let reviewer = if config.features.enabled(Feature::GuardianApproval) + && requirements + .approvals_reviewer + .can_set(&ApprovalsReviewer::AutoReview) + .is_ok() + { + ApprovalsReviewer::AutoReview + } else { + ApprovalsReviewer::User + }; + requirements + .approvals_reviewer + .can_set(&reviewer) + .is_ok() + .then_some(reviewer) +} + impl ChatWidget { pub(super) fn open_permission_profiles_popup(&mut self) { let active_profile_id = self diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 61050f3ff3..44ccef548f 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -3121,6 +3121,7 @@ async fn model_picker_hides_show_in_picker_false_models_from_cache() { model: slug.to_string(), display_name: slug.to_string(), description: format!("{slug} description"), + model_specialty: None, default_reasoning_effort: ReasoningEffortConfig::Medium, supported_reasoning_efforts: vec![ReasoningEffortPreset { effort: ReasoningEffortConfig::Medium, @@ -3615,6 +3616,7 @@ async fn single_reasoning_option_skips_selection() { model: "model-with-single-reasoning".to_string(), display_name: "model-with-single-reasoning".to_string(), description: "".to_string(), + model_specialty: None, default_reasoning_effort: ReasoningEffortConfig::High, supported_reasoning_efforts: single_effort, supports_personality: false, diff --git a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs index 89ea3a9bc5..6bef4f93d1 100644 --- a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs +++ b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs @@ -106,8 +106,7 @@ impl ChatWidget { Line::from(vec![ "We couldn't complete the world-writable scan, so protections cannot be verified. " .into(), - format!("The Windows sandbox cannot guarantee protection in {mode_label}.") - .fg(Color::Red), + format!("The Windows sandbox cannot guarantee protection in {mode_label}.").red(), ]) } else { Line::from(vec![ diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__cyber_model_auto_review_notice.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__cyber_model_auto_review_notice.snap new file mode 100644 index 0000000000..5811457def --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__cyber_model_auto_review_notice.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/app/tests.rs +expression: rendered +--- +⚠ Cyber models default to "Approve for me" for safety reasons. diff --git a/codex-rs/windows-sandbox-rs/BUILD.bazel b/codex-rs/windows-sandbox-rs/BUILD.bazel index af54a80c47..4a4253472d 100644 --- a/codex-rs/windows-sandbox-rs/BUILD.bazel +++ b/codex-rs/windows-sandbox-rs/BUILD.bazel @@ -2,7 +2,6 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "windows-sandbox-rs", - binary_test_target_compatible_with = ["@platforms//os:windows"], build_script_data = [ "codex-windows-sandbox-setup.manifest", ],