diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d6792587f..55467c06b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -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, diff --git a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json index 4e096cbe0..91f74376f 100644 --- a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json @@ -547,7 +547,7 @@ "cwd": { "anyOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, { "type": "null" diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index a0d49cc54..bfafd5483 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3972,7 +3972,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 97934dd5b..31ca31de2 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -371,7 +371,7 @@ "cwd": { "anyOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, { "type": "null" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 9a13e8652..c75ae416f 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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." diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 0f6084a99..a4a0267dc 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -15192,7 +15192,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 233ff8dc9..027edd036 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -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." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index a55d9e776..64152e9cf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -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." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index e27c911b3..61f61e74d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -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." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 2b7caeca7..43a76f145 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -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." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 0caa0daf5..1e1504b73 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -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." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index a98d3586e..6e62eae14 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -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." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 51e56f14b..ff70a017b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -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." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index e8c309507..f9a65cdd8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -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." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 52cba55ed..f455f6dff 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -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." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 245cc946d..0c3eac4de 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -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." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index bf497ecfa..0e44cfb25 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -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." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 14f25cfcd..f88a1fae5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -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." diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 24d1dccf4..7e6cef2c3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -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." diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index da88eacd4..083767a6c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -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." diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index edb3fa636..3a43d7d3f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -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." diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts index 230e5b786..4f02c92ca 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts @@ -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 | null, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index 4ccab77b3..1cdac99c6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -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). */ diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders.rs b/codex-rs/app-server-protocol/src/protocol/item_builders.rs index 17e0f9aef..66019980a 100644 --- a/codex-rs/app-server-protocol/src/protocol/item_builders.rs +++ b/codex-rs/app-server-protocol/src/protocol/item_builders.rs @@ -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 { + // 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; diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs b/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs new file mode 100644 index 000000000..b892fcf50 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs @@ -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()), + }, + ] + ); +} diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 90a6f36ad..001fa7cf9 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -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(), }], diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index 5638e3bd4..34cfe8e13 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -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, #[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, + pub cwd: Option, /// Best-effort parsed command actions for friendly display. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 3b5e7d8a3..0be6e68bb 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -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() diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index ed135271a..a77b0f466 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -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, } @@ -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, 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, source: CommandExecutionSource, command_actions: Vec, @@ -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(), }], diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index b5295b9b7..8c202a806 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -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", diff --git a/codex-rs/app-server/src/transport_tests.rs b/codex-rs/app-server/src/transport_tests.rs index 97372d6ae..439284a17 100644 --- a/codex-rs/app-server/src/transport_tests.rs +++ b/codex-rs/app-server/src/transport_tests.rs @@ -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 { diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index dca58867c..f577a01fd 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -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); diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index 15072c1cc..c6f8f0e19 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -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?; diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 057718b9f..aea750024 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -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, diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index bb385f37b..8034dfd62 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -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, @@ -123,7 +123,7 @@ pub(crate) async fn emit_exec_command_begin( pub(crate) enum ToolEmitter { Shell { command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, source: ExecCommandSource, parsed_cmd: Vec, }, @@ -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>, diff --git a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs index 593dd7ea0..ae3c9406a 100644 --- a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs +++ b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs @@ -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() diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 7ac077431..8187fc0cb 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -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" ); diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 60667bb5d..a667cdd53 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -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, .. } => { diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 2cee9b0e8..0f8867094 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -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, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e77590c5e..d51a30add 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3246,7 +3246,7 @@ pub struct ExecCommandBeginEvent { /// The command to be executed. pub command: Vec, /// The command's working directory if not the default cwd for the agent. - pub cwd: AbsolutePathBuf, + pub cwd: PathUri, pub parsed_cmd: Vec, /// 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, /// The command's working directory if not the default cwd for the agent. - pub cwd: AbsolutePathBuf, + pub cwd: PathUri, pub parsed_cmd: Vec, /// Where the command originated. Defaults to Agent for backward compatibility. #[serde(default)] diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index bbba24530..a998c98fc 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -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> { match event { EventMsg::ExecCommandBegin(event) if event.source != ExecCommandSource::UserShell => { diff --git a/codex-rs/rollout-trace/src/protocol_event_tests.rs b/codex-rs/rollout-trace/src/protocol_event_tests.rs index b18c7200a..200c35d94 100644 --- a/codex-rs/rollout-trace/src/protocol_event_tests.rs +++ b/codex-rs/rollout-trace/src/protocol_event_tests.rs @@ -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(()) +} diff --git a/codex-rs/tui/src/app/agent_status_feed_tests.rs b/codex-rs/tui/src/app/agent_status_feed_tests.rs index 5b8bc4b59..d7583d2b4 100644 --- a/codex-rs/tui/src/app/agent_status_feed_tests.rs +++ b/codex-rs/tui/src/app/agent_status_feed_tests.rs @@ -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, diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 406cd687f..d20410535 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -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, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 0d4ef9ee3..51151206f 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -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, diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 6b76a6518..00b7fcded 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -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, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ab6d493d4..94cdd9e03 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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, diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index f8ecb3d0a..7b4c23e27 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -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, diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index f4fcc85e4..383ab4305 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -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, diff --git a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs index 5c9b92b51..1ec9a9c38 100644 --- a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs +++ b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs @@ -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 { diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 60eb2d891..1b8a7bedd 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -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, diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index dc32e8c7b..9a50b3783 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -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, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 166750133..d8c79f56b 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -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, diff --git a/codex-rs/utils/path-uri/src/api_path_string.rs b/codex-rs/utils/path-uri/src/api_path_string.rs index 5fc204b17..3e33f316c 100644 --- a/codex-rs/utils/path-uri/src/api_path_string.rs +++ b/codex-rs/utils/path-uri/src/api_path_string.rs @@ -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::try_from(self.clone()).ok() + } + + /// Parses this API string as a host-native absolute path. + pub fn to_inferred_abs_path(&self) -> Option { + 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 for LegacyAppPathString { } } +impl From for LegacyAppPathString { + fn from(path: PathUri) -> Self { + Self(path.inferred_native_path_string()) + } +} + +impl TryFrom for PathUri { + type Error = LegacyAppPathStringError; + + fn try_from(path: LegacyAppPathString) -> Result { + 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 for AbsolutePathBuf { + type Error = LegacyAppPathStringError; + + fn try_from(path: LegacyAppPathString) -> Result { + 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, }, } diff --git a/codex-rs/utils/path-uri/src/api_path_string_tests.rs b/codex-rs/utils/path-uri/src/api_path_string_tests.rs index 42829a783..3439ca11e 100644 --- a/codex-rs/utils/path-uri/src/api_path_string_tests.rs +++ b/codex-rs/utils/path-uri/src/api_path_string_tests.rs @@ -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::(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::(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 [ diff --git a/codex-rs/utils/path-uri/src/tests.rs b/codex-rs/utils/path-uri/src/tests.rs index 6e7dc7294..4ccb9e9c8 100644 --- a/codex-rs/utils/path-uri/src/tests.rs +++ b/codex-rs/utils/path-uri/src/tests.rs @@ -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}" + ); } }