From 20c092b634f6292c6b0030ee8add71e581702c10 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Fri, 12 Jun 2026 18:00:57 +0000 Subject: [PATCH] app-server: use native path strings for command actions --- codex-rs/Cargo.lock | 2 + .../analytics/src/analytics_client_tests.rs | 3 +- codex-rs/app-server-protocol/Cargo.toml | 1 + ...CommandExecutionRequestApprovalParams.json | 5 +- .../schema/json/ServerNotification.json | 5 +- .../schema/json/ServerRequest.json | 5 +- .../codex_app_server_protocol.schemas.json | 5 +- .../codex_app_server_protocol.v2.schemas.json | 5 +- .../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 | 5 +- .../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/NativePathString.ts | 13 ++ .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/CommandAction.ts | 4 +- .../src/protocol/item_builders.rs | 17 +- .../src/protocol/v2/item.rs | 152 ++++++++++++++-- .../src/protocol/v2/tests.rs | 54 ++++++ codex-rs/app-server/README.md | 2 +- .../app-server/src/bespoke_event_handling.rs | 5 +- codex-rs/tui/Cargo.toml | 1 + .../tui/src/chatwidget/command_lifecycle.rs | 2 +- codex-rs/tui/src/chatwidget/exec_state.rs | 2 +- .../tui/src/chatwidget/tests/exec_flow.rs | 8 +- codex-rs/tui/src/chatwidget/tests/helpers.rs | 8 +- .../src/chatwidget/tests/status_and_layout.rs | 8 +- .../utils/path-uri/src/native_path_string.rs | 164 +++++++++++++++++- .../path-uri/src/native_path_string_tests.rs | 111 ++++++++++++ 39 files changed, 602 insertions(+), 56 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/NativePathString.ts diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4ae2d33c4b4a..5a5a392855c8 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2096,6 +2096,7 @@ dependencies = [ "codex-shell-command", "codex-utils-absolute-path", "codex-utils-cargo-bin", + "codex-utils-path-uri", "inventory", "pretty_assertions", "rmcp", @@ -4013,6 +4014,7 @@ dependencies = [ "codex-utils-home-dir", "codex-utils-oss", "codex-utils-path", + "codex-utils-path-uri", "codex-utils-plugins", "codex-utils-sandbox-summary", "codex-utils-sleep-inhibitor", diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index c0983a3dba8d..cdf163c09abc 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -2106,7 +2106,8 @@ async fn item_lifecycle_notifications_publish_command_execution_event() { CommandAction::Read { command: "cat README.md".to_string(), name: "README.md".to_string(), - path: test_path_buf("/tmp/README.md").abs(), + path: serde_json::from_value(serde_json::json!("/tmp/README.md")) + .expect("native path string"), }, CommandAction::ListFiles { command: "ls".to_string(), diff --git a/codex-rs/app-server-protocol/Cargo.toml b/codex-rs/app-server-protocol/Cargo.toml index 0749b07e0838..dc69e2d4bf21 100644 --- a/codex-rs/app-server-protocol/Cargo.toml +++ b/codex-rs/app-server-protocol/Cargo.toml @@ -19,6 +19,7 @@ codex-experimental-api-macros = { workspace = true } codex-protocol = { workspace = true } codex-shell-command = { workspace = true } codex-utils-absolute-path = { workspace = true } +codex-utils-path-uri = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json index 922db80f2f7e..8466684e6fab 100644 --- a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json @@ -95,7 +95,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/NativePathString" }, "type": { "enum": [ @@ -469,6 +469,9 @@ } ] }, + "NativePathString": { + "type": "string" + }, "NetworkApprovalContext": { "properties": { "host": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 1c37ea6f3351..1219ae6da5cf 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -779,7 +779,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/NativePathString" }, "type": { "enum": [ @@ -2447,6 +2447,9 @@ ], "type": "object" }, + "NativePathString": { + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 5ab8d7c63bb6..7124e36feeee 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -164,7 +164,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/NativePathString" }, "type": { "enum": [ @@ -1428,6 +1428,9 @@ ], "type": "object" }, + "NativePathString": { + "type": "string" + }, "NetworkApprovalContext": { "properties": { "host": { 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 9aedb8a7ea2a..196491359f7b 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 @@ -7119,7 +7119,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "$ref": "#/definitions/v2/NativePathString" }, "type": { "enum": [ @@ -12115,6 +12115,9 @@ "title": "ModelVerificationNotification", "type": "object" }, + "NativePathString": { + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", 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 ebe1efe26d54..fe2296ad93b8 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 @@ -3432,7 +3432,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/NativePathString" }, "type": { "enum": [ @@ -8588,6 +8588,9 @@ "title": "ModelVerificationNotification", "type": "object" }, + "NativePathString": { + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", 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 162f3aa3d92e..cf9840e1be6f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -82,7 +82,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/NativePathString" }, "type": { "enum": [ @@ -394,6 +394,9 @@ } ] }, + "NativePathString": { + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 af3b1ddd1b44..af818652392e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -82,7 +82,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/NativePathString" }, "type": { "enum": [ @@ -394,6 +394,9 @@ } ] }, + "NativePathString": { + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 8918f6cae40d..886537916983 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -219,7 +219,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/NativePathString" }, "type": { "enum": [ @@ -531,6 +531,9 @@ } ] }, + "NativePathString": { + "type": "string" + }, "NonSteerableTurnKind": { "enum": [ "review", 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 fa1277b0ef55..8083c440511d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -301,7 +301,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/NativePathString" }, "type": { "enum": [ @@ -636,6 +636,9 @@ } ] }, + "NativePathString": { + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", 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 1b53d4ca2ecc..b99f53c13474 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -222,7 +222,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/NativePathString" }, "type": { "enum": [ @@ -557,6 +557,9 @@ } ] }, + "NativePathString": { + "type": "string" + }, "NonSteerableTurnKind": { "enum": [ "review", 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 c98fa1f2a553..d35105272850 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -222,7 +222,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/NativePathString" }, "type": { "enum": [ @@ -557,6 +557,9 @@ } ] }, + "NativePathString": { + "type": "string" + }, "NonSteerableTurnKind": { "enum": [ "review", 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 345d84d794cc..c810ba01fdb4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -222,7 +222,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/NativePathString" }, "type": { "enum": [ @@ -557,6 +557,9 @@ } ] }, + "NativePathString": { + "type": "string" + }, "NonSteerableTurnKind": { "enum": [ "review", 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 98ef79517bb8..690b55bf9a36 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -301,7 +301,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/NativePathString" }, "type": { "enum": [ @@ -636,6 +636,9 @@ } ] }, + "NativePathString": { + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", 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 f63dfe6ffbc7..c9d137effe0a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -222,7 +222,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/NativePathString" }, "type": { "enum": [ @@ -557,6 +557,9 @@ } ] }, + "NativePathString": { + "type": "string" + }, "NonSteerableTurnKind": { "enum": [ "review", 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 052023361ae3..193d0773d735 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -301,7 +301,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/NativePathString" }, "type": { "enum": [ @@ -636,6 +636,9 @@ } ] }, + "NativePathString": { + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", 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 cdeda64b7cdf..31231226595a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -222,7 +222,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/NativePathString" }, "type": { "enum": [ @@ -557,6 +557,9 @@ } ] }, + "NativePathString": { + "type": "string" + }, "NonSteerableTurnKind": { "enum": [ "review", 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 2a7281fcbbb9..254b0ae7fdda 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -222,7 +222,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/NativePathString" }, "type": { "enum": [ @@ -557,6 +557,9 @@ } ] }, + "NativePathString": { + "type": "string" + }, "NonSteerableTurnKind": { "enum": [ "review", 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 3cc329c9e528..71dd1023b0fa 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -219,7 +219,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/NativePathString" }, "type": { "enum": [ @@ -531,6 +531,9 @@ } ] }, + "NativePathString": { + "type": "string" + }, "NonSteerableTurnKind": { "enum": [ "review", 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 7dbea8af530b..a5b164a24d32 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -219,7 +219,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/NativePathString" }, "type": { "enum": [ @@ -531,6 +531,9 @@ } ] }, + "NativePathString": { + "type": "string" + }, "NonSteerableTurnKind": { "enum": [ "review", 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 362e789ea84a..4e55afcb1d1b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -219,7 +219,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/NativePathString" }, "type": { "enum": [ @@ -531,6 +531,9 @@ } ] }, + "NativePathString": { + "type": "string" + }, "NonSteerableTurnKind": { "enum": [ "review", diff --git a/codex-rs/app-server-protocol/schema/typescript/NativePathString.ts b/codex-rs/app-server-protocol/schema/typescript/NativePathString.ts new file mode 100644 index 000000000000..d440830db827 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/NativePathString.ts @@ -0,0 +1,13 @@ +// 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. + +/** + * A UTF-8 path rendered using an explicitly selected native path convention. + * + * "Native" refers to the supplied [`PathConvention`], which may be foreign to + * the operating system running this process. The inner string is private so + * path-producing code must render through [`Self::from_path_uri`] rather than + * accidentally applying the current host's path rules. + */ +export type NativePathString = string; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 149b3aec0d62..6348993d6437 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -46,6 +46,7 @@ export type { LocalShellStatus } from "./LocalShellStatus"; export type { McpServerInfo } from "./McpServerInfo"; export type { MessagePhase } from "./MessagePhase"; export type { ModeKind } from "./ModeKind"; +export type { NativePathString } from "./NativePathString"; export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction"; export type { ParsedCommand } from "./ParsedCommand"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandAction.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandAction.ts index a17fb06a0c0a..18113a9a4aef 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandAction.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandAction.ts @@ -1,6 +1,6 @@ // 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 { NativePathString } from "../NativePathString"; -export type CommandAction = { "type": "read", command: string, name: string, path: AbsolutePathBuf, } | { "type": "listFiles", command: string, path: string | null, } | { "type": "search", command: string, query: string | null, path: string | null, } | { "type": "unknown", command: string, }; +export type CommandAction = { "type": "read", command: string, name: string, path: NativePathString, } | { "type": "listFiles", command: string, path: string | null, } | { "type": "search", command: string, query: string | null, path: string | null, } | { "type": "unknown", command: string, }; 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 17e0f9aef48a..5711c5e500f9 100644 --- a/codex-rs/app-server-protocol/src/protocol/item_builders.rs +++ b/codex-rs/app-server-protocol/src/protocol/item_builders.rs @@ -34,6 +34,7 @@ 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 std::collections::HashMap; use std::path::PathBuf; @@ -77,7 +78,9 @@ pub fn build_command_execution_approval_request_item( .parsed_cmd .iter() .cloned() - .map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd)) + .map(|parsed| { + CommandAction::from_core_with_cwd(parsed, &payload.cwd, PathConvention::native()) + }) .collect(), aggregated_output: None, exit_code: None, @@ -97,7 +100,9 @@ pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> Th .parsed_cmd .iter() .cloned() - .map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd)) + .map(|parsed| { + CommandAction::from_core_with_cwd(parsed, &payload.cwd, PathConvention::native()) + }) .collect(), aggregated_output: None, exit_code: None, @@ -124,7 +129,9 @@ pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> Thread .parsed_cmd .iter() .cloned() - .map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd)) + .map(|parsed| { + CommandAction::from_core_with_cwd(parsed, &payload.cwd, PathConvention::native()) + }) .collect(), aggregated_output, exit_code: Some(payload.exit_code), @@ -180,7 +187,9 @@ pub fn build_item_from_guardian_event( } else { parsed_cmd .into_iter() - .map(|parsed| CommandAction::from_core_with_cwd(parsed, cwd)) + .map(|parsed| { + CommandAction::from_core_with_cwd(parsed, cwd, PathConvention::native()) + }) .collect() }; Some(ThreadItem::CommandExecution { 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 f556890a96aa..4f6c7ffb0971 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -31,13 +31,19 @@ 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::NativePathString; +use codex_utils_path_uri::NativePathStringError; +use codex_utils_path_uri::PathConvention; +use codex_utils_path_uri::PathUri; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; use serde_with::serde_as; use std::collections::HashMap; +use std::io; use std::path::PathBuf; +use thiserror::Error; use ts_rs::TS; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] @@ -108,7 +114,7 @@ pub enum CommandAction { Read { command: String, name: String, - path: AbsolutePathBuf, + path: NativePathString, }, ListFiles { command: String, @@ -124,6 +130,35 @@ pub enum CommandAction { }, } +#[derive(Debug, Clone, PartialEq)] +pub enum ResolvedCommandAction { + Read { + command: String, + name: String, + path: PathUri, + }, + ListFiles { + command: String, + path: Option, + }, + Search { + command: String, + query: Option, + path: Option, + }, + Unknown { + command: String, + }, +} + +#[derive(Debug, Error)] +pub enum CommandActionPathError { + #[error(transparent)] + NativePath(#[from] NativePathStringError), + #[error(transparent)] + Io(#[from] io::Error), +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -163,49 +198,130 @@ impl From for MemoryCitationEntry { } impl CommandAction { - pub fn into_core(self) -> CoreParsedCommand { + pub fn resolve( + self, + convention: PathConvention, + ) -> Result { match self { CommandAction::Read { command: cmd, name, path, - } => CoreParsedCommand::Read { - cmd, + } => Ok(ResolvedCommandAction::Read { + command: cmd, name, - path: path.into_path_buf(), - }, + path: path.to_path_uri(convention)?, + }), CommandAction::ListFiles { command: cmd, path } => { - CoreParsedCommand::ListFiles { cmd, path } + Ok(ResolvedCommandAction::ListFiles { command: cmd, path }) } CommandAction::Search { command: cmd, query, path, - } => CoreParsedCommand::Search { cmd, query, path }, - CommandAction::Unknown { command: cmd } => CoreParsedCommand::Unknown { cmd }, + } => Ok(ResolvedCommandAction::Search { + command: cmd, + query, + path, + }), + CommandAction::Unknown { command: cmd } => { + Ok(ResolvedCommandAction::Unknown { command: cmd }) + } } } - pub fn from_core_with_cwd(value: CoreParsedCommand, cwd: &AbsolutePathBuf) -> Self { + pub fn into_core_for_native_host(self) -> CoreParsedCommand { + let command = self.command().to_string(); + let Ok(action) = self.resolve(PathConvention::native()) else { + return CoreParsedCommand::Unknown { cmd: command }; + }; + action + .try_into_core_for_native_host() + .unwrap_or(CoreParsedCommand::Unknown { cmd: command }) + } + + pub fn from_core_with_cwd( + value: CoreParsedCommand, + cwd: &AbsolutePathBuf, + convention: PathConvention, + ) -> Self { + let command = core_parsed_command_text(&value).to_string(); + Self::try_from_core_with_cwd(value, cwd, convention) + .unwrap_or(CommandAction::Unknown { command }) + } + + pub fn try_from_core_with_cwd( + value: CoreParsedCommand, + cwd: &AbsolutePathBuf, + convention: PathConvention, + ) -> Result { match value { - CoreParsedCommand::Read { cmd, name, path } => CommandAction::Read { + CoreParsedCommand::Read { cmd, name, path } => { + let path = cwd.join(path); + let path = PathUri::from_abs_path(&path)?; + let path = NativePathString::from_path_uri(&path, convention)?; + Ok(CommandAction::Read { + command: cmd, + name, + path, + }) + } + CoreParsedCommand::ListFiles { cmd, path } => { + Ok(CommandAction::ListFiles { command: cmd, path }) + } + CoreParsedCommand::Search { cmd, query, path } => Ok(CommandAction::Search { + command: cmd, + query, + path, + }), + CoreParsedCommand::Unknown { cmd } => Ok(CommandAction::Unknown { command: cmd }), + } + } + + fn command(&self) -> &str { + match self { + Self::Read { command, .. } + | Self::ListFiles { command, .. } + | Self::Search { command, .. } + | Self::Unknown { command } => command, + } + } +} + +impl ResolvedCommandAction { + pub fn try_into_core_for_native_host(self) -> io::Result { + match self { + Self::Read { command: cmd, name, - path: cwd.join(path), - }, - CoreParsedCommand::ListFiles { cmd, path } => { - CommandAction::ListFiles { command: cmd, path } + path, + } => Ok(CoreParsedCommand::Read { + cmd, + name, + path: path.to_abs_path()?.into_path_buf(), + }), + Self::ListFiles { command: cmd, path } => { + Ok(CoreParsedCommand::ListFiles { cmd, path }) } - CoreParsedCommand::Search { cmd, query, path } => CommandAction::Search { + Self::Search { command: cmd, query, path, - }, - CoreParsedCommand::Unknown { cmd } => CommandAction::Unknown { command: cmd }, + } => Ok(CoreParsedCommand::Search { cmd, query, path }), + Self::Unknown { command: cmd } => Ok(CoreParsedCommand::Unknown { cmd }), } } } +fn core_parsed_command_text(command: &CoreParsedCommand) -> &str { + match command { + CoreParsedCommand::Read { cmd, .. } + | CoreParsedCommand::ListFiles { cmd, .. } + | CoreParsedCommand::Search { cmd, .. } + | CoreParsedCommand::Unknown { cmd } => cmd, + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index c570b87ce4a6..5e91832adb11 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -21,6 +21,7 @@ use codex_protocol::models::ImageDetail; use codex_protocol::models::MessagePhase; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; use codex_protocol::models::WebSearchAction as CoreWebSearchAction; +use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand; use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMode; use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandboxEntry; @@ -57,6 +58,59 @@ fn test_absolute_path() -> AbsolutePathBuf { absolute_path("readable") } +#[test] +fn command_action_read_round_trips_as_a_native_path_string() { + let cwd = absolute_path("workspace"); + let action = CommandAction::try_from_core_with_cwd( + CoreParsedCommand::Read { + cmd: "cat README.md".to_string(), + name: "README.md".to_string(), + path: PathBuf::from("README.md"), + }, + &cwd, + codex_utils_path_uri::PathConvention::native(), + ) + .expect("native command action path"); + let value = serde_json::to_value(&action).expect("serialize command action"); + + assert_eq!( + value, + json!({ + "type": "read", + "command": "cat README.md", + "name": "README.md", + "path": cwd.join("README.md").display().to_string(), + }) + ); + assert_eq!( + serde_json::from_value::(value).expect("deserialize command action"), + action + ); +} + +#[test] +fn command_action_read_resolves_foreign_native_path_to_path_uri() { + let action: CommandAction = serde_json::from_value(json!({ + "type": "read", + "command": "type README.md", + "name": "README.md", + "path": "C:\\workspace\\README.md", + })) + .expect("deserialize Windows command action"); + + assert_eq!( + action + .resolve(codex_utils_path_uri::PathConvention::Windows) + .expect("resolve Windows command path"), + ResolvedCommandAction::Read { + command: "type README.md".to_string(), + name: "README.md".to_string(), + path: codex_utils_path_uri::PathUri::parse("file:///C:/workspace/README.md") + .expect("Windows path URI"), + } + ); +} + #[test] fn thread_sources_round_trip_as_scalar_labels() { for (source, label) in [ diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index cc36bb451837..567199c64eae 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1310,7 +1310,7 @@ Today both notifications carry an empty `items` array even when item events were - `agentMessage` — `{id, text}` containing the accumulated agent reply. - `plan` — `{id, text}` emitted for plan-mode turns; plan text can stream via `item/plan/delta` (experimental). - `reasoning` — `{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models). -- `commandExecution` — `{id, command, cwd, status, commandActions, aggregatedOutput?, exitCode?, durationMs?}` for sandboxed commands; `status` is `inProgress`, `completed`, `failed`, or `declined`. +- `commandExecution` — `{id, command, cwd, status, commandActions, aggregatedOutput?, exitCode?, durationMs?}` for sandboxed commands; `status` is `inProgress`, `completed`, `failed`, or `declined`. A `read` command action's `path` is a string rendered using the source execution environment's native path convention, which may differ from the app-server host's convention. - `fileChange` — `{id, changes, status}` describing proposed edits; `changes` list `{path, kind, diff}` and `status` is `inProgress`, `completed`, `failed`, or `declined`. - `mcpToolCall` — `{id, server, tool, status, arguments, mcpAppResourceUri?, pluginId, result?, error?}` describing MCP calls; `status` is `inProgress`, `completed`, or `failed`. - `collabToolCall` — `{id, tool, status, senderThreadId, receiverThreadId?, newThreadId?, prompt?, agentStatus?}` describing collab tool calls (`spawn_agent`, `send_input`, `resume_agent`, `wait`, `close_agent`); `status` is `inProgress`, `completed`, or `failed`. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index d1099d3b199c..15308985b9af 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::PathConvention; use std::collections::HashMap; use std::sync::Arc; use std::time::SystemTime; @@ -564,7 +565,9 @@ pub(crate) async fn apply_bespoke_event_handling( let command_actions = parsed_cmd .iter() .cloned() - .map(|parsed| V2ParsedCommand::from_core_with_cwd(parsed, &cwd)) + .map(|parsed| { + V2ParsedCommand::from_core_with_cwd(parsed, &cwd, PathConvention::native()) + }) .collect::>(); let presentation = if let Some(network_approval_context) = network_approval_context.map(V2NetworkApprovalContext::from) diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 581f67bf94fa..bdbb294930a4 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -63,6 +63,7 @@ codex-utils-fuzzy-match = { workspace = true } codex-utils-home-dir = { workspace = true } codex-utils-oss = { workspace = true } codex-utils-path = { workspace = true } +codex-utils-path-uri = { workspace = true } codex-utils-plugins = { workspace = true } codex-utils-sandbox-summary = { workspace = true } codex-utils-sleep-inhibitor = { workspace = true } diff --git a/codex-rs/tui/src/chatwidget/command_lifecycle.rs b/codex-rs/tui/src/chatwidget/command_lifecycle.rs index 8e6630f8b37e..73ef523a624a 100644 --- a/codex-rs/tui/src/chatwidget/command_lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/command_lifecycle.rs @@ -348,7 +348,7 @@ impl ChatWidget { let event_command = split_command_string(&command); let event_parsed = command_actions .into_iter() - .map(codex_app_server_protocol::CommandAction::into_core) + .map(codex_app_server_protocol::CommandAction::into_core_for_native_host) .collect(); let duration = Duration::from_millis(duration_ms.unwrap_or_default().max(0) as u64); let exit_code = exit_code.unwrap_or_default(); diff --git a/codex-rs/tui/src/chatwidget/exec_state.rs b/codex-rs/tui/src/chatwidget/exec_state.rs index c8bb698cef97..19093e334f73 100644 --- a/codex-rs/tui/src/chatwidget/exec_state.rs +++ b/codex-rs/tui/src/chatwidget/exec_state.rs @@ -77,7 +77,7 @@ pub(super) fn command_execution_command_and_parsed( command_actions .iter() .cloned() - .map(codex_app_server_protocol::CommandAction::into_core) + .map(codex_app_server_protocol::CommandAction::into_core_for_native_host) .collect(), ) } diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 8dbace437849..ffd8626eda27 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -350,7 +350,13 @@ async fn exec_end_without_begin_uses_event_command() { ]; let command_actions = codex_shell_command::parse_command::parse_command(&command) .into_iter() - .map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &chat.config.cwd)) + .map(|parsed| { + AppServerCommandAction::from_core_with_cwd( + parsed, + &chat.config.cwd, + codex_utils_path_uri::PathConvention::native(), + ) + }) .collect(); let cwd = chat.config.cwd.clone(); handle_exec_end( diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index c8d8fdc15e06..34e8fde692ed 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -811,7 +811,13 @@ pub(super) fn begin_exec_with_source( let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()]; let command_actions = codex_shell_command::parse_command::parse_command(&command) .into_iter() - .map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &chat.config.cwd)) + .map(|parsed| { + AppServerCommandAction::from_core_with_cwd( + parsed, + &chat.config.cwd, + codex_utils_path_uri::PathConvention::native(), + ) + }) .collect(); let item = AppServerThreadItem::CommandExecution { id: call_id.to_string(), 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 62aede458d08..2be355552b89 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -3604,7 +3604,13 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { let command_actions = parsed_cmd .iter() .cloned() - .map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &chat.config.cwd)) + .map(|parsed| { + AppServerCommandAction::from_core_with_cwd( + parsed, + &chat.config.cwd, + codex_utils_path_uri::PathConvention::native(), + ) + }) .collect::>(); let cwd = chat.config.cwd.clone(); handle_exec_begin( diff --git a/codex-rs/utils/path-uri/src/native_path_string.rs b/codex-rs/utils/path-uri/src/native_path_string.rs index 07554b632c6b..d3d10807d1cb 100644 --- a/codex-rs/utils/path-uri/src/native_path_string.rs +++ b/codex-rs/utils/path-uri/src/native_path_string.rs @@ -1,6 +1,7 @@ use crate::PathUri; use schemars::JsonSchema; use serde::Deserialize; +use serde::Deserializer; use serde::Serialize; use serde::Serializer; use std::fmt; @@ -71,6 +72,20 @@ impl NativePathString { Ok(Self(value)) } + /// Parses this native path string using the supplied path convention. + /// + /// TODO(anp): Once `PathUri` carries an environment identifier, accept the + /// source environment context and compose its identifier into the URI. + pub fn to_path_uri( + &self, + convention: PathConvention, + ) -> Result { + match convention { + PathConvention::Posix => parse_posix_path(self.as_str()), + PathConvention::Windows => parse_windows_path(self.as_str()), + } + } + pub fn as_str(&self) -> &str { &self.0 } @@ -95,6 +110,15 @@ impl Serialize for NativePathString { } } +impl<'de> Deserialize<'de> for NativePathString { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + String::deserialize(deserializer).map(Self) + } +} + impl JsonSchema for NativePathString { fn schema_name() -> String { "NativePathString".to_string() @@ -166,6 +190,117 @@ fn render_windows_path(path: &PathUri) -> Result Ok(rendered) } +fn parse_posix_path(path: &str) -> Result { + if !path.starts_with('/') || path.contains('\0') { + return Err(invalid_native_path(path, PathConvention::Posix)); + } + build_path_uri( + /*host*/ None, + path[1..].split('/'), + path, + PathConvention::Posix, + ) +} + +fn parse_windows_path(path: &str) -> Result { + if path.contains('\0') { + return Err(invalid_native_path(path, PathConvention::Windows)); + } + + if let Some(rest) = path.strip_prefix(r"\\").or_else(|| path.strip_prefix("//")) { + let mut segments = rest.split(['\\', '/']); + let Some(host) = segments.next().filter(|host| !host.is_empty()) else { + return Err(invalid_native_path(path, PathConvention::Windows)); + }; + let Some(share) = segments.next().filter(|share| !share.is_empty()) else { + return Err(invalid_native_path(path, PathConvention::Windows)); + }; + if !is_valid_windows_component(share) { + return Err(invalid_native_path(path, PathConvention::Windows)); + } + return build_path_uri( + Some(host), + std::iter::once(share).chain(segments), + path, + PathConvention::Windows, + ); + } + + let bytes = path.as_bytes(); + if bytes.len() < 3 + || !bytes[0].is_ascii_alphabetic() + || bytes[1] != b':' + || !matches!(bytes[2], b'\\' | b'/') + { + return Err(invalid_native_path(path, PathConvention::Windows)); + } + let drive = &path[..2]; + let segments = path[3..].split(['\\', '/']); + build_path_uri( + /*host*/ None, + std::iter::once(drive).chain(segments), + path, + PathConvention::Windows, + ) +} + +fn build_path_uri<'a>( + host: Option<&str>, + segments: impl IntoIterator, + native_path: &str, + convention: PathConvention, +) -> Result { + let segments = segments.into_iter().collect::>(); + let preserve_trailing_separator = segments + .last() + .is_some_and(|segment| matches!(*segment, "" | "." | "..")); + let protected_segments = usize::from(convention == PathConvention::Windows); + let mut normalized_segments = Vec::with_capacity(segments.len()); + for segment in segments { + match segment { + "" | "." => {} + ".." => { + if normalized_segments.len() > protected_segments { + normalized_segments.pop(); + } + } + _ => { + let is_drive_prefix = convention == PathConvention::Windows + && host.is_none() + && normalized_segments.is_empty() + && is_windows_drive(segment); + if convention == PathConvention::Windows + && !is_drive_prefix + && !is_valid_windows_component(segment) + { + return Err(invalid_native_path(native_path, convention)); + } + normalized_segments.push(segment); + } + } + } + if preserve_trailing_separator { + normalized_segments.push(""); + } + + let mut url = + url::Url::parse("file:///").map_err(|_| invalid_native_path(native_path, convention))?; + if let Some(host) = host { + url.set_host(Some(host)) + .map_err(|_| invalid_native_path(native_path, convention))?; + } + { + let mut url_segments = url + .path_segments_mut() + .unwrap_or_else(|()| unreachable!("file URLs support path segments")); + url_segments.clear(); + for segment in normalized_segments { + url_segments.push(segment); + } + } + PathUri::try_from(url).map_err(|_| invalid_native_path(native_path, convention)) +} + fn path_segments(url: &url::Url) -> std::str::Split<'_, char> { url.path_segments() .unwrap_or_else(|| unreachable!("validated file URLs have path segments")) @@ -196,10 +331,7 @@ fn validate_windows_component( path: &PathUri, component: &str, ) -> Result<(), NativePathStringError> { - let contains_invalid_character = component - .chars() - .any(|character| character <= '\u{1f}' || r#"<>:"/\|?*"#.contains(character)); - if contains_invalid_character || component.ends_with([' ', '.']) { + if !is_valid_windows_component(component) { return Err(NativePathStringError::InvalidWindowsComponent { path: path.to_string(), component: component.to_string(), @@ -208,6 +340,18 @@ fn validate_windows_component( Ok(()) } +fn is_valid_windows_component(component: &str) -> bool { + let contains_invalid_character = component + .chars() + .any(|character| character <= '\u{1f}' || r#"<>:"/\|?*"#.contains(character)); + !contains_invalid_character && !component.ends_with([' ', '.']) +} + +fn is_windows_drive(component: &str) -> bool { + let bytes = component.as_bytes(); + bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' +} + fn incompatible_convention(path: &PathUri, convention: PathConvention) -> NativePathStringError { NativePathStringError::IncompatibleConvention { path: path.to_string(), @@ -215,6 +359,13 @@ fn incompatible_convention(path: &PathUri, convention: PathConvention) -> Native } } +fn invalid_native_path(path: &str, convention: PathConvention) -> NativePathStringError { + NativePathStringError::InvalidNativePath { + path: path.to_string(), + convention, + } +} + #[derive(Debug, Error, PartialEq, Eq)] pub enum NativePathStringError { #[error("path URI `{path}` cannot be rendered using {convention} path syntax")] @@ -231,6 +382,11 @@ pub enum NativePathStringError { }, #[error("path URI `{path}` contains invalid Windows path component `{component}`")] InvalidWindowsComponent { path: String, component: String }, + #[error("native path `{path}` is not an absolute {convention} path")] + InvalidNativePath { + path: String, + convention: PathConvention, + }, } #[cfg(test)] diff --git a/codex-rs/utils/path-uri/src/native_path_string_tests.rs b/codex-rs/utils/path-uri/src/native_path_string_tests.rs index 6741f57ad34e..948a5ca27306 100644 --- a/codex-rs/utils/path-uri/src/native_path_string_tests.rs +++ b/codex-rs/utils/path-uri/src/native_path_string_tests.rs @@ -155,3 +155,114 @@ fn serializes_as_a_string() { r#""/workspace/src/lib.rs""# ); } + +#[test] +fn deserialized_posix_paths_round_trip_through_path_uri() { + for value in [ + "/", + "/home/alice/a file.rs", + "/tmp/", + "/tmp/%A0.txt", + "/tmp/☃", + "/tmp/a\\b", + ] { + let native: NativePathString = + serde_json::from_value(serde_json::json!(value)).expect("native path string"); + let path = native + .to_path_uri(PathConvention::Posix) + .expect("absolute POSIX path should parse"); + + assert_eq!( + NativePathString::from_path_uri(&path, PathConvention::Posix), + Ok(native), + "round-tripping {value}" + ); + } +} + +#[test] +fn deserialized_windows_paths_round_trip_through_path_uri() { + for value in [ + r"C:\", + r"C:\Users\Alice Smith\src\main.rs", + r"d:\snowman\☃", + r"C:\test with %25\c#code", + r"\\server\share\src\main.rs", + "\\\\server\\share\\", + ] { + let native: NativePathString = + serde_json::from_value(serde_json::json!(value)).expect("native path string"); + let path = native + .to_path_uri(PathConvention::Windows) + .expect("absolute Windows path should parse"); + + assert_eq!( + NativePathString::from_path_uri(&path, PathConvention::Windows), + Ok(native), + "round-tripping {value}" + ); + } +} + +#[test] +fn native_path_strings_normalize_navigation_components() { + for (value, convention, expected_uri, expected_native) in [ + ( + "/workspace/src/../README.md", + PathConvention::Posix, + "file:///workspace/README.md", + "/workspace/README.md", + ), + ( + "/../../workspace/./README.md", + PathConvention::Posix, + "file:///workspace/README.md", + "/workspace/README.md", + ), + ( + r"C:\workspace\src\..\README.md", + PathConvention::Windows, + "file:///C:/workspace/README.md", + r"C:\workspace\README.md", + ), + ( + r"\\server\share\src\..\README.md", + PathConvention::Windows, + "file://server/share/README.md", + r"\\server\share\README.md", + ), + ] { + let native: NativePathString = + serde_json::from_value(serde_json::json!(value)).expect("native path string"); + let path = native + .to_path_uri(convention) + .expect("absolute native path should parse"); + + assert_eq!(path.to_string(), expected_uri, "parsing {value}"); + assert_eq!( + NativePathString::from_path_uri(&path, convention).map(NativePathString::into_string), + Ok(expected_native.to_string()), + "rendering normalized {value}" + ); + } +} + +#[test] +fn native_path_string_rejects_invalid_native_paths() { + for (value, convention) in [ + ("relative/path", PathConvention::Posix), + ("relative\\path", PathConvention::Windows), + (r"C:relative", PathConvention::Windows), + (r"\\server", PathConvention::Windows), + (r"C:\invalid?name", PathConvention::Windows), + (r"C:\workspace\D:\file.rs", PathConvention::Windows), + ] { + let native: NativePathString = + serde_json::from_value(serde_json::json!(value)).expect("native path string"); + + assert!(matches!( + native.to_path_uri(convention), + Err(NativePathStringError::InvalidNativePath { .. }) + )); + } +}