diff --git a/CODEX_PLUS_PLUS_LEDGER.md b/CODEX_PLUS_PLUS_LEDGER.md index 3c3426bf5c69..4b7cad4c3bfc 100644 --- a/CODEX_PLUS_PLUS_LEDGER.md +++ b/CODEX_PLUS_PLUS_LEDGER.md @@ -20,7 +20,7 @@ for the capability to work; use `—` when the capability is fully isolated. | Durable user message inbox | Runtime | Let root agents leave bounded, non-blocking human notes that survive legacy and paginated history without entering model context, render distinctly, remain reviewable in the current thread, and show session-local unread markers for background threads. Subagents use native parent-worker communication instead. | `codex-rs/config/src/codex_plus_plus/user_message_inbox.rs`
`codex-rs/core/src/codex_plus_plus/user_message_inbox.rs`
`codex-rs/protocol/src/codex_plus_plus/user_message.rs`
`codex-rs/tui/src/codex_plus_plus/user_message_inbox.rs`
`codex-rs/tui/src/codex_plus_plus/user_messages.rs` | `codex-rs/config/src/config_toml.rs`
`codex-rs/core/config.schema.json`
`codex-rs/core/src/tools/spec_plan.rs`
`codex-rs/app-server-protocol/src/protocol/thread_history.rs`
`codex-rs/tui/src/app.rs`
`codex-rs/tui/src/app/agent_status_feed.rs`
`codex-rs/tui/src/app/app_server_events.rs`
`codex-rs/tui/src/app/session_lifecycle.rs`
`codex-rs/tui/src/app/side.rs`
`codex-rs/tui/src/app/thread_routing.rs`
`codex-rs/tui/src/chatwidget.rs`
`codex-rs/tui/src/chatwidget/constructor.rs`
`codex-rs/tui/src/chatwidget/streaming.rs`
`codex-rs/tui/src/chatwidget/slash_dispatch.rs`
`codex-rs/tui/src/slash_command.rs` | [#64](https://github.com/Pimpmuckl/codex/pull/64), [#66](https://github.com/Pimpmuckl/codex/pull/66), [#67](https://github.com/Pimpmuckl/codex/pull/67), [#68](https://github.com/Pimpmuckl/codex/pull/68) | | Codex++ settings menu | TUI | Persist and verify automatic account selection, weekly-window auto-start, capacity-retry policy, and the experimental agent inbox from one focused settings surface. | `codex-rs/tui/src/chatwidget/slash_dispatch/codex_plus_plus/mod.rs`
`codex-rs/tui/src/codex_plus_plus/account_policy.rs` | `codex-rs/tui/src/chatwidget/slash_dispatch.rs`
`codex-rs/tui/src/app_event.rs`
`codex-rs/tui/src/app/event_dispatch.rs`
`codex-rs/tui/src/slash_command.rs`
`codex-rs/config/src/config_toml.rs`
`codex-rs/core/config.schema.json` | [#15](https://github.com/Pimpmuckl/codex/pull/15), [#28](https://github.com/Pimpmuckl/codex/pull/28), [#37](https://github.com/Pimpmuckl/codex/pull/37), [#66](https://github.com/Pimpmuckl/codex/pull/66) | | PreToolUse `ask` Guardian review | Hooks | Treat hook `ask` as a capability-gated automatic Guardian review for every tool and block unless that review approves. | `codex-rs/core/src/tools/codex_plus_plus/pre_tool_use_review.rs` | `codex-rs/core/src/tools/registry.rs`
`codex-rs/core/src/tools/handlers/mcp.rs`
`codex-rs/core/src/hook_runtime.rs`
`codex-rs/hooks/src/events/pre_tool_use.rs`
`codex-rs/protocol/src/approvals.rs`
`codex-rs/app-server-protocol/src/protocol/v2/item.rs` | [#30](https://github.com/Pimpmuckl/codex/pull/30), [#36](https://github.com/Pimpmuckl/codex/pull/36), [#42](https://github.com/Pimpmuckl/codex/pull/42) | -| Managed Destructive Command Guard | Hooks | Detect, install, verify, enable, disable, update, and repair the pinned DCG companion through native marketplace, plugin, config, installer, and hook-trust APIs with binary rollback. | `codex-rs/tui/src/codex_plus_plus/destructive_command_guard/mod.rs` | — | [#72](https://github.com/Pimpmuckl/codex/pull/72) | +| Managed Destructive Command Guard | Hooks | Detect, install, verify, enable, disable, update, and repair the pinned DCG companion through native APIs with binary rollback; expose those states and actions in Codex++ settings and introduce Guardian once per global profile. | `codex-rs/tui/src/codex_plus_plus/destructive_command_guard/mod.rs`
`codex-rs/tui/src/chatwidget/slash_dispatch/codex_plus_plus/dcg.rs`
`codex-rs/config/src/codex_plus_plus/dcg_nux.rs`
`codex-rs/tui/src/codex_plus_plus/welcome.rs` | `codex-rs/config/src/config_toml.rs`
`codex-rs/core/config.schema.json`
`codex-rs/tui/src/app_event.rs`
`codex-rs/tui/src/app/event_dispatch.rs`
`codex-rs/tui/src/app/startup_prompts.rs`
`codex-rs/tui/src/history_cell/session.rs`
`codex-rs/tui/src/chatwidget/slash_dispatch.rs` | [#72](https://github.com/Pimpmuckl/codex/pull/72) | | Native Windows TUI input ownership | Platform | Keep Windows console input in native mode, repair runtime drift, rebuild the event stream safely, and restore the original mode on exit. | `codex-rs/tui/src/tui/codex_plus_plus/native_windows_input.rs` | `codex-rs/tui/src/tui.rs`
`codex-rs/tui/src/tui/event_stream.rs` | [#8](https://github.com/Pimpmuckl/codex/pull/8), [#11](https://github.com/Pimpmuckl/codex/pull/11), [#20](https://github.com/Pimpmuckl/codex/pull/20) | | Cross-platform migration checksums | Compatibility | Keep embedded SQLite migration checksums stable across LF and CRLF packaging, while accepting databases created by either line-ending variant. | `codex-rs/state/src/migrations.rs` | `.gitattributes`
`codex-rs/state/src/runtime.rs` | `4662e307be` | | Codex++ welcome guidance | TUI | Replace upstream app promotion with a concise Codex++ tip and preserve the same help in session history. | `codex-rs/tui/src/codex_plus_plus/welcome.rs` | `codex-rs/tui/src/tooltips.rs`
`codex-rs/tui/src/history_cell/session.rs` | [#43](https://github.com/Pimpmuckl/codex/pull/43) | diff --git a/codex-rs/config/src/codex_plus_plus/dcg_nux.rs b/codex-rs/config/src/codex_plus_plus/dcg_nux.rs new file mode 100644 index 000000000000..17833e0f20c8 --- /dev/null +++ b/codex-rs/config/src/codex_plus_plus/dcg_nux.rs @@ -0,0 +1,10 @@ +use crate::ConfigLayerStack; + +/// Returns whether the global Destructive Command Guard introduction was shown. +pub fn shown(config: &ConfigLayerStack) -> bool { + config + .effective_user_config() + .and_then(|config| config.get("codex_plus_plus_dcg_nux_shown").cloned()) + .and_then(|value| value.as_bool()) + .unwrap_or(false) +} diff --git a/codex-rs/config/src/codex_plus_plus/mod.rs b/codex-rs/config/src/codex_plus_plus/mod.rs index 9e8241bd91a2..78e57097d587 100644 --- a/codex-rs/config/src/codex_plus_plus/mod.rs +++ b/codex-rs/config/src/codex_plus_plus/mod.rs @@ -1,7 +1,9 @@ +mod dcg_nux; mod model_capacity_retry; mod user_message_inbox; mod weekly_usage_window; +pub use dcg_nux::shown as codex_plus_plus_dcg_nux_shown; pub use model_capacity_retry::ModelCapacityRetryMode; pub use user_message_inbox::UserMessageInbox; pub use weekly_usage_window::WeeklyUsageWindowAutoStart; diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index db034ad8dcf8..6cde28d0f759 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -271,6 +271,8 @@ pub struct ConfigToml { pub model_capacity_retry_mode: Option, /// Whether agents can leave durable, non-blocking messages for the user. pub user_message_inbox: Option, + /// Whether the global Destructive Command Guard introduction was shown. + pub codex_plus_plus_dcg_nux_shown: Option, /// Definition for MCP servers that Codex can reach out to for tool calls. #[serde(default)] // Uses the raw MCP input shape (custom deserialization) rather than `McpServerConfig`. diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index e99a08e327af..1df46d506d93 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -49,6 +49,7 @@ pub use cloud_config_layers::cloud_config_layers_from_fragments; pub use codex_plus_plus::ModelCapacityRetryMode; pub use codex_plus_plus::UserMessageInbox; pub use codex_plus_plus::WeeklyUsageWindowAutoStart; +pub use codex_plus_plus::codex_plus_plus_dcg_nux_shown; pub use codex_protocol::config_types::ProfileV2Name; pub use codex_protocol::config_types::ProfileV2NameParseError; pub use codex_utils_absolute_path::AbsolutePathBuf; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 635203669c9d..6274d1d1ef9b 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4749,6 +4749,10 @@ "default": null, "description": "Preferred backend for storing CLI auth credentials. file (default): Use a file in the Codex home directory. keyring: Use an OS-specific keyring service. auto: Use the keyring if available, otherwise use a file." }, + "codex_plus_plus_dcg_nux_shown": { + "description": "Whether the global Destructive Command Guard introduction was shown.", + "type": "boolean" + }, "compact_prompt": { "description": "Compact prompt used for history compaction.", "type": "string" diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 31055cb84efd..4c25bae4a781 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -33,6 +33,7 @@ use crate::chatwidget::ChatWidget; use crate::chatwidget::ExternalEditorState; use crate::chatwidget::ReplayKind; use crate::chatwidget::ThreadInputState; +use crate::codex_plus_plus::destructive_command_guard::DcgManager; use crate::cwd_prompt::CwdPromptAction; use crate::diff_render::DiffSummary; use crate::exec_command::split_command_string; @@ -887,6 +888,12 @@ impl App { &initial_images, ); let thread_and_widget_started_at = Instant::now(); + let _show_dcg_nux = DcgManager::management_supported(&app_server) + && !codex_config::codex_plus_plus_dcg_nux_shown(&config.config_layer_stack); + #[cfg(not(test))] + if _show_dcg_nux { + crate::codex_plus_plus::mark_dcg_nux_pending(); + } let pending_startup_thread_start = matches!( &session_selection, SessionSelection::StartFresh | SessionSelection::Exit @@ -1344,6 +1351,9 @@ See the Codex keymap documentation for supported actions and examples." // Allow widgets to process any pending timers before rendering. self.chat_widget.pre_draw_tick(); let rendered_area = self.render_chat_widget_frame(tui)?; + if crate::codex_plus_plus::take_dcg_nux_render_pending() { + persist_dcg_nux(&self.config).await; + } if self.chat_widget.ambient_pet_image_enabled() { let terminal_size = tui.terminal.size()?; let ambient_pet_area = Rect::new( diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index d8479239057f..e4ec87bbf7cb 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -5,6 +5,10 @@ use super::resize_reflow::trailing_run_start; use super::*; +use crate::codex_plus_plus::apply_dcg_action; +use crate::codex_plus_plus::destructive_command_guard::DcgManager; +use crate::codex_plus_plus::destructive_command_guard::DcgStatus; +use crate::codex_plus_plus::destructive_command_guard::RepairReason; use crate::config_update::format_config_error; use crate::external_agent_config_migration_flow::ExternalAgentConfigMigrationFlowOutcome; #[cfg(target_os = "windows")] @@ -1831,6 +1835,55 @@ impl App { ) .await; } + AppEvent::OpenCodexPlusPlusSettings => { + let request_id = DcgManager::begin_status_detection(); + let manager = DcgManager::new(app_server, &self.config); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let status = match manager { + Ok(manager) => manager.detect_status().await, + Err(err) => { + tracing::error!(error = %err, "failed to initialize DCG manager"); + DcgStatus::NeedsRepair(RepairReason::StatusUnavailable) + } + }; + app_event_tx.send(AppEvent::DcgStatusDetected(request_id, status)); + }); + } + AppEvent::DcgStatusDetected(request_id, status) => { + if DcgManager::is_current_status_detection(request_id) { + self.chat_widget.finish_dcg_status_detection(status); + } + } + AppEvent::OpenDcgInstallConfirmation => { + self.chat_widget.open_dcg_install_confirmation(); + } + AppEvent::ManageDcg(action) => { + if DcgManager::try_begin_operation() { + let manager = DcgManager::new(app_server, &self.config); + self.chat_widget.show_dcg_progress(action); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let change = match manager { + Ok(manager) => apply_dcg_action(manager, action) + .await + .inspect_err(|err| { + tracing::error!(error = %err, ?action, "DCG operation failed"); + }) + .ok(), + Err(err) => { + tracing::error!(error = %err, "failed to initialize DCG manager"); + None + } + }; + app_event_tx.send(AppEvent::DcgOperationCompleted { action, change }); + }); + } + } + AppEvent::DcgOperationCompleted { action, change } => { + DcgManager::finish_operation(); + self.chat_widget.finish_dcg_action(action, change); + } AppEvent::OpenCodexPlusPlusAccounts => { let statuses = self .weekly_window_scheduler diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index 55a377ec1104..f906a14ef2fa 100644 --- a/codex-rs/tui/src/app/startup_prompts.rs +++ b/codex-rs/tui/src/app/startup_prompts.rs @@ -268,6 +268,19 @@ pub(super) async fn prepare_startup_tooltip_override( Some(tooltip_override.message) } +pub(super) async fn persist_dcg_nux(config: &Config) { + if let Err(err) = ConfigEditsBuilder::for_config(config) + .with_edits([crate::legacy_core::config::edit::ConfigEdit::SetPath { + segments: vec!["codex_plus_plus_dcg_nux_shown".to_string()], + value: true.into(), + }]) + .apply() + .await + { + tracing::error!(error = %err, "failed to persist DCG introduction state"); + } +} + pub(super) async fn handle_model_migration_prompt_if_needed( tui: &mut tui::Tui, config: &mut Config, @@ -400,6 +413,28 @@ mod tests { Ok(()) } + #[tokio::test] + async fn dcg_nux_persists_globally_and_prepares_once() { + let codex_home = tempdir().expect("temp codex home"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config"); + assert!(!codex_config::codex_plus_plus_dcg_nux_shown( + &config.config_layer_stack + )); + persist_dcg_nux(&config).await; + let reloaded = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("reloaded config"); + assert!(codex_config::codex_plus_plus_dcg_nux_shown( + &reloaded.config_layer_stack + )); + } + fn skill_error(path: &str, message: &str) -> SkillErrorInfo { SkillErrorInfo { path: PathBuf::from(path), diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 5c4f1161e8c4..473c04fe040f 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -868,6 +868,18 @@ pub(crate) enum AppEvent { user_message_inbox: UserMessageInbox, }, + OpenCodexPlusPlusSettings, + DcgStatusDetected( + u64, + crate::codex_plus_plus::destructive_command_guard::DcgStatus, + ), + OpenDcgInstallConfirmation, + ManageDcg(crate::codex_plus_plus::DcgAction), + DcgOperationCompleted { + action: crate::codex_plus_plus::DcgAction, + change: Option, + }, + OpenCodexPlusPlusAccounts, /// Update memory settings and persist them to config.toml. UpdateMemorySettings { diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index eeb8bece5018..c9d5b430fa14 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -455,7 +455,8 @@ impl ChatWidget { self.defer_input_until_settings_applied(); } SlashCommand::CodexPlusPlus => { - self.open_codex_plus_plus_popup(); + self.show_dcg_status_progress(); + self.app_event_tx.send(AppEvent::OpenCodexPlusPlusSettings); self.defer_input_until_settings_applied(); } SlashCommand::Inbox => { diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch/codex_plus_plus/codex_plus_plus_tests.rs b/codex-rs/tui/src/chatwidget/slash_dispatch/codex_plus_plus/codex_plus_plus_tests.rs index 64e49e61f370..35d4359a9696 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch/codex_plus_plus/codex_plus_plus_tests.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch/codex_plus_plus/codex_plus_plus_tests.rs @@ -8,6 +8,8 @@ use super::*; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::BottomPaneView; use crate::bottom_pane::ListSelectionView; +use crate::codex_plus_plus::destructive_command_guard::DcgUnsupportedReason; +use crate::codex_plus_plus::destructive_command_guard::RepairReason; use crate::keymap::RuntimeKeymap; use crate::test_backend::VT100Backend; @@ -30,6 +32,7 @@ fn settings_view( capacity, /*current_user_message_inbox*/ false, weekly_supported, + None, &keymap, ), AppEventSender::new(tx), @@ -175,7 +178,69 @@ fn settings_hint_uses_list_keymap() { .map(|span| span.content.as_ref()) .collect::(); - assert_eq!(text, "Press space to toggle; enter to save; q to cancel"); + assert_eq!( + text, + "Press space to toggle; enter to save or manage; q to cancel" + ); +} + +#[test] +fn dcg_views_snapshot() { + let i = dcg::settings_item( + DcgStatus::NotInstalled, + Default::default(), + /*save_weekly*/ false, + ); + let (tx, mut rx) = unbounded_channel(); + (i.actions[0])(&AppEventSender::new(tx)); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::PersistCodexPlusPlusSettings { .. }) + ); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenDcgInstallConfirmation)); + let states = [ + DcgStatus::NotInstalled, + DcgStatus::Enabled("0.6.8-codexpp.1".to_string()), + DcgStatus::Disabled("0.6.8-codexpp.1".to_string()), + DcgStatus::UpdateAvailable { + installed_version: Some("0.6.7".to_string()), + target_version: "0.6.8".to_string(), + }, + DcgStatus::ExternalInstallation("0.6.8".to_string()), + DcgStatus::NeedsRepair(RepairReason::HookUntrusted), + DcgStatus::NeedsRepair(RepairReason::StatusUnavailable), + DcgStatus::Unsupported(DcgUnsupportedReason::Platform), + ]; + let flow = [ + dcg::confirmation_params(), + dcg::progress_params(crate::codex_plus_plus::DcgAction::InstallAndEnable), + dcg::failure_params(), + ] + .map(summarize_params) + .join("\n"); + insta::assert_snapshot!( + "codex_plus_plus_dcg_settings_states", + format!("{}\n{flow}", states.map(render_dcg_item).join(" || ")) + ); +} + +fn render_dcg_item(status: DcgStatus) -> String { + let item = dcg::settings_item( + status, + SettingsSelection::default(), + /*save_weekly*/ false, + ); + format!("{} | {} action(s)", item.name, item.actions.len()) +} + +fn summarize_params(params: SelectionViewParams) -> String { + let items = params + .items + .into_iter() + .map(|item| format!("{}|{}", item.name, item.description.unwrap_or_default())) + .collect::>() + .join("\n"); + format!("{}\n{items}", params.title.unwrap_or_default()) } #[test] diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch/codex_plus_plus/dcg.rs b/codex-rs/tui/src/chatwidget/slash_dispatch/codex_plus_plus/dcg.rs new file mode 100644 index 000000000000..2f652027e988 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/slash_dispatch/codex_plus_plus/dcg.rs @@ -0,0 +1,202 @@ +use super::*; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::SelectionAction; +use crate::codex_plus_plus::DcgAction; +use crate::codex_plus_plus::destructive_command_guard::DcgChange; +use crate::codex_plus_plus::destructive_command_guard::DcgStatus; +use crate::codex_plus_plus::destructive_command_guard::DcgUnsupportedReason; +use crate::codex_plus_plus::destructive_command_guard::RepairReason; + +const DESCRIPTION: &str = "Review destructive shell commands with Guardian."; +const PROGRESS_VIEW_ID: &str = "dcg-operation-progress"; +const STATUS_VIEW_ID: &str = "dcg-status-progress"; + +pub(super) fn settings_item( + status: DcgStatus, + selection: SettingsSelection, + save_weekly: bool, +) -> SelectionItem { + let (state, action) = match status { + DcgStatus::NotInstalled => ( + "Not installed".to_string(), + Some(DcgAction::InstallAndEnable), + ), + DcgStatus::Enabled(version) => (format!("Enabled · {version}"), Some(DcgAction::Disable)), + DcgStatus::Disabled(version) => (format!("Disabled · {version}"), Some(DcgAction::Enable)), + DcgStatus::UpdateAvailable { target_version, .. } => ( + format!("Update available · {target_version}"), + Some(DcgAction::Update), + ), + DcgStatus::ExternalInstallation(_) => ("External installation".to_string(), None), + DcgStatus::NeedsRepair(reason) => ( + "Needs repair".to_string(), + (!matches!( + reason, + RepairReason::MarketplaceConfigMalformed + | RepairReason::MarketplacePinMismatch + | RepairReason::HookDisabled + | RepairReason::StatusUnavailable + )) + .then_some(DcgAction::Repair(reason)), + ), + DcgStatus::Unsupported(DcgUnsupportedReason::Platform) => { + ("Unsupported on this platform".to_string(), None) + } + DcgStatus::Unsupported(DcgUnsupportedReason::RemoteHookHost) => { + ("Manage on the shell host".to_string(), None) + } + }; + let actions = action + .map(|action| { + vec![Box::new(move |tx: &AppEventSender| { + tx.send(AppEvent::PersistCodexPlusPlusSettings { + automatic_account_selection: selected( + &selection.automatic, + AutomaticOn, + AutomaticOff, + ), + weekly_usage_window_auto_start: save_weekly + .then(|| selected(&selection.weekly, WeeklyOn, WeeklyOff)), + model_capacity_retry_mode: selected( + &selection.capacity_indefinite, + CapacityIndefinite, + CapacityBounded, + ), + user_message_inbox: selected(&selection.user_message_inbox, InboxOn, InboxOff), + }); + tx.send(match action { + DcgAction::InstallAndEnable => AppEvent::OpenDcgInstallConfirmation, + _ => AppEvent::ManageDcg(action), + }); + }) as SelectionAction] + }) + .unwrap_or_default(); + SelectionItem { + name: format!("Destructive Command Guard · {state}"), + description: Some(DESCRIPTION.to_string()), + is_disabled: action.is_none(), + actions, + dismiss_on_select: true, + ..Default::default() + } +} + +fn selected(value: &AtomicBool, on: T, off: T) -> T { + [off, on][usize::from(value.load(Ordering::Relaxed))] +} + +pub(super) fn confirmation_params() -> SelectionViewParams { + SelectionViewParams { + title: Some("Install Destructive Command Guard?".to_string()), + subtitle: Some("Codex++ will enable protection for new sessions.".to_string()), + items: vec![ + SelectionItem { + name: "Install and enable".to_string(), + description: Some(DESCRIPTION.to_string()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::ManageDcg(DcgAction::InstallAndEnable)); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Cancel".to_string(), + dismiss_on_select: true, + ..Default::default() + }, + ], + ..Default::default() + } +} + +pub(super) fn progress_params(action: DcgAction) -> SelectionViewParams { + let mut params = message_params(format!("{}…", action_verb(action)), "Working…"); + params.view_id = Some(PROGRESS_VIEW_ID); + params.allow_cancel = false; + params +} + +pub(super) fn failure_params() -> SelectionViewParams { + message_params( + "Operation failed".to_string(), + "The operation did not finish. Try again.", + ) +} + +fn message_params(name: String, description: &str) -> SelectionViewParams { + SelectionViewParams { + title: Some("Destructive Command Guard".to_string()), + items: vec![SelectionItem { + name, + description: Some(description.to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } +} + +fn action_verb(action: DcgAction) -> &'static str { + match action { + DcgAction::InstallAndEnable => "Installing and enabling", + DcgAction::Enable => "Enabling", + DcgAction::Disable => "Disabling", + DcgAction::Update => "Updating", + DcgAction::Repair(_) => "Repairing", + } +} + +fn action_succeeded(action: DcgAction, status: &DcgStatus) -> bool { + match action { + DcgAction::Disable => matches!(status, DcgStatus::Disabled(_)), + _ => matches!(status, DcgStatus::Enabled(_)), + } +} + +impl ChatWidget { + pub(crate) fn show_dcg_status_progress(&mut self) { + let mut params = message_params("Checking command guard…".to_string(), "Working…"); + params.view_id = Some(STATUS_VIEW_ID); + self.show_dcg_view(params); + } + + pub(crate) fn finish_dcg_status_detection(&mut self, status: DcgStatus) { + if self.bottom_pane.dismiss_active_view_if_id(STATUS_VIEW_ID) { + self.open_codex_plus_plus_popup(status); + } + } + + pub(crate) fn open_dcg_install_confirmation(&mut self) { + self.show_dcg_view(confirmation_params()); + } + + pub(crate) fn show_dcg_progress(&mut self, action: DcgAction) { + self.show_dcg_view(progress_params(action)); + } + + pub(crate) fn finish_dcg_action(&mut self, action: DcgAction, change: Option) { + if !self.bottom_pane.dismiss_active_view_if_id(PROGRESS_VIEW_ID) { + self.bottom_pane.dismiss_view_by_id(PROGRESS_VIEW_ID); + return; + } + match change { + Some(change) if action_succeeded(action, &change.status) => { + debug_assert!(!change.takes_effect_in_current_session); + self.add_info_message( + "Destructive Command Guard settings updated. Changes apply to new sessions." + .to_string(), + /*hint*/ None, + ); + self.open_codex_plus_plus_popup(change.status); + } + _ => self.show_dcg_view(failure_params()), + } + } + + fn show_dcg_view(&mut self, params: SelectionViewParams) { + let keymap = self.bottom_pane.list_keymap(); + let view = ListSelectionView::new(params, self.app_event_tx.clone(), keymap); + self.bottom_pane.show_view(Box::new(view)); + self.request_redraw(); + } +} diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch/codex_plus_plus/mod.rs b/codex-rs/tui/src/chatwidget/slash_dispatch/codex_plus_plus/mod.rs index dd6f3395cd28..65607bb0ead4 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch/codex_plus_plus/mod.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch/codex_plus_plus/mod.rs @@ -1,6 +1,7 @@ //! Codex++ settings exposed through fork slash commands. mod accounts; +mod dcg; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -25,6 +26,7 @@ use ratatui::text::Line; use super::super::*; use crate::bottom_pane::ListSelectionView; use crate::bottom_pane::SelectionToggle; +use crate::codex_plus_plus::destructive_command_guard::DcgStatus; use crate::key_hint; use crate::keymap::ListKeymap; use crate::keymap::primary_binding; @@ -37,7 +39,7 @@ impl ChatWidget { self.request_redraw(); } - pub(super) fn open_codex_plus_plus_popup(&mut self) { + pub(crate) fn open_codex_plus_plus_popup(&mut self, dcg_status: DcgStatus) { let list_keymap = settings_list_keymap(self.bottom_pane.list_keymap()); let params = codex_plus_plus_settings_params( self.config.automatic_account_selection, @@ -45,6 +47,7 @@ impl ChatWidget { self.config.model_capacity_retry_mode, crate::codex_plus_plus::user_message_inbox_enabled(&self.config.config_layer_stack), self.weekly_start_supported, + Some(dcg_status), &list_keymap, ); let view = ListSelectionView::new(params, self.app_event_tx.clone(), list_keymap); @@ -85,7 +88,7 @@ impl ChatWidget { } } -#[derive(Clone)] +#[derive(Clone, Default)] struct SettingsSelection { automatic: Arc, weekly: Arc, @@ -109,6 +112,7 @@ fn codex_plus_plus_settings_params( current_capacity: ModelCapacityRetryMode, current_user_message_inbox: bool, weekly_supported: bool, + dcg_status: Option, list_keymap: &ListKeymap, ) -> SelectionViewParams { let selection = SettingsSelection { @@ -124,6 +128,12 @@ fn codex_plus_plus_settings_params( selection.clone(), weekly_supported, )]; + if let Some(status) = dcg_status { + items.insert( + 0, + dcg::settings_item(status, selection.clone(), weekly_supported), + ); + } if weekly_supported { items.push(settings_item( "Start unused weekly windows", @@ -211,7 +221,7 @@ fn settings_hint_line(list_keymap: &ListKeymap) -> Line<'static> { " to toggle".into(), ]; if let Some(accept) = primary_binding(&list_keymap.accept) { - spans.extend(["; ".into(), accept.into(), " to save".into()]); + spans.extend(["; ".into(), accept.into(), " to save or manage".into()]); } if let Some(cancel) = primary_binding(&list_keymap.cancel) { spans.extend(["; ".into(), cancel.into(), " to cancel".into()]); diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch/codex_plus_plus/snapshots/codex_tui__chatwidget__slash_dispatch__codex_plus_plus__tests__codex_plus_plus_dcg_settings_states.snap b/codex-rs/tui/src/chatwidget/slash_dispatch/codex_plus_plus/snapshots/codex_tui__chatwidget__slash_dispatch__codex_plus_plus__tests__codex_plus_plus_dcg_settings_states.snap new file mode 100644 index 000000000000..7cd49f5f0498 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/slash_dispatch/codex_plus_plus/snapshots/codex_tui__chatwidget__slash_dispatch__codex_plus_plus__tests__codex_plus_plus_dcg_settings_states.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/slash_dispatch/codex_plus_plus/codex_plus_plus_tests.rs +expression: "format!(\"{}\\n{flow}\", states.map(render_dcg_item).join(\" || \"))" +--- +Destructive Command Guard · Not installed | 1 action(s) || Destructive Command Guard · Enabled · 0.6.8-codexpp.1 | 1 action(s) || Destructive Command Guard · Disabled · 0.6.8-codexpp.1 | 1 action(s) || Destructive Command Guard · Update available · 0.6.8 | 1 action(s) || Destructive Command Guard · External installation | 0 action(s) || Destructive Command Guard · Needs repair | 1 action(s) || Destructive Command Guard · Needs repair | 0 action(s) || Destructive Command Guard · Unsupported on this platform | 0 action(s) +Install Destructive Command Guard? +Install and enable|Review destructive shell commands with Guardian. +Cancel| +Destructive Command Guard +Installing and enabling…|Working… +Destructive Command Guard +Operation failed|The operation did not finish. Try again. diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 2cf958f14917..4b0c102c42f0 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -407,6 +407,12 @@ async fn queued_slash_menu_cancel_drains_next_input() { KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), ) .await; + assert_cancelled_queued_menu_drains_next_input( + "/codexplusplus", + "Checking command guard", + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + ) + .await; } #[tokio::test] diff --git a/codex-rs/tui/src/codex_plus_plus/destructive_command_guard/mod.rs b/codex-rs/tui/src/codex_plus_plus/destructive_command_guard/mod.rs index 7a687fcaa327..0a15c0bd9561 100644 --- a/codex-rs/tui/src/codex_plus_plus/destructive_command_guard/mod.rs +++ b/codex-rs/tui/src/codex_plus_plus/destructive_command_guard/mod.rs @@ -26,8 +26,14 @@ use codex_core_plugins::store::PluginStore; use codex_plugin::PluginId; use codex_utils_absolute_path::AbsolutePathBuf; use std::io::ErrorKind; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; use std::path::Path; use std::path::PathBuf; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use std::time::Duration; use tokio::process::Command; use uuid::Uuid; @@ -38,6 +44,9 @@ const PINNED_SOURCE: &str = "https://github.com/Pimpmuckl/destructive_command_gu const PINNED_TAG: &str = "v0.6.8-codexpp.1"; const PINNED_VERSION: &str = "0.6.8-codexpp.1"; const PINNED_COMMIT: &str = "fd4e90a41fa493aa9a61c715bd5114b7fcf4348e"; +const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(2); +static OPERATION_IN_FLIGHT: AtomicBool = AtomicBool::new(false); +static STATUS_DETECTION_ID: AtomicU64 = AtomicU64::new(0); #[cfg(target_os = "windows")] const BINARY_NAME: &str = "dcg.exe"; #[cfg(not(target_os = "windows"))] @@ -120,8 +129,11 @@ impl DcgManager { return DcgStatus::NeedsRepair(RepairReason::PluginMissing); } Ok(None) => { + let Some(binary) = external_binary() else { + return DcgStatus::NotInstalled; + }; return self - .reported_version(Path::new("dcg")) + .reported_version(&binary) .await .map_or(DcgStatus::NotInstalled, DcgStatus::ExternalInstallation); } @@ -178,6 +190,26 @@ impl DcgManager { self.install_managed().await } + pub(crate) fn try_begin_operation() -> bool { + !OPERATION_IN_FLIGHT.swap(true, Ordering::AcqRel) + } + + pub(crate) fn finish_operation() { + OPERATION_IN_FLIGHT.store(false, Ordering::Release); + } + + pub(crate) fn management_supported(app_server: &AppServerSession) -> bool { + PLATFORM_SUPPORTED && !app_server.uses_remote_workspace() + } + + pub(crate) fn begin_status_detection() -> u64 { + STATUS_DETECTION_ID.fetch_add(1, Ordering::AcqRel) + 1 + } + + pub(crate) fn is_current_status_detection(id: u64) -> bool { + STATUS_DETECTION_ID.load(Ordering::Acquire) == id + } + pub(crate) async fn enable(&self) -> Result { self.set_enabled(/*enabled*/ true).await } @@ -233,8 +265,10 @@ impl DcgManager { .await?; let plugin_was_enabled = self .read_plugin(marketplace.installed_root.as_path()) - .await - .is_ok_and(|response| response.plugin.summary.enabled); + .await? + .plugin + .summary + .enabled; let binary = self.binary_path(); let data_root = binary.parent().context("DCG binary path has no parent")?; tokio::fs::create_dir_all(data_root).await?; @@ -490,7 +524,12 @@ impl DcgManager { } async fn reported_version(&self, binary: &Path) -> Option { - let output = Command::new(binary).arg("--version").output().await.ok()?; + let mut command = Command::new(binary); + command.arg("--version").kill_on_drop(true); + let output = tokio::time::timeout(VERSION_PROBE_TIMEOUT, command.output()) + .await + .ok()? + .ok()?; if !output.status.success() { return None; } @@ -525,6 +564,19 @@ impl DcgManager { } } +fn external_binary() -> Option { + let path = std::env::var_os("PATH")?; + let mut candidates = std::env::split_paths(&path).map(|directory| directory.join(BINARY_NAME)); + #[cfg(unix)] + return candidates.find(|candidate| { + candidate + .metadata() + .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) + }); + #[cfg(not(unix))] + candidates.find(|candidate| candidate.is_file()) +} + fn request_id(prefix: &str) -> RequestId { RequestId::String(format!("{prefix}-{}", Uuid::new_v4())) } diff --git a/codex-rs/tui/src/codex_plus_plus/mod.rs b/codex-rs/tui/src/codex_plus_plus/mod.rs index 855fd0f8ac50..cf7a84c2e003 100644 --- a/codex-rs/tui/src/codex_plus_plus/mod.rs +++ b/codex-rs/tui/src/codex_plus_plus/mod.rs @@ -1,7 +1,6 @@ //! Codex++ TUI capabilities kept separate from upstream-owned orchestration. mod account_policy; -#[allow(dead_code)] pub(crate) mod destructive_command_guard; #[cfg(any(not(debug_assertions), test))] mod lag_warning; @@ -13,6 +12,10 @@ mod weekly_window_scheduler; mod welcome; pub(crate) use account_policy::persist_settings; +use anyhow::Result; +use destructive_command_guard::DcgChange; +use destructive_command_guard::DcgManager; +use destructive_command_guard::RepairReason; #[cfg(not(debug_assertions))] pub(crate) use lag_warning::lag_warning; pub(crate) use model_capacity_retry::status_details as model_capacity_retry_status_details; @@ -32,5 +35,28 @@ pub(crate) use user_message_inbox::recognize as recognize_user_message; pub(crate) use weekly_window_scheduler::WeeklyWindowScheduler; pub(crate) use weekly_window_scheduler::WeeklyWindowStatus; pub(crate) use welcome::WELCOME_TIP; +#[cfg(not(test))] +pub(crate) use welcome::mark_dcg_nux_pending; pub(crate) use welcome::replace_upstream_app_promo; +pub(crate) use welcome::take_dcg_nux_help_lines; +pub(crate) use welcome::take_dcg_nux_render_pending; pub(crate) use welcome::welcome_help_lines; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DcgAction { + InstallAndEnable, + Enable, + Disable, + Update, + Repair(RepairReason), +} + +pub(crate) async fn apply_dcg_action(manager: DcgManager, action: DcgAction) -> Result { + match action { + DcgAction::InstallAndEnable => manager.install_and_enable().await, + DcgAction::Enable => manager.enable().await, + DcgAction::Disable => manager.disable().await, + DcgAction::Update => manager.update().await, + DcgAction::Repair(reason) => manager.repair(reason).await, + } +} diff --git a/codex-rs/tui/src/codex_plus_plus/snapshots/codex_tui__codex_plus_plus__welcome__tests__codex_plus_plus_welcome_help.snap b/codex-rs/tui/src/codex_plus_plus/snapshots/codex_tui__codex_plus_plus__welcome__tests__codex_plus_plus_welcome_help.snap index 85a82fa8b09b..3db67421d81c 100644 --- a/codex-rs/tui/src/codex_plus_plus/snapshots/codex_tui__codex_plus_plus__welcome__tests__codex_plus_plus_welcome_help.snap +++ b/codex-rs/tui/src/codex_plus_plus/snapshots/codex_tui__codex_plus_plus__welcome__tests__codex_plus_plus_welcome_help.snap @@ -1,6 +1,10 @@ --- source: tui/src/codex_plus_plus/welcome_tests.rs -expression: terminal.backend().to_string() +expression: "[render_welcome(true, 5), render_welcome(false, 3),].join(\"\\n\")" --- Welcome to Codex++. Use /codexplusplus for settings and /accounts to enable or disable accounts. + Tip: Enable Destructive Command Guard in /codexplusplus to send risky + commands to Guardian for review even in full --yolo mode. + Welcome to Codex++. Use /codexplusplus for settings and /accounts to enable + or disable accounts. diff --git a/codex-rs/tui/src/codex_plus_plus/welcome.rs b/codex-rs/tui/src/codex_plus_plus/welcome.rs index 91decb70ed79..21824a55ecdf 100644 --- a/codex-rs/tui/src/codex_plus_plus/welcome.rs +++ b/codex-rs/tui/src/codex_plus_plus/welcome.rs @@ -2,8 +2,19 @@ use ratatui::style::Stylize; use ratatui::text::Line; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; pub(crate) const WELCOME_TIP: &str = "Welcome to **Codex++**. Use **/codexplusplus** for settings and **/accounts** to enable or disable accounts."; +pub(crate) const DCG_NUX_TIP: &str = "Tip: Enable Destructive Command Guard in /codexplusplus to send risky commands to Guardian for review even in full --yolo mode."; + +static DCG_NUX_PENDING: AtomicBool = AtomicBool::new(false); +static DCG_NUX_RENDER_PENDING: AtomicBool = AtomicBool::new(false); + +#[cfg(not(test))] +pub(crate) fn mark_dcg_nux_pending() { + DCG_NUX_PENDING.store(true, Ordering::Relaxed); +} pub(crate) fn replace_upstream_app_promo(tip: &'static str) -> &'static str { if tip.contains("codex app") { @@ -14,9 +25,32 @@ pub(crate) fn replace_upstream_app_promo(tip: &'static str) -> &'static str { } pub(crate) fn welcome_help_lines() -> Vec> { - let tip = WELCOME_TIP.replace("**", ""); + wrapped_tip(&WELCOME_TIP.replace("**", "")) +} + +pub(crate) fn take_dcg_nux_help_lines() -> Option>> { + DCG_NUX_PENDING + .swap(false, Ordering::Relaxed) + .then(|| wrapped_tip(DCG_NUX_TIP)) + .inspect(|_| DCG_NUX_RENDER_PENDING.store(true, Ordering::Relaxed)) +} + +pub(crate) fn take_dcg_nux_render_pending() -> bool { + DCG_NUX_RENDER_PENDING.swap(false, Ordering::Relaxed) +} + +#[cfg(test)] +fn welcome_help_lines_for(show_dcg_nux: bool) -> Vec> { + let mut lines = welcome_help_lines(); + if show_dcg_nux { + lines.extend(wrapped_tip(DCG_NUX_TIP)); + } + lines +} + +fn wrapped_tip(tip: &str) -> Vec> { textwrap::wrap( - &tip, + tip, textwrap::Options::new(/*width*/ 78) .initial_indent(" ") .subsequent_indent(" "), diff --git a/codex-rs/tui/src/codex_plus_plus/welcome_tests.rs b/codex-rs/tui/src/codex_plus_plus/welcome_tests.rs index 31a29ad38cde..17b4272dab7a 100644 --- a/codex-rs/tui/src/codex_plus_plus/welcome_tests.rs +++ b/codex-rs/tui/src/codex_plus_plus/welcome_tests.rs @@ -14,16 +14,27 @@ fn replaces_only_upstream_app_promo() { assert_eq!(unrelated_tip, replace_upstream_app_promo(unrelated_tip)); } -#[test] -fn welcome_help_snapshot() { - let mut terminal = - Terminal::new(VT100Backend::new(/*width*/ 80, /*height*/ 3)).expect("terminal"); +fn render_welcome(show_dcg_nux: bool, height: u16) -> String { + let mut terminal = Terminal::new(VT100Backend::new(/*width*/ 80, height)).expect("terminal"); terminal - .draw(|frame| frame.render_widget(Text::from(welcome_help_lines()), frame.area())) + .draw(|frame| { + frame.render_widget( + Text::from(welcome_help_lines_for(show_dcg_nux)), + frame.area(), + ) + }) .expect("render welcome help"); + terminal.backend().to_string() +} +#[test] +fn welcome_help_first_and_later_startup_snapshot() { insta::assert_snapshot!( "codex_plus_plus_welcome_help", - terminal.backend().to_string() + [ + render_welcome(/*show_dcg_nux*/ true, /*height*/ 5), + render_welcome(/*show_dcg_nux*/ false, /*height*/ 3), + ] + .join("\n") ); } diff --git a/codex-rs/tui/src/history_cell/session.rs b/codex-rs/tui/src/history_cell/session.rs index 3d47f228fb3f..9704768259a0 100644 --- a/codex-rs/tui/src/history_cell/session.rs +++ b/codex-rs/tui/src/history_cell/session.rs @@ -213,6 +213,14 @@ pub(crate) fn new_session_info( parts.push(Box::new(PlainHistoryCell { lines })); } } + if let Some(mut dcg_lines) = crate::codex_plus_plus::take_dcg_nux_help_lines() { + if !is_first_event { + let mut welcome_lines = crate::codex_plus_plus::welcome_help_lines(); + welcome_lines.append(&mut dcg_lines); + dcg_lines = welcome_lines; + } + parts.push(Box::new(PlainHistoryCell { lines: dcg_lines })); + } SessionInfoCell(CompositeHistoryCell { parts }) }