Compare commits

...

14 Commits

Author SHA1 Message Date
Charles Cunningham
94ddb3bc23 Tests for image request behavior 2026-01-11 22:26:03 -08:00
Charles Cunningham
61812e5695 Small code quality improvement 2026-01-11 22:25:33 -08:00
Charles Cunningham
b4aea65cf7 Fix test 2026-01-11 22:25:33 -08:00
Charles Cunningham
0865baaeba Fix Image label numbering upon earlier placeholder deletion 2026-01-11 22:25:33 -08:00
Charles Cunningham
1c4e08caf9 Wrap Image UserInput in <image> tags as well 2026-01-11 22:25:33 -08:00
Charles Cunningham
20e4a2cf3f Bring back match statements on image_dimensions 2026-01-11 22:25:32 -08:00
Charles Cunningham
509952dc4b dd local_image_content_items 2026-01-11 22:25:32 -08:00
Charles Cunningham
c6d6fa8e71 Simplify attach_image 2026-01-11 22:25:32 -08:00
Charles Cunningham
0d4873f671 Fix test 2026-01-11 22:25:32 -08:00
Charles Cunningham
5e4e63c6ab Use [Image #1] format more consistently across Ctrl+V and drag/drop, put image above user message 2026-01-11 22:25:17 -08:00
Charles Cunningham
29c2859e6e Small improvements 2026-01-11 22:20:45 -08:00
Charles Cunningham
95bc7bbf8a Wrap image with XML open/close tags 2026-01-11 22:20:45 -08:00
Charles Cunningham
aa07b6b6fd Simplify image placeholder 2026-01-11 22:20:27 -08:00
Charles Cunningham
9395ef9834 Label attached images so agent can understand in-message labels 2026-01-11 22:05:50 -08:00
13 changed files with 906 additions and 1350 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "<image name=[Image #1]>"
},
{
"type": "input_image",
"image_url": "__IMAGE_URL__"
},
{
"type": "input_text",
"text": "</image>"
},
{
"type": "input_text",
"text": "pasted image"
}
]
}

View File

@@ -0,0 +1,22 @@
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "<image>"
},
{
"type": "input_image",
"image_url": "__IMAGE_URL__"
},
{
"type": "input_text",
"text": "</image>"
},
{
"type": "input_text",
"text": "dropped image"
}
]
}

View File

