Compare commits

..

1 Commits

Author SHA1 Message Date
David Wiesen
8cfd67900a fix(tui): summarize inactive subagent approvals 2026-04-25 20:44:56 -07:00
6 changed files with 109 additions and 135 deletions

View File

@@ -26,6 +26,7 @@ use codex_app_server_protocol::AdditionalFileSystemPermissions;
use codex_app_server_protocol::AdditionalNetworkPermissions;
use codex_app_server_protocol::AdditionalPermissionProfile;
use codex_app_server_protocol::AgentMessageDeltaNotification;
use codex_app_server_protocol::CommandAction;
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::JSONRPCErrorError;
@@ -2409,7 +2410,7 @@ async fn side_defers_subagent_approval_overlay_until_side_exits() -> Result<()>
assert_eq!(app.chat_widget.has_active_view(), false);
assert_eq!(
app.chat_widget.pending_thread_approvals(),
&["Robie [explorer]".to_string()]
&["Robie [explorer]: echo hello".to_string()]
);
app.side_threads.remove(&side_thread_id);
@@ -2536,6 +2537,47 @@ async fn inactive_thread_exec_approval_splits_shell_wrapped_command() {
);
}
#[tokio::test]
async fn refresh_pending_thread_approvals_falls_back_to_command_actions() -> anyhow::Result<()> {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let main_thread_id = ThreadId::new();
let agent_thread_id = ThreadId::new();
app.active_thread_id = Some(main_thread_id);
app.side_threads.insert(
agent_thread_id,
SideThreadContext {
side_parent_id: main_thread_id,
label: "Robie [explorer]".to_string(),
turns: Vec::new(),
pending_history: Vec::new(),
},
);
let mut request = exec_approval_request(
agent_thread_id,
"turn-approval",
"call-approval",
/*approval_id*/ None,
);
let ServerRequest::CommandExecutionRequestApproval { params, .. } = &mut request else {
panic!("expected exec approval request");
};
params.command = None;
params.command_actions = Some(vec![CommandAction::Unknown {
command: "git status --short".to_string(),
}]);
app.handle_thread_request(agent_thread_id, request).await?;
assert_eq!(
app.chat_widget.pending_thread_approvals(),
&["Robie [explorer]: git status --short".to_string()]
);
Ok(())
}
#[tokio::test]
async fn inactive_thread_permissions_approval_preserves_file_system_permissions() {
let app = make_test_app().await;

View File

@@ -7,6 +7,56 @@
use super::*;
impl App {
fn pending_thread_approval_summary(&self, request: &ServerRequest) -> Option<String> {
match request {
ServerRequest::CommandExecutionRequestApproval { params, .. } => {
if let Some(network_approval_context) = params.network_approval_context.as_ref() {
Some(format!(
"network access to {}",
network_approval_context.host
))
} else if let Some(command) = params
.command
.as_deref()
.map(str::trim)
.filter(|command| !command.is_empty())
{
Some(command.to_string())
} else {
params.command_actions.as_ref().and_then(|actions| {
actions.iter().find_map(|action| match action {
codex_app_server_protocol::CommandAction::Read { command, .. }
| codex_app_server_protocol::CommandAction::ListFiles {
command, ..
}
| codex_app_server_protocol::CommandAction::Search {
command, ..
}
| codex_app_server_protocol::CommandAction::Unknown { command } => {
let command = command.trim();
(!command.is_empty()).then(|| command.to_string())
}
})
})
}
}
ServerRequest::FileChangeRequestApproval { .. } => {
Some("apply_patch edits".to_string())
}
ServerRequest::PermissionsRequestApproval { params, .. } => params
.reason
.as_deref()
.map(str::trim)
.filter(|reason| !reason.is_empty())
.map(|reason| format!("permissions request: {reason}"))
.or_else(|| Some("permissions request".to_string())),
ServerRequest::McpServerElicitationRequest { params, .. } => {
Some(format!("input for {}", params.server_name))
}
_ => None,
}
}
pub(super) async fn shutdown_current_thread(&mut self, app_server: &mut AppServerSession) {
if let Some(thread_id) = self.chat_widget.thread_id() {
// Clear any in-flight rollback guard when switching threads.
@@ -736,31 +786,18 @@ impl App {
pub(super) async fn refresh_pending_thread_approvals(&mut self) {
let side_parent_thread_id = self.active_side_parent_thread_id();
let channels: Vec<(ThreadId, Arc<Mutex<ThreadEventStore>>)> = self
.thread_event_channels
.iter()
.map(|(thread_id, channel)| (*thread_id, Arc::clone(&channel.store)))
.collect();
let mut pending_thread_ids = Vec::new();
for (thread_id, store) in channels {
if Some(thread_id) == self.active_thread_id || Some(thread_id) == side_parent_thread_id
{
continue;
}
let store = store.lock().await;
if store.has_pending_thread_approvals() {
pending_thread_ids.push(thread_id);
}
}
pending_thread_ids.sort_by_key(ThreadId::to_string);
let threads = pending_thread_ids
let mut threads = self
.pending_inactive_thread_requests()
.await
.into_iter()
.map(|thread_id| self.thread_label(thread_id))
.collect();
.filter(|(thread_id, _)| Some(*thread_id) != side_parent_thread_id)
.filter_map(|(thread_id, request)| {
self.pending_thread_approval_summary(&request)
.map(|summary| format!("{}: {summary}", self.thread_label(thread_id)))
})
.collect::<Vec<_>>();
threads.sort();
threads.dedup();
self.chat_widget.set_pending_thread_approvals(threads);
}

View File

@@ -71,7 +71,7 @@ impl RunnerTransport {
pub(crate) fn spawn_runner_transport(
codex_home: &Path,
_cwd: &Path,
cwd: &Path,
sandbox_creds: &SandboxCreds,
log_dir: Option<&Path>,
) -> Result<RunnerTransport> {
@@ -94,11 +94,7 @@ pub(crate) fn spawn_runner_transport(
);
let mut cmdline_vec = to_wide(&runner_full_cmd);
let exe_w = to_wide(&runner_cmdline);
let runner_launch_cwd = runner_exe
.parent()
.unwrap_or(codex_home)
.to_path_buf();
let cwd_w = to_wide(runner_launch_cwd.as_os_str());
let cwd_w = to_wide(cwd);
let user_w = to_wide(&sandbox_creds.username);
let domain_w = to_wide(".");
let password_w = to_wide(&sandbox_creds.password);

View File

@@ -321,8 +321,7 @@ mod windows_impl {
);
let mut cmdline_vec: Vec<u16> = to_wide(&runner_full_cmd);
let exe_w: Vec<u16> = to_wide(&runner_cmdline);
let runner_launch_cwd = runner_exe.parent().unwrap_or(codex_home).to_path_buf();
let cwd_w: Vec<u16> = to_wide(runner_launch_cwd.as_os_str());
let cwd_w: Vec<u16> = to_wide(cwd);
// Minimal CPWL launch: inherit env, no desktop override, no handle inheritance.
let env_block: Option<Vec<u16>> = None;
@@ -337,10 +336,9 @@ mod windows_impl {
log_note(
&format!(
"runner launch: exe={} cmdline={} cwd={} requested_cwd={}",
"runner launch: exe={} cmdline={} cwd={}",
runner_exe.display(),
runner_full_cmd,
runner_launch_cwd.display(),
cwd.display()
),
logs_base_dir,

View File

@@ -1,6 +1,5 @@
use crate::dpapi;
use crate::logging::debug_log;
use crate::path_normalization::unsupported_workspace_root_reason;
use crate::policy::SandboxPolicy;
use crate::setup::gather_read_roots;
use crate::setup::gather_write_roots;
@@ -13,8 +12,7 @@ use crate::setup::SandboxNetworkIdentity;
use crate::setup::SandboxUserRecord;
use crate::setup::SandboxUsersFile;
use crate::setup::SetupMarker;
use crate::setup_error::failure;
use crate::SetupErrorCode;
use anyhow::anyhow;
use anyhow::Context;
use anyhow::Result;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
@@ -36,22 +34,6 @@ pub struct SandboxCreds {
pub password: String,
}
fn ensure_supported_workspace_roots<'a>(
paths: impl IntoIterator<Item = &'a Path>,
) -> Result<()> {
for path in paths {
if let Some(reason) = unsupported_workspace_root_reason(path) {
return Err(failure(
SetupErrorCode::HelperUnknownError,
format!(
"Windows sandbox only supports local drive workspaces; {reason}. Move the workspace to a local drive or run outside the Windows sandbox."
),
));
}
}
Ok(())
}
/// Returns true when the on-disk setup artifacts exist and match the current
/// setup version.
///
@@ -166,13 +148,6 @@ pub fn require_logon_sandbox_creds(
let needed_write = write_roots_override
.map(<[PathBuf]>::to_vec)
.unwrap_or_else(|| gather_write_roots(policy, policy_cwd, command_cwd, env_map));
ensure_supported_workspace_roots(
[policy_cwd, command_cwd]
.into_iter()
.chain(needed_read.iter().map(PathBuf::as_path))
.chain(needed_write.iter().map(PathBuf::as_path))
.chain(deny_write_paths_override.iter().map(PathBuf::as_path)),
)?;
let network_identity = SandboxNetworkIdentity::from_policy(policy, proxy_enforced);
let desired_offline_proxy_settings =
offline_proxy_settings_from_env(env_map, network_identity);

View File

@@ -1,9 +1,5 @@
use std::path::Path;
use std::path::PathBuf;
#[cfg(target_os = "windows")]
use windows_sys::Win32::Storage::FileSystem::DRIVE_REMOTE;
#[cfg(target_os = "windows")]
use windows_sys::Win32::Storage::FileSystem::GetDriveTypeW;
pub fn canonicalize_path(path: &Path) -> PathBuf {
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
@@ -16,58 +12,9 @@ pub fn canonical_path_key(path: &Path) -> String {
.to_ascii_lowercase()
}
#[cfg(target_os = "windows")]
pub fn unsupported_workspace_root_reason(path: &Path) -> Option<String> {
let display = path.display().to_string();
let raw = path.as_os_str().to_string_lossy();
if is_unc_like_path_str(&raw) {
return Some(format!("{display} uses a UNC/network path"));
}
let drive_root = drive_root_from_path_str(&raw)?;
let drive_root_wide = crate::winutil::to_wide(&drive_root);
let drive_type = unsafe { GetDriveTypeW(drive_root_wide.as_ptr()) };
(drive_type == DRIVE_REMOTE).then(|| {
format!(
"{display} is on mapped drive {}",
drive_root.display()
)
})
}
#[cfg(target_os = "windows")]
fn is_unc_like_path_str(path: &str) -> bool {
if path.starts_with(r"\\?\UNC\")
|| path.starts_with(r"\\.\UNC\")
{
return true;
}
if path.starts_with(r"\\?\") || path.starts_with(r"\\.\") {
return false;
}
path.starts_with(r"\\") || path.starts_with("//")
}
#[cfg(target_os = "windows")]
fn drive_root_from_path_str(path: &str) -> Option<PathBuf> {
let trimmed = path
.strip_prefix(r"\\?\")
.or_else(|| path.strip_prefix(r"\\.\"))
.unwrap_or(path);
let bytes = trimmed.as_bytes();
if bytes.len() < 2 || bytes[1] != b':' {
return None;
}
Some(PathBuf::from(format!("{}:\\", trimmed.chars().next()?)))
}
#[cfg(test)]
mod tests {
use super::canonical_path_key;
#[cfg(target_os = "windows")]
use super::drive_root_from_path_str;
#[cfg(target_os = "windows")]
use super::is_unc_like_path_str;
use pretty_assertions::assert_eq;
use std::path::Path;
@@ -78,25 +25,4 @@ mod tests {
assert_eq!(canonical_path_key(windows_style), canonical_path_key(slash_style));
}
#[cfg(target_os = "windows")]
#[test]
fn detects_unc_like_workspace_paths() {
assert!(is_unc_like_path_str(r"\\server\share\repo"));
assert!(is_unc_like_path_str(r"\\?\UNC\server\share\repo"));
assert!(!is_unc_like_path_str(r"C:\repo"));
}
#[cfg(target_os = "windows")]
#[test]
fn extracts_drive_root_from_verbatim_or_normal_drive_paths() {
assert_eq!(
drive_root_from_path_str(r"L:\repo").expect("drive root"),
Path::new(r"L:\")
);
assert_eq!(
drive_root_from_path_str(r"\\?\L:\repo").expect("verbatim drive root"),
Path::new(r"L:\")
);
}
}