From 4a4c8fad12d2ffb61a145afbc4d1d8ca4b8f6b84 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Sun, 14 Jun 2026 07:10:16 +0000 Subject: [PATCH] app-server: preserve command item cwd --- .../analytics/src/analytics_client_tests.rs | 2 +- .../schema/json/ServerNotification.json | 2 +- .../codex_app_server_protocol.schemas.json | 2 +- .../codex_app_server_protocol.v2.schemas.json | 2 +- .../json/v2/ItemCompletedNotification.json | 5 +- .../json/v2/ItemStartedNotification.json | 5 +- .../schema/json/v2/ReviewStartResponse.json | 5 +- .../schema/json/v2/ThreadForkResponse.json | 5 +- .../schema/json/v2/ThreadListResponse.json | 5 +- .../json/v2/ThreadMetadataUpdateResponse.json | 5 +- .../schema/json/v2/ThreadReadResponse.json | 5 +- .../schema/json/v2/ThreadResumeResponse.json | 5 +- .../json/v2/ThreadRollbackResponse.json | 5 +- .../schema/json/v2/ThreadStartResponse.json | 2 +- .../json/v2/ThreadStartedNotification.json | 5 +- .../json/v2/ThreadUnarchiveResponse.json | 5 +- .../json/v2/TurnCompletedNotification.json | 5 +- .../schema/json/v2/TurnStartResponse.json | 5 +- .../json/v2/TurnStartedNotification.json | 5 +- .../schema/typescript/v2/ThreadItem.ts | 3 +- .../src/protocol/item_builders.rs | 47 ++++++++++++------- .../src/protocol/thread_history.rs | 17 +++++-- .../src/protocol/v2/item.rs | 2 +- codex-rs/app-server/README.md | 1 + .../app-server/src/bespoke_event_handling.rs | 14 ++++-- .../app-server/tests/suite/v2/turn_start.rs | 6 ++- .../tests/suite/v2/turn_start_zsh_fork.rs | 9 +++- .../remote_env_windows_test.rs | 3 +- .../src/event_processor_with_human_output.rs | 2 +- .../tests/event_processor_with_json_output.rs | 16 +++++-- .../tui/src/app/agent_status_feed_tests.rs | 4 +- codex-rs/tui/src/chatwidget/interrupts.rs | 3 +- .../tui/src/chatwidget/tests/app_server.rs | 4 +- .../tui/src/chatwidget/tests/exec_flow.rs | 2 +- codex-rs/tui/src/chatwidget/tests/helpers.rs | 4 +- .../src/chatwidget/tests/status_and_layout.rs | 2 +- 36 files changed, 154 insertions(+), 65 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 30151c0802..96756edd57 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -823,7 +823,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: serde_json::from_value(json!("/tmp")).expect("API cwd should deserialize"), process_id: Some("pid-1".to_string()), source: CommandExecutionSource::Agent, status, diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 1cf3d96c81..285b8ab525 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3964,7 +3964,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/ApiPathString" } ], "description": "The command's working directory." 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 3739e0cb93..f652c4c0b8 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 @@ -17392,7 +17392,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "$ref": "#/definitions/v2/ApiPathString" } ], "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 508935e020..efa516cd53 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 @@ -15200,7 +15200,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/ApiPathString" } ], "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 233ff8dc98..30f094399d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -5,6 +5,9 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "ApiPathString": { + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -687,7 +690,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/ApiPathString" } ], "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 a55d9e7766..02375322b4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -5,6 +5,9 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "ApiPathString": { + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -687,7 +690,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/ApiPathString" } ], "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 e27c911b36..36e95dbad5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -5,6 +5,9 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "ApiPathString": { + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -831,7 +834,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/ApiPathString" } ], "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 4a666672b6..048c8eac60 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -28,6 +28,9 @@ "AgentPath": { "type": "string" }, + "ApiPathString": { + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -1315,7 +1318,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/ApiPathString" } ], "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 c1d1b6759a..2f943a3e41 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -8,6 +8,9 @@ "AgentPath": { "type": "string" }, + "ApiPathString": { + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -1130,7 +1133,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/ApiPathString" } ], "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 801d1cf131..ff34fc25f7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -8,6 +8,9 @@ "AgentPath": { "type": "string" }, + "ApiPathString": { + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -1130,7 +1133,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/ApiPathString" } ], "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 01b5ed907e..cd72fdd0c7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -8,6 +8,9 @@ "AgentPath": { "type": "string" }, + "ApiPathString": { + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -1130,7 +1133,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/ApiPathString" } ], "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 032d1dd74e..20a2407a12 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -28,6 +28,9 @@ "AgentPath": { "type": "string" }, + "ApiPathString": { + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -1315,7 +1318,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/ApiPathString" } ], "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 c1ad534756..48996c3d51 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -8,6 +8,9 @@ "AgentPath": { "type": "string" }, + "ApiPathString": { + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -1130,7 +1133,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/ApiPathString" } ], "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 5b8be8f50a..8ac19dcc9f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1318,7 +1318,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/ApiPathString" } ], "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 577df57e05..7514426e95 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -8,6 +8,9 @@ "AgentPath": { "type": "string" }, + "ApiPathString": { + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -1130,7 +1133,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/ApiPathString" } ], "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 fcc42e6261..d9a007b602 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -8,6 +8,9 @@ "AgentPath": { "type": "string" }, + "ApiPathString": { + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -1130,7 +1133,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/ApiPathString" } ], "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 24d1dccf47..03499178d9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -5,6 +5,9 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "ApiPathString": { + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -831,7 +834,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/ApiPathString" } ], "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 da88eacd48..9e2830ced2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -5,6 +5,9 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "ApiPathString": { + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -831,7 +834,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/ApiPathString" } ], "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 edb3fa636a..3c35174867 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -5,6 +5,9 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "ApiPathString": { + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -831,7 +834,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/ApiPathString" } ], "description": "The command's working directory." 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 4ccab77b32..70090ff516 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 { ApiPathString } from "../ApiPathString"; 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: ApiPathString, /** * 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 c4be57b207..63471bf285 100644 --- a/codex-rs/app-server-protocol/src/protocol/item_builders.rs +++ b/codex-rs/app-server-protocol/src/protocol/item_builders.rs @@ -43,13 +43,28 @@ use std::path::PathBuf; #[expect( clippy::expect_used, - reason = "legacy app-server command items still require a host-native cwd" + reason = "command event paths are absolute and have an inferred convention" )] -fn project_command_cwd_to_host(cwd: &PathUri) -> AbsolutePathBuf { - let cwd = ApiPathString::from_path_uri(cwd, PathConvention::native()) +fn command_cwd_for_api_and_actions(cwd: &PathUri) -> (ApiPathString, AbsolutePathBuf) { + let convention = cwd + .infer_path_convention() + .expect("command cwd should have a native path convention"); + let api_cwd = ApiPathString::from_path_uri(cwd, convention) + .expect("command cwd should render using its native path convention"); + let host_cwd = ApiPathString::from_path_uri(cwd, PathConvention::native()) .expect("command cwd should render using the app-server host path convention"); - AbsolutePathBuf::try_from(cwd.into_string()) - .expect("projected command cwd should be absolute on the app-server host") + let host_cwd = AbsolutePathBuf::try_from(host_cwd.into_string()) + .expect("projected command cwd should be absolute on the app-server host"); + (api_cwd, host_cwd) +} + +#[expect( + clippy::expect_used, + reason = "guardian command paths are native to the app-server host" +)] +fn api_path_from_host(cwd: &AbsolutePathBuf) -> ApiPathString { + ApiPathString::from_abs_path(cwd, PathConvention::native()) + .expect("guardian command cwd should render using the host path convention") } pub fn build_file_change_approval_request_item( @@ -81,11 +96,11 @@ pub fn build_file_change_end_item(payload: &PatchApplyEndEvent) -> ThreadItem { pub fn build_command_execution_approval_request_item( payload: &ExecApprovalRequestEvent, ) -> ThreadItem { - let cwd = project_command_cwd_to_host(&payload.cwd); + let (cwd, host_cwd) = command_cwd_for_api_and_actions(&payload.cwd); ThreadItem::CommandExecution { id: payload.call_id.clone(), command: shlex_join(&payload.command), - cwd: cwd.clone(), + cwd, process_id: None, source: CommandExecutionSource::Agent, status: CommandExecutionStatus::InProgress, @@ -93,7 +108,7 @@ pub fn build_command_execution_approval_request_item( .parsed_cmd .iter() .cloned() - .map(|parsed| CommandAction::from_core_with_cwd(parsed, &cwd)) + .map(|parsed| CommandAction::from_core_with_cwd(parsed, &host_cwd)) .collect(), aggregated_output: None, exit_code: None, @@ -102,11 +117,11 @@ pub fn build_command_execution_approval_request_item( } pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> ThreadItem { - let cwd = project_command_cwd_to_host(&payload.cwd); + let (cwd, host_cwd) = command_cwd_for_api_and_actions(&payload.cwd); ThreadItem::CommandExecution { id: payload.call_id.clone(), command: shlex_join(&payload.command), - cwd: cwd.clone(), + cwd, process_id: payload.process_id.clone(), source: payload.source.into(), status: CommandExecutionStatus::InProgress, @@ -114,7 +129,7 @@ pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> Th .parsed_cmd .iter() .cloned() - .map(|parsed| CommandAction::from_core_with_cwd(parsed, &cwd)) + .map(|parsed| CommandAction::from_core_with_cwd(parsed, &host_cwd)) .collect(), aggregated_output: None, exit_code: None, @@ -129,12 +144,12 @@ 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 cwd = project_command_cwd_to_host(&payload.cwd); + let (cwd, host_cwd) = command_cwd_for_api_and_actions(&payload.cwd); ThreadItem::CommandExecution { id: payload.call_id.clone(), command: shlex_join(&payload.command), - cwd: cwd.clone(), + cwd, process_id: payload.process_id.clone(), source: payload.source.into(), status: (&payload.status).into(), @@ -142,7 +157,7 @@ pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> Thread .parsed_cmd .iter() .cloned() - .map(|parsed| CommandAction::from_core_with_cwd(parsed, &cwd)) + .map(|parsed| CommandAction::from_core_with_cwd(parsed, &host_cwd)) .collect(), aggregated_output, exit_code: Some(payload.exit_code), @@ -168,7 +183,7 @@ pub fn build_item_from_guardian_event( Some(ThreadItem::CommandExecution { id: id.clone(), command, - cwd: cwd.clone(), + cwd: api_path_from_host(cwd), process_id: None, source: CommandExecutionSource::Agent, status, @@ -204,7 +219,7 @@ pub fn build_item_from_guardian_event( Some(ThreadItem::CommandExecution { id: id.clone(), command, - cwd: cwd.clone(), + cwd: api_path_from_host(cwd), process_id: None, source: CommandExecutionSource::Agent, status, 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 2882dfbff4..36758850e1 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1278,12 +1278,19 @@ mod tests { use codex_protocol::protocol::WebSearchEndEvent; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; + use codex_utils_path_uri::ApiPathString; + use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use std::path::PathBuf; use std::time::Duration; use uuid::Uuid; + fn test_api_path(path: &str) -> ApiPathString { + ApiPathString::from_abs_path(&test_path_buf(path).abs(), PathConvention::native()) + .expect("API path") + } + #[test] fn builds_multiple_turns_with_reasoning_items() { let events = vec![ @@ -2124,7 +2131,7 @@ mod tests { ThreadItem::CommandExecution { id: "exec-1".into(), command: "echo 'hello world'".into(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_api_path("/tmp"), process_id: Some("pid-1".into()), source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Completed, @@ -2355,7 +2362,7 @@ mod tests { ThreadItem::CommandExecution { id: "exec-declined".into(), command: "ls".into(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_api_path("/tmp"), process_id: Some("pid-2".into()), source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Declined, @@ -2453,7 +2460,7 @@ mod tests { ThreadItem::CommandExecution { id: "guardian-exec".into(), command: "rm -rf /tmp/guardian".into(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_api_path("/tmp"), process_id: None, source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Declined, @@ -2519,7 +2526,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_api_path("/tmp"), process_id: None, source: CommandExecutionSource::Agent, status: CommandExecutionStatus::InProgress, @@ -2617,7 +2624,7 @@ mod tests { ThreadItem::CommandExecution { id: "exec-late".into(), command: "echo done".into(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_api_path("/tmp"), process_id: Some("pid-42".into()), source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Completed, 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 721900c133..0b3798ea7f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -257,7 +257,7 @@ pub enum ThreadItem { /// The command to be executed. command: String, /// The command's working directory. - cwd: AbsolutePathBuf, + cwd: ApiPathString, /// Identifier for the underlying PTY process (when available). process_id: Option, #[serde(default)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 4376762777..95e8595bc3 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1389,6 +1389,7 @@ There are additional item-specific events: - `item/commandExecution/outputDelta` — streams stdout/stderr for the command; append deltas in order to render live output alongside `aggregatedOutput` in the final item. Final `commandExecution` items include parsed `commandActions`, `status`, `exitCode`, and `durationMs` so the UI can summarize what ran and whether it succeeded. +- `cwd` uses the selected execution environment's native path syntax. #### fileChange diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 4fa8dd9762..f1c5221001 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -131,7 +131,7 @@ enum CommandExecutionApprovalPresentation { #[derive(Debug, PartialEq)] struct CommandExecutionCompletionItem { command: String, - cwd: AbsolutePathBuf, + cwd: ApiPathString, command_actions: Vec, } @@ -592,7 +592,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: api_cwd.clone(), command_actions: command_actions.clone(), }; CommandExecutionApprovalPresentation::Command(completion_item) @@ -1370,7 +1370,7 @@ async fn start_command_execution_item( turn_id: String, item_id: String, command: String, - cwd: AbsolutePathBuf, + cwd: ApiPathString, command_actions: Vec, source: CommandExecutionSource, outgoing: &ThreadScopedOutgoingMessageSender, @@ -1414,7 +1414,7 @@ async fn complete_command_execution_item( turn_id: String, item_id: String, command: String, - cwd: AbsolutePathBuf, + cwd: ApiPathString, process_id: Option, source: CommandExecutionSource, command_actions: Vec, @@ -2338,7 +2338,11 @@ mod tests { fn command_execution_completion_item(command: &str) -> CommandExecutionCompletionItem { CommandExecutionCompletionItem { command: command.to_string(), - cwd: test_path_buf("/tmp").abs(), + cwd: ApiPathString::from_abs_path( + &test_path_buf("/tmp").abs(), + PathConvention::native(), + ) + .expect("API cwd"), command_actions: vec![V2ParsedCommand::Unknown { command: command.to_string(), }], 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 48c8f5cd23..caf65d4c42 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -2439,7 +2439,11 @@ 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()); + let second_cwd = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(second_cwd)?; + assert_eq!( + cwd, + ApiPathString::from_abs_path(&second_cwd, PathConvention::native())? + ); 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 15072c1ccf..500e28a74d 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 @@ -32,6 +32,9 @@ use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as V2UserInput; use codex_features::FEATURES; use codex_features::Feature; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::ApiPathString; +use codex_utils_path_uri::PathConvention; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; @@ -167,7 +170,11 @@ 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()); + let workspace = AbsolutePathBuf::try_from(workspace)?; + assert_eq!( + cwd, + ApiPathString::from_abs_path(&workspace, PathConvention::native())? + ); mcp.interrupt_turn_and_wait_for_aborted(thread.id, turn.id, DEFAULT_READ_TIMEOUT) .await?; 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 4a6d9ac295..0601ed06f8 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 @@ -332,8 +332,7 @@ async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Resul unreachable!("loop returns only command execution items"); }; assert_eq!(id, CALL_ID); - // TODO(anp): Make command execution cwd a PathUri so this stays `C:\windows`. - assert_eq!(cwd, std::path::PathBuf::from("/C:/windows").abs()); + assert_eq!(cwd.as_str(), r"C:\windows"); // TODO(anp): Parse command actions using the selected environment's path convention so // their paths remain Windows-native instead of degrading the action to Unknown. assert_eq!(command_actions.len(), 1); 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 60667bb5df..9317a9daa8 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -71,7 +71,7 @@ impl EventProcessorWithHumanOutput { "{}\n{} in {}", "exec".style(self.italic).style(self.magenta), command.style(self.bold), - cwd.display() + cwd.as_str() ); } 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 2cee9b0e83..496467b340 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -35,6 +35,8 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SessionConfiguredEvent; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; +use codex_utils_path_uri::ApiPathString; +use codex_utils_path_uri::PathConvention; use pretty_assertions::assert_eq; use serde_json::json; @@ -75,6 +77,12 @@ use codex_exec::TurnStartedEvent; use codex_exec::Usage; use codex_exec::WebSearchItem; +#[expect(clippy::expect_used, reason = "test paths are absolute")] +fn api_path(path: &str) -> ApiPathString { + ApiPathString::from_abs_path(&test_path_buf(path).abs(), PathConvention::native()) + .expect("API path") +} + #[test] fn map_todo_items_preserves_text_and_completion_state() { let items = EventProcessorWithJsonOutput::map_todo_items(&[ @@ -170,7 +178,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: api_path("/tmp/project"), process_id: Some("123".to_string()), source: CommandExecutionSource::UserShell, status: ApiCommandExecutionStatus::InProgress, @@ -210,7 +218,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: api_path("/tmp/project"), process_id: Some("123".to_string()), source: CommandExecutionSource::UserShell, status: ApiCommandExecutionStatus::Completed, @@ -1321,7 +1329,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: api_path("/tmp/project"), process_id: Some("123".to_string()), source: CommandExecutionSource::UserShell, status: ApiCommandExecutionStatus::InProgress, @@ -1361,7 +1369,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: api_path("/tmp/project"), process_id: Some("123".to_string()), source: CommandExecutionSource::UserShell, status: ApiCommandExecutionStatus::Completed, 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 5b8bc4b59b..a981008a33 100644 --- a/codex-rs/tui/src/app/agent_status_feed_tests.rs +++ b/codex-rs/tui/src/app/agent_status_feed_tests.rs @@ -1,8 +1,8 @@ use super::*; +use crate::test_support::test_api_path; use codex_app_server_protocol::CommandExecutionSource; use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::ItemCompletedNotification; -use codex_utils_absolute_path::AbsolutePathBuf; #[test] fn agent_status_uses_bounded_buffered_activity() { @@ -12,7 +12,7 @@ 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: test_api_path("/workspace"), process_id: None, source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Completed, diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index 301f918418..286a9d1cb3 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -138,6 +138,7 @@ impl QueuedInterrupt { #[cfg(test)] mod tests { use crate::approval_events::ExecApprovalRequestEvent; + use crate::test_support::test_api_path; use codex_app_server_protocol::CommandExecutionSource; use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::ThreadItem; @@ -176,7 +177,7 @@ mod tests { ThreadItem::CommandExecution { id: call_id.to_string(), command: "true".to_string(), - cwd: AbsolutePathBuf::current_dir().expect("current dir"), + cwd: test_api_path("/tmp"), 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 f4fcc85e43..ff31c46fcb 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_api_path("/tmp"), 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_api_path("/tmp"), process_id: None, source: AppServerCommandExecutionSource::UserShell, status: AppServerCommandExecutionStatus::Completed, diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 8233f6dac4..d9c1112083 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -352,7 +352,7 @@ async fn exec_end_without_begin_uses_event_command() { .into_iter() .map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &chat.config.cwd)) .collect(); - let cwd = chat.config.cwd.clone(); + let cwd = test_api_path("/tmp/project"); handle_exec_end( &mut chat, AppServerThreadItem::CommandExecution { diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index dc32e8c7ba..f90787f7ca 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: test_api_path("/tmp/project"), 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: test_api_path("/tmp/project"), 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 f748d94e81..088e926c28 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -3607,7 +3607,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { .cloned() .map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &chat.config.cwd)) .collect::>(); - let cwd = chat.config.cwd.clone(); + let cwd = test_api_path("/tmp/project"); handle_exec_begin( &mut chat, AppServerThreadItem::CommandExecution {