Files
codex/codex-rs/exec/src/event_processor_with_human_output.rs
Sean Huang 83dc7d11e8 Preserve executor path URIs in permission profile workspace roots (#45863)
## Why

Executor profile roots can use path conventions that are not native to the current host. Converting them to host paths during configuration or turn reconstruction can reject or drop those roots, while case-insensitive comparison can hide Windows path spelling changes.

## What changed

- Store profile roots as URI-backed `ProfileWorkspaceRoot` values throughout permission snapshots and thread settings, preserving spelling in equality and deduplication.
- Keep effective workspace roots as `PathUri` values for permission materialization and status summaries. Convert Windows sandbox root hints to native paths only at native Windows sandbox boundaries, rejecting incompatible roots.
- Omit the legacy rollout `workspace_roots` field when profile roots cannot be represented as host paths, retaining the compiled permission profile.

## Testing

Add regression coverage for Windows and UNC root spelling changes, settings restoration and turn recording with foreign roots, executor-root status display, and backend-specific Windows root conversion.

GitOrigin-RevId: 903c068fd74959bdd10e7cb1141aa42b953a59a4
2026-09-16 04:23:46 +00:00

536 lines
20 KiB
Rust

use std::io::IsTerminal;
use std::path::PathBuf;
use codex_app_server_protocol::CommandExecutionStatus;
use codex_app_server_protocol::McpToolCallStatus;
use codex_app_server_protocol::PatchApplyStatus;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadTokenUsage;
use codex_app_server_protocol::TurnStatus;
use codex_core::config::Config;
use codex_model_provider_info::WireApi;
use codex_protocol::num_format::format_with_separators;
use codex_protocol::protocol::SessionConfiguredEvent;
use codex_utils_path_uri::PathUri;
use codex_utils_sandbox_summary::summarize_permission_profile;
use owo_colors::OwoColorize;
use owo_colors::Style;
use crate::event_processor::CodexStatus;
use crate::event_processor::EventProcessor;
use crate::event_processor::handle_last_message;
pub(crate) struct EventProcessorWithHumanOutput {
bold: Style,
cyan: Style,
dimmed: Style,
green: Style,
italic: Style,
magenta: Style,
red: Style,
yellow: Style,
show_agent_reasoning: bool,
show_raw_agent_reasoning: bool,
last_message_path: Option<PathBuf>,
final_message: Option<String>,
final_message_rendered: bool,
emit_final_message_on_shutdown: bool,
last_total_token_usage: Option<ThreadTokenUsage>,
}
impl EventProcessorWithHumanOutput {
pub(crate) fn create_with_ansi(
with_ansi: bool,
config: &Config,
last_message_path: Option<PathBuf>,
) -> Self {
let style = |styled: Style, plain: Style| if with_ansi { styled } else { plain };
Self {
bold: style(Style::new().bold(), Style::new()),
cyan: style(Style::new().cyan(), Style::new()),
dimmed: style(Style::new().dimmed(), Style::new()),
green: style(Style::new().green(), Style::new()),
italic: style(Style::new().italic(), Style::new()),
magenta: style(Style::new().magenta(), Style::new()),
red: style(Style::new().red(), Style::new()),
yellow: style(Style::new().yellow(), Style::new()),
show_agent_reasoning: !config.hide_agent_reasoning,
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
last_message_path,
final_message: None,
final_message_rendered: false,
emit_final_message_on_shutdown: false,
last_total_token_usage: None,
}
}
fn render_item_started(&self, item: &ThreadItem) {
match item {
ThreadItem::CommandExecution { command, cwd, .. } => {
eprintln!(
"{}\n{} in {cwd}",
"exec".style(self.italic).style(self.magenta),
command.style(self.bold),
);
}
ThreadItem::McpToolCall { server, tool, .. } => {
eprintln!(
"{} {} {}",
"mcp:".style(self.bold),
format!("{server}/{tool}").style(self.cyan),
"started".style(self.dimmed)
);
}
ThreadItem::WebSearch(item) => {
eprintln!("{} {}", "web search:".style(self.bold), item.query);
}
ThreadItem::FileChange { .. } => {
eprintln!("{}", "apply patch".style(self.bold));
}
ThreadItem::CollabAgentToolCall { tool, .. } => {
eprintln!("{} {:?}", "collab:".style(self.bold), tool);
}
_ => {}
}
}
fn render_item_completed(&mut self, item: ThreadItem) {
match item {
ThreadItem::AgentMessage { text, .. } => {
eprintln!(
"{}\n{}",
"codex".style(self.italic).style(self.magenta),
text
);
self.final_message = Some(text);
self.final_message_rendered = true;
}
ThreadItem::Reasoning {
summary, content, ..
} => {
if self.show_agent_reasoning
&& let Some(text) =
reasoning_text(&summary, &content, self.show_raw_agent_reasoning)
&& !text.trim().is_empty()
{
eprintln!("{}", text.style(self.dimmed));
}
}
ThreadItem::CommandExecution {
command: _,
aggregated_output,
exit_code,
status,
duration_ms,
..
} => {
let duration_suffix = duration_ms
.map(|duration_ms| format!(" in {duration_ms}ms"))
.unwrap_or_default();
match status {
CommandExecutionStatus::Completed => {
eprintln!(
"{}",
format!(" succeeded{duration_suffix}:").style(self.green)
);
}
CommandExecutionStatus::Failed => {
let exit_code = exit_code.unwrap_or(1);
eprintln!(
"{}",
format!(" exited {exit_code}{duration_suffix}:").style(self.red)
);
}
CommandExecutionStatus::Declined => {
eprintln!(
"{}",
format!(" declined{duration_suffix}:").style(self.yellow)
);
}
CommandExecutionStatus::InProgress => {
eprintln!(
"{}",
format!(" in progress{duration_suffix}:").style(self.dimmed)
);
}
}
if let Some(output) = aggregated_output
&& !output.trim().is_empty()
{
eprintln!("{output}");
}
}
ThreadItem::FileChange {
changes, status, ..
} => {
let status_text = match status {
PatchApplyStatus::Completed => "completed",
PatchApplyStatus::Failed => "failed",
PatchApplyStatus::Declined => "declined",
PatchApplyStatus::InProgress => "in_progress",
};
eprintln!("{} {}", "patch:".style(self.bold), status_text);
for change in changes {
eprintln!("{}", change.path.style(self.dimmed));
}
}
ThreadItem::McpToolCall {
server,
tool,
status,
error,
..
} => {
let status_text = match status {
McpToolCallStatus::Completed => "completed".style(self.green),
McpToolCallStatus::Failed => "failed".style(self.red),
McpToolCallStatus::InProgress => "in_progress".style(self.dimmed),
};
eprintln!(
"{} {} {}",
"mcp:".style(self.bold),
format!("{server}/{tool}").style(self.cyan),
format!("({status_text})").style(self.dimmed)
);
if let Some(error) = error {
eprintln!("{}", error.message.style(self.red));
}
}
ThreadItem::WebSearch(item) => {
eprintln!("{} {}", "web search:".style(self.bold), item.query);
}
ThreadItem::ContextCompaction { .. } => {
eprintln!("{}", "context compacted".style(self.dimmed));
}
_ => {}
}
}
}
impl EventProcessor for EventProcessorWithHumanOutput {
fn print_config_summary(
&mut self,
config: &Config,
prompt: &str,
session_configured_event: &SessionConfiguredEvent,
) {
const VERSION: &str = env!("CARGO_PKG_VERSION");
eprintln!("OpenAI Codex v{VERSION}\n--------");
for (key, value) in config_summary_entries(config, session_configured_event) {
eprintln!("{} {}", format!("{key}:").style(self.bold), value);
}
eprintln!("--------");
eprintln!("{}\n{}", "user".style(self.cyan), prompt);
}
fn process_server_notification(&mut self, notification: ServerNotification) -> CodexStatus {
match notification {
ServerNotification::ConfigWarning(notification) => {
let details = notification
.details
.map(|details| format!(" ({details})"))
.unwrap_or_default();
eprintln!(
"{} {}{}",
"warning:".style(self.yellow).style(self.bold),
notification.summary,
details
);
CodexStatus::Running
}
ServerNotification::Warning(notification) => self.process_warning(notification.message),
ServerNotification::AuthRecoveryStarted(notification) => {
eprintln!("{}", notification.message);
CodexStatus::Running
}
ServerNotification::AuthRecoveryCompleted(notification) => {
eprintln!("{}", notification.message.style(self.green));
CodexStatus::Running
}
ServerNotification::Error(notification) => {
eprintln!(
"{} {}",
"ERROR:".style(self.red).style(self.bold),
notification.error
);
CodexStatus::Running
}
ServerNotification::DeprecationNotice(notification) => {
eprintln!(
"{} {}",
"deprecated:".style(self.yellow).style(self.bold),
notification.summary
);
if let Some(details) = notification.details {
eprintln!("{}", details.style(self.dimmed));
}
CodexStatus::Running
}
ServerNotification::HookStarted(notification) => {
eprintln!(
"{} {}",
"hook:".style(self.bold),
format!("{:?}", notification.run.event_name).style(self.dimmed)
);
CodexStatus::Running
}
ServerNotification::HookCompleted(notification) => {
eprintln!(
"{} {} {:?}",
"hook:".style(self.bold),
format!("{:?}", notification.run.event_name).style(self.dimmed),
notification.run.status
);
CodexStatus::Running
}
ServerNotification::ItemStarted(notification) => {
self.render_item_started(&notification.item);
CodexStatus::Running
}
ServerNotification::ItemCompleted(notification) => {
self.render_item_completed(notification.item);
CodexStatus::Running
}
ServerNotification::ModelRerouted(notification) => {
eprintln!(
"{} {} -> {}",
"model rerouted:".style(self.yellow).style(self.bold),
notification.from_model,
notification.to_model
);
CodexStatus::Running
}
ServerNotification::ModelVerification(_) => CodexStatus::Running,
ServerNotification::ThreadTokenUsageUpdated(notification) => {
self.last_total_token_usage = Some(notification.token_usage);
CodexStatus::Running
}
ServerNotification::TurnCompleted(notification) => match notification.turn.status {
TurnStatus::Completed => {
let rendered_message = self
.final_message_rendered
.then(|| self.final_message.clone())
.flatten();
if let Some(final_message) =
final_message_from_turn_items(notification.turn.items.as_slice())
{
self.final_message_rendered =
rendered_message.as_deref() == Some(final_message.as_str());
self.final_message = Some(final_message);
}
self.emit_final_message_on_shutdown = true;
CodexStatus::InitiateShutdown
}
TurnStatus::Failed => {
self.final_message = None;
self.final_message_rendered = false;
self.emit_final_message_on_shutdown = false;
if let Some(error) = notification.turn.error {
eprintln!("{} {}", "ERROR:".style(self.red).style(self.bold), error);
}
CodexStatus::InitiateShutdown
}
TurnStatus::Interrupted => {
self.final_message = None;
self.final_message_rendered = false;
self.emit_final_message_on_shutdown = false;
eprintln!("{}", "turn interrupted".style(self.dimmed));
CodexStatus::InitiateShutdown
}
TurnStatus::InProgress => CodexStatus::Running,
},
ServerNotification::TurnDiffUpdated(notification) => {
if !notification.diff.trim().is_empty() {
eprintln!("{}", notification.diff);
}
CodexStatus::Running
}
ServerNotification::TurnPlanUpdated(notification) => {
if let Some(explanation) = notification.explanation {
eprintln!("{}", explanation.style(self.italic));
}
for step in notification.plan {
match step.status {
codex_app_server_protocol::TurnPlanStepStatus::Completed => {
eprintln!(" {} {}", "".style(self.green), step.step);
}
codex_app_server_protocol::TurnPlanStepStatus::InProgress => {
eprintln!(" {} {}", "".style(self.cyan), step.step);
}
codex_app_server_protocol::TurnPlanStepStatus::Pending => {
eprintln!(
" {} {}",
"".style(self.dimmed),
step.step.style(self.dimmed)
);
}
}
}
CodexStatus::Running
}
ServerNotification::TurnStarted(_) => CodexStatus::Running,
_ => CodexStatus::Running,
}
}
fn process_warning(&mut self, message: String) -> CodexStatus {
eprintln!(
"{} {message}",
"warning:".style(self.yellow).style(self.bold)
);
CodexStatus::Running
}
fn print_final_output(&mut self) {
if self.emit_final_message_on_shutdown
&& let Some(path) = self.last_message_path.as_deref()
{
handle_last_message(self.final_message.as_deref(), path);
}
if let Some(usage) = &self.last_total_token_usage {
eprintln!(
"{}\n{}",
"tokens used".style(self.dimmed),
format_with_separators(blended_total(usage))
);
}
#[allow(clippy::print_stdout)]
if should_print_final_message_to_stdout(
self.emit_final_message_on_shutdown
.then_some(self.final_message.as_deref())
.flatten(),
std::io::stdout().is_terminal(),
std::io::stderr().is_terminal(),
) && let Some(message) = self.final_message.as_deref()
{
println!("{message}");
} else if should_print_final_message_to_tty(
self.emit_final_message_on_shutdown
.then_some(self.final_message.as_deref())
.flatten(),
self.final_message_rendered,
std::io::stdout().is_terminal(),
std::io::stderr().is_terminal(),
) && let Some(message) = self.final_message.as_deref()
{
eprintln!(
"{}\n{}",
"codex".style(self.italic).style(self.magenta),
message
);
}
}
}
fn config_summary_entries(
config: &Config,
session_configured_event: &SessionConfiguredEvent,
) -> Vec<(&'static str, String)> {
let permission_profile = config.permissions.effective_permission_profile();
let mut entries = vec![
("workdir", config.cwd.display().to_string()),
("model", session_configured_event.model.clone()),
(
"provider",
session_configured_event.model_provider_id.clone(),
),
(
"approval",
config.permissions.approval_policy.value().to_string(),
),
(
"sandbox",
summarize_permission_profile(
&permission_profile,
&PathUri::from_abs_path(&config.cwd),
&config.effective_workspace_roots(),
),
),
];
if config.model_provider.wire_api == WireApi::Responses {
entries.push((
"reasoning effort",
config
.model_reasoning_effort
.as_ref()
.map(std::string::ToString::to_string)
.unwrap_or_else(|| "none".to_string()),
));
entries.push((
"reasoning summaries",
config
.model_reasoning_summary
.map(|summary| summary.to_string())
.unwrap_or_else(|| "none".to_string()),
));
}
entries.push((
"session id",
session_configured_event.session_id.to_string(),
));
entries
}
fn reasoning_text(
summary: &[String],
content: &[String],
show_raw_agent_reasoning: bool,
) -> Option<String> {
let entries = if show_raw_agent_reasoning && !content.is_empty() {
content
} else {
summary
};
if entries.is_empty() {
None
} else {
Some(entries.join("\n"))
}
}
fn final_message_from_turn_items(items: &[ThreadItem]) -> Option<String> {
items
.iter()
.rev()
.find_map(|item| match item {
ThreadItem::AgentMessage { text, .. } => Some(text.clone()),
_ => None,
})
.or_else(|| {
items.iter().rev().find_map(|item| match item {
ThreadItem::Plan { text, .. } => Some(text.clone()),
_ => None,
})
})
}
fn blended_total(usage: &ThreadTokenUsage) -> i64 {
let cached_input = usage.total.cached_input_tokens.max(0);
let non_cached_input = (usage.total.input_tokens - cached_input).max(0);
(non_cached_input + usage.total.output_tokens.max(0)).max(0)
}
fn should_print_final_message_to_stdout(
final_message: Option<&str>,
stdout_is_terminal: bool,
stderr_is_terminal: bool,
) -> bool {
final_message.is_some() && !(stdout_is_terminal && stderr_is_terminal)
}
fn should_print_final_message_to_tty(
final_message: Option<&str>,
final_message_rendered: bool,
stdout_is_terminal: bool,
stderr_is_terminal: bool,
) -> bool {
final_message.is_some() && !final_message_rendered && stdout_is_terminal && stderr_is_terminal
}
#[cfg(test)]
#[path = "event_processor_with_human_output_tests.rs"]
mod tests;