mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Elevated sandbox NUX (#8789)
Elevated Sandbox NUX: * prompt for elevated sandbox setup when agent mode is selected (via /approvals or at startup) * prompt for degraded sandbox if elevated setup is declined or fails * introduce /elevate-sandbox command to upgrade from degraded experience.
This commit is contained in:
committed by
GitHub
Unverified
parent
bdfdebcfa1
commit
6372ba9d5f
@@ -1504,6 +1504,15 @@ impl Config {
|
||||
}
|
||||
self.forced_auto_mode_downgraded_on_windows = !value;
|
||||
}
|
||||
|
||||
pub fn set_windows_elevated_sandbox_globally(&mut self, value: bool) {
|
||||
crate::safety::set_windows_elevated_sandbox_enabled(value);
|
||||
if value {
|
||||
self.features.enable(Feature::WindowsSandboxElevated);
|
||||
} else {
|
||||
self.features.disable(Feature::WindowsSandboxElevated);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_review_model() -> String {
|
||||
|
||||
@@ -51,6 +51,7 @@ pub mod token_data;
|
||||
mod truncate;
|
||||
mod unified_exec;
|
||||
mod user_instructions;
|
||||
pub mod windows_sandbox;
|
||||
pub use model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY;
|
||||
pub use model_provider_info::DEFAULT_LMSTUDIO_PORT;
|
||||
pub use model_provider_info::DEFAULT_OLLAMA_PORT;
|
||||
@@ -114,6 +115,8 @@ pub use command_safety::is_safe_command;
|
||||
pub use exec_policy::ExecPolicyError;
|
||||
pub use exec_policy::load_exec_policy;
|
||||
pub use safety::get_platform_sandbox;
|
||||
pub use safety::is_windows_elevated_sandbox_enabled;
|
||||
pub use safety::set_windows_elevated_sandbox_enabled;
|
||||
pub use safety::set_windows_sandbox_enabled;
|
||||
// Re-export the protocol types from the standalone `codex-protocol` crate so existing
|
||||
// `codex_core::protocol::...` references continue to work across the workspace.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// Kill switch for the elevated sandbox NUX on Windows.
|
||||
///
|
||||
/// When false, revert to the previous sandbox NUX, which only
|
||||
/// prompts users to enable the legacy sandbox feature.
|
||||
pub const ELEVATED_SANDBOX_NUX_ENABLED: bool = true;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn sandbox_setup_is_complete(codex_home: &Path) -> bool {
|
||||
codex_windows_sandbox::sandbox_setup_is_complete(codex_home)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn sandbox_setup_is_complete(_codex_home: &Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn run_elevated_setup(
|
||||
policy: &SandboxPolicy,
|
||||
policy_cwd: &Path,
|
||||
command_cwd: &Path,
|
||||
env_map: &HashMap<String, String>,
|
||||
codex_home: &Path,
|
||||
) -> anyhow::Result<()> {
|
||||
codex_windows_sandbox::run_elevated_setup(
|
||||
policy,
|
||||
policy_cwd,
|
||||
command_cwd,
|
||||
env_map,
|
||||
codex_home,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn run_elevated_setup(
|
||||
_policy: &SandboxPolicy,
|
||||
_policy_cwd: &Path,
|
||||
_command_cwd: &Path,
|
||||
_env_map: &HashMap<String, String>,
|
||||
_codex_home: &Path,
|
||||
) -> anyhow::Result<()> {
|
||||
anyhow::bail!("elevated Windows sandbox setup is only supported on Windows")
|
||||
}
|
||||
+86
-3
@@ -1,5 +1,9 @@
|
||||
use crate::app_backtrack::BacktrackState;
|
||||
use crate::app_event::AppEvent;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::app_event::WindowsSandboxEnableMode;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::app_event::WindowsSandboxFallbackReason;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
use crate::chatwidget::ChatWidget;
|
||||
@@ -792,19 +796,91 @@ impl App {
|
||||
AppEvent::OpenWindowsSandboxEnablePrompt { preset } => {
|
||||
self.chat_widget.open_windows_sandbox_enable_prompt(preset);
|
||||
}
|
||||
AppEvent::EnableWindowsSandboxForAgentMode { preset } => {
|
||||
AppEvent::OpenWindowsSandboxFallbackPrompt { preset, reason } => {
|
||||
self.chat_widget.clear_windows_sandbox_setup_status();
|
||||
self.chat_widget
|
||||
.open_windows_sandbox_fallback_prompt(preset, reason);
|
||||
}
|
||||
AppEvent::BeginWindowsSandboxElevatedSetup { preset } => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let policy = preset.sandbox.clone();
|
||||
let policy_cwd = self.config.cwd.clone();
|
||||
let command_cwd = policy_cwd.clone();
|
||||
let env_map: std::collections::HashMap<String, String> =
|
||||
std::env::vars().collect();
|
||||
let codex_home = self.config.codex_home.clone();
|
||||
let tx = self.app_event_tx.clone();
|
||||
|
||||
// If the elevated setup already ran on this machine, don't prompt for
|
||||
// elevation again - just flip the config to use the elevated path.
|
||||
if codex_core::windows_sandbox::sandbox_setup_is_complete(codex_home.as_path())
|
||||
{
|
||||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset,
|
||||
mode: WindowsSandboxEnableMode::Elevated,
|
||||
});
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
self.chat_widget.show_windows_sandbox_setup_status();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = codex_core::windows_sandbox::run_elevated_setup(
|
||||
&policy,
|
||||
policy_cwd.as_path(),
|
||||
command_cwd.as_path(),
|
||||
&env_map,
|
||||
codex_home.as_path(),
|
||||
);
|
||||
let event = match result {
|
||||
Ok(()) => AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset: preset.clone(),
|
||||
mode: WindowsSandboxEnableMode::Elevated,
|
||||
},
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
"failed to run elevated Windows sandbox setup"
|
||||
);
|
||||
AppEvent::OpenWindowsSandboxFallbackPrompt {
|
||||
preset,
|
||||
reason: WindowsSandboxFallbackReason::ElevationFailed,
|
||||
}
|
||||
}
|
||||
};
|
||||
tx.send(event);
|
||||
});
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = preset;
|
||||
}
|
||||
}
|
||||
AppEvent::EnableWindowsSandboxForAgentMode { preset, mode } => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
self.chat_widget.clear_windows_sandbox_setup_status();
|
||||
let profile = self.active_profile.as_deref();
|
||||
let feature_key = Feature::WindowsSandbox.key();
|
||||
let elevated_key = Feature::WindowsSandboxElevated.key();
|
||||
let elevated_enabled = matches!(mode, WindowsSandboxEnableMode::Elevated);
|
||||
match ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
.with_profile(profile)
|
||||
.set_feature_enabled(feature_key, true)
|
||||
.set_feature_enabled(elevated_key, elevated_enabled)
|
||||
.apply()
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
self.config.set_windows_sandbox_globally(true);
|
||||
self.config
|
||||
.set_windows_elevated_sandbox_globally(elevated_enabled);
|
||||
self.chat_widget
|
||||
.set_feature_enabled(Feature::WindowsSandbox, true);
|
||||
self.chat_widget.set_feature_enabled(
|
||||
Feature::WindowsSandboxElevated,
|
||||
elevated_enabled,
|
||||
);
|
||||
self.chat_widget.clear_forced_auto_mode_downgrade();
|
||||
if let Some((sample_paths, extra_count, failed_scan)) =
|
||||
self.chat_widget.world_writable_warning_details()
|
||||
@@ -833,7 +909,14 @@ impl App {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::UpdateSandboxPolicy(preset.sandbox.clone()));
|
||||
self.chat_widget.add_info_message(
|
||||
"Enabled experimental Windows sandbox.".to_string(),
|
||||
match mode {
|
||||
WindowsSandboxEnableMode::Elevated => {
|
||||
"Enabled elevated agent sandbox.".to_string()
|
||||
}
|
||||
WindowsSandboxEnableMode::Legacy => {
|
||||
"Enabled non-elevated agent sandbox.".to_string()
|
||||
}
|
||||
},
|
||||
None,
|
||||
);
|
||||
}
|
||||
@@ -851,7 +934,7 @@ impl App {
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = preset;
|
||||
let _ = (preset, mode);
|
||||
}
|
||||
}
|
||||
AppEvent::PersistModelSelection { model, effort } => {
|
||||
|
||||
@@ -15,6 +15,19 @@ use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
pub(crate) enum WindowsSandboxEnableMode {
|
||||
Elevated,
|
||||
Legacy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
pub(crate) enum WindowsSandboxFallbackReason {
|
||||
ElevationFailed,
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum AppEvent {
|
||||
@@ -106,10 +119,24 @@ pub(crate) enum AppEvent {
|
||||
preset: ApprovalPreset,
|
||||
},
|
||||
|
||||
/// Open the Windows sandbox fallback prompt after declining or failing elevation.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
OpenWindowsSandboxFallbackPrompt {
|
||||
preset: ApprovalPreset,
|
||||
reason: WindowsSandboxFallbackReason,
|
||||
},
|
||||
|
||||
/// Begin the elevated Windows sandbox setup flow.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
BeginWindowsSandboxElevatedSetup {
|
||||
preset: ApprovalPreset,
|
||||
},
|
||||
|
||||
/// Enable the Windows sandbox feature and switch to Agent mode.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
EnableWindowsSandboxForAgentMode {
|
||||
preset: ApprovalPreset,
|
||||
mode: WindowsSandboxEnableMode,
|
||||
},
|
||||
|
||||
/// Update the current approval policy in the running app and widget.
|
||||
|
||||
@@ -64,6 +64,13 @@ use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
fn windows_degraded_sandbox_active() -> bool {
|
||||
cfg!(target_os = "windows")
|
||||
&& codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||||
&& codex_core::get_platform_sandbox().is_some()
|
||||
&& !codex_core::is_windows_elevated_sandbox_enabled()
|
||||
}
|
||||
|
||||
/// If the pasted content exceeds this number of characters, replace it with a
|
||||
/// placeholder in the UI.
|
||||
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
|
||||
@@ -1229,6 +1236,10 @@ impl ChatComposer {
|
||||
&& rest.is_empty()
|
||||
&& let Some((_n, cmd)) = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(|(_, cmd)| {
|
||||
windows_degraded_sandbox_active()
|
||||
|| *cmd != SlashCommand::ElevateSandbox
|
||||
})
|
||||
.find(|(n, _)| *n == name)
|
||||
{
|
||||
self.textarea.set_text("");
|
||||
@@ -1296,6 +1307,10 @@ impl ChatComposer {
|
||||
if !treat_as_plain_text {
|
||||
let is_builtin = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(|(_, cmd)| {
|
||||
windows_degraded_sandbox_active()
|
||||
|| *cmd != SlashCommand::ElevateSandbox
|
||||
})
|
||||
.any(|(command_name, _)| command_name == name);
|
||||
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
|
||||
let is_known_prompt = name
|
||||
@@ -1715,7 +1730,6 @@ impl ChatComposer {
|
||||
self.active_popup = ActivePopup::None;
|
||||
return;
|
||||
}
|
||||
|
||||
let skill_token = self.current_skill_token();
|
||||
|
||||
let allow_command_popup = file_token.is_none() && skill_token.is_none();
|
||||
@@ -1785,6 +1799,9 @@ impl ChatComposer {
|
||||
|
||||
let builtin_match = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(|(_, cmd)| {
|
||||
windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox
|
||||
})
|
||||
.any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some());
|
||||
|
||||
if builtin_match {
|
||||
@@ -3054,44 +3071,6 @@ mod tests {
|
||||
assert!(composer.textarea.is_empty(), "composer should be cleared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_review_with_args_dispatches_command_with_args() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
type_chars_humanlike(&mut composer, &['/', 'r', 'e', 'v', 'i', 'e', 'w', ' ']);
|
||||
type_chars_humanlike(&mut composer, &['f', 'i', 'x', ' ', 't', 'h', 'i', 's']);
|
||||
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
match result {
|
||||
InputResult::CommandWithArgs(cmd, args) => {
|
||||
assert_eq!(cmd, SlashCommand::Review);
|
||||
assert_eq!(args, "fix this");
|
||||
}
|
||||
InputResult::Command(cmd) => {
|
||||
panic!("expected args for '/review', got bare command: {cmd:?}")
|
||||
}
|
||||
InputResult::Submitted(text) => {
|
||||
panic!("expected command dispatch, got literal submit: {text}")
|
||||
}
|
||||
InputResult::None => panic!("expected CommandWithArgs result for '/review'"),
|
||||
}
|
||||
assert!(composer.textarea.is_empty(), "composer should be cleared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_args_supports_quoted_paths_single_arg() {
|
||||
let args = extract_positional_args_for_prompt_line(
|
||||
@@ -4393,59 +4372,6 @@ mod tests {
|
||||
assert_eq!(result, InputResult::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_navigation_takes_priority_over_popups() {
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
composer.set_skill_mentions(Some(vec![SkillMetadata {
|
||||
name: "codex-cli-release-notes".to_string(),
|
||||
description: "example".to_string(),
|
||||
short_description: None,
|
||||
path: PathBuf::from("skills/codex-cli-release-notes/SKILL.md"),
|
||||
scope: SkillScope::Repo,
|
||||
}]));
|
||||
|
||||
// Seed local history; the newest entry triggers the skills popup.
|
||||
composer.history.record_local_submission("older");
|
||||
composer
|
||||
.history
|
||||
.record_local_submission("$codex-cli-release-notes");
|
||||
|
||||
// First Up recalls "$...", but we should not open the skills popup while browsing history.
|
||||
let (result, _redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
assert_eq!(result, InputResult::None);
|
||||
assert_eq!(composer.textarea.text(), "$codex-cli-release-notes");
|
||||
assert!(
|
||||
matches!(composer.active_popup, ActivePopup::None),
|
||||
"expected no skills popup while browsing history"
|
||||
);
|
||||
|
||||
// Second Up should navigate history again (no popup should interfere).
|
||||
let (result, _redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
assert_eq!(result, InputResult::None);
|
||||
assert_eq!(composer.textarea.text(), "older");
|
||||
assert!(
|
||||
matches!(composer.active_popup, ActivePopup::None),
|
||||
"expected popup to be dismissed after history navigation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_popup_activated_for_bare_slash_and_valid_prefixes() {
|
||||
// use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
@@ -4603,6 +4529,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(composer.attached_images.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_disabled_ignores_keypresses_and_hides_cursor() {
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
@@ -15,6 +15,13 @@ use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn windows_degraded_sandbox_active() -> bool {
|
||||
cfg!(target_os = "windows")
|
||||
&& codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||||
&& codex_core::get_platform_sandbox().is_some()
|
||||
&& !codex_core::is_windows_elevated_sandbox_enabled()
|
||||
}
|
||||
|
||||
/// A selectable item in the popup: either a built-in command or a user prompt.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum CommandItem {
|
||||
@@ -32,9 +39,11 @@ pub(crate) struct CommandPopup {
|
||||
|
||||
impl CommandPopup {
|
||||
pub(crate) fn new(mut prompts: Vec<CustomPrompt>, skills_enabled: bool) -> Self {
|
||||
let allow_elevate_sandbox = windows_degraded_sandbox_active();
|
||||
let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(|(_, cmd)| skills_enabled || *cmd != SlashCommand::Skills)
|
||||
.filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
|
||||
.collect();
|
||||
// Exclude prompts that collide with builtin command names and sort by name.
|
||||
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
|
||||
|
||||
+298
-18
@@ -85,6 +85,9 @@ use tokio::task::JoinHandle;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::app_event::WindowsSandboxEnableMode;
|
||||
use crate::app_event::WindowsSandboxFallbackReason;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
use crate::bottom_pane::BetaFeatureItem;
|
||||
@@ -1737,6 +1740,45 @@ impl ChatWidget {
|
||||
SlashCommand::Approvals => {
|
||||
self.open_approvals_popup();
|
||||
}
|
||||
SlashCommand::ElevateSandbox => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let windows_degraded_sandbox_enabled = codex_core::get_platform_sandbox()
|
||||
.is_some()
|
||||
&& !codex_core::is_windows_elevated_sandbox_enabled();
|
||||
if !windows_degraded_sandbox_enabled
|
||||
|| !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||||
{
|
||||
// This command should not be visible/recognized outside degraded mode,
|
||||
// but guard anyway in case something dispatches it directly.
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(preset) = builtin_approval_presets()
|
||||
.into_iter()
|
||||
.find(|preset| preset.id == "auto")
|
||||
else {
|
||||
// Avoid panicking in interactive UI; treat this as a recoverable
|
||||
// internal error.
|
||||
self.add_error_message(
|
||||
"Internal error: missing the 'auto' approval preset.".to_string(),
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(err) = self.config.approval_policy.can_set(&preset.approval) {
|
||||
self.add_error_message(err.to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
self.app_event_tx
|
||||
.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset });
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// Not supported; on non-Windows this command should never be reachable.
|
||||
};
|
||||
}
|
||||
SlashCommand::Experimental => {
|
||||
self.open_experimental_popup();
|
||||
}
|
||||
@@ -2841,10 +2883,25 @@ impl ChatWidget {
|
||||
let current_sandbox = self.config.sandbox_policy.get();
|
||||
let mut items: Vec<SelectionItem> = Vec::new();
|
||||
let presets: Vec<ApprovalPreset> = builtin_approval_presets();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let windows_degraded_sandbox_enabled = codex_core::get_platform_sandbox().is_some()
|
||||
&& !codex_core::is_windows_elevated_sandbox_enabled();
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let windows_degraded_sandbox_enabled = false;
|
||||
|
||||
let show_elevate_sandbox_hint = codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||||
&& windows_degraded_sandbox_enabled
|
||||
&& presets.iter().any(|preset| preset.id == "auto");
|
||||
|
||||
for preset in presets.into_iter() {
|
||||
let is_current =
|
||||
Self::preset_matches_current(current_approval, current_sandbox, &preset);
|
||||
let name = preset.label.to_string();
|
||||
let name = if preset.id == "auto" && windows_degraded_sandbox_enabled {
|
||||
"Agent (non-elevated sandbox)".to_string()
|
||||
} else {
|
||||
preset.label.to_string()
|
||||
};
|
||||
let description = Some(preset.description.to_string());
|
||||
let disabled_reason = match self.config.approval_policy.can_set(&preset.approval) {
|
||||
Ok(()) => None,
|
||||
@@ -2868,11 +2925,24 @@ impl ChatWidget {
|
||||
{
|
||||
if codex_core::get_platform_sandbox().is_none() {
|
||||
let preset_clone = preset.clone();
|
||||
vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenWindowsSandboxEnablePrompt {
|
||||
preset: preset_clone.clone(),
|
||||
});
|
||||
})]
|
||||
if codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||||
&& codex_core::windows_sandbox::sandbox_setup_is_complete(
|
||||
self.config.codex_home.as_path(),
|
||||
)
|
||||
{
|
||||
vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset: preset_clone.clone(),
|
||||
mode: WindowsSandboxEnableMode::Elevated,
|
||||
});
|
||||
})]
|
||||
} else {
|
||||
vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenWindowsSandboxEnablePrompt {
|
||||
preset: preset_clone.clone(),
|
||||
});
|
||||
})]
|
||||
}
|
||||
} else if let Some((sample_paths, extra_count, failed_scan)) =
|
||||
self.world_writable_warning_details()
|
||||
{
|
||||
@@ -2907,8 +2977,18 @@ impl ChatWidget {
|
||||
});
|
||||
}
|
||||
|
||||
let footer_note = show_elevate_sandbox_hint.then(|| {
|
||||
vec![
|
||||
"The non-elevated sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To upgrade to the elevated sandbox, run ".dim(),
|
||||
"/setup-elevated-sandbox".cyan(),
|
||||
".".dim(),
|
||||
]
|
||||
.into()
|
||||
});
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: Some("Select Approval Mode".to_string()),
|
||||
footer_note,
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
header: Box::new(()),
|
||||
@@ -3185,36 +3265,106 @@ impl ChatWidget {
|
||||
pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) {
|
||||
use ratatui_macros::line;
|
||||
|
||||
if !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED {
|
||||
// Legacy flow (pre-NUX): explain the experimental sandbox and let the user enable it
|
||||
// directly (no elevation prompts).
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(*Box::new(
|
||||
Paragraph::new(vec![
|
||||
line!["Agent mode on Windows uses an experimental sandbox to limit network and filesystem access.".bold()],
|
||||
line!["Learn more: https://developers.openai.com/codex/windows"],
|
||||
])
|
||||
.wrap(Wrap { trim: false }),
|
||||
));
|
||||
|
||||
let preset_clone = preset;
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: "Enable experimental sandbox".to_string(),
|
||||
description: None,
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset: preset_clone.clone(),
|
||||
mode: WindowsSandboxEnableMode::Legacy,
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Go back".to_string(),
|
||||
description: None,
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenApprovalsPopup);
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: None,
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
header: Box::new(header),
|
||||
..Default::default()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let current_approval = self.config.approval_policy.value();
|
||||
let current_sandbox = self.config.sandbox_policy.get();
|
||||
let presets = builtin_approval_presets();
|
||||
let stay_full_access = presets
|
||||
.iter()
|
||||
.find(|preset| preset.id == "full-access")
|
||||
.is_some_and(|preset| {
|
||||
Self::preset_matches_current(current_approval, current_sandbox, preset)
|
||||
});
|
||||
let stay_actions = if stay_full_access {
|
||||
Vec::new()
|
||||
} else {
|
||||
presets
|
||||
.iter()
|
||||
.find(|preset| preset.id == "read-only")
|
||||
.map(|preset| {
|
||||
Self::approval_preset_actions(preset.approval, preset.sandbox.clone())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let stay_label = if stay_full_access {
|
||||
"Stay in Agent Full Access".to_string()
|
||||
} else {
|
||||
"Stay in Read-Only".to_string()
|
||||
};
|
||||
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(*Box::new(
|
||||
Paragraph::new(vec![
|
||||
line!["Agent mode on Windows uses an experimental sandbox to limit network and filesystem access.".bold()],
|
||||
line![
|
||||
"Learn more: https://developers.openai.com/codex/windows"
|
||||
],
|
||||
line!["Set Up Agent Sandbox".bold()],
|
||||
line![""],
|
||||
line!["Agent mode uses an experimental Windows sandbox that protects your files and prevents network access by default."],
|
||||
line!["Learn more: https://developers.openai.com/codex/windows"],
|
||||
])
|
||||
.wrap(Wrap { trim: false }),
|
||||
));
|
||||
|
||||
let preset_clone = preset;
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: "Enable experimental sandbox".to_string(),
|
||||
name: "Set up agent sandbox (requires elevation)".to_string(),
|
||||
description: None,
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset: preset_clone.clone(),
|
||||
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
|
||||
preset: preset.clone(),
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Go back".to_string(),
|
||||
name: stay_label,
|
||||
description: None,
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenApprovalsPopup);
|
||||
})],
|
||||
actions: stay_actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -3232,6 +3382,107 @@ impl ChatWidget {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn open_windows_sandbox_fallback_prompt(
|
||||
&mut self,
|
||||
preset: ApprovalPreset,
|
||||
reason: WindowsSandboxFallbackReason,
|
||||
) {
|
||||
use ratatui_macros::line;
|
||||
|
||||
let _ = reason;
|
||||
|
||||
let current_approval = self.config.approval_policy.value();
|
||||
let current_sandbox = self.config.sandbox_policy.get();
|
||||
let presets = builtin_approval_presets();
|
||||
let stay_full_access = presets
|
||||
.iter()
|
||||
.find(|preset| preset.id == "full-access")
|
||||
.is_some_and(|preset| {
|
||||
Self::preset_matches_current(current_approval, current_sandbox, preset)
|
||||
});
|
||||
let stay_actions = if stay_full_access {
|
||||
Vec::new()
|
||||
} else {
|
||||
presets
|
||||
.iter()
|
||||
.find(|preset| preset.id == "read-only")
|
||||
.map(|preset| {
|
||||
Self::approval_preset_actions(preset.approval, preset.sandbox.clone())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let stay_label = if stay_full_access {
|
||||
"Stay in Agent Full Access".to_string()
|
||||
} else {
|
||||
"Stay in Read-Only".to_string()
|
||||
};
|
||||
|
||||
let mut lines = Vec::new();
|
||||
lines.push(line!["Use Non-Elevated Sandbox?".bold()]);
|
||||
lines.push(line![""]);
|
||||
lines.push(line![
|
||||
"Elevation failed. You can also use a non-elevated sandbox, which protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected."
|
||||
]);
|
||||
lines.push(line![
|
||||
"Learn more: https://developers.openai.com/codex/windows"
|
||||
]);
|
||||
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(*Box::new(Paragraph::new(lines).wrap(Wrap { trim: false })));
|
||||
|
||||
let elevated_preset = preset.clone();
|
||||
let legacy_preset = preset;
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: "Try elevated agent sandbox setup again".to_string(),
|
||||
description: None,
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
|
||||
preset: elevated_preset.clone(),
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Use non-elevated agent sandbox".to_string(),
|
||||
description: None,
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset: legacy_preset.clone(),
|
||||
mode: WindowsSandboxEnableMode::Legacy,
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: stay_label,
|
||||
description: None,
|
||||
actions: stay_actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: None,
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
header: Box::new(header),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn open_windows_sandbox_fallback_prompt(
|
||||
&mut self,
|
||||
_preset: ApprovalPreset,
|
||||
_reason: WindowsSandboxFallbackReason,
|
||||
) {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) {
|
||||
if self.config.forced_auto_mode_downgraded_on_windows
|
||||
@@ -3247,6 +3498,34 @@ impl ChatWidget {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) {}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn show_windows_sandbox_setup_status(&mut self) {
|
||||
// While elevated sandbox setup runs, prevent typing so the user doesn't
|
||||
// accidentally queue messages that will run under an unexpected mode.
|
||||
self.bottom_pane.set_composer_input_enabled(
|
||||
false,
|
||||
Some("Input disabled until setup completes.".to_string()),
|
||||
);
|
||||
self.bottom_pane.ensure_status_indicator();
|
||||
self.bottom_pane.set_interrupt_hint_visible(false);
|
||||
self.set_status_header("Setting up agent sandbox. This can take a minute.".to_string());
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn show_windows_sandbox_setup_status(&mut self) {}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {
|
||||
self.bottom_pane.set_composer_input_enabled(true, None);
|
||||
self.bottom_pane.hide_status_indicator();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn clear_forced_auto_mode_downgrade(&mut self) {
|
||||
self.config.forced_auto_mode_downgraded_on_windows = false;
|
||||
@@ -3279,6 +3558,7 @@ impl ChatWidget {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) {
|
||||
if enabled {
|
||||
self.config.features.enable(feature);
|
||||
|
||||
+1
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 1980
|
||||
expression: popup
|
||||
---
|
||||
Select Approval Mode
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 2003
|
||||
expression: popup
|
||||
---
|
||||
Select Approval Mode
|
||||
|
||||
› 1. Read Only (current) Requires approval to edit files and run
|
||||
commands.
|
||||
2. Agent (non-elevated sandbox) Read and edit files, and run commands.
|
||||
3. Agent (full access) Codex can edit files outside this workspace
|
||||
and run commands with network access.
|
||||
Exercise caution when using.
|
||||
|
||||
The non-elevated sandbox protects your files and prevents network access under
|
||||
most circumstances. However, it carries greater risk if prompt injected. To
|
||||
upgrade to the elevated sandbox, run /setup-elevated-sandbox.
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -64,6 +64,8 @@ use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
#[cfg(target_os = "windows")]
|
||||
use serial_test::serial;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::NamedTempFile;
|
||||
@@ -76,6 +78,11 @@ fn set_windows_sandbox_enabled(enabled: bool) {
|
||||
codex_core::set_windows_sandbox_enabled(enabled);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn set_windows_elevated_sandbox_enabled(enabled: bool) {
|
||||
codex_core::set_windows_elevated_sandbox_enabled(enabled);
|
||||
}
|
||||
|
||||
async fn test_config() -> Config {
|
||||
// Use base defaults to avoid depending on host state.
|
||||
let codex_home = std::env::temp_dir();
|
||||
@@ -2027,6 +2034,35 @@ async fn approvals_selection_popup_snapshot() {
|
||||
assert_snapshot!("approvals_selection_popup", popup);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn approvals_selection_popup_snapshot_windows_degraded_sandbox() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
let was_sandbox_enabled = codex_core::get_platform_sandbox().is_some();
|
||||
let was_elevated_enabled = codex_core::is_windows_elevated_sandbox_enabled();
|
||||
|
||||
chat.config.notices.hide_full_access_warning = None;
|
||||
chat.config.features.enable(Feature::WindowsSandbox);
|
||||
chat.config
|
||||
.features
|
||||
.disable(Feature::WindowsSandboxElevated);
|
||||
set_windows_sandbox_enabled(true);
|
||||
set_windows_elevated_sandbox_enabled(false);
|
||||
|
||||
chat.open_approvals_popup();
|
||||
|
||||
let popup = render_bottom_popup(&chat, 80);
|
||||
insta::with_settings!({ snapshot_suffix => "windows_degraded" }, {
|
||||
assert_snapshot!("approvals_selection_popup", popup);
|
||||
});
|
||||
|
||||
// Avoid leaking sandbox global state into other tests.
|
||||
set_windows_sandbox_enabled(was_sandbox_enabled);
|
||||
set_windows_elevated_sandbox_enabled(was_elevated_enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preset_matching_ignores_extra_writable_roots() {
|
||||
let preset = builtin_approval_presets()
|
||||
@@ -2077,8 +2113,8 @@ async fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() {
|
||||
|
||||
let popup = render_bottom_popup(&chat, 120);
|
||||
assert!(
|
||||
popup.contains("Agent mode on Windows uses an experimental sandbox"),
|
||||
"expected auto mode prompt to mention enabling the sandbox feature, popup: {popup}"
|
||||
popup.contains("requires elevation"),
|
||||
"expected auto mode prompt to mention elevation, popup: {popup}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2094,12 +2130,16 @@ async fn startup_prompts_for_windows_sandbox_when_agent_requested() {
|
||||
|
||||
let popup = render_bottom_popup(&chat, 120);
|
||||
assert!(
|
||||
popup.contains("Agent mode on Windows uses an experimental sandbox"),
|
||||
"expected startup prompt to explain sandbox: {popup}"
|
||||
popup.contains("requires elevation"),
|
||||
"expected startup prompt to explain elevation: {popup}"
|
||||
);
|
||||
assert!(
|
||||
popup.contains("Enable experimental sandbox"),
|
||||
"expected startup prompt to offer enabling the sandbox: {popup}"
|
||||
popup.contains("Set up agent sandbox"),
|
||||
"expected startup prompt to offer agent sandbox setup: {popup}"
|
||||
);
|
||||
assert!(
|
||||
popup.contains("Stay in"),
|
||||
"expected startup prompt to offer staying in current mode: {popup}"
|
||||
);
|
||||
|
||||
set_windows_sandbox_enabled(true);
|
||||
|
||||
@@ -14,6 +14,8 @@ pub enum SlashCommand {
|
||||
// more frequently used commands should be listed first.
|
||||
Model,
|
||||
Approvals,
|
||||
#[strum(serialize = "setup-elevated-sandbox")]
|
||||
ElevateSandbox,
|
||||
Experimental,
|
||||
Skills,
|
||||
Review,
|
||||
@@ -54,6 +56,7 @@ impl SlashCommand {
|
||||
SlashCommand::Ps => "list background terminals",
|
||||
SlashCommand::Model => "choose what model and reasoning effort to use",
|
||||
SlashCommand::Approvals => "choose what Codex can do without approval",
|
||||
SlashCommand::ElevateSandbox => "set up elevated agent sandbox",
|
||||
SlashCommand::Experimental => "toggle beta features",
|
||||
SlashCommand::Mcp => "list configured MCP tools",
|
||||
SlashCommand::Logout => "log out of Codex",
|
||||
@@ -78,6 +81,7 @@ impl SlashCommand {
|
||||
// | SlashCommand::Undo
|
||||
| SlashCommand::Model
|
||||
| SlashCommand::Approvals
|
||||
| SlashCommand::ElevateSandbox
|
||||
| SlashCommand::Experimental
|
||||
| SlashCommand::Review
|
||||
| SlashCommand::Logout => false,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use crate::app_backtrack::BacktrackState;
|
||||
use crate::app_event::AppEvent;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::app_event::WindowsSandboxEnableMode;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::app_event::WindowsSandboxFallbackReason;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
use crate::chatwidget::ChatWidget;
|
||||
@@ -1569,19 +1573,91 @@ impl App {
|
||||
AppEvent::OpenWindowsSandboxEnablePrompt { preset } => {
|
||||
self.chat_widget.open_windows_sandbox_enable_prompt(preset);
|
||||
}
|
||||
AppEvent::EnableWindowsSandboxForAgentMode { preset } => {
|
||||
AppEvent::OpenWindowsSandboxFallbackPrompt { preset, reason } => {
|
||||
self.chat_widget.clear_windows_sandbox_setup_status();
|
||||
self.chat_widget
|
||||
.open_windows_sandbox_fallback_prompt(preset, reason);
|
||||
}
|
||||
AppEvent::BeginWindowsSandboxElevatedSetup { preset } => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let policy = preset.sandbox.clone();
|
||||
let policy_cwd = self.config.cwd.clone();
|
||||
let command_cwd = policy_cwd.clone();
|
||||
let env_map: std::collections::HashMap<String, String> =
|
||||
std::env::vars().collect();
|
||||
let codex_home = self.config.codex_home.clone();
|
||||
let tx = self.app_event_tx.clone();
|
||||
|
||||
// If the elevated setup already ran on this machine, don't prompt for
|
||||
// elevation again - just flip the config to use the elevated path.
|
||||
if codex_core::windows_sandbox::sandbox_setup_is_complete(codex_home.as_path())
|
||||
{
|
||||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset,
|
||||
mode: WindowsSandboxEnableMode::Elevated,
|
||||
});
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
self.chat_widget.show_windows_sandbox_setup_status();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = codex_core::windows_sandbox::run_elevated_setup(
|
||||
&policy,
|
||||
policy_cwd.as_path(),
|
||||
command_cwd.as_path(),
|
||||
&env_map,
|
||||
codex_home.as_path(),
|
||||
);
|
||||
let event = match result {
|
||||
Ok(()) => AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset: preset.clone(),
|
||||
mode: WindowsSandboxEnableMode::Elevated,
|
||||
},
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
"failed to run elevated Windows sandbox setup"
|
||||
);
|
||||
AppEvent::OpenWindowsSandboxFallbackPrompt {
|
||||
preset,
|
||||
reason: WindowsSandboxFallbackReason::ElevationFailed,
|
||||
}
|
||||
}
|
||||
};
|
||||
tx.send(event);
|
||||
});
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = preset;
|
||||
}
|
||||
}
|
||||
AppEvent::EnableWindowsSandboxForAgentMode { preset, mode } => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
self.chat_widget.clear_windows_sandbox_setup_status();
|
||||
let profile = self.active_profile.as_deref();
|
||||
let feature_key = Feature::WindowsSandbox.key();
|
||||
let elevated_key = Feature::WindowsSandboxElevated.key();
|
||||
let elevated_enabled = matches!(mode, WindowsSandboxEnableMode::Elevated);
|
||||
match ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
.with_profile(profile)
|
||||
.set_feature_enabled(feature_key, true)
|
||||
.set_feature_enabled(elevated_key, elevated_enabled)
|
||||
.apply()
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
self.config.set_windows_sandbox_globally(true);
|
||||
self.config
|
||||
.set_windows_elevated_sandbox_globally(elevated_enabled);
|
||||
self.chat_widget
|
||||
.set_feature_enabled(Feature::WindowsSandbox, true);
|
||||
self.chat_widget.set_feature_enabled(
|
||||
Feature::WindowsSandboxElevated,
|
||||
elevated_enabled,
|
||||
);
|
||||
self.chat_widget.clear_forced_auto_mode_downgrade();
|
||||
if let Some((sample_paths, extra_count, failed_scan)) =
|
||||
self.chat_widget.world_writable_warning_details()
|
||||
@@ -1610,7 +1686,14 @@ impl App {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::UpdateSandboxPolicy(preset.sandbox.clone()));
|
||||
self.chat_widget.add_info_message(
|
||||
"Enabled experimental Windows sandbox.".to_string(),
|
||||
match mode {
|
||||
WindowsSandboxEnableMode::Elevated => {
|
||||
"Enabled elevated agent sandbox.".to_string()
|
||||
}
|
||||
WindowsSandboxEnableMode::Legacy => {
|
||||
"Enabled non-elevated agent sandbox.".to_string()
|
||||
}
|
||||
},
|
||||
None,
|
||||
);
|
||||
}
|
||||
@@ -1628,7 +1711,7 @@ impl App {
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = preset;
|
||||
let _ = (preset, mode);
|
||||
}
|
||||
}
|
||||
AppEvent::PersistModelSelection { model, effort } => {
|
||||
|
||||
@@ -14,6 +14,19 @@ use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
pub(crate) enum WindowsSandboxEnableMode {
|
||||
Elevated,
|
||||
Legacy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
pub(crate) enum WindowsSandboxFallbackReason {
|
||||
ElevationFailed,
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum AppEvent {
|
||||
@@ -105,10 +118,24 @@ pub(crate) enum AppEvent {
|
||||
preset: ApprovalPreset,
|
||||
},
|
||||
|
||||
/// Open the Windows sandbox fallback prompt after declining or failing elevation.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
OpenWindowsSandboxFallbackPrompt {
|
||||
preset: ApprovalPreset,
|
||||
reason: WindowsSandboxFallbackReason,
|
||||
},
|
||||
|
||||
/// Begin the elevated Windows sandbox setup flow.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
BeginWindowsSandboxElevatedSetup {
|
||||
preset: ApprovalPreset,
|
||||
},
|
||||
|
||||
/// Enable the Windows sandbox feature and switch to Agent mode.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
EnableWindowsSandboxForAgentMode {
|
||||
preset: ApprovalPreset,
|
||||
mode: WindowsSandboxEnableMode,
|
||||
},
|
||||
|
||||
/// Update the current approval policy in the running app and widget.
|
||||
|
||||
@@ -67,6 +67,13 @@ use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
fn windows_degraded_sandbox_active() -> bool {
|
||||
cfg!(target_os = "windows")
|
||||
&& codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||||
&& codex_core::get_platform_sandbox().is_some()
|
||||
&& !codex_core::is_windows_elevated_sandbox_enabled()
|
||||
}
|
||||
|
||||
/// If the pasted content exceeds this number of characters, replace it with a
|
||||
/// placeholder in the UI.
|
||||
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
|
||||
@@ -1146,6 +1153,10 @@ impl ChatComposer {
|
||||
&& rest.is_empty()
|
||||
&& let Some((_n, cmd)) = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(|(_, cmd)| {
|
||||
windows_degraded_sandbox_active()
|
||||
|| *cmd != SlashCommand::ElevateSandbox
|
||||
})
|
||||
.find(|(n, _)| *n == name)
|
||||
{
|
||||
self.textarea.set_text("");
|
||||
@@ -1213,6 +1224,10 @@ impl ChatComposer {
|
||||
if !treat_as_plain_text {
|
||||
let is_builtin = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(|(_, cmd)| {
|
||||
windows_degraded_sandbox_active()
|
||||
|| *cmd != SlashCommand::ElevateSandbox
|
||||
})
|
||||
.any(|(command_name, _)| command_name == name);
|
||||
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
|
||||
let is_known_prompt = name
|
||||
@@ -1659,6 +1674,15 @@ impl ChatComposer {
|
||||
|
||||
fn sync_popups(&mut self) {
|
||||
let file_token = Self::current_at_token(&self.textarea);
|
||||
let browsing_history = self
|
||||
.history
|
||||
.should_handle_navigation(self.textarea.text(), self.textarea.cursor());
|
||||
// When browsing input history (shell-style Up/Down recall), skip all popup
|
||||
// synchronization so nothing steals focus from continued history navigation.
|
||||
if browsing_history {
|
||||
self.active_popup = ActivePopup::None;
|
||||
return;
|
||||
}
|
||||
let skill_token = self.current_skill_token();
|
||||
|
||||
let allow_command_popup = file_token.is_none() && skill_token.is_none();
|
||||
@@ -1728,6 +1752,9 @@ impl ChatComposer {
|
||||
|
||||
let builtin_match = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(|(_, cmd)| {
|
||||
windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox
|
||||
})
|
||||
.any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some());
|
||||
|
||||
if builtin_match {
|
||||
@@ -2975,44 +3002,6 @@ mod tests {
|
||||
assert!(composer.textarea.is_empty(), "composer should be cleared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_review_with_args_dispatches_command_with_args() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
type_chars_humanlike(&mut composer, &['/', 'r', 'e', 'v', 'i', 'e', 'w', ' ']);
|
||||
type_chars_humanlike(&mut composer, &['f', 'i', 'x', ' ', 't', 'h', 'i', 's']);
|
||||
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
match result {
|
||||
InputResult::CommandWithArgs(cmd, args) => {
|
||||
assert_eq!(cmd, SlashCommand::Review);
|
||||
assert_eq!(args, "fix this");
|
||||
}
|
||||
InputResult::Command(cmd) => {
|
||||
panic!("expected args for '/review', got bare command: {cmd:?}")
|
||||
}
|
||||
InputResult::Submitted(text) => {
|
||||
panic!("expected command dispatch, got literal submit: {text}")
|
||||
}
|
||||
InputResult::None => panic!("expected CommandWithArgs result for '/review'"),
|
||||
}
|
||||
assert!(composer.textarea.is_empty(), "composer should be cleared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_args_supports_quoted_paths_single_arg() {
|
||||
let args = extract_positional_args_for_prompt_line(
|
||||
@@ -4303,6 +4292,7 @@ mod tests {
|
||||
"'/zzz' should not activate slash popup because it is not a prefix of any built-in command"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_disabled_ignores_keypresses_and_hides_cursor() {
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
@@ -15,6 +15,13 @@ use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn windows_degraded_sandbox_active() -> bool {
|
||||
cfg!(target_os = "windows")
|
||||
&& codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||||
&& codex_core::get_platform_sandbox().is_some()
|
||||
&& !codex_core::is_windows_elevated_sandbox_enabled()
|
||||
}
|
||||
|
||||
/// A selectable item in the popup: either a built-in command or a user prompt.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum CommandItem {
|
||||
@@ -32,9 +39,11 @@ pub(crate) struct CommandPopup {
|
||||
|
||||
impl CommandPopup {
|
||||
pub(crate) fn new(mut prompts: Vec<CustomPrompt>, skills_enabled: bool) -> Self {
|
||||
let allow_elevate_sandbox = windows_degraded_sandbox_active();
|
||||
let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(|(_, cmd)| skills_enabled || *cmd != SlashCommand::Skills)
|
||||
.filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
|
||||
.collect();
|
||||
// Exclude prompts that collide with builtin command names and sort by name.
|
||||
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
|
||||
|
||||
+307
-18
@@ -10,6 +10,7 @@ use codex_backend_client::Client as BackendClient;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConstraintResult;
|
||||
use codex_core::config::types::Notifications;
|
||||
use codex_core::features::Feature;
|
||||
use codex_core::git_info::current_branch_name;
|
||||
use codex_core::git_info::local_git_branches;
|
||||
use codex_core::models_manager::manager::ModelsManager;
|
||||
@@ -83,6 +84,9 @@ use tokio::task::JoinHandle;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::app_event::WindowsSandboxEnableMode;
|
||||
use crate::app_event::WindowsSandboxFallbackReason;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
use crate::bottom_pane::BottomPane;
|
||||
@@ -1570,6 +1574,45 @@ impl ChatWidget {
|
||||
SlashCommand::Approvals => {
|
||||
self.open_approvals_popup();
|
||||
}
|
||||
SlashCommand::ElevateSandbox => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let windows_degraded_sandbox_enabled = codex_core::get_platform_sandbox()
|
||||
.is_some()
|
||||
&& !codex_core::is_windows_elevated_sandbox_enabled();
|
||||
if !windows_degraded_sandbox_enabled
|
||||
|| !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||||
{
|
||||
// This command should not be visible/recognized outside degraded mode,
|
||||
// but guard anyway in case something dispatches it directly.
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(preset) = builtin_approval_presets()
|
||||
.into_iter()
|
||||
.find(|preset| preset.id == "auto")
|
||||
else {
|
||||
// Avoid panicking in interactive UI; treat this as a recoverable
|
||||
// internal error.
|
||||
self.add_error_message(
|
||||
"Internal error: missing the 'auto' approval preset.".to_string(),
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(err) = self.config.approval_policy.can_set(&preset.approval) {
|
||||
self.add_error_message(err.to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
self.app_event_tx
|
||||
.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset });
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// Not supported; on non-Windows this command should never be reachable.
|
||||
};
|
||||
}
|
||||
SlashCommand::Quit | SlashCommand::Exit => {
|
||||
self.request_exit();
|
||||
}
|
||||
@@ -2594,10 +2637,25 @@ impl ChatWidget {
|
||||
let current_sandbox = self.config.sandbox_policy.get();
|
||||
let mut items: Vec<SelectionItem> = Vec::new();
|
||||
let presets: Vec<ApprovalPreset> = builtin_approval_presets();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let windows_degraded_sandbox_enabled = codex_core::get_platform_sandbox().is_some()
|
||||
&& !codex_core::is_windows_elevated_sandbox_enabled();
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let windows_degraded_sandbox_enabled = false;
|
||||
|
||||
let show_elevate_sandbox_hint = codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||||
&& windows_degraded_sandbox_enabled
|
||||
&& presets.iter().any(|preset| preset.id == "auto");
|
||||
|
||||
for preset in presets.into_iter() {
|
||||
let is_current =
|
||||
Self::preset_matches_current(current_approval, current_sandbox, &preset);
|
||||
let name = preset.label.to_string();
|
||||
let name = if preset.id == "auto" && windows_degraded_sandbox_enabled {
|
||||
"Agent (non-elevated sandbox)".to_string()
|
||||
} else {
|
||||
preset.label.to_string()
|
||||
};
|
||||
let description_text = preset.description;
|
||||
let description = Some(description_text.to_string());
|
||||
let requires_confirmation = preset.id == "full-access"
|
||||
@@ -2618,11 +2676,24 @@ impl ChatWidget {
|
||||
{
|
||||
if codex_core::get_platform_sandbox().is_none() {
|
||||
let preset_clone = preset.clone();
|
||||
vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenWindowsSandboxEnablePrompt {
|
||||
preset: preset_clone.clone(),
|
||||
});
|
||||
})]
|
||||
if codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||||
&& codex_core::windows_sandbox::sandbox_setup_is_complete(
|
||||
self.config.codex_home.as_path(),
|
||||
)
|
||||
{
|
||||
vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset: preset_clone.clone(),
|
||||
mode: WindowsSandboxEnableMode::Elevated,
|
||||
});
|
||||
})]
|
||||
} else {
|
||||
vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenWindowsSandboxEnablePrompt {
|
||||
preset: preset_clone.clone(),
|
||||
});
|
||||
})]
|
||||
}
|
||||
} else if let Some((sample_paths, extra_count, failed_scan)) =
|
||||
self.world_writable_warning_details()
|
||||
{
|
||||
@@ -2656,8 +2727,18 @@ impl ChatWidget {
|
||||
});
|
||||
}
|
||||
|
||||
let footer_note = show_elevate_sandbox_hint.then(|| {
|
||||
vec![
|
||||
"The non-elevated sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To upgrade to the elevated sandbox, run ".dim(),
|
||||
"/setup-elevated-sandbox".cyan(),
|
||||
".".dim(),
|
||||
]
|
||||
.into()
|
||||
});
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: Some("Select Approval Mode".to_string()),
|
||||
footer_note,
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
header: Box::new(()),
|
||||
@@ -2915,36 +2996,106 @@ impl ChatWidget {
|
||||
pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) {
|
||||
use ratatui_macros::line;
|
||||
|
||||
if !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED {
|
||||
// Legacy flow (pre-NUX): explain the experimental sandbox and let the user enable it
|
||||
// directly (no elevation prompts).
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(*Box::new(
|
||||
Paragraph::new(vec![
|
||||
line!["Agent mode on Windows uses an experimental sandbox to limit network and filesystem access.".bold()],
|
||||
line!["Learn more: https://developers.openai.com/codex/windows"],
|
||||
])
|
||||
.wrap(Wrap { trim: false }),
|
||||
));
|
||||
|
||||
let preset_clone = preset;
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: "Enable experimental sandbox".to_string(),
|
||||
description: None,
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset: preset_clone.clone(),
|
||||
mode: WindowsSandboxEnableMode::Legacy,
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Go back".to_string(),
|
||||
description: None,
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenApprovalsPopup);
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: None,
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
header: Box::new(header),
|
||||
..Default::default()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let current_approval = self.config.approval_policy.value();
|
||||
let current_sandbox = self.config.sandbox_policy.get();
|
||||
let presets = builtin_approval_presets();
|
||||
let stay_full_access = presets
|
||||
.iter()
|
||||
.find(|preset| preset.id == "full-access")
|
||||
.is_some_and(|preset| {
|
||||
Self::preset_matches_current(current_approval, current_sandbox, preset)
|
||||
});
|
||||
let stay_actions = if stay_full_access {
|
||||
Vec::new()
|
||||
} else {
|
||||
presets
|
||||
.iter()
|
||||
.find(|preset| preset.id == "read-only")
|
||||
.map(|preset| {
|
||||
Self::approval_preset_actions(preset.approval, preset.sandbox.clone())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let stay_label = if stay_full_access {
|
||||
"Stay in Agent Full Access".to_string()
|
||||
} else {
|
||||
"Stay in Read-Only".to_string()
|
||||
};
|
||||
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(*Box::new(
|
||||
Paragraph::new(vec![
|
||||
line!["Agent mode on Windows uses an experimental sandbox to limit network and filesystem access.".bold()],
|
||||
line![
|
||||
"Learn more: https://developers.openai.com/codex/windows"
|
||||
],
|
||||
line!["Set Up Agent Sandbox".bold()],
|
||||
line![""],
|
||||
line!["Agent mode uses an experimental Windows sandbox that protects your files and prevents network access by default."],
|
||||
line!["Learn more: https://developers.openai.com/codex/windows"],
|
||||
])
|
||||
.wrap(Wrap { trim: false }),
|
||||
));
|
||||
|
||||
let preset_clone = preset;
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: "Enable experimental sandbox".to_string(),
|
||||
name: "Set up agent sandbox (requires elevation)".to_string(),
|
||||
description: None,
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset: preset_clone.clone(),
|
||||
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
|
||||
preset: preset.clone(),
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Go back".to_string(),
|
||||
name: stay_label,
|
||||
description: None,
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenApprovalsPopup);
|
||||
})],
|
||||
actions: stay_actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -2962,6 +3113,107 @@ impl ChatWidget {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn open_windows_sandbox_fallback_prompt(
|
||||
&mut self,
|
||||
preset: ApprovalPreset,
|
||||
reason: WindowsSandboxFallbackReason,
|
||||
) {
|
||||
use ratatui_macros::line;
|
||||
|
||||
let _ = reason;
|
||||
|
||||
let current_approval = self.config.approval_policy.value();
|
||||
let current_sandbox = self.config.sandbox_policy.get();
|
||||
let presets = builtin_approval_presets();
|
||||
let stay_full_access = presets
|
||||
.iter()
|
||||
.find(|preset| preset.id == "full-access")
|
||||
.is_some_and(|preset| {
|
||||
Self::preset_matches_current(current_approval, current_sandbox, preset)
|
||||
});
|
||||
let stay_actions = if stay_full_access {
|
||||
Vec::new()
|
||||
} else {
|
||||
presets
|
||||
.iter()
|
||||
.find(|preset| preset.id == "read-only")
|
||||
.map(|preset| {
|
||||
Self::approval_preset_actions(preset.approval, preset.sandbox.clone())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let stay_label = if stay_full_access {
|
||||
"Stay in Agent Full Access".to_string()
|
||||
} else {
|
||||
"Stay in Read-Only".to_string()
|
||||
};
|
||||
|
||||
let mut lines = Vec::new();
|
||||
lines.push(line!["Use Non-Elevated Sandbox?".bold()]);
|
||||
lines.push(line![""]);
|
||||
lines.push(line![
|
||||
"Elevation failed. You can also use a non-elevated sandbox, which protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected."
|
||||
]);
|
||||
lines.push(line![
|
||||
"Learn more: https://developers.openai.com/codex/windows"
|
||||
]);
|
||||
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(*Box::new(Paragraph::new(lines).wrap(Wrap { trim: false })));
|
||||
|
||||
let elevated_preset = preset.clone();
|
||||
let legacy_preset = preset;
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: "Try elevated agent sandbox setup again".to_string(),
|
||||
description: None,
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
|
||||
preset: elevated_preset.clone(),
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Use non-elevated agent sandbox".to_string(),
|
||||
description: None,
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset: legacy_preset.clone(),
|
||||
mode: WindowsSandboxEnableMode::Legacy,
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: stay_label,
|
||||
description: None,
|
||||
actions: stay_actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: None,
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
header: Box::new(header),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn open_windows_sandbox_fallback_prompt(
|
||||
&mut self,
|
||||
_preset: ApprovalPreset,
|
||||
_reason: WindowsSandboxFallbackReason,
|
||||
) {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) {
|
||||
if self.config.forced_auto_mode_downgraded_on_windows
|
||||
@@ -2977,6 +3229,34 @@ impl ChatWidget {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) {}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn show_windows_sandbox_setup_status(&mut self) {
|
||||
// While elevated sandbox setup runs, prevent typing so the user doesn't
|
||||
// accidentally queue messages that will run under an unexpected mode.
|
||||
self.bottom_pane.set_composer_input_enabled(
|
||||
false,
|
||||
Some("Input disabled until setup completes.".to_string()),
|
||||
);
|
||||
self.bottom_pane.ensure_status_indicator();
|
||||
self.bottom_pane.set_interrupt_hint_visible(false);
|
||||
self.set_status_header("Setting up agent sandbox. This can take a minute.".to_string());
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn show_windows_sandbox_setup_status(&mut self) {}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {
|
||||
self.bottom_pane.set_composer_input_enabled(true, None);
|
||||
self.bottom_pane.hide_status_indicator();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn clear_forced_auto_mode_downgrade(&mut self) {
|
||||
self.config.forced_auto_mode_downgraded_on_windows = false;
|
||||
@@ -3009,6 +3289,15 @@ impl ChatWidget {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) {
|
||||
if enabled {
|
||||
self.config.features.enable(feature);
|
||||
} else {
|
||||
self.config.features.disable(feature);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) {
|
||||
self.config.notices.hide_full_access_warning = Some(acknowledged);
|
||||
}
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
---
|
||||
source: tui2/src/chatwidget/tests.rs
|
||||
assertion_line: 1773
|
||||
expression: popup
|
||||
---
|
||||
Select Approval Mode
|
||||
|
||||
› 1. Read Only (current) Requires approval to edit files and run
|
||||
commands.
|
||||
2. Agent (non-elevated sandbox) Read and edit files, and run commands.
|
||||
3. Agent (full access) Codex can edit files outside this workspace
|
||||
and run commands with network access.
|
||||
Exercise caution when using.
|
||||
|
||||
The non-elevated sandbox protects your files and prevents network access under
|
||||
most circumstances. However, it carries greater risk if prompt injected. To
|
||||
upgrade to the elevated sandbox, run /setup-elevated-sandbox.
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -10,6 +10,8 @@ use codex_core::CodexAuth;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::Constrained;
|
||||
#[cfg(target_os = "windows")]
|
||||
use codex_core::features::Feature;
|
||||
use codex_core::models_manager::manager::ModelsManager;
|
||||
use codex_core::protocol::AgentMessageDeltaEvent;
|
||||
use codex_core::protocol::AgentMessageEvent;
|
||||
@@ -60,6 +62,8 @@ use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
#[cfg(target_os = "windows")]
|
||||
use serial_test::serial;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::NamedTempFile;
|
||||
@@ -72,6 +76,11 @@ fn set_windows_sandbox_enabled(enabled: bool) {
|
||||
codex_core::set_windows_sandbox_enabled(enabled);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn set_windows_elevated_sandbox_enabled(enabled: bool) {
|
||||
codex_core::set_windows_elevated_sandbox_enabled(enabled);
|
||||
}
|
||||
|
||||
async fn test_config() -> Config {
|
||||
// Use base defaults to avoid depending on host state.
|
||||
let codex_home = std::env::temp_dir();
|
||||
@@ -1786,6 +1795,35 @@ async fn approvals_selection_popup_snapshot() {
|
||||
assert_snapshot!("approvals_selection_popup", popup);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn approvals_selection_popup_snapshot_windows_degraded_sandbox() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
let was_sandbox_enabled = codex_core::get_platform_sandbox().is_some();
|
||||
let was_elevated_enabled = codex_core::is_windows_elevated_sandbox_enabled();
|
||||
|
||||
chat.config.notices.hide_full_access_warning = None;
|
||||
chat.config.features.enable(Feature::WindowsSandbox);
|
||||
chat.config
|
||||
.features
|
||||
.disable(Feature::WindowsSandboxElevated);
|
||||
set_windows_sandbox_enabled(true);
|
||||
set_windows_elevated_sandbox_enabled(false);
|
||||
|
||||
chat.open_approvals_popup();
|
||||
|
||||
let popup = render_bottom_popup(&chat, 80);
|
||||
insta::with_settings!({ snapshot_suffix => "windows_degraded" }, {
|
||||
assert_snapshot!("approvals_selection_popup", popup);
|
||||
});
|
||||
|
||||
// Avoid leaking sandbox global state into other tests.
|
||||
set_windows_sandbox_enabled(was_sandbox_enabled);
|
||||
set_windows_elevated_sandbox_enabled(was_elevated_enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preset_matching_ignores_extra_writable_roots() {
|
||||
let preset = builtin_approval_presets()
|
||||
@@ -1836,8 +1874,8 @@ async fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() {
|
||||
|
||||
let popup = render_bottom_popup(&chat, 120);
|
||||
assert!(
|
||||
popup.contains("Agent mode on Windows uses an experimental sandbox"),
|
||||
"expected auto mode prompt to mention enabling the sandbox feature, popup: {popup}"
|
||||
popup.contains("requires elevation"),
|
||||
"expected auto mode prompt to mention elevation, popup: {popup}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1853,12 +1891,16 @@ async fn startup_prompts_for_windows_sandbox_when_agent_requested() {
|
||||
|
||||
let popup = render_bottom_popup(&chat, 120);
|
||||
assert!(
|
||||
popup.contains("Agent mode on Windows uses an experimental sandbox"),
|
||||
"expected startup prompt to explain sandbox: {popup}"
|
||||
popup.contains("requires elevation"),
|
||||
"expected startup prompt to explain elevation: {popup}"
|
||||
);
|
||||
assert!(
|
||||
popup.contains("Enable experimental sandbox"),
|
||||
"expected startup prompt to offer enabling the sandbox: {popup}"
|
||||
popup.contains("Set up agent sandbox"),
|
||||
"expected startup prompt to offer agent sandbox setup: {popup}"
|
||||
);
|
||||
assert!(
|
||||
popup.contains("Stay in"),
|
||||
"expected startup prompt to offer staying in current mode: {popup}"
|
||||
);
|
||||
|
||||
set_windows_sandbox_enabled(true);
|
||||
|
||||
@@ -14,6 +14,8 @@ pub enum SlashCommand {
|
||||
// more frequently used commands should be listed first.
|
||||
Model,
|
||||
Approvals,
|
||||
#[strum(serialize = "setup-elevated-sandbox")]
|
||||
ElevateSandbox,
|
||||
Skills,
|
||||
Review,
|
||||
New,
|
||||
@@ -51,6 +53,7 @@ impl SlashCommand {
|
||||
SlashCommand::Status => "show current session configuration and token usage",
|
||||
SlashCommand::Model => "choose what model and reasoning effort to use",
|
||||
SlashCommand::Approvals => "choose what Codex can do without approval",
|
||||
SlashCommand::ElevateSandbox => "set up elevated agent sandbox",
|
||||
SlashCommand::Mcp => "list configured MCP tools",
|
||||
SlashCommand::Logout => "log out of Codex",
|
||||
SlashCommand::Rollout => "print the rollout file path",
|
||||
@@ -74,6 +77,7 @@ impl SlashCommand {
|
||||
// | SlashCommand::Undo
|
||||
| SlashCommand::Model
|
||||
| SlashCommand::Approvals
|
||||
| SlashCommand::ElevateSandbox
|
||||
| SlashCommand::Review
|
||||
| SlashCommand::Logout => false,
|
||||
SlashCommand::Diff
|
||||
|
||||
@@ -30,6 +30,18 @@ pub struct SandboxCreds {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Returns true when the on-disk setup artifacts exist and match the current
|
||||
/// setup version.
|
||||
///
|
||||
/// This reuses the same marker/users validation used by `require_logon_sandbox_creds`.
|
||||
pub fn sandbox_setup_is_complete(codex_home: &Path) -> bool {
|
||||
let marker_ok = matches!(load_marker(codex_home), Ok(Some(marker)) if marker.version_matches());
|
||||
if !marker_ok {
|
||||
return false;
|
||||
}
|
||||
matches!(load_users(codex_home), Ok(Some(users)) if users.version_matches())
|
||||
}
|
||||
|
||||
fn load_marker(codex_home: &Path) -> Result<Option<SetupMarker>> {
|
||||
let path = setup_marker_path(codex_home);
|
||||
let marker = match fs::read_to_string(&path) {
|
||||
|
||||
@@ -45,6 +45,8 @@ pub use hide_users::hide_newly_created_users;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use identity::require_logon_sandbox_creds;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use identity::sandbox_setup_is_complete;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use logging::log_note;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use logging::LOG_FILE_NAME;
|
||||
|
||||
Reference in New Issue
Block a user