unified-exec: retain PathUri in command events (#28780)

## Why

App-server must report command events containing foreign-platform paths
without changing existing client or rollout path-string formats.

## What changed

- retain `PathUri` through exec command begin/end events
- convert cwd values to `LegacyAppPathString` at the app-server
compatibility boundary
- drop command actions with foreign paths and log them
- serialize rollout-trace cwd values using their inferred native path
representation
- restore Wine coverage for retained Windows cwd values and successful
completion
This commit is contained in:
Adam Perry @ OpenAI
2026-06-17 22:00:04 -07:00
committed by GitHub
Unverified
parent 285eff6c3e
commit 3931bc2bde
56 changed files with 566 additions and 125 deletions
@@ -831,7 +831,7 @@ fn sample_command_execution_item_with_id(
ThreadItem::CommandExecution {
id: id.to_string(),
command: "echo hi".to_string(),
cwd: test_path_buf("/tmp").abs(),
cwd: test_path_buf("/tmp").abs().into(),
process_id: Some("pid-1".to_string()),
source: CommandExecutionSource::Agent,
status,
@@ -547,7 +547,7 @@
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
},
{
"type": "null"
@@ -3972,7 +3972,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
}
],
"description": "The command's working directory."
+1 -1
View File
@@ -371,7 +371,7 @@
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
},
{
"type": "null"
@@ -2378,7 +2378,7 @@
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
"$ref": "#/definitions/v2/LegacyAppPathString"
},
{
"type": "null"
@@ -17392,7 +17392,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
"$ref": "#/definitions/v2/LegacyAppPathString"
}
],
"description": "The command's working directory."
@@ -15192,7 +15192,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
}
],
"description": "The command's working directory."
@@ -294,6 +294,9 @@
],
"type": "string"
},
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -687,7 +690,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
}
],
"description": "The command's working directory."
@@ -294,6 +294,9 @@
],
"type": "string"
},
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -687,7 +690,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
}
],
"description": "The command's working directory."
@@ -431,6 +431,9 @@
],
"type": "string"
},
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -831,7 +834,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
}
],
"description": "The command's working directory."
@@ -536,6 +536,9 @@
],
"type": "string"
},
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -1323,7 +1326,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
}
],
"description": "The command's working directory."
@@ -457,6 +457,9 @@
],
"type": "string"
},
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -1138,7 +1141,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
}
],
"description": "The command's working directory."
@@ -457,6 +457,9 @@
],
"type": "string"
},
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -1138,7 +1141,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
}
],
"description": "The command's working directory."
@@ -457,6 +457,9 @@
],
"type": "string"
},
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -1138,7 +1141,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
}
],
"description": "The command's working directory."
@@ -536,6 +536,9 @@
],
"type": "string"
},
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -1323,7 +1326,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
}
],
"description": "The command's working directory."
@@ -457,6 +457,9 @@
],
"type": "string"
},
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -1138,7 +1141,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
}
],
"description": "The command's working directory."
@@ -536,6 +536,9 @@
],
"type": "string"
},
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -1323,7 +1326,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
}
],
"description": "The command's working directory."
@@ -457,6 +457,9 @@
],
"type": "string"
},
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -1138,7 +1141,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
}
],
"description": "The command's working directory."
@@ -457,6 +457,9 @@
],
"type": "string"
},
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -1138,7 +1141,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
}
],
"description": "The command's working directory."
@@ -431,6 +431,9 @@
],
"type": "string"
},
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -831,7 +834,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
}
],
"description": "The command's working directory."
@@ -431,6 +431,9 @@
],
"type": "string"
},
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -831,7 +834,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
}
],
"description": "The command's working directory."
@@ -431,6 +431,9 @@
],
"type": "string"
},
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -831,7 +834,7 @@
"cwd": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
}
],
"description": "The command's working directory."
@@ -1,7 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { LegacyAppPathString } from "../LegacyAppPathString";
import type { CommandAction } from "./CommandAction";
import type { ExecPolicyAmendment } from "./ExecPolicyAmendment";
import type { NetworkApprovalContext } from "./NetworkApprovalContext";
@@ -34,7 +34,7 @@ networkApprovalContext?: NetworkApprovalContext | null, /**
command?: string | null, /**
* The command's working directory.
*/
cwd?: AbsolutePathBuf | null, /**
cwd?: LegacyAppPathString | null, /**
* Best-effort parsed command actions for friendly display.
*/
commandActions?: Array<CommandAction> | null, /**
@@ -2,6 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { LegacyAppPathString } from "../LegacyAppPathString";
import type { MessagePhase } from "../MessagePhase";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { JsonValue } from "../serde_json/JsonValue";
@@ -32,7 +33,7 @@ command: string,
/**
* The command's working directory.
*/
cwd: AbsolutePathBuf,
cwd: LegacyAppPathString,
/**
* Identifier for the underlying PTY process (when available).
*/
@@ -23,6 +23,7 @@ use crate::protocol::v2::PatchApplyStatus;
use crate::protocol::v2::PatchChangeKind;
use crate::protocol::v2::ThreadItem;
use codex_protocol::ThreadId;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
use codex_protocol::protocol::ExecApprovalRequestEvent;
use codex_protocol::protocol::ExecCommandBeginEvent;
@@ -34,8 +35,11 @@ use codex_protocol::protocol::PatchApplyBeginEvent;
use codex_protocol::protocol::PatchApplyEndEvent;
use codex_shell_command::parse_command::parse_command;
use codex_shell_command::parse_command::shlex_join;
use codex_utils_path_uri::PathConvention;
use codex_utils_path_uri::PathUri;
use std::collections::HashMap;
use std::path::PathBuf;
use tracing::warn;
pub fn build_file_change_approval_request_item(
payload: &ApplyPatchApprovalRequestEvent,
@@ -69,7 +73,7 @@ pub fn build_command_execution_approval_request_item(
ThreadItem::CommandExecution {
id: payload.call_id.clone(),
command: shlex_join(&payload.command),
cwd: payload.cwd.clone(),
cwd: payload.cwd.clone().into(),
process_id: None,
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::InProgress,
@@ -86,19 +90,15 @@ pub fn build_command_execution_approval_request_item(
}
pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> ThreadItem {
let command_actions = command_actions_for_path_uri(&payload.parsed_cmd, &payload.cwd);
ThreadItem::CommandExecution {
id: payload.call_id.clone(),
command: shlex_join(&payload.command),
cwd: payload.cwd.clone(),
cwd: payload.cwd.clone().into(),
process_id: payload.process_id.clone(),
source: payload.source.into(),
status: CommandExecutionStatus::InProgress,
command_actions: payload
.parsed_cmd
.iter()
.cloned()
.map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd))
.collect(),
command_actions,
aggregated_output: None,
exit_code: None,
duration_ms: None,
@@ -112,26 +112,63 @@ pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> Thread
Some(payload.aggregated_output.clone())
};
let duration_ms = i64::try_from(payload.duration.as_millis()).unwrap_or(i64::MAX);
let command_actions = command_actions_for_path_uri(&payload.parsed_cmd, &payload.cwd);
ThreadItem::CommandExecution {
id: payload.call_id.clone(),
command: shlex_join(&payload.command),
cwd: payload.cwd.clone(),
cwd: payload.cwd.clone().into(),
process_id: payload.process_id.clone(),
source: payload.source.into(),
status: (&payload.status).into(),
command_actions: payload
.parsed_cmd
.iter()
.cloned()
.map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd))
.collect(),
command_actions,
aggregated_output,
exit_code: Some(payload.exit_code),
duration_ms: Some(duration_ms),
}
}
fn command_actions_for_path_uri(parsed_cmd: &[ParsedCommand], cwd: &PathUri) -> Vec<CommandAction> {
// TODO(anp): Carry PathUri into CommandAction so foreign Read actions retain resolved paths.
// Until then, omit those actions rather than project a foreign cwd onto the host.
let native_cwd = if cwd.infer_path_convention() == Some(PathConvention::native()) {
cwd.to_abs_path().ok()
} else {
None
};
parsed_cmd
.iter()
.cloned()
.filter_map(|parsed| match parsed {
ParsedCommand::Read { cmd, name, path } => match native_cwd.as_ref() {
Some(native_cwd) => Some(CommandAction::Read {
command: cmd,
name,
path: native_cwd.join(path),
}),
None => {
warn!(
command = cmd,
%cwd,
"omitting read command action whose path cannot be resolved against a foreign cwd"
);
None
}
},
ParsedCommand::ListFiles { cmd, path } => {
Some(CommandAction::ListFiles { command: cmd, path })
}
ParsedCommand::Search { cmd, query, path } => Some(CommandAction::Search {
command: cmd,
query,
path,
}),
ParsedCommand::Unknown { cmd } => Some(CommandAction::Unknown { command: cmd }),
})
.collect()
}
/// Build a guardian-derived [`ThreadItem`].
///
/// Currently this only synthesizes [`ThreadItem::CommandExecution`] for
@@ -150,7 +187,7 @@ pub fn build_item_from_guardian_event(
Some(ThreadItem::CommandExecution {
id: id.clone(),
command,
cwd: cwd.clone(),
cwd: cwd.clone().into(),
process_id: None,
source: CommandExecutionSource::Agent,
status,
@@ -186,7 +223,7 @@ pub fn build_item_from_guardian_event(
Some(ThreadItem::CommandExecution {
id: id.clone(),
command,
cwd: cwd.clone(),
cwd: cwd.clone().into(),
process_id: None,
source: CommandExecutionSource::Agent,
status,
@@ -315,3 +352,7 @@ fn format_file_change_diff(change: &FileChange) -> String {
}
}
}
#[cfg(test)]
#[path = "item_builders_tests.rs"]
mod tests;
@@ -0,0 +1,41 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn foreign_read_is_omitted_without_dropping_other_command_actions() {
#[cfg(windows)]
let cwd = PathUri::parse("file:///usr/local/src").expect("valid foreign POSIX cwd");
#[cfg(not(windows))]
let cwd = PathUri::parse("file:///C:/src").expect("valid foreign Windows cwd");
let parsed_cmd = vec![
ParsedCommand::Read {
cmd: "cat file.txt".to_string(),
name: "file.txt".to_string(),
path: PathBuf::from("file.txt"),
},
ParsedCommand::ListFiles {
cmd: "ls".to_string(),
path: Some("subdir".to_string()),
},
ParsedCommand::Search {
cmd: "rg needle".to_string(),
query: Some("needle".to_string()),
path: Some("src".to_string()),
},
];
assert_eq!(
command_actions_for_path_uri(&parsed_cmd, &cwd),
vec![
CommandAction::ListFiles {
command: "ls".to_string(),
path: Some("subdir".to_string()),
},
CommandAction::Search {
command: "rg needle".to_string(),
query: Some("needle".to_string()),
path: Some("src".to_string()),
},
]
);
}
@@ -2376,7 +2376,7 @@ mod tests {
turn_id: "turn-1".into(),
completed_at_ms: 0,
command: vec!["echo".into(), "hello world".into()],
cwd: test_path_buf("/tmp").abs(),
cwd: test_path_buf("/tmp").abs().into(),
parsed_cmd: vec![ParsedCommand::Unknown {
cmd: "echo hello world".into(),
}],
@@ -2427,7 +2427,7 @@ mod tests {
ThreadItem::CommandExecution {
id: "exec-1".into(),
command: "echo 'hello world'".into(),
cwd: test_path_buf("/tmp").abs(),
cwd: test_path_buf("/tmp").abs().into(),
process_id: Some("pid-1".into()),
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::Completed,
@@ -2616,7 +2616,7 @@ mod tests {
turn_id: "turn-1".into(),
completed_at_ms: 0,
command: vec!["ls".into()],
cwd: test_path_buf("/tmp").abs(),
cwd: test_path_buf("/tmp").abs().into(),
parsed_cmd: vec![ParsedCommand::Unknown { cmd: "ls".into() }],
source: ExecCommandSource::Agent,
interaction_input: None,
@@ -2658,7 +2658,7 @@ mod tests {
ThreadItem::CommandExecution {
id: "exec-declined".into(),
command: "ls".into(),
cwd: test_path_buf("/tmp").abs(),
cwd: test_path_buf("/tmp").abs().into(),
process_id: Some("pid-2".into()),
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::Declined,
@@ -2756,7 +2756,7 @@ mod tests {
ThreadItem::CommandExecution {
id: "guardian-exec".into(),
command: "rm -rf /tmp/guardian".into(),
cwd: test_path_buf("/tmp").abs(),
cwd: test_path_buf("/tmp").abs().into(),
process_id: None,
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::Declined,
@@ -2822,7 +2822,7 @@ mod tests {
ThreadItem::CommandExecution {
id: "guardian-execve".into(),
command: "/bin/rm -f /tmp/file.sqlite".into(),
cwd: test_path_buf("/tmp").abs(),
cwd: test_path_buf("/tmp").abs().into(),
process_id: None,
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::InProgress,
@@ -2882,7 +2882,7 @@ mod tests {
turn_id: "turn-a".into(),
completed_at_ms: 0,
command: vec!["echo".into(), "done".into()],
cwd: test_path_buf("/tmp").abs(),
cwd: test_path_buf("/tmp").abs().into(),
parsed_cmd: vec![ParsedCommand::Unknown {
cmd: "echo done".into(),
}],
@@ -2920,7 +2920,7 @@ mod tests {
ThreadItem::CommandExecution {
id: "exec-late".into(),
command: "echo done".into(),
cwd: test_path_buf("/tmp").abs(),
cwd: test_path_buf("/tmp").abs().into(),
process_id: Some("pid-42".into()),
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::Completed,
@@ -2980,7 +2980,7 @@ mod tests {
turn_id: "turn-missing".into(),
completed_at_ms: 0,
command: vec!["echo".into(), "done".into()],
cwd: test_path_buf("/tmp").abs(),
cwd: test_path_buf("/tmp").abs().into(),
parsed_cmd: vec![ParsedCommand::Unknown {
cmd: "echo done".into(),
}],
@@ -31,6 +31,7 @@ use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus;
use codex_protocol::protocol::ReviewDecision as CoreReviewDecision;
use codex_protocol::protocol::SubAgentActivityKind as CoreSubAgentActivityKind;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::LegacyAppPathString;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
@@ -256,7 +257,7 @@ pub enum ThreadItem {
/// The command to be executed.
command: String,
/// The command's working directory.
cwd: AbsolutePathBuf,
cwd: LegacyAppPathString,
/// Identifier for the underlying PTY process (when available).
process_id: Option<String>,
#[serde(default)]
@@ -1339,7 +1340,7 @@ pub struct CommandExecutionRequestApprovalParams {
/// The command's working directory.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub cwd: Option<AbsolutePathBuf>,
pub cwd: Option<LegacyAppPathString>,
/// Best-effort parsed command actions for friendly display.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
+1 -1
View File
@@ -2092,7 +2092,7 @@ impl CodexClient {
println!("< command: {command}");
}
if let Some(cwd) = cwd.as_ref() {
println!("< cwd: {}", cwd.display());
println!("< cwd: {cwd}");
}
if let Some(command_actions) = command_actions.as_ref()
&& !command_actions.is_empty()
@@ -113,6 +113,7 @@ use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestU
use codex_sandboxing::policy_transforms::intersect_permission_profiles;
use codex_shell_command::parse_command::shlex_join;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::LegacyAppPathString;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::SystemTime;
@@ -129,7 +130,7 @@ enum CommandExecutionApprovalPresentation {
#[derive(Debug, PartialEq)]
struct CommandExecutionCompletionItem {
command: String,
cwd: AbsolutePathBuf,
cwd: LegacyAppPathString,
command_actions: Vec<V2ParsedCommand>,
}
@@ -575,7 +576,7 @@ pub(crate) async fn apply_bespoke_event_handling(
let command_string = shlex_join(&command);
let completion_item = CommandExecutionCompletionItem {
command: command_string,
cwd: cwd.clone(),
cwd: cwd.clone().into(),
command_actions: command_actions.clone(),
};
CommandExecutionApprovalPresentation::Command(completion_item)
@@ -1354,7 +1355,7 @@ async fn start_command_execution_item(
turn_id: String,
item_id: String,
command: String,
cwd: AbsolutePathBuf,
cwd: LegacyAppPathString,
command_actions: Vec<V2ParsedCommand>,
source: CommandExecutionSource,
outgoing: &ThreadScopedOutgoingMessageSender,
@@ -1398,7 +1399,7 @@ async fn complete_command_execution_item(
turn_id: String,
item_id: String,
command: String,
cwd: AbsolutePathBuf,
cwd: LegacyAppPathString,
process_id: Option<String>,
source: CommandExecutionSource,
command_actions: Vec<V2ParsedCommand>,
@@ -2323,7 +2324,7 @@ mod tests {
fn command_execution_completion_item(command: &str) -> CommandExecutionCompletionItem {
CommandExecutionCompletionItem {
command: command.to_string(),
cwd: test_path_buf("/tmp").abs(),
cwd: test_path_buf("/tmp").abs().into(),
command_actions: vec![V2ParsedCommand::Unknown {
command: command.to_string(),
}],
@@ -553,8 +553,7 @@ fn resolve_turn_environment_selections(
let environment_id = environment.environment_id;
let cwd = environment
.cwd
.infer_absolute_path_convention()
.and_then(|convention| environment.cwd.to_path_uri(convention).ok())
.to_inferred_path_uri()
.ok_or_else(|| {
invalid_request(format!(
"invalid cwd for environment `{environment_id}`: path `{}` does not use absolute POSIX or Windows path syntax",
+2 -2
View File
@@ -254,7 +254,7 @@ async fn command_execution_request_approval_strips_additional_permissions_withou
reason: Some("Need extra read access".to_string()),
network_approval_context: None,
command: Some("cat file".to_string()),
cwd: Some(absolute_path("/tmp")),
cwd: Some(absolute_path("/tmp").into()),
command_actions: None,
additional_permissions: Some(
codex_app_server_protocol::AdditionalPermissionProfile {
@@ -320,7 +320,7 @@ async fn command_execution_request_approval_keeps_additional_permissions_with_ca
reason: Some("Need extra read access".to_string()),
network_approval_context: None,
command: Some("cat file".to_string()),
cwd: Some(absolute_path("/tmp")),
cwd: Some(absolute_path("/tmp").into()),
command_actions: None,
additional_permissions: Some(
codex_app_server_protocol::AdditionalPermissionProfile {
@@ -2440,7 +2440,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
else {
unreachable!("loop ensures we break on command execution items");
};
assert_eq!(cwd.as_path(), second_cwd.as_path());
assert_eq!(cwd.as_str(), second_cwd.to_string_lossy().as_ref());
let expected_command = format_with_current_shell_display("echo second turn");
assert_eq!(command, expected_command);
assert_eq!(status, CommandExecutionStatus::InProgress);
@@ -167,7 +167,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> {
assert!(command.contains("/bin/sh -c"));
assert!(command.contains("sleep 0.01"));
assert!(command.contains(&release_marker.display().to_string()));
assert_eq!(cwd.as_path(), workspace.as_path());
assert_eq!(cwd.as_str(), workspace.to_string_lossy().as_ref());
mcp.interrupt_turn_and_wait_for_aborted(thread.id, turn.id, DEFAULT_READ_TIMEOUT)
.await?;
+4 -4
View File
@@ -188,7 +188,7 @@ pub(crate) async fn execute_user_shell_command(
turn_id: turn_context.sub_id.clone(),
started_at_ms: now_unix_timestamp_ms(),
command: display_command.clone(),
cwd: cwd.clone(),
cwd: cwd.clone().into(),
parsed_cmd: parsed_cmd.clone(),
source: ExecCommandSource::UserShell,
interaction_input: None,
@@ -262,7 +262,7 @@ pub(crate) async fn execute_user_shell_command(
turn_id: turn_context.sub_id.clone(),
completed_at_ms: now_unix_timestamp_ms(),
command: display_command.clone(),
cwd: cwd.clone(),
cwd: cwd.clone().into(),
parsed_cmd: parsed_cmd.clone(),
source: ExecCommandSource::UserShell,
interaction_input: None,
@@ -287,7 +287,7 @@ pub(crate) async fn execute_user_shell_command(
turn_id: turn_context.sub_id.clone(),
completed_at_ms: now_unix_timestamp_ms(),
command: display_command.clone(),
cwd: cwd.clone(),
cwd: cwd.clone().into(),
parsed_cmd: parsed_cmd.clone(),
source: ExecCommandSource::UserShell,
interaction_input: None,
@@ -332,7 +332,7 @@ pub(crate) async fn execute_user_shell_command(
turn_id: turn_context.sub_id.clone(),
completed_at_ms: now_unix_timestamp_ms(),
command: display_command,
cwd,
cwd: cwd.into(),
parsed_cmd,
source: ExecCommandSource::UserShell,
interaction_input: None,
+6 -10
View File
@@ -96,7 +96,7 @@ fn tracker_update_for_known_delta<'a>(
pub(crate) async fn emit_exec_command_begin(
ctx: ToolEventCtx<'_>,
command: &[String],
cwd: &AbsolutePathBuf,
cwd: &PathUri,
parsed_cmd: &[ParsedCommand],
source: ExecCommandSource,
interaction_input: Option<String>,
@@ -123,7 +123,7 @@ pub(crate) async fn emit_exec_command_begin(
pub(crate) enum ToolEmitter {
Shell {
command: Vec<String>,
cwd: AbsolutePathBuf,
cwd: PathUri,
source: ExecCommandSource,
parsed_cmd: Vec<ParsedCommand>,
},
@@ -146,7 +146,7 @@ impl ToolEmitter {
let parsed_cmd = parse_command(&command);
Self::Shell {
command,
cwd,
cwd: PathUri::from_abs_path(&cwd),
source,
parsed_cmd,
}
@@ -321,15 +321,11 @@ impl ToolEmitter {
},
stage,
) => {
// TODO(anp): Migrate exec command protocol events to PathUri.
let Ok(cwd) = cwd.to_abs_path() else {
return;
};
emit_exec_stage(
ctx,
ExecCommandInput::new(
command,
&cwd,
cwd,
parsed_cmd,
*source,
/*interaction_input*/ None,
@@ -437,7 +433,7 @@ impl ToolEmitter {
struct ExecCommandInput<'a> {
command: &'a [String],
cwd: &'a AbsolutePathBuf,
cwd: &'a PathUri,
parsed_cmd: &'a [ParsedCommand],
source: ExecCommandSource,
interaction_input: Option<&'a str>,
@@ -447,7 +443,7 @@ struct ExecCommandInput<'a> {
impl<'a> ExecCommandInput<'a> {
fn new(
command: &'a [String],
cwd: &'a AbsolutePathBuf,
cwd: &'a PathUri,
parsed_cmd: &'a [ParsedCommand],
source: ExecCommandSource,
interaction_input: Option<&'a str>,
@@ -20,6 +20,7 @@ use codex_features::Feature;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecCommandStatus;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::protocol::TurnEnvironmentSelections;
@@ -127,10 +128,6 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> {
})
.await?;
// TODO(anp): Re-enable these event assertions once exec command events retain a
// PathUri cwd. Today the host-native event conversion drops begin/end events for a
// foreign cwd.
/*
let mut begin = None;
let mut end = None;
let mut turn_complete = false;
@@ -161,13 +158,9 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> {
assert_eq!(&begin.command[1..], ["-NoProfile", "-Command", COMMAND]);
let end = end.context("exec_command should emit an end event")?;
let expected_cwd = PathUri::parse("file:///C:/windows")?;
assert_eq!((&begin.cwd, &end.cwd), (&expected_cwd, &expected_cwd));
assert_eq!((end.exit_code, end.status), (0, ExecCommandStatus::Completed));
*/
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let request = response_mock
.last_request()
+5 -5
View File
@@ -437,7 +437,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> {
assert_command(&begin_event.command, "-lc", "/bin/echo hello unified exec");
assert_eq!(begin_event.cwd.as_path(), cwd.as_path());
assert_eq!(begin_event.cwd, PathUri::from_path(&cwd)?);
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
@@ -507,8 +507,8 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> {
.await;
assert_eq!(
begin_event.cwd.as_path(),
workdir.as_path(),
begin_event.cwd,
PathUri::from_path(&workdir)?,
"exec_command cwd should resolve relative workdir against turn cwd",
);
@@ -569,8 +569,8 @@ async fn unified_exec_respects_workdir_override() -> Result<()> {
.await;
assert_eq!(
begin_event.cwd.as_path(),
workdir.as_path(),
begin_event.cwd,
PathUri::from_path(&workdir)?,
"exec_command cwd should reflect the requested workdir override"
);
@@ -68,10 +68,9 @@ impl EventProcessorWithHumanOutput {
match item {
ThreadItem::CommandExecution { command, cwd, .. } => {
eprintln!(
"{}\n{} in {}",
"{}\n{} in {cwd}",
"exec".style(self.italic).style(self.magenta),
command.style(self.bold),
cwd.display()
);
}
ThreadItem::McpToolCall { server, tool, .. } => {
@@ -170,7 +170,7 @@ fn command_execution_started_and_completed_translate_to_thread_events() {
let command_item = ThreadItem::CommandExecution {
id: "cmd-1".to_string(),
command: "ls".to_string(),
cwd: test_path_buf("/tmp/project").abs(),
cwd: test_path_buf("/tmp/project").abs().into(),
process_id: Some("123".to_string()),
source: CommandExecutionSource::UserShell,
status: ApiCommandExecutionStatus::InProgress,
@@ -210,7 +210,7 @@ fn command_execution_started_and_completed_translate_to_thread_events() {
item: ThreadItem::CommandExecution {
id: "cmd-1".to_string(),
command: "ls".to_string(),
cwd: test_path_buf("/tmp/project").abs(),
cwd: test_path_buf("/tmp/project").abs().into(),
process_id: Some("123".to_string()),
source: CommandExecutionSource::UserShell,
status: ApiCommandExecutionStatus::Completed,
@@ -1321,7 +1321,7 @@ fn turn_completion_reconciles_started_items_from_turn_items() {
item: ThreadItem::CommandExecution {
id: "cmd-1".to_string(),
command: "ls".to_string(),
cwd: test_path_buf("/tmp/project").abs(),
cwd: test_path_buf("/tmp/project").abs().into(),
process_id: Some("123".to_string()),
source: CommandExecutionSource::UserShell,
status: ApiCommandExecutionStatus::InProgress,
@@ -1361,7 +1361,7 @@ fn turn_completion_reconciles_started_items_from_turn_items() {
items: vec![ThreadItem::CommandExecution {
id: "cmd-1".to_string(),
command: "ls".to_string(),
cwd: test_path_buf("/tmp/project").abs(),
cwd: test_path_buf("/tmp/project").abs().into(),
process_id: Some("123".to_string()),
source: CommandExecutionSource::UserShell,
status: ApiCommandExecutionStatus::Completed,
+2 -2
View File
@@ -3246,7 +3246,7 @@ pub struct ExecCommandBeginEvent {
/// The command to be executed.
pub command: Vec<String>,
/// The command's working directory if not the default cwd for the agent.
pub cwd: AbsolutePathBuf,
pub cwd: PathUri,
pub parsed_cmd: Vec<ParsedCommand>,
/// Where the command originated. Defaults to Agent for backward compatibility.
#[serde(default)]
@@ -3272,7 +3272,7 @@ pub struct ExecCommandEndEvent {
/// The command that was executed.
pub command: Vec<String>,
/// The command's working directory if not the default cwd for the agent.
pub cwd: AbsolutePathBuf,
pub cwd: PathUri,
pub parsed_cmd: Vec<ParsedCommand>,
/// Where the command originated. Defaults to Agent for backward compatibility.
#[serde(default)]
+120 -2
View File
@@ -24,6 +24,7 @@ use codex_protocol::protocol::PatchApplyStatus;
use codex_protocol::protocol::SubAgentActivityEvent;
use codex_protocol::protocol::TurnAbortReason;
use serde::Serialize;
use std::time::Duration;
use crate::AgentThreadId;
use crate::CodexTurnId;
@@ -120,8 +121,12 @@ impl Serialize for ToolRuntimePayload<'_> {
S: serde::Serializer,
{
match self {
ToolRuntimePayload::ExecCommandBegin(event) => event.serialize(serializer),
ToolRuntimePayload::ExecCommandEnd(event) => event.serialize(serializer),
ToolRuntimePayload::ExecCommandBegin(event) => {
ExecCommandBeginTracePayload::from(*event).serialize(serializer)
}
ToolRuntimePayload::ExecCommandEnd(event) => {
ExecCommandEndTracePayload::from(*event).serialize(serializer)
}
ToolRuntimePayload::PatchApplyBegin(event) => event.serialize(serializer),
ToolRuntimePayload::PatchApplyEnd(event) => event.serialize(serializer),
ToolRuntimePayload::McpToolCallBegin(event) => event.serialize(serializer),
@@ -139,6 +144,119 @@ impl Serialize for ToolRuntimePayload<'_> {
}
}
/// Rollout-trace representation of an exec begin event.
///
/// Rollout traces share the rollout compatibility requirement that paths remain path-flavored
/// strings on disk, even though live events carry `PathUri` internally.
#[derive(Serialize)]
struct ExecCommandBeginTracePayload<'a> {
call_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
process_id: Option<&'a str>,
turn_id: &'a str,
started_at_ms: i64,
command: &'a [String],
cwd: String,
parsed_cmd: &'a [codex_protocol::parse_command::ParsedCommand],
source: ExecCommandSource,
#[serde(skip_serializing_if = "Option::is_none")]
interaction_input: Option<&'a str>,
}
impl<'a> From<&'a ExecCommandBeginEvent> for ExecCommandBeginTracePayload<'a> {
fn from(event: &'a ExecCommandBeginEvent) -> Self {
let ExecCommandBeginEvent {
call_id,
process_id,
turn_id,
started_at_ms,
command,
cwd,
parsed_cmd,
source,
interaction_input,
} = event;
Self {
call_id,
process_id: process_id.as_deref(),
turn_id,
started_at_ms: *started_at_ms,
command,
cwd: cwd.inferred_native_path_string(),
parsed_cmd,
source: *source,
interaction_input: interaction_input.as_deref(),
}
}
}
/// Rollout-trace representation of an exec end event.
///
/// Like [`ExecCommandBeginTracePayload`], this renders `cwd` as an inferred native path to preserve
/// the on-disk format rather than serializing the internal `PathUri`.
#[derive(Serialize)]
struct ExecCommandEndTracePayload<'a> {
call_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
process_id: Option<&'a str>,
turn_id: &'a str,
completed_at_ms: i64,
command: &'a [String],
cwd: String,
parsed_cmd: &'a [codex_protocol::parse_command::ParsedCommand],
source: ExecCommandSource,
#[serde(skip_serializing_if = "Option::is_none")]
interaction_input: Option<&'a str>,
stdout: &'a str,
stderr: &'a str,
aggregated_output: &'a str,
exit_code: i32,
duration: Duration,
formatted_output: &'a str,
status: &'a ExecCommandStatus,
}
impl<'a> From<&'a ExecCommandEndEvent> for ExecCommandEndTracePayload<'a> {
fn from(event: &'a ExecCommandEndEvent) -> Self {
let ExecCommandEndEvent {
call_id,
process_id,
turn_id,
completed_at_ms,
command,
cwd,
parsed_cmd,
source,
interaction_input,
stdout,
stderr,
aggregated_output,
exit_code,
duration,
formatted_output,
status,
} = event;
Self {
call_id,
process_id: process_id.as_deref(),
turn_id,
completed_at_ms: *completed_at_ms,
command,
cwd: cwd.inferred_native_path_string(),
parsed_cmd,
source: *source,
interaction_input: interaction_input.as_deref(),
stdout,
stderr,
aggregated_output,
exit_code: *exit_code,
duration: *duration,
formatted_output,
status,
}
}
}
pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option<ToolRuntimeTraceEvent<'_>> {
match event {
EventMsg::ExecCommandBegin(event) if event.source != ExecCommandSource::UserShell => {
@@ -1,10 +1,15 @@
use codex_protocol::AgentPath;
use codex_protocol::ThreadId;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecCommandBeginEvent;
use codex_protocol::protocol::ExecCommandEndEvent;
use codex_protocol::protocol::ExecCommandSource;
use codex_protocol::protocol::ExecCommandStatus;
use codex_protocol::protocol::SubAgentActivityEvent;
use codex_protocol::protocol::SubAgentActivityKind;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::time::Duration;
use super::ToolRuntimeTraceEvent;
use super::tool_runtime_trace_event;
@@ -44,3 +49,81 @@ fn sub_agent_activity_is_a_terminal_tool_runtime_event() -> anyhow::Result<()> {
);
Ok(())
}
#[test]
fn exec_command_trace_payloads_use_inferred_native_cwd() -> anyhow::Result<()> {
// Convention inference depends on the URI spelling, not the test host, so exercise both
// Windows and POSIX paths on every platform.
let begin = EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
call_id: "call-begin".to_string(),
process_id: Some("process-1".to_string()),
turn_id: "turn-1".to_string(),
started_at_ms: 1234,
command: vec!["pwd".to_string()],
cwd: "file:///C:/windows".parse()?,
parsed_cmd: Vec::new(),
source: ExecCommandSource::Agent,
interaction_input: None,
});
let end = EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id: "call-end".to_string(),
process_id: None,
turn_id: "turn-1".to_string(),
completed_at_ms: 2345,
command: vec!["pwd".to_string()],
cwd: "file:///workspace/project".parse()?,
parsed_cmd: Vec::new(),
source: ExecCommandSource::UnifiedExecInteraction,
interaction_input: Some("input".to_string()),
stdout: "output".to_string(),
stderr: String::new(),
aggregated_output: "output".to_string(),
exit_code: 0,
duration: Duration::from_millis(250),
formatted_output: "output".to_string(),
status: ExecCommandStatus::Completed,
});
let Some(ToolRuntimeTraceEvent::Started { payload, .. }) = tool_runtime_trace_event(&begin)
else {
panic!("expected started tool runtime event");
};
assert_eq!(
serde_json::to_value(payload)?,
json!({
"call_id": "call-begin",
"process_id": "process-1",
"turn_id": "turn-1",
"started_at_ms": 1234,
"command": ["pwd"],
"cwd": r"C:\windows",
"parsed_cmd": [],
"source": "agent"
})
);
let Some(ToolRuntimeTraceEvent::Ended { payload, .. }) = tool_runtime_trace_event(&end) else {
panic!("expected ended tool runtime event");
};
assert_eq!(
serde_json::to_value(payload)?,
json!({
"call_id": "call-end",
"turn_id": "turn-1",
"completed_at_ms": 2345,
"command": ["pwd"],
"cwd": "/workspace/project",
"parsed_cmd": [],
"source": "unified_exec_interaction",
"interaction_input": "input",
"stdout": "output",
"stderr": "",
"aggregated_output": "output",
"exit_code": 0,
"duration": {"secs": 0, "nanos": 250000000},
"formatted_output": "output",
"status": "completed"
})
);
Ok(())
}
@@ -12,7 +12,9 @@ fn agent_status_uses_bounded_buffered_activity() {
item: ThreadItem::CommandExecution {
id: "command-1".to_string(),
command: "cargo test -p codex-tui".to_string(),
cwd: AbsolutePathBuf::try_from("/workspace").expect("absolute path"),
cwd: AbsolutePathBuf::try_from("/workspace")
.expect("absolute path")
.into(),
process_id: None,
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::Completed,
@@ -619,7 +619,7 @@ mod tests {
reason: None,
network_approval_context: None,
command: Some("echo hi".to_string()),
cwd: Some(test_path_buf("/tmp").abs()),
cwd: Some(test_path_buf("/tmp").abs().into()),
command_actions: None,
additional_permissions: None,
proposed_execpolicy_amendment: None,
+1 -1
View File
@@ -4697,7 +4697,7 @@ fn exec_approval_request(
reason: Some("needs approval".to_string()),
network_approval_context: None,
command: Some("echo hello".to_string()),
cwd: Some(test_path_buf("/tmp/project").abs()),
cwd: Some(test_path_buf("/tmp/project").abs().into()),
command_actions: None,
additional_permissions: None,
proposed_execpolicy_amendment: None,
+1 -1
View File
@@ -492,7 +492,7 @@ mod tests {
reason: Some("needs approval".to_string()),
network_approval_context: None,
command: Some("echo hello".to_string()),
cwd: Some(test_path_buf("/tmp/project").abs()),
cwd: Some(test_path_buf("/tmp/project").abs().into()),
command_actions: None,
additional_permissions: None,
proposed_execpolicy_amendment: None,
+7 -1
View File
@@ -840,6 +840,12 @@ fn exec_approval_request_from_params(
params: CommandExecutionRequestApprovalParams,
fallback_cwd: &AbsolutePathBuf,
) -> ExecApprovalRequestEvent {
// TODO(anp): Keep this as PathUri once `tui::approval_events::ExecApprovalRequestEvent` and
// approval rendering support foreign paths.
let cwd = params
.cwd
.and_then(|cwd| cwd.to_inferred_abs_path())
.unwrap_or_else(|| fallback_cwd.clone());
ExecApprovalRequestEvent {
call_id: params.item_id,
command: params
@@ -847,7 +853,7 @@ fn exec_approval_request_from_params(
.as_deref()
.map(split_command_string)
.unwrap_or_default(),
cwd: params.cwd.unwrap_or_else(|| fallback_cwd.clone()),
cwd,
reason: params.reason,
network_approval_context: params.network_approval_context,
additional_permissions: params.additional_permissions,
+1 -1
View File
@@ -177,7 +177,7 @@ mod tests {
ThreadItem::CommandExecution {
id: call_id.to_string(),
command: "true".to_string(),
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
cwd: AbsolutePathBuf::current_dir().expect("current dir").into(),
process_id: None,
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::InProgress,
@@ -521,7 +521,7 @@ async fn live_app_server_command_execution_strips_shell_wrapper() {
item: AppServerThreadItem::CommandExecution {
id: "cmd-1".to_string(),
command: command.clone(),
cwd: test_path_buf("/tmp").abs(),
cwd: test_path_buf("/tmp").abs().into(),
process_id: None,
source: AppServerCommandExecutionSource::UserShell,
status: AppServerCommandExecutionStatus::InProgress,
@@ -543,7 +543,7 @@ async fn live_app_server_command_execution_strips_shell_wrapper() {
item: AppServerThreadItem::CommandExecution {
id: "cmd-1".to_string(),
command,
cwd: test_path_buf("/tmp").abs(),
cwd: test_path_buf("/tmp").abs().into(),
process_id: None,
source: AppServerCommandExecutionSource::UserShell,
status: AppServerCommandExecutionStatus::Completed,
@@ -65,7 +65,7 @@ fn app_server_exec_approval_request_splits_shell_wrapped_command() {
shlex::try_join(["/bin/zsh", "-lc", script])
.expect("round-trippable shell wrapper"),
),
cwd: Some(test_path_buf("/tmp").abs()),
cwd: Some(test_path_buf("/tmp").abs().into()),
command_actions: None,
additional_permissions: None,
proposed_execpolicy_amendment: None,
@@ -107,7 +107,7 @@ fn app_server_exec_approval_request_preserves_permissions_context() {
protocol: codex_app_server_protocol::NetworkApprovalProtocol::Socks5Tcp,
}),
command: Some("ls".to_string()),
cwd: Some(test_path_buf("/tmp").abs()),
cwd: Some(test_path_buf("/tmp").abs().into()),
command_actions: None,
additional_permissions: Some(AppServerAdditionalPermissionProfile {
network: Some(AppServerAdditionalNetworkPermissions {
@@ -64,7 +64,7 @@ fn app_server_exec_approval_request_splits_shell_wrapped_command() {
shlex::try_join(["/bin/zsh", "-lc", script])
.expect("round-trippable shell wrapper"),
),
cwd: Some(test_path_buf("/tmp").abs()),
cwd: Some(test_path_buf("/tmp").abs().into()),
command_actions: None,
additional_permissions: None,
proposed_execpolicy_amendment: None,
@@ -363,7 +363,7 @@ async fn exec_end_without_begin_uses_event_command() {
AppServerThreadItem::CommandExecution {
id: "call-orphan".to_string(),
command: codex_shell_command::parse_command::shlex_join(&command),
cwd,
cwd: cwd.into(),
process_id: None,
source: ExecCommandSource::Agent,
status: AppServerCommandExecutionStatus::Completed,
+2 -2
View File
@@ -821,7 +821,7 @@ pub(super) fn begin_exec_with_source(
let item = AppServerThreadItem::CommandExecution {
id: call_id.to_string(),
command: codex_shell_command::parse_command::shlex_join(&command),
cwd: chat.config.cwd.clone(),
cwd: chat.config.cwd.clone().into(),
process_id: None,
source,
status: AppServerCommandExecutionStatus::InProgress,
@@ -844,7 +844,7 @@ pub(super) fn begin_unified_exec_startup(
let item = AppServerThreadItem::CommandExecution {
id: call_id.to_string(),
command: codex_shell_command::parse_command::shlex_join(&command),
cwd: chat.config.cwd.clone(),
cwd: chat.config.cwd.clone().into(),
process_id: Some(process_id.to_string()),
source: ExecCommandSource::UnifiedExecStartup,
status: AppServerCommandExecutionStatus::InProgress,
@@ -3614,7 +3614,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() {
AppServerThreadItem::CommandExecution {
id: "c1".into(),
command: codex_shell_command::parse_command::shlex_join(&command),
cwd: cwd.clone(),
cwd: cwd.clone().into(),
process_id: None,
source: ExecCommandSource::Agent,
status: AppServerCommandExecutionStatus::InProgress,
@@ -3629,7 +3629,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() {
AppServerThreadItem::CommandExecution {
id: "c1".into(),
command: codex_shell_command::parse_command::shlex_join(&command),
cwd,
cwd: cwd.into(),
process_id: None,
source: ExecCommandSource::Agent,
status: AppServerCommandExecutionStatus::Completed,
+54 -3
View File
@@ -70,11 +70,21 @@ impl LegacyAppPathString {
PathUri::from_absolute_native_path(&self.0, convention).ok_or_else(|| {
LegacyAppPathStringError::InvalidNativePath {
path: self.0.clone(),
convention,
convention: Some(convention),
}
})
}
/// Parses this API string as an absolute path using the convention inferred from its spelling.
pub fn to_inferred_path_uri(&self) -> Option<PathUri> {
PathUri::try_from(self.clone()).ok()
}
/// Parses this API string as a host-native absolute path.
pub fn to_inferred_abs_path(&self) -> Option<AbsolutePathBuf> {
AbsolutePathBuf::try_from(self.clone()).ok()
}
/// Infers the path convention of an absolute API path from its spelling.
///
/// Relative paths and ambiguous spellings return `None`. In particular,
@@ -111,6 +121,44 @@ impl From<AbsolutePathBuf> for LegacyAppPathString {
}
}
impl From<PathUri> for LegacyAppPathString {
fn from(path: PathUri) -> Self {
Self(path.inferred_native_path_string())
}
}
impl TryFrom<LegacyAppPathString> for PathUri {
type Error = LegacyAppPathStringError;
fn try_from(path: LegacyAppPathString) -> Result<Self, Self::Error> {
let Some(convention) = path.infer_absolute_path_convention() else {
return Err(LegacyAppPathStringError::InvalidNativePath {
path: path.0,
convention: None,
});
};
PathUri::from_absolute_native_path(path.as_str(), convention).ok_or({
LegacyAppPathStringError::InvalidNativePath {
path: path.0,
convention: Some(convention),
}
})
}
}
impl TryFrom<LegacyAppPathString> for AbsolutePathBuf {
type Error = LegacyAppPathStringError;
fn try_from(path: LegacyAppPathString) -> Result<Self, Self::Error> {
AbsolutePathBuf::from_absolute_path_checked(path.as_str()).map_err(|_| {
LegacyAppPathStringError::InvalidNativePath {
path: path.0,
convention: None,
}
})
}
}
fn render_opaque_fallback(
path: &PathUri,
path_bytes: &[u8],
@@ -279,10 +327,13 @@ pub enum LegacyAppPathStringError {
path: String,
convention: PathConvention,
},
#[error("path `{path}` is not absolute using {convention} path syntax")]
#[error(
"path `{path}` is not absolute{convention}",
convention = .convention.map(|convention| format!(" using {convention} path syntax")).unwrap_or_default()
)]
InvalidNativePath {
path: String,
convention: PathConvention,
convention: Option<PathConvention>,
},
}
@@ -369,7 +369,21 @@ fn relative_api_path_is_invalid_when_converted_to_a_path_uri() {
path.to_path_uri(PathConvention::Posix),
Err(LegacyAppPathStringError::InvalidNativePath {
path: raw_path.to_string(),
convention: PathConvention::Posix,
convention: Some(PathConvention::Posix),
})
);
assert_eq!(
PathUri::try_from(path.clone()),
Err(LegacyAppPathStringError::InvalidNativePath {
path: raw_path.to_string(),
convention: None,
})
);
assert_eq!(
AbsolutePathBuf::try_from(path),
Err(LegacyAppPathStringError::InvalidNativePath {
path: raw_path.to_string(),
convention: None,
})
);
}
@@ -388,7 +402,7 @@ fn other_non_absolute_api_paths_cannot_be_converted_to_path_uris() {
path.to_path_uri(convention),
Err(LegacyAppPathStringError::InvalidNativePath {
path: raw_path.to_string(),
convention,
convention: Some(convention),
})
);
}
@@ -423,6 +437,51 @@ fn infers_absolute_path_conventions_from_api_text() {
}
}
#[test]
fn converts_absolute_api_paths_using_the_inferred_convention() {
for (raw_path, convention, expected_uri) in [
(
r"C:\workspace\file.rs",
PathConvention::Windows,
"file:///C:/workspace/file.rs",
),
(
"/workspace/file.rs",
PathConvention::Posix,
"file:///workspace/file.rs",
),
] {
let path = serde_json::from_value::<LegacyAppPathString>(serde_json::json!(raw_path))
.expect("absolute API path should deserialize");
assert_eq!(
path.to_inferred_path_uri(),
Some(PathUri::parse(expected_uri).expect("expected URI should parse")),
);
assert_eq!(
PathUri::try_from(path.clone()),
path.to_path_uri(convention)
);
}
}
#[test]
fn converts_native_api_path_to_inferred_absolute_path() {
#[cfg(windows)]
let raw_path = r"C:\workspace\file.rs";
#[cfg(not(windows))]
let raw_path = "/workspace/file.rs";
let path = serde_json::from_value::<LegacyAppPathString>(serde_json::json!(raw_path))
.expect("absolute API path should deserialize");
let expected = AbsolutePathBuf::try_from(raw_path).expect("native absolute path should parse");
assert_eq!(
AbsolutePathBuf::try_from(path.clone()),
Ok(expected.clone())
);
assert_eq!(path.to_inferred_abs_path(), Some(expected));
}
#[test]
fn foreign_absolute_syntax_deserializes_without_host_interpretation() {
for (raw_path, convention) in [
+5
View File
@@ -119,6 +119,11 @@ fn inferred_native_path_string_uses_the_inferred_convention() {
expected,
"rendering {uri}"
);
assert_eq!(
LegacyAppPathString::from(path).as_str(),
expected,
"rendering typed API path {uri}"
);
}
}