feat(tui): clarify Windows auto mode requirements (#5568)

## Summary
- Coerce Windows `workspace-write` configs back to read-only, surface
the forced downgrade in the approvals popup,
  and funnel users toward WSL or Full Access.
- Add WSL installation instructions to the Auto preset on Windows while
keeping the preset available for other
  platforms.
- Skip the trust-on-first-run prompt on native Windows so new folders
remain read-only without additional
  confirmation.
- Expose a structured sandbox policy resolution from config to flag
Windows downgrades and adjust tests (core,
exec, TUI) to reflect the new behavior; provide a Windows-only approvals
snapshot.

  ## Testing
  - cargo fmt
- cargo test -p codex-core
config::tests::add_dir_override_extends_workspace_writable_roots
- cargo test -p codex-exec
suite::resume::exec_resume_preserves_cli_configuration_overrides
- cargo test -p codex-tui
chatwidget::tests::approvals_selection_popup_snapshot
- cargo test -p codex-tui
approvals_popup_includes_wsl_note_for_auto_mode
  - cargo test -p codex-tui windows_skips_trust_prompt
  - just fix -p codex-core
  - just fix -p codex-tui
This commit is contained in:
Josh McKinney
2025-10-27 18:19:32 -07:00
committed by GitHub
parent d7b333be97
commit 66a4b89822
10 changed files with 336 additions and 74 deletions

View File

@@ -24,21 +24,21 @@ pub fn builtin_approval_presets() -> Vec<ApprovalPreset> {
ApprovalPreset {
id: "read-only",
label: "Read Only",
description: "Codex can read files and answer questions. Codex requires approval to make edits, run commands, or access network",
description: "Codex can read files and answer questions. Codex requires approval to make edits, run commands, or access network.",
approval: AskForApproval::OnRequest,
sandbox: SandboxPolicy::ReadOnly,
},
ApprovalPreset {
id: "auto",
label: "Auto",
description: "Codex can read files, make edits, and run commands in the workspace. Codex requires approval to work outside the workspace or access network",
description: "Codex can read files, make edits, and run commands in the workspace. Codex requires approval to work outside the workspace or access network.",
approval: AskForApproval::OnRequest,
sandbox: SandboxPolicy::new_workspace_write_policy(),
},
ApprovalPreset {
id: "full-access",
label: "Full Access",
description: "Codex can read files, make edits, and run commands with network access, without approval. Exercise caution",
description: "Codex can read files, make edits, and run commands with network access, without approval. Exercise caution.",
approval: AskForApproval::Never,
sandbox: SandboxPolicy::DangerFullAccess,
},

View File

@@ -108,6 +108,10 @@ pub struct Config {
/// for either of approval_policy or sandbox_mode.
pub did_user_set_custom_approval_policy_or_sandbox_mode: bool,
/// On Windows, indicates that a previously configured workspace-write sandbox
/// was coerced to read-only because native auto mode is unsupported.
pub forced_auto_mode_downgraded_on_windows: bool,
pub shell_environment_policy: ShellEnvironmentPolicy,
/// When `true`, `AgentReasoning` events emitted by the backend will be
@@ -1022,6 +1026,12 @@ impl From<ToolsToml> for Tools {
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct SandboxPolicyResolution {
pub policy: SandboxPolicy,
pub forced_auto_mode_downgraded_on_windows: bool,
}
impl ConfigToml {
/// Derive the effective sandbox policy from the configuration.
fn derive_sandbox_policy(
@@ -1029,7 +1039,7 @@ impl ConfigToml {
sandbox_mode_override: Option<SandboxMode>,
profile_sandbox_mode: Option<SandboxMode>,
resolved_cwd: &Path,
) -> SandboxPolicy {
) -> SandboxPolicyResolution {
let resolved_sandbox_mode = sandbox_mode_override
.or(profile_sandbox_mode)
.or(self.sandbox_mode)
@@ -1044,7 +1054,7 @@ impl ConfigToml {
})
})
.unwrap_or_default();
match resolved_sandbox_mode {
let mut sandbox_policy = match resolved_sandbox_mode {
SandboxMode::ReadOnly => SandboxPolicy::new_read_only_policy(),
SandboxMode::WorkspaceWrite => match self.sandbox_workspace_write.as_ref() {
Some(SandboxWorkspaceWrite {
@@ -1061,6 +1071,17 @@ impl ConfigToml {
None => SandboxPolicy::new_workspace_write_policy(),
},
SandboxMode::DangerFullAccess => SandboxPolicy::DangerFullAccess,
};
let mut forced_auto_mode_downgraded_on_windows = false;
if cfg!(target_os = "windows")
&& matches!(resolved_sandbox_mode, SandboxMode::WorkspaceWrite)
{
sandbox_policy = SandboxPolicy::new_read_only_policy();
forced_auto_mode_downgraded_on_windows = true;
}
SandboxPolicyResolution {
policy: sandbox_policy,
forced_auto_mode_downgraded_on_windows,
}
}
@@ -1221,8 +1242,10 @@ impl Config {
.get_active_project(&resolved_cwd)
.unwrap_or(ProjectConfig { trust_level: None });
let mut sandbox_policy =
cfg.derive_sandbox_policy(sandbox_mode, config_profile.sandbox_mode, &resolved_cwd);
let SandboxPolicyResolution {
policy: mut sandbox_policy,
forced_auto_mode_downgraded_on_windows,
} = cfg.derive_sandbox_policy(sandbox_mode, config_profile.sandbox_mode, &resolved_cwd);
if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy {
for path in additional_writable_roots {
if !writable_roots.iter().any(|existing| existing == &path) {
@@ -1353,6 +1376,7 @@ impl Config {
approval_policy,
sandbox_policy,
did_user_set_custom_approval_policy_or_sandbox_mode,
forced_auto_mode_downgraded_on_windows,
shell_environment_policy,
notify: cfg.notify,
user_instructions,
@@ -1604,13 +1628,17 @@ network_access = false # This should be ignored.
let sandbox_full_access_cfg = toml::from_str::<ConfigToml>(sandbox_full_access)
.expect("TOML deserialization should succeed");
let sandbox_mode_override = None;
let resolution = sandbox_full_access_cfg.derive_sandbox_policy(
sandbox_mode_override,
None,
&PathBuf::from("/tmp/test"),
);
assert_eq!(
SandboxPolicy::DangerFullAccess,
sandbox_full_access_cfg.derive_sandbox_policy(
sandbox_mode_override,
None,
&PathBuf::from("/tmp/test")
)
resolution,
SandboxPolicyResolution {
policy: SandboxPolicy::DangerFullAccess,
forced_auto_mode_downgraded_on_windows: false,
}
);
let sandbox_read_only = r#"
@@ -1623,13 +1651,17 @@ network_access = true # This should be ignored.
let sandbox_read_only_cfg = toml::from_str::<ConfigToml>(sandbox_read_only)
.expect("TOML deserialization should succeed");
let sandbox_mode_override = None;
let resolution = sandbox_read_only_cfg.derive_sandbox_policy(
sandbox_mode_override,
None,
&PathBuf::from("/tmp/test"),
);
assert_eq!(
SandboxPolicy::ReadOnly,
sandbox_read_only_cfg.derive_sandbox_policy(
sandbox_mode_override,
None,
&PathBuf::from("/tmp/test")
)
resolution,
SandboxPolicyResolution {
policy: SandboxPolicy::ReadOnly,
forced_auto_mode_downgraded_on_windows: false,
}
);
let sandbox_workspace_write = r#"
@@ -1646,19 +1678,33 @@ exclude_slash_tmp = true
let sandbox_workspace_write_cfg = toml::from_str::<ConfigToml>(sandbox_workspace_write)
.expect("TOML deserialization should succeed");
let sandbox_mode_override = None;
assert_eq!(
SandboxPolicy::WorkspaceWrite {
writable_roots: vec![PathBuf::from("/my/workspace")],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
},
sandbox_workspace_write_cfg.derive_sandbox_policy(
sandbox_mode_override,
None,
&PathBuf::from("/tmp/test")
)
let resolution = sandbox_workspace_write_cfg.derive_sandbox_policy(
sandbox_mode_override,
None,
&PathBuf::from("/tmp/test"),
);
if cfg!(target_os = "windows") {
assert_eq!(
resolution,
SandboxPolicyResolution {
policy: SandboxPolicy::ReadOnly,
forced_auto_mode_downgraded_on_windows: true,
}
);
} else {
assert_eq!(
resolution,
SandboxPolicyResolution {
policy: SandboxPolicy::WorkspaceWrite {
writable_roots: vec![PathBuf::from("/my/workspace")],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
},
forced_auto_mode_downgraded_on_windows: false,
}
);
}
let sandbox_workspace_write = r#"
sandbox_mode = "workspace-write"
@@ -1677,19 +1723,33 @@ trust_level = "trusted"
let sandbox_workspace_write_cfg = toml::from_str::<ConfigToml>(sandbox_workspace_write)
.expect("TOML deserialization should succeed");
let sandbox_mode_override = None;
assert_eq!(
SandboxPolicy::WorkspaceWrite {
writable_roots: vec![PathBuf::from("/my/workspace")],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
},
sandbox_workspace_write_cfg.derive_sandbox_policy(
sandbox_mode_override,
None,
&PathBuf::from("/tmp/test")
)
let resolution = sandbox_workspace_write_cfg.derive_sandbox_policy(
sandbox_mode_override,
None,
&PathBuf::from("/tmp/test"),
);
if cfg!(target_os = "windows") {
assert_eq!(
resolution,
SandboxPolicyResolution {
policy: SandboxPolicy::ReadOnly,
forced_auto_mode_downgraded_on_windows: true,
}
);
} else {
assert_eq!(
resolution,
SandboxPolicyResolution {
policy: SandboxPolicy::WorkspaceWrite {
writable_roots: vec![PathBuf::from("/my/workspace")],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
},
forced_auto_mode_downgraded_on_windows: false,
}
);
}
}
#[test]
@@ -1714,19 +1774,30 @@ trust_level = "trusted"
)?;
let expected_backend = canonicalize(&backend).expect("canonicalize backend directory");
match config.sandbox_policy {
SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
assert_eq!(
writable_roots
.iter()
.filter(|root| **root == expected_backend)
.count(),
1,
"expected single writable root entry for {}",
expected_backend.display()
);
if cfg!(target_os = "windows") {
assert!(
config.forced_auto_mode_downgraded_on_windows,
"expected workspace-write request to be downgraded on Windows"
);
match config.sandbox_policy {
SandboxPolicy::ReadOnly => {}
other => panic!("expected read-only policy on Windows, got {other:?}"),
}
} else {
match config.sandbox_policy {
SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
assert_eq!(
writable_roots
.iter()
.filter(|root| **root == expected_backend)
.count(),
1,
"expected single writable root entry for {}",
expected_backend.display()
);
}
other => panic!("expected workspace-write policy, got {other:?}"),
}
other => panic!("expected workspace-write policy, got {other:?}"),
}
Ok(())
@@ -1841,10 +1912,16 @@ trust_level = "trusted"
codex_home.path().to_path_buf(),
)?;
assert!(matches!(
config.sandbox_policy,
SandboxPolicy::WorkspaceWrite { .. }
));
if cfg!(target_os = "windows") {
assert!(matches!(config.sandbox_policy, SandboxPolicy::ReadOnly));
assert!(config.forced_auto_mode_downgraded_on_windows);
} else {
assert!(matches!(
config.sandbox_policy,
SandboxPolicy::WorkspaceWrite { .. }
));
assert!(!config.forced_auto_mode_downgraded_on_windows);
}
Ok(())
}
@@ -2943,6 +3020,7 @@ model_verbosity = "high"
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
did_user_set_custom_approval_policy_or_sandbox_mode: true,
forced_auto_mode_downgraded_on_windows: false,
shell_environment_policy: ShellEnvironmentPolicy::default(),
user_instructions: None,
notify: None,
@@ -3012,6 +3090,7 @@ model_verbosity = "high"
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
did_user_set_custom_approval_policy_or_sandbox_mode: true,
forced_auto_mode_downgraded_on_windows: false,
shell_environment_policy: ShellEnvironmentPolicy::default(),
user_instructions: None,
notify: None,
@@ -3096,6 +3175,7 @@ model_verbosity = "high"
approval_policy: AskForApproval::OnFailure,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
did_user_set_custom_approval_policy_or_sandbox_mode: true,
forced_auto_mode_downgraded_on_windows: false,
shell_environment_policy: ShellEnvironmentPolicy::default(),
user_instructions: None,
notify: None,
@@ -3166,6 +3246,7 @@ model_verbosity = "high"
approval_policy: AskForApproval::OnFailure,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
did_user_set_custom_approval_policy_or_sandbox_mode: true,
forced_auto_mode_downgraded_on_windows: false,
shell_environment_policy: ShellEnvironmentPolicy::default(),
user_instructions: None,
notify: None,

View File

@@ -234,10 +234,17 @@ fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> {
stderr.contains("model: gpt-5-high"),
"stderr missing model override: {stderr}"
);
assert!(
stderr.contains("sandbox: workspace-write"),
"stderr missing sandbox override: {stderr}"
);
if cfg!(target_os = "windows") {
assert!(
stderr.contains("sandbox: read-only"),
"stderr missing downgraded sandbox note: {stderr}"
);
} else {
assert!(
stderr.contains("sandbox: workspace-write"),
"stderr missing sandbox override: {stderr}"
);
}
let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2)
.expect("no resumed session file containing marker2");

View File

@@ -369,6 +369,9 @@ impl App {
AppEvent::OpenFeedbackConsent { category } => {
self.chat_widget.open_feedback_consent(category);
}
AppEvent::ShowWindowsAutoModeInstructions => {
self.chat_widget.open_windows_auto_mode_instructions();
}
AppEvent::PersistModelSelection { model, effort } => {
let profile = self.active_profile.as_deref();
match persist_model_selection(&self.config.codex_home, profile, &model, effort)

View File

@@ -72,6 +72,9 @@ pub(crate) enum AppEvent {
preset: ApprovalPreset,
},
/// Show Windows Subsystem for Linux setup instructions for auto mode.
ShowWindowsAutoModeInstructions,
/// Update the current approval policy in the running app and widget.
UpdateAskForApprovalPolicy(AskForApproval),

View File

@@ -88,6 +88,8 @@ use crate::history_cell::AgentMessageCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::McpToolCallCell;
use crate::markdown::append_markdown;
#[cfg(target_os = "windows")]
use crate::onboarding::WSL_INSTRUCTIONS;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::slash_command::SlashCommand;
@@ -1787,11 +1789,38 @@ impl ChatWidget {
let current_sandbox = self.config.sandbox_policy.clone();
let mut items: Vec<SelectionItem> = Vec::new();
let presets: Vec<ApprovalPreset> = builtin_approval_presets();
#[cfg(target_os = "windows")]
let header_renderable: Box<dyn Renderable> = if self
.config
.forced_auto_mode_downgraded_on_windows
{
use ratatui_macros::line;
let mut header = ColumnRenderable::new();
header.push(line![
"Codex forced your settings back to Read Only on this Windows machine.".bold()
]);
header.push(line![
"To re-enable Auto mode, run Codex inside Windows Subsystem for Linux (WSL) or enable Full Access manually.".dim()
]);
Box::new(header)
} else {
Box::new(())
};
#[cfg(not(target_os = "windows"))]
let header_renderable: Box<dyn Renderable> = Box::new(());
for preset in presets.into_iter() {
let is_current =
current_approval == preset.approval && current_sandbox == preset.sandbox;
let name = preset.label.to_string();
let description = Some(preset.description.to_string());
let description_text = preset.description;
let description = if cfg!(target_os = "windows") && preset.id == "auto" {
Some(format!(
"{description_text}\nRequires Windows Subsystem for Linux (WSL). Show installation instructions..."
))
} else {
Some(description_text.to_string())
};
let requires_confirmation = preset.id == "full-access"
&& !self
.config
@@ -1805,6 +1834,10 @@ impl ChatWidget {
preset: preset_clone.clone(),
});
})]
} else if cfg!(target_os = "windows") && preset.id == "auto" {
vec![Box::new(|tx| {
tx.send(AppEvent::ShowWindowsAutoModeInstructions);
})]
} else {
Self::approval_preset_actions(preset.approval, preset.sandbox.clone())
};
@@ -1822,6 +1855,7 @@ impl ChatWidget {
title: Some("Select Approval Mode".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
header: header_renderable,
..Default::default()
});
}
@@ -1909,6 +1943,43 @@ impl ChatWidget {
});
}
#[cfg(target_os = "windows")]
pub(crate) fn open_windows_auto_mode_instructions(&mut self) {
use ratatui_macros::line;
let mut header = ColumnRenderable::new();
header.push(line![
"Auto mode requires Windows Subsystem for Linux (WSL2).".bold()
]);
header.push(line!["Run Codex inside WSL to enable sandboxed commands."]);
header.push(line![""]);
header.push(Paragraph::new(WSL_INSTRUCTIONS).wrap(Wrap { trim: false }));
let items = vec![SelectionItem {
name: "Back".to_string(),
description: Some(
"Return to the approval mode list. Auto mode stays disabled outside WSL."
.to_string(),
),
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()
});
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn open_windows_auto_mode_instructions(&mut self) {}
/// Set the approval policy in the widget's config copy.
pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) {
self.config.approval_policy = policy;

View File

@@ -6,12 +6,12 @@ expression: popup
1. Read Only (current) Codex can read files and answer questions. Codex
requires approval to make edits, run commands, or
access network
access network.
2. Auto Codex can read files, make edits, and run commands
in the workspace. Codex requires approval to work
outside the workspace or access network
outside the workspace or access network.
3. Full Access Codex can read files, make edits, and run commands
with network access, without approval. Exercise
caution
caution.
Press enter to confirm or esc to go back

View File

@@ -0,0 +1,19 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Select Approval Mode
1. Read Only (current) Codex can read files and answer questions. Codex
requires approval to make edits, run commands, or
access network.
2. Auto Codex can read files, make edits, and run commands
in the workspace. Codex requires approval to work
outside the workspace or access network.
Requires Windows Subsystem for Linux (WSL). Show
installation instructions...
3. Full Access Codex can read files, make edits, and run commands
with network access, without approval. Exercise
caution.
Press enter to confirm or esc to go back

View File

@@ -1276,9 +1276,36 @@ fn approvals_selection_popup_snapshot() {
chat.open_approvals_popup();
let popup = render_bottom_popup(&chat, 80);
#[cfg(target_os = "windows")]
insta::with_settings!({ snapshot_suffix => "windows" }, {
assert_snapshot!("approvals_selection_popup", popup);
});
#[cfg(not(target_os = "windows"))]
assert_snapshot!("approvals_selection_popup", popup);
}
#[test]
fn approvals_popup_includes_wsl_note_for_auto_mode() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
if cfg!(target_os = "windows") {
chat.config.forced_auto_mode_downgraded_on_windows = true;
}
chat.open_approvals_popup();
let popup = render_bottom_popup(&chat, 80);
assert_eq!(
popup.contains("Requires Windows Subsystem for Linux (WSL)"),
cfg!(target_os = "windows"),
"expected auto preset description to mention WSL requirement only on Windows, popup: {popup}"
);
assert_eq!(
popup.contains("Codex forced your settings back to Read Only on this Windows machine."),
cfg!(target_os = "windows") && chat.config.forced_auto_mode_downgraded_on_windows,
"expected downgrade notice only when auto mode is forced off on Windows, popup: {popup}"
);
}
#[test]
fn full_access_confirmation_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
@@ -1293,6 +1320,20 @@ fn full_access_confirmation_popup_snapshot() {
assert_snapshot!("full_access_confirmation_popup", popup);
}
#[cfg(target_os = "windows")]
#[test]
fn windows_auto_mode_instructions_popup_lists_install_steps() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
chat.open_windows_auto_mode_instructions();
let popup = render_bottom_popup(&chat, 120);
assert!(
popup.contains("wsl --install"),
"expected WSL instructions popup to include install command, popup: {popup}"
);
}
#[test]
fn model_reasoning_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();

View File

@@ -507,14 +507,16 @@ async fn load_config_or_exit(
/// or if the current cwd project is already trusted. If not, we need to
/// show the trust screen.
fn should_show_trust_screen(config: &Config) -> bool {
if config.did_user_set_custom_approval_policy_or_sandbox_mode {
// if the user has overridden either approval policy or sandbox mode,
// skip the trust flow
false
} else {
// otherwise, skip iff the active project is trusted
!config.active_project.is_trusted()
if cfg!(target_os = "windows") {
// Native Windows cannot enforce sandboxed write access without WSL; skip the trust prompt entirely.
return false;
}
if config.did_user_set_custom_approval_policy_or_sandbox_mode {
// Respect explicit approval/sandbox overrides made by the user.
return false;
}
// otherwise, skip iff the active project is trusted
!config.active_project.is_trusted()
}
fn should_show_onboarding(
@@ -543,3 +545,38 @@ fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool
login_status == LoginStatus::NotAuthenticated
}
#[cfg(test)]
mod tests {
use super::*;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
use codex_core::config::ProjectConfig;
use tempfile::TempDir;
#[test]
fn windows_skips_trust_prompt() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let mut config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
temp_dir.path().to_path_buf(),
)?;
config.did_user_set_custom_approval_policy_or_sandbox_mode = false;
config.active_project = ProjectConfig { trust_level: None };
let should_show = should_show_trust_screen(&config);
if cfg!(target_os = "windows") {
assert!(
!should_show,
"Windows trust prompt should always be skipped on native Windows"
);
} else {
assert!(
should_show,
"Non-Windows should still show trust prompt when project is untrusted"
);
}
Ok(())
}
}