@@ -0,0 +1,203 @@
use anyhow::Context;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use codex_core::protocol::RolloutItem;
use codex_core::protocol::RolloutLine;
use codex_core::protocol::SandboxPolicy;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::user_input::UserInput;
use codex_utils_cargo_bin::find_resource;
use core_test_support::responses;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use image::ImageBuffer;
use image::Rgba;
use pretty_assertions::assert_eq;
use std::path::Path;
fn load_expected_response_item(path: &str, image_url: &str) -> anyhow::Result<ResponseItem> {
let full_path = find_resource!(path).context("fixture path should resolve")?;
let raw = std::fs::read_to_string(&full_path)
.with_context(|| format!("read fixture {}", full_path.display()))?;
let replaced = raw.replace("__IMAGE_URL__", image_url);
let response = serde_json::from_str(&replaced)
.with_context(|| format!("parse response item fixture {}", full_path.display()))?;
Ok(response)
}
fn find_user_message_with_image(text: &str) -> Option<ResponseItem> {
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let rollout: RolloutLine = match serde_json::from_str(trimmed) {
Ok(rollout) => rollout,
Err(_) => continue,
};
if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) =
&rollout.item
&& role == "user"
&& content
.iter()
.any(|span| matches!(span, ContentItem::InputImage { .. }))
&& let RolloutItem::ResponseItem(item) = rollout.item.clone()
{
return Some(item);
}
}
None
}
fn write_test_png(path: &Path, color: [u8; 4]) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let image = ImageBuffer::from_pixel(2, 2, Rgba(color));
image.save(path)?;
Ok(())
}
fn extract_image_url(item: &ResponseItem) -> Option<String> {
match item {
ResponseItem::Message { content, .. } => content.iter().find_map(|span| match span {
ContentItem::InputImage { image_url } => Some(image_url.clone()),
_ => None,
}),
_ => None,
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let TestCodex {
codex,
cwd,
session_configured,
..
} = test_codex().build(&server).await?;
let rel_path = "images/paste.png";
let abs_path = cwd.path().join(rel_path);
write_test_png(&abs_path, [12, 34, 56, 255])?;
let response = sse(vec![
ev_response_created("resp-1"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-1"),
]);
responses::mount_sse_once(&server, response).await;
let session_model = session_configured.model.clone();
codex
.submit(Op::UserTurn {
items: vec![
UserInput::LocalImage {
path: abs_path.clone(),
},
UserInput::Text {
text: "pasted image".to_string(),
},
],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await;
let rollout_text = std::fs::read_to_string(codex.rollout_path())?;
let actual = find_user_message_with_image(&rollout_text)
.expect("expected user message with input image in rollout");
let image_url = extract_image_url(&actual).expect("expected image url in rollout");
let expected = load_expected_response_item(
"tests/fixtures/rollout_copy_paste_local_image.json",
&image_url,
)?;
assert_eq!(actual, expected);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let TestCodex {
codex,
cwd,
session_configured,
..
} = test_codex().build(&server).await?;
let image_url =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII="
.to_string();
let response = sse(vec![
ev_response_created("resp-1"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-1"),
]);
responses::mount_sse_once(&server, response).await;
let session_model = session_configured.model.clone();
codex
.submit(Op::UserTurn {
items: vec![
UserInput::Image {
image_url: image_url.clone(),
},
UserInput::Text {
text: "dropped image".to_string(),
},
],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await;
let rollout_text = std::fs::read_to_string(codex.rollout_path())?;
let actual = find_user_message_with_image(&rollout_text)
.expect("expected user message with input image in rollout");
let image_url = extract_image_url(&actual).expect("expected image url in rollout");
let expected =
load_expected_response_item("tests/fixtures/rollout_drag_drop_image.json", &image_url)?;
assert_eq!(actual, expected);
Ok(())
}

View File

@@ -31,6 +31,7 @@ mod exec_policy;
mod fork_thread;
mod grep_files;
mod hierarchical_agents;
mod image_rollout;
mod items;
mod json_result;
mod list_dir;

View File

@@ -65,13 +65,6 @@ 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;
@@ -1215,10 +1208,6 @@ 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("");
@@ -1286,10 +1275,6 @@ 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
@@ -1611,6 +1596,7 @@ 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();
@@ -1680,9 +1666,6 @@ 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 {
@@ -2780,6 +2763,18 @@ mod tests {
}
}
#[test]
fn image_placeholder_snapshots() {
snapshot_composer_state("image_placeholder_single", false, |composer| {
composer.attach_image(PathBuf::from("/tmp/image1.png"));
});
snapshot_composer_state("image_placeholder_multiple", false, |composer| {
composer.attach_image(PathBuf::from("/tmp/image1.png"));
composer.attach_image(PathBuf::from("/tmp/image2.png"));
});
}
#[test]
fn slash_popup_model_first_for_mo_ui() {
use ratatui::Terminal;
@@ -2952,6 +2947,44 @@ 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(
@@ -4278,6 +4311,59 @@ 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};
@@ -4435,7 +4521,6 @@ mod tests {
);
assert_eq!(composer.attached_images.len(), 1);
}
#[test]
fn input_disabled_ignores_keypresses_and_hides_cursor() {
use crossterm::event::KeyCode;

View File

@@ -0,0 +1,14 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 2099
expression: terminal.backend()
---
" "
" [Image #1][Image #2] "
" "
" "
" "
" "
" "
" "
" 100% context left "

View File

@@ -0,0 +1,14 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 2099
expression: terminal.backend()
---
" "
" [Image #1] "
" "
" "
" "
" "
" "
" "
" 100% context left "

View File

@@ -49,11 +49,11 @@ use codex_core::protocol::ReviewRequest;
use codex_core::protocol::ReviewTarget;
use codex_core::protocol::SkillsListEntry;
use codex_core::protocol::StreamErrorEvent;
use codex_core::protocol::TaskCompleteEvent;
use codex_core::protocol::TerminalInteractionEvent;
use codex_core::protocol::TokenUsage;
use codex_core::protocol::TokenUsageInfo;
use codex_core::protocol::TurnAbortReason;
use codex_core::protocol::TurnCompleteEvent;
use codex_core::protocol::TurnDiffEvent;
use codex_core::protocol::UndoCompletedEvent;
use codex_core::protocol::UndoStartedEvent;
@@ -85,9 +85,6 @@ 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;
@@ -1695,12 +1692,6 @@ impl ChatWidget {
}
match cmd {
SlashCommand::Feedback => {
if !self.config.feedback_enabled {
let params = crate::bottom_pane::feedback_disabled_params();
self.bottom_pane.show_selection_view(params);
self.request_redraw();
return;
}
// Step 1: pick a category (UI built in feedback_view)
let params =
crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone());
@@ -1738,45 +1729,6 @@ 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();
}
@@ -2104,8 +2056,8 @@ impl ChatWidget {
self.on_agent_reasoning_final();
}
EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(),
EventMsg::TurnStarted(_) => self.on_task_started(),
EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message }) => {
EventMsg::TaskStarted(_) => self.on_task_started(),
EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => {
self.on_task_complete(last_agent_message)
}
EventMsg::TokenCount(ev) => {
@@ -2881,25 +2833,10 @@ 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 = if preset.id == "auto" && windows_degraded_sandbox_enabled {
"Agent (non-elevated sandbox)".to_string()
} else {
preset.label.to_string()
};
let name = 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,
@@ -2923,24 +2860,11 @@ impl ChatWidget {
{
if codex_core::get_platform_sandbox().is_none() {
let preset_clone = preset.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(),
});
})]
}
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()
{
@@ -2975,18 +2899,8 @@ 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(()),
@@ -3263,106 +3177,36 @@ 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!["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"],
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: "Set up agent sandbox (requires elevation)".to_string(),
name: "Enable experimental sandbox".to_string(),
description: None,
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
preset: preset.clone(),
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset: preset_clone.clone(),
});
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: stay_label,
name: "Go back".to_string(),
description: None,
actions: stay_actions,
actions: vec![Box::new(|tx| {
tx.send(AppEvent::OpenApprovalsPopup);
})],
dismiss_on_select: true,
..Default::default()
},
@@ -3380,107 +3224,6 @@ 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
@@ -3496,34 +3239,6 @@ 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;
@@ -3556,7 +3271,6 @@ 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);

View File

@@ -39,12 +39,12 @@ use codex_core::protocol::RateLimitWindow;
use codex_core::protocol::ReviewRequest;
use codex_core::protocol::ReviewTarget;
use codex_core::protocol::StreamErrorEvent;
use codex_core::protocol::TaskCompleteEvent;
use codex_core::protocol::TaskStartedEvent;
use codex_core::protocol::TerminalInteractionEvent;
use codex_core::protocol::TokenCountEvent;
use codex_core::protocol::TokenUsage;
use codex_core::protocol::TokenUsageInfo;
use codex_core::protocol::TurnCompleteEvent;
use codex_core::protocol::TurnStartedEvent;
use codex_core::protocol::UndoCompletedEvent;
use codex_core::protocol::UndoStartedEvent;
use codex_core::protocol::ViewImageToolCallEvent;
@@ -64,8 +64,6 @@ 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;
@@ -78,11 +76,6 @@ 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();
@@ -1320,7 +1313,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() {
chat.handle_codex_event(Event {
id: "turn-wait-1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
msg: EventMsg::TaskComplete(TaskCompleteEvent {
last_agent_message: None,
}),
});
@@ -1369,7 +1362,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() {
chat.handle_codex_event(Event {
id: "turn-wait-3".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
msg: EventMsg::TaskComplete(TaskCompleteEvent {
last_agent_message: None,
}),
});
@@ -1763,7 +1756,7 @@ async fn interrupted_turn_error_message_snapshot() {
// Simulate an in-progress task so the widget is in a running state.
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
msg: EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: None,
}),
});
@@ -2034,35 +2027,6 @@ 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()
@@ -2113,8 +2077,8 @@ async fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() {
let popup = render_bottom_popup(&chat, 120);
assert!(
popup.contains("requires elevation"),
"expected auto mode prompt to mention elevation, popup: {popup}"
popup.contains("Agent mode on Windows uses an experimental sandbox"),
"expected auto mode prompt to mention enabling the sandbox feature, popup: {popup}"
);
}
@@ -2130,16 +2094,12 @@ async fn startup_prompts_for_windows_sandbox_when_agent_requested() {
let popup = render_bottom_popup(&chat, 120);
assert!(
popup.contains("requires elevation"),
"expected startup prompt to explain elevation: {popup}"
popup.contains("Agent mode on Windows uses an experimental sandbox"),
"expected startup prompt to explain sandbox: {popup}"
);
assert!(
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}"
popup.contains("Enable experimental sandbox"),
"expected startup prompt to offer enabling the sandbox: {popup}"
);
set_windows_sandbox_enabled(true);
@@ -2756,7 +2716,7 @@ async fn ui_snapshots_small_heights_task_running() {
// Activate status line
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
msg: EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: None,
}),
});
@@ -2787,7 +2747,7 @@ async fn status_widget_and_approval_modal_snapshot() {
// Begin a running task so the status indicator would be active.
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
msg: EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: None,
}),
});
@@ -2839,7 +2799,7 @@ async fn status_widget_active_snapshot() {
// Activate the status indicator by simulating a task start.
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
msg: EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: None,
}),
});
@@ -3418,7 +3378,7 @@ async fn stream_recovery_restores_previous_status_header() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_codex_event(Event {
id: "task".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
msg: EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: None,
}),
});
@@ -3455,7 +3415,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() {
// Begin turn
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
msg: EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: None,
}),
});
@@ -3479,7 +3439,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() {
// End turn
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
msg: EventMsg::TaskComplete(TaskCompleteEvent {
last_agent_message: None,
}),
});
@@ -3649,7 +3609,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() {
});
chat.handle_codex_event(Event {
id: "t1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
msg: EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: None,
}),
});
@@ -3693,7 +3653,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() {
chat.handle_codex_event(Event {
id: "t1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
msg: EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: None,
}),
});
@@ -3764,7 +3724,7 @@ printf 'fenced within fenced\n'
// Finalize the stream without sending a final AgentMessage, to flush any tail.
chat.handle_codex_event(Event {
id: "t1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
msg: EventMsg::TaskComplete(TaskCompleteEvent {
last_agent_message: None,
}),
});
@@ -3781,7 +3741,7 @@ async fn chatwidget_tall() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_codex_event(Event {
id: "t1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
msg: EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: None,
}),
});

View File

@@ -68,13 +68,6 @@ 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;
@@ -1149,10 +1142,6 @@ 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("");
@@ -1220,10 +1209,6 @@ 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
@@ -1571,15 +1556,6 @@ 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();
@@ -1649,9 +1625,6 @@ 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 {
@@ -2899,6 +2872,44 @@ 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(
@@ -4215,7 +4226,6 @@ 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;

View File

@@ -10,7 +10,6 @@ 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;
@@ -48,11 +47,11 @@ use codex_core::protocol::ReviewRequest;
use codex_core::protocol::ReviewTarget;
use codex_core::protocol::SkillsListEntry;
use codex_core::protocol::StreamErrorEvent;
use codex_core::protocol::TaskCompleteEvent;
use codex_core::protocol::TerminalInteractionEvent;
use codex_core::protocol::TokenUsage;
use codex_core::protocol::TokenUsageInfo;
use codex_core::protocol::TurnAbortReason;
use codex_core::protocol::TurnCompleteEvent;
use codex_core::protocol::TurnDiffEvent;
use codex_core::protocol::UndoCompletedEvent;
use codex_core::protocol::UndoStartedEvent;
@@ -84,9 +83,6 @@ 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;
@@ -1529,12 +1525,6 @@ impl ChatWidget {
}
match cmd {
SlashCommand::Feedback => {
if !self.config.feedback_enabled {
let params = crate::bottom_pane::feedback_disabled_params();
self.bottom_pane.show_selection_view(params);
self.request_redraw();
return;
}
// Step 1: pick a category (UI built in feedback_view)
let params =
crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone());
@@ -1572,45 +1562,6 @@ 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();
}
@@ -1910,8 +1861,8 @@ impl ChatWidget {
self.on_agent_reasoning_final();
}
EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(),
EventMsg::TurnStarted(_) => self.on_task_started(),
EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message }) => {
EventMsg::TaskStarted(_) => self.on_task_started(),
EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => {
self.on_task_complete(last_agent_message)
}
EventMsg::TokenCount(ev) => {
@@ -2635,25 +2586,10 @@ 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 = if preset.id == "auto" && windows_degraded_sandbox_enabled {
"Agent (non-elevated sandbox)".to_string()
} else {
preset.label.to_string()
};
let name = preset.label.to_string();
let description_text = preset.description;
let description = Some(description_text.to_string());
let requires_confirmation = preset.id == "full-access"
@@ -2674,24 +2610,11 @@ impl ChatWidget {
{
if codex_core::get_platform_sandbox().is_none() {
let preset_clone = preset.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(),
});
})]
}
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()
{
@@ -2725,18 +2648,8 @@ 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(()),
@@ -2994,106 +2907,36 @@ 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!["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"],
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: "Set up agent sandbox (requires elevation)".to_string(),
name: "Enable experimental sandbox".to_string(),
description: None,
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
preset: preset.clone(),
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset: preset_clone.clone(),
});
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: stay_label,
name: "Go back".to_string(),
description: None,
actions: stay_actions,
actions: vec![Box::new(|tx| {
tx.send(AppEvent::OpenApprovalsPopup);
})],
dismiss_on_select: true,
..Default::default()
},
@@ -3111,107 +2954,6 @@ 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
@@ -3227,34 +2969,6 @@ 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;
@@ -3287,15 +3001,6 @@ 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);
}

View File

@@ -10,8 +10,6 @@ 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;
@@ -38,11 +36,11 @@ use codex_core::protocol::RateLimitWindow;
use codex_core::protocol::ReviewRequest;
use codex_core::protocol::ReviewTarget;
use codex_core::protocol::StreamErrorEvent;
use codex_core::protocol::TaskCompleteEvent;
use codex_core::protocol::TaskStartedEvent;
use codex_core::protocol::TokenCountEvent;
use codex_core::protocol::TokenUsage;
use codex_core::protocol::TokenUsageInfo;
use codex_core::protocol::TurnCompleteEvent;
use codex_core::protocol::TurnStartedEvent;
use codex_core::protocol::UndoCompletedEvent;
use codex_core::protocol::UndoStartedEvent;
use codex_core::protocol::ViewImageToolCallEvent;
@@ -62,8 +60,6 @@ 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,11 +72,6 @@ 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();
@@ -1589,7 +1580,7 @@ async fn interrupted_turn_error_message_snapshot() {
// Simulate an in-progress task so the widget is in a running state.
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
msg: EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: None,
}),
});
@@ -1795,35 +1786,6 @@ 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()
@@ -1874,8 +1836,8 @@ async fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() {
let popup = render_bottom_popup(&chat, 120);
assert!(
popup.contains("requires elevation"),
"expected auto mode prompt to mention elevation, popup: {popup}"
popup.contains("Agent mode on Windows uses an experimental sandbox"),
"expected auto mode prompt to mention enabling the sandbox feature, popup: {popup}"
);
}
@@ -1891,16 +1853,12 @@ async fn startup_prompts_for_windows_sandbox_when_agent_requested() {
let popup = render_bottom_popup(&chat, 120);
assert!(
popup.contains("requires elevation"),
"expected startup prompt to explain elevation: {popup}"
popup.contains("Agent mode on Windows uses an experimental sandbox"),
"expected startup prompt to explain sandbox: {popup}"
);
assert!(
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}"
popup.contains("Enable experimental sandbox"),
"expected startup prompt to offer enabling the sandbox: {popup}"
);
set_windows_sandbox_enabled(true);
@@ -2383,7 +2341,7 @@ async fn ui_snapshots_small_heights_task_running() {
// Activate status line
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
msg: EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: None,
}),
});
@@ -2414,7 +2372,7 @@ async fn status_widget_and_approval_modal_snapshot() {
// Begin a running task so the status indicator would be active.
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
msg: EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: None,
}),
});
@@ -2466,7 +2424,7 @@ async fn status_widget_active_snapshot() {
// Activate the status indicator by simulating a task start.
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
msg: EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: None,
}),
});
@@ -3041,7 +2999,7 @@ async fn stream_recovery_restores_previous_status_header() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_codex_event(Event {
id: "task".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
msg: EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: None,
}),
});
@@ -3078,7 +3036,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() {
// Begin turn
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
msg: EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: None,
}),
});
@@ -3102,7 +3060,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() {
// End turn
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
msg: EventMsg::TaskComplete(TaskCompleteEvent {
last_agent_message: None,
}),
});
@@ -3272,7 +3230,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() {
});
chat.handle_codex_event(Event {
id: "t1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
msg: EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: None,
}),
});
@@ -3316,7 +3274,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() {
chat.handle_codex_event(Event {
id: "t1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
msg: EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: None,
}),
});
@@ -3387,7 +3345,7 @@ printf 'fenced within fenced\n'
// Finalize the stream without sending a final AgentMessage, to flush any tail.
chat.handle_codex_event(Event {
id: "t1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
msg: EventMsg::TaskComplete(TaskCompleteEvent {
last_agent_message: None,
}),
});
@@ -3404,7 +3362,7 @@ async fn chatwidget_tall() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_codex_event(Event {
id: "t1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
msg: EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: None,
}),
});