diff --git a/bazel/rules/testing/wine/src/lib.rs b/bazel/rules/testing/wine/src/lib.rs index 3b99f5870..76cb029e1 100644 --- a/bazel/rules/testing/wine/src/lib.rs +++ b/bazel/rules/testing/wine/src/lib.rs @@ -105,6 +105,14 @@ impl WineTestCommand { } impl WineTestProcess { + /// Returns the host path to this process's isolated Wine prefix. + pub fn prefix_path(&self) -> &Path { + let Some(processes) = self.processes.as_ref() else { + panic!("Wine process guard is missing"); + }; + processes.prefix.path() + } + /// Takes the piped standard output of the Wine process. /// /// This may only be called once for a process created by 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 e9611c67e..0a18c588f 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 @@ -16973,9 +16973,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "$ref": "#/definitions/v2/LegacyAppPathString" }, "type": "array" }, @@ -18688,9 +18688,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "$ref": "#/definitions/v2/LegacyAppPathString" }, "type": "array" }, @@ -19105,9 +19105,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "$ref": "#/definitions/v2/LegacyAppPathString" }, "type": "array" }, 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 bac9a48e7..0ddfdb62b 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 @@ -14752,9 +14752,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": "array" }, @@ -16467,9 +16467,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": "array" }, @@ -16884,9 +16884,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": "array" }, 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 9ced571cf..bebd19449 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -2368,9 +2368,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": "array" }, 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 28a5f5b24..73e228845 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -2394,9 +2394,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": "array" }, 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 1baf6e4bd..726088c0d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -2368,9 +2368,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": "array" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts index c5b1201c2..957756247 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.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 { ReasoningEffort } from "../ReasoningEffort"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -9,9 +10,9 @@ import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; export type ThreadForkResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** - * Instruction source files currently loaded for this thread. + * Environment-native paths to instruction source files currently loaded for this thread. */ -instructionSources: Array, approvalPolicy: AskForApproval, /** +instructionSources: Array, approvalPolicy: AskForApproval, /** * Reviewer currently used for approval requests on this thread. */ approvalsReviewer: ApprovalsReviewer, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts index 7a4f90377..e1f7d642b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.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 { ReasoningEffort } from "../ReasoningEffort"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -9,9 +10,9 @@ import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; export type ThreadResumeResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** - * Instruction source files currently loaded for this thread. + * Environment-native paths to instruction source files currently loaded for this thread. */ -instructionSources: Array, approvalPolicy: AskForApproval, /** +instructionSources: Array, approvalPolicy: AskForApproval, /** * Reviewer currently used for approval requests on this thread. */ approvalsReviewer: ApprovalsReviewer, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts index 38859a380..992ab5dba 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.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 { ReasoningEffort } from "../ReasoningEffort"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -9,9 +10,9 @@ import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; export type ThreadStartResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** - * Instruction source files currently loaded for this thread. + * Environment-native paths to instruction source files currently loaded for this thread. */ -instructionSources: Array, approvalPolicy: AskForApproval, /** +instructionSources: Array, approvalPolicy: AskForApproval, /** * Reviewer currently used for approval requests on this thread. */ approvalsReviewer: ApprovalsReviewer, /** diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index e5a882b3b..3336f74a6 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2573,7 +2573,11 @@ mod tests { service_tier: None, cwd, runtime_workspace_roots: Vec::new(), - instruction_sources: vec![absolute_path("/tmp/AGENTS.md")], + instruction_sources: vec![ + codex_utils_path_uri::LegacyAppPathString::from_abs_path(&absolute_path( + "/tmp/AGENTS.md", + )), + ], approval_policy: v2::AskForApproval::OnFailure, approvals_reviewer: v2::ApprovalsReviewer::User, sandbox: v2::SandboxPolicy::DangerFullAccess, 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 0368bf45f..459b11f7f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -36,6 +36,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use codex_utils_path_uri::LegacyAppPathString; +use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use serde_json::Value as JsonValue; use serde_json::json; @@ -3685,17 +3686,49 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { serde_json::from_value(response.clone()).expect("thread/start response"); let resume: ThreadResumeResponse = serde_json::from_value(response.clone()).expect("thread/resume response"); - let fork: ThreadForkResponse = serde_json::from_value(response).expect("thread/fork response"); + let fork: ThreadForkResponse = + serde_json::from_value(response.clone()).expect("thread/fork response"); - assert_eq!(start.instruction_sources, Vec::::new()); + assert_eq!(start.instruction_sources, Vec::::new()); assert_eq!(start.thread.parent_thread_id, None); assert_eq!(start.thread.recency_at, None); - assert_eq!(resume.instruction_sources, Vec::::new()); - assert_eq!(fork.instruction_sources, Vec::::new()); + assert_eq!( + resume.instruction_sources, + Vec::::new() + ); + assert_eq!(fork.instruction_sources, Vec::::new()); assert_eq!(start.active_permission_profile, None); assert_eq!(resume.active_permission_profile, None); assert_eq!(resume.initial_turns_page, None); assert_eq!(fork.active_permission_profile, None); + + let foreign_source: LegacyAppPathString = + serde_json::from_value(json!(r"C:\workspace\AGENTS.md")).expect("foreign source"); + let mut response_with_foreign_source = response; + response_with_foreign_source["instructionSources"] = json!([foreign_source.as_str()]); + let start: ThreadStartResponse = serde_json::from_value(response_with_foreign_source.clone()) + .expect("thread/start response with foreign source"); + let resume: ThreadResumeResponse = serde_json::from_value(response_with_foreign_source.clone()) + .expect("thread/resume response with foreign source"); + let fork: ThreadForkResponse = serde_json::from_value(response_with_foreign_source) + .expect("thread/fork response with foreign source"); + assert_eq!(start.instruction_sources, vec![foreign_source.clone()]); + assert_eq!(resume.instruction_sources, vec![foreign_source.clone()]); + assert_eq!(fork.instruction_sources, vec![foreign_source]); + let foreign_source_uri = + PathUri::parse("file:///C:/workspace/AGENTS.md").expect("foreign source URI"); + assert_eq!( + start.instruction_source_path_uris(), + vec![foreign_source_uri.clone()] + ); + assert_eq!( + resume.instruction_source_path_uris(), + vec![foreign_source_uri.clone()] + ); + assert_eq!( + fork.instruction_source_path_uris(), + vec![foreign_source_uri] + ); } #[test] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index b5809f09b..59adcbaa8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -26,6 +26,8 @@ use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::LegacyAppPathString; +use codex_utils_path_uri::PathUri; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -161,9 +163,9 @@ pub struct ThreadStartResponse { #[experimental("thread/start.runtimeWorkspaceRoots")] #[serde(default)] pub runtime_workspace_roots: Vec, - /// Instruction source files currently loaded for this thread. + /// Environment-native paths to instruction source files currently loaded for this thread. #[serde(default)] - pub instruction_sources: Vec, + pub instruction_sources: Vec, #[experimental(nested)] pub approval_policy: AskForApproval, /// Reviewer currently used for approval requests on this thread. @@ -179,6 +181,13 @@ pub struct ThreadStartResponse { pub reasoning_effort: Option, } +impl ThreadStartResponse { + /// Parses valid absolute instruction source paths and omits malformed legacy values. + pub fn instruction_source_path_uris(&self) -> Vec { + instruction_source_path_uris(&self.instruction_sources) + } +} + #[derive( Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, )] @@ -375,9 +384,9 @@ pub struct ThreadResumeResponse { #[experimental("thread/resume.runtimeWorkspaceRoots")] #[serde(default)] pub runtime_workspace_roots: Vec, - /// Instruction source files currently loaded for this thread. + /// Environment-native paths to instruction source files currently loaded for this thread. #[serde(default)] - pub instruction_sources: Vec, + pub instruction_sources: Vec, #[experimental(nested)] pub approval_policy: AskForApproval, /// Reviewer currently used for approval requests on this thread. @@ -397,6 +406,13 @@ pub struct ThreadResumeResponse { pub initial_turns_page: Option, } +impl ThreadResumeResponse { + /// Parses valid absolute instruction source paths and omits malformed legacy values. + pub fn instruction_source_path_uris(&self) -> Vec { + instruction_source_path_uris(&self.instruction_sources) + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -523,9 +539,9 @@ pub struct ThreadForkResponse { #[experimental("thread/fork.runtimeWorkspaceRoots")] #[serde(default)] pub runtime_workspace_roots: Vec, - /// Instruction source files currently loaded for this thread. + /// Environment-native paths to instruction source files currently loaded for this thread. #[serde(default)] - pub instruction_sources: Vec, + pub instruction_sources: Vec, #[experimental(nested)] pub approval_policy: AskForApproval, /// Reviewer currently used for approval requests on this thread. @@ -541,6 +557,30 @@ pub struct ThreadForkResponse { pub reasoning_effort: Option, } +impl ThreadForkResponse { + /// Parses valid absolute instruction source paths and omits malformed legacy values. + pub fn instruction_source_path_uris(&self) -> Vec { + instruction_source_path_uris(&self.instruction_sources) + } +} + +fn instruction_source_path_uris(sources: &[LegacyAppPathString]) -> Vec { + // Instruction sources are advisory diagnostics. Warn and fail open so a malformed legacy + // path cannot fail thread start, resume, or fork. + sources + .iter() + .filter_map(|source| { + source.to_inferred_path_uri().or_else(|| { + tracing::warn!( + path = source.as_str(), + "ignoring invalid instruction source path from app-server" + ); + None + }) + }) + .collect() +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index deda1c517..871f9cee7 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -140,7 +140,7 @@ Example with notification opt-out: - `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are also started in that environment; HTTP MCP declarations remain inactive. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. -- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. +- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. - `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Experimental clients can use `parentThreadId` to filter direct spawned children represented by persisted spawn-edge state. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 63883cbdf..1aa6e4eea 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1181,7 +1181,7 @@ impl ThreadRequestProcessor { ) .await?; - let instruction_sources = thread.instruction_sources().await; + let instruction_sources = thread.legacy_instruction_sources().await; let config_snapshot = thread .config_snapshot() .instrument(tracing::info_span!( @@ -2681,7 +2681,7 @@ impl ThreadRequestProcessor { self.outgoing.send_error(request_id, err).await; return Ok(()); } - let instruction_sources = codex_thread.instruction_sources().await; + let instruction_sources = codex_thread.legacy_instruction_sources().await; let SessionConfiguredEvent { rollout_path, .. } = session_configured; let Some(rollout_path) = rollout_path else { let error = @@ -2989,7 +2989,7 @@ impl ThreadRequestProcessor { /*include_turns*/ false, ); thread_summary.session_id = existing_thread.session_configured().session_id.to_string(); - let instruction_sources = existing_thread.instruction_sources().await; + let instruction_sources = existing_thread.legacy_instruction_sources().await; let listener_command_tx = { let thread_state = thread_state.lock().await; @@ -3422,7 +3422,7 @@ impl ThreadRequestProcessor { .map_err(|err| core_thread_write_error("inherit source thread name", err))?; } - let instruction_sources = forked_thread.instruction_sources().await; + let instruction_sources = forked_thread.legacy_instruction_sources().await; // Auto-attach a conversation listener when forking a thread. log_listener_attach_result( diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 890d4da5e..91de76b99 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -13,7 +13,7 @@ use codex_protocol::ThreadId; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RolloutItem; use codex_rollout::state_db::StateDbHandle; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::LegacyAppPathString; use std::collections::HashMap; use std::collections::HashSet; use std::sync::Arc; @@ -31,7 +31,7 @@ pub(crate) struct PendingThreadResumeRequest { pub(crate) request_id: ConnectionRequestId, pub(crate) history_items: Vec, pub(crate) config_snapshot: ThreadConfigSnapshot, - pub(crate) instruction_sources: Vec, + pub(crate) instruction_sources: Vec, pub(crate) thread_summary: codex_app_server_protocol::Thread, pub(crate) emit_thread_goal_update: bool, pub(crate) thread_goal_state_db: Option, @@ -200,6 +200,7 @@ mod tests { use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; + use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; #[test] diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index cd508fc68..07ee66caf 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -82,6 +82,7 @@ use codex_rollout::append_rollout_item_to_path; use codex_rollout::read_session_meta_line; use codex_state::StateRuntime; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::LegacyAppPathString; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; @@ -288,7 +289,8 @@ async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Resul .. } = to_response::(start_resp)?; let project_agents = AbsolutePathBuf::try_from(project_agents)?; - assert_eq!(instruction_sources, vec![project_agents.clone()]); + let project_agents_source = LegacyAppPathString::from_abs_path(&project_agents); + assert_eq!(instruction_sources, vec![project_agents_source.clone()]); let turn_id = mcp .send_turn_start_request(TurnStartParams { @@ -330,7 +332,7 @@ async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Resul .. } = to_response::(resume_resp)?; - assert_eq!(instruction_sources, vec![project_agents]); + assert_eq!(instruction_sources, vec![project_agents_source]); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index c0946ba67..dcfeb4fa9 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -381,7 +381,7 @@ async fn thread_start_response_includes_loaded_instruction_sources() -> Result<( let instruction_sources = instruction_sources .into_iter() - .map(normalize_path_for_comparison) + .map(|path| normalize_path_for_comparison(path.as_str())) .collect::>(); let expected_instruction_sources = vec![ std::fs::canonicalize(global_agents_path)?, @@ -428,7 +428,7 @@ async fn thread_start_response_excludes_empty_project_instruction_source() -> Re let instruction_sources = instruction_sources .into_iter() - .map(normalize_path_for_comparison) + .map(|path| normalize_path_for_comparison(path.as_str())) .collect::>(); let expected_instruction_sources = vec![normalize_path_for_comparison(std::fs::canonicalize( global_agents_path, @@ -474,7 +474,7 @@ async fn thread_start_without_selected_environment_includes_only_global_instruct assert_eq!( instruction_sources .into_iter() - .map(normalize_path_for_comparison) + .map(|path| normalize_path_for_comparison(path.as_str())) .collect::>(), vec![normalize_path_for_comparison(std::fs::canonicalize( global_agents_path, diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 861e7b4ac..2465cb6d5 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -53,16 +53,11 @@ pub(crate) async fn load_project_instructions( let mut loaded = LoadedAgentsMd::from_user_instructions(user_instructions); for turn_environment in &environments.turn_environments { let filesystem = turn_environment.environment.get_filesystem(); - // TODO(anp): Migrate AGENTS.md discovery to PathUri so instructions can be loaded from - // environment-native foreign working directories. - let Ok(cwd) = turn_environment.cwd().to_abs_path() else { - continue; - }; match read_agents_md( config, filesystem.as_ref(), &turn_environment.environment_id, - &cwd, + turn_environment.cwd(), ) .await { @@ -97,7 +92,7 @@ async fn read_agents_md( config: &Config, fs: &dyn ExecutorFileSystem, environment_id: &str, - cwd: &AbsolutePathBuf, + cwd: &PathUri, ) -> io::Result> { let max_total = config.project_doc_max_bytes; @@ -118,15 +113,14 @@ async fn read_agents_md( break; } - let path_uri = PathUri::from_abs_path(&p); - match fs.get_metadata(&path_uri, /*sandbox*/ None).await { + match fs.get_metadata(&p, /*sandbox*/ None).await { Ok(metadata) if !metadata.is_file => continue, Ok(_) => {} Err(err) if err.kind() == io::ErrorKind::NotFound => continue, Err(err) => return Err(err), } - let mut data = match fs.read_file(&path_uri, /*sandbox*/ None).await { + let mut data = match fs.read_file(&p, /*sandbox*/ None).await { Ok(data) => data, Err(err) if err.kind() == io::ErrorKind::NotFound => continue, Err(err) => return Err(err), @@ -139,7 +133,7 @@ async fn read_agents_md( if size > remaining { tracing::warn!( "Project doc `{}` exceeds remaining budget ({} bytes) - truncating.", - p.display(), + p.inferred_native_path_string(), remaining, ); } @@ -169,9 +163,9 @@ async fn read_agents_md( /// directory, inclusive. Symlinks are allowed. async fn agents_md_paths( config: &Config, - cwd: &AbsolutePathBuf, + cwd: &PathUri, fs: &dyn ExecutorFileSystem, -) -> io::Result> { +) -> io::Result> { let dir = cwd.clone(); let mut merged = TomlValue::Table(toml::map::Map::new()); @@ -194,18 +188,18 @@ async fn agents_md_paths( }; let mut project_root = None; if !project_root_markers.is_empty() { - for ancestor in dir.ancestors() { + for current in dir.ancestors() { for marker in &project_root_markers { - let marker_path = ancestor.join(marker); - let marker_path_uri = PathUri::from_abs_path(&marker_path); - let marker_exists = match fs.get_metadata(&marker_path_uri, /*sandbox*/ None).await - { + let marker_path = current + .join(marker) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; + let marker_exists = match fs.get_metadata(&marker_path, /*sandbox*/ None).await { Ok(_) => true, Err(err) if err.kind() == io::ErrorKind::NotFound => false, Err(err) => return Err(err), }; if marker_exists { - project_root = Some(ancestor.clone()); + project_root = Some(current.clone()); break; } } @@ -215,7 +209,7 @@ async fn agents_md_paths( } } - let search_dirs: Vec = if let Some(root) = project_root { + let search_dirs: Vec = if let Some(root) = project_root { let mut dirs = Vec::new(); let mut cursor = dir.clone(); loop { @@ -234,13 +228,14 @@ async fn agents_md_paths( vec![dir] }; - let mut found: Vec = Vec::new(); + let mut found: Vec = Vec::new(); let candidate_filenames = candidate_filenames(config); for d in search_dirs { for name in &candidate_filenames { - let candidate = d.join(name); - let candidate_uri = PathUri::from_abs_path(&candidate); - match fs.get_metadata(&candidate_uri, /*sandbox*/ None).await { + let candidate = d + .join(name) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; + match fs.get_metadata(&candidate, /*sandbox*/ None).await { Ok(md) if md.is_file => { found.push(candidate); break; @@ -371,7 +366,7 @@ impl LoadedAgentsMd { fn environment_labeled_text(&self) -> String { let mut output = String::new(); let mut has_previous = false; - let mut previous_environment: Option<(&str, &AbsolutePathBuf)> = None; + let mut previous_environment: Option<(&str, &PathUri)> = None; if let Some(instructions) = &self.user_instructions { output.push_str(&instructions.text); has_previous = true; @@ -394,7 +389,7 @@ impl LoadedAgentsMd { output.push_str(&format!( "for `{}` with root {}\n\n", environment_id, - cwd.display() + cwd.inferred_native_path_string() )); } output.push_str(&entry.contents); @@ -421,7 +416,7 @@ impl LoadedAgentsMd { None } else { self.single_project_cwd() - .map(|cwd| cwd.to_string_lossy().into_owned()) + .map(PathUri::inferred_native_path_string) }; ContextUserInstructions { directory, @@ -436,10 +431,10 @@ impl LoadedAgentsMd { } /// Returns the AGENTS.md files that supplied instruction entries. - pub fn sources(&self) -> impl Iterator { + pub fn sources(&self) -> impl Iterator + '_ { self.user_instructions .iter() - .map(|instructions| &instructions.source) + .map(|instructions| PathUri::from_abs_path(&instructions.source)) .chain( self.entries .iter() @@ -463,7 +458,7 @@ impl LoadedAgentsMd { }) } - fn single_project_cwd(&self) -> Option<&AbsolutePathBuf> { + fn single_project_cwd(&self) -> Option<&PathUri> { self.entries .iter() .find_map(|entry| match &entry.provenance { @@ -488,9 +483,9 @@ enum InstructionProvenance { /// Workspace instructions discovered from project AGENTS.md files. Project { /// Exact AGENTS.md file, distinct from the environment's selected cwd. - source_path: AbsolutePathBuf, + source_path: PathUri, environment_id: String, - cwd: AbsolutePathBuf, + cwd: PathUri, }, /// Instructions without a file source, including internally defined guidance. @@ -498,9 +493,9 @@ enum InstructionProvenance { } impl InstructionProvenance { - fn path(&self) -> Option<&AbsolutePathBuf> { + fn path(&self) -> Option { match self { - Self::Project { source_path, .. } => Some(source_path), + Self::Project { source_path, .. } => Some(source_path.clone()), Self::Internal => None, } } diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index 42604629a..ab027112a 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -250,8 +250,13 @@ async fn load_agents_md(config: &TestConfig) -> Option { .await } -async fn agents_md_paths(config: &TestConfig) -> std::io::Result> { - super::agents_md_paths(&config.config, &config.cwd, LOCAL_FS.as_ref()).await +async fn agents_md_paths(config: &TestConfig) -> std::io::Result> { + super::agents_md_paths( + &config.config, + &PathUri::from_abs_path(&config.cwd), + LOCAL_FS.as_ref(), + ) + .await } fn resolved_local_environments( @@ -277,12 +282,101 @@ fn resolved_local_environments( fn project_provenance(path: AbsolutePathBuf, cwd: AbsolutePathBuf) -> InstructionProvenance { InstructionProvenance::Project { - source_path: path, + source_path: PathUri::from_abs_path(&path), environment_id: "local".to_string(), - cwd, + cwd: PathUri::from_abs_path(&cwd), } } +#[test] +fn foreign_agents_md_uses_environment_native_paths() { + let (cwd, rendered_cwd) = if cfg!(windows) { + ( + PathUri::parse("file:///codex%20runtime").expect("POSIX cwd URI"), + "/codex runtime", + ) + } else { + ( + PathUri::parse("file:///C:/codex%20runtime").expect("Windows cwd URI"), + r"C:\codex runtime", + ) + }; + let source_path = cwd.join("AGENTS.md").expect("AGENTS.md URI"); + let loaded = LoadedAgentsMd { + user_instructions: None, + entries: vec![InstructionEntry { + contents: "remote instructions".to_string(), + provenance: InstructionProvenance::Project { + source_path: source_path.clone(), + environment_id: "remote".to_string(), + cwd, + }, + }], + }; + + assert_eq!( + loaded.render(), + format!( + "# AGENTS.md instructions for {rendered_cwd} + + +remote instructions +" + ) + ); + assert_eq!(loaded.sources().collect::>(), vec![source_path]); +} + +#[test] +fn multi_environment_agents_md_renders_mixed_path_conventions() { + let posix_cwd = PathUri::parse("file:///srv/project").expect("POSIX cwd URI"); + let windows_cwd = PathUri::parse("file:///C:/workspace").expect("Windows cwd URI"); + let posix_source = posix_cwd.join("AGENTS.md").expect("POSIX AGENTS.md URI"); + let windows_source = windows_cwd + .join("AGENTS.md") + .expect("Windows AGENTS.md URI"); + let loaded = LoadedAgentsMd { + user_instructions: None, + entries: vec![ + InstructionEntry { + contents: "POSIX instructions".to_string(), + provenance: InstructionProvenance::Project { + source_path: posix_source.clone(), + environment_id: "posix".to_string(), + cwd: posix_cwd, + }, + }, + InstructionEntry { + contents: "Windows instructions".to_string(), + provenance: InstructionProvenance::Project { + source_path: windows_source.clone(), + environment_id: "windows".to_string(), + cwd: windows_cwd, + }, + }, + ], + }; + + assert_eq!( + loaded.render(), + r#"# AGENTS.md instructions + + +for `posix` with root /srv/project + +POSIX instructions + +for `windows` with root C:\workspace + +Windows instructions +"# + ); + assert_eq!( + loaded.sources().collect::>(), + vec![posix_source, windows_source] + ); +} + /// Helper that returns a `Config` pointing at `root` and using `limit` as /// the maximum number of bytes to embed from AGENTS.md. The caller can /// optionally specify a custom `instructions` string – when `None` the @@ -508,7 +602,7 @@ async fn read_agents_md_propagates_metadata_errors() { }; let cwd = config.cwd.clone(); - let err = read_agents_md(&config.config, &fs, "local", &cwd) + let err = read_agents_md(&config.config, &fs, "local", &PathUri::from_abs_path(&cwd)) .await .expect_err("metadata error"); @@ -526,7 +620,7 @@ async fn read_agents_md_propagates_read_errors() { }; let cwd = config.cwd.clone(); - let err = read_agents_md(&config.config, &fs, "local", &cwd) + let err = read_agents_md(&config.config, &fs, "local", &PathUri::from_abs_path(&cwd)) .await .expect_err("read error"); @@ -544,7 +638,7 @@ async fn read_agents_md_ignores_files_removed_after_discovery() { }; let cwd = config.cwd.clone(); - let loaded = read_agents_md(&config.config, &fs, "local", &cwd) + let loaded = read_agents_md(&config.config, &fs, "local", &PathUri::from_abs_path(&cwd)) .await .expect("removed file is recoverable"); @@ -659,17 +753,18 @@ secondary doc"#, ); assert_eq!(loaded.render(), expected_fragment); assert_eq!( - loaded.sources().cloned().collect::>(), + loaded.sources().collect::>(), vec![ - config - .user_instructions - .as_ref() - .expect("global instructions") - .source - .clone(), - primary.path().join("AGENTS.md").abs(), - primary_nested.join("AGENTS.md").abs(), - secondary.path().join("AGENTS.md").abs(), + PathUri::from_abs_path( + &config + .user_instructions + .as_ref() + .expect("global instructions") + .source, + ), + PathUri::from_abs_path(&primary.path().join("AGENTS.md").abs()), + PathUri::from_abs_path(&primary_nested.join("AGENTS.md").abs()), + PathUri::from_abs_path(&secondary.path().join("AGENTS.md").abs()), ] ); } @@ -898,7 +993,10 @@ async fn concatenates_root_and_cwd_docs() { assert_eq!(loaded.text(), "root doc\n\ncrate doc"); assert_eq!( loaded.sources().collect::>(), - vec![&root_agents, &crate_agents] + vec![ + PathUri::from_abs_path(&root_agents), + PathUri::from_abs_path(&crate_agents), + ] ); } @@ -925,8 +1023,8 @@ async fn project_root_markers_are_honored_for_agents_discovery() { let expected_parent = root.path().join("AGENTS.md").abs(); let expected_child = cfg.cwd.join("AGENTS.md"); assert_eq!(discovery.len(), 2); - assert_eq!(discovery[0], expected_parent); - assert_eq!(discovery[1], expected_child); + assert_eq!(discovery[0], PathUri::from_abs_path(&expected_parent)); + assert_eq!(discovery[1], PathUri::from_abs_path(&expected_child)); let res = get_user_instructions(&cfg).await.expect("doc expected"); assert_eq!(res, "parent doc\n\nchild doc"); @@ -971,8 +1069,8 @@ async fn project_layers_do_not_override_project_root_markers() { assert_eq!( discovery, vec![ - root.path().join("AGENTS.md").abs(), - config.cwd.join("AGENTS.md"), + PathUri::from_abs_path(&root.path().join("AGENTS.md").abs()), + PathUri::from_abs_path(&config.cwd.join("AGENTS.md")), ] ); } @@ -991,7 +1089,10 @@ async fn agents_md_paths_preserve_symlinked_cwd() { cfg.cwd = linked_cwd.abs(); let discovery = agents_md_paths(&cfg).await.expect("discover paths"); - assert_eq!(discovery, vec![cfg.cwd.join("AGENTS.md")]); + assert_eq!( + discovery, + vec![PathUri::from_abs_path(&cfg.cwd.join("AGENTS.md"))] + ); let res = get_user_instructions(&cfg).await.expect("doc expected"); assert_eq!(res, "project doc"); @@ -1050,7 +1151,10 @@ async fn instruction_sources_include_global_before_agents_md_docs() { assert_eq!(loaded.user_instructions(), cfg.user_instructions.as_ref()); assert_eq!( loaded.sources().collect::>(), - vec![&global_agents, &project_agents] + vec![ + PathUri::from_abs_path(&global_agents), + PathUri::from_abs_path(&project_agents), + ] ); assert_eq!( loaded.text(), @@ -1091,7 +1195,10 @@ async fn child_agents_message_after_project_docs_is_not_an_instruction_source() assert_eq!(loaded, expected); assert_eq!( loaded.sources().collect::>(), - vec![&global_agents, &project_agents] + vec![ + PathUri::from_abs_path(&global_agents), + PathUri::from_abs_path(&project_agents), + ] ); assert_eq!( loaded.text(), @@ -1117,8 +1224,8 @@ async fn agents_local_md_preferred() { let discovery = agents_md_paths(&cfg).await.expect("discover paths"); assert_eq!(discovery.len(), 1); assert_eq!( - discovery[0].file_name().unwrap().to_string_lossy(), - LOCAL_AGENTS_MD_FILENAME + discovery[0].basename().as_deref(), + Some(LOCAL_AGENTS_MD_FILENAME) ); } @@ -1166,12 +1273,9 @@ async fn agents_md_preferred_over_fallbacks() { let discovery = agents_md_paths(&cfg).await.expect("discover paths"); assert_eq!(discovery.len(), 1); - assert!( - discovery[0] - .file_name() - .unwrap() - .to_string_lossy() - .eq(DEFAULT_AGENTS_MD_FILENAME) + assert_eq!( + discovery[0].basename().as_deref(), + Some(DEFAULT_AGENTS_MD_FILENAME) ); } @@ -1186,7 +1290,7 @@ async fn agents_md_directory_is_ignored() { assert_eq!(res, None); let discovery = agents_md_paths(&cfg).await.expect("discover paths"); - assert_eq!(discovery, Vec::::new()); + assert_eq!(discovery, Vec::::new()); } #[cfg(unix)] @@ -1209,7 +1313,7 @@ async fn agents_md_special_file_is_ignored() { assert_eq!(res, None); let discovery = agents_md_paths(&cfg).await.expect("discover paths"); - assert_eq!(discovery, Vec::::new()); + assert_eq!(discovery, Vec::::new()); } #[tokio::test] @@ -1228,11 +1332,8 @@ async fn override_directory_falls_back_to_agents_md_file() { let discovery = agents_md_paths(&cfg).await.expect("discover paths"); assert_eq!(discovery.len(), 1); assert_eq!( - discovery[0] - .file_name() - .expect("file name") - .to_string_lossy(), - DEFAULT_AGENTS_MD_FILENAME + discovery[0].basename().as_deref(), + Some(DEFAULT_AGENTS_MD_FILENAME) ); } diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 8c4485729..ab46d8b3a 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -42,6 +42,7 @@ use codex_thread_store::ThreadMetadataPatch; use codex_thread_store::ThreadStoreError; use codex_thread_store::ThreadStoreResult; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::PathUri; use rmcp::model::ReadResourceRequestParams; use std::collections::BTreeMap; @@ -564,10 +565,19 @@ impl CodexThread { } /// Returns the files that supplied the thread's loaded model instructions. - pub async fn instruction_sources(&self) -> Vec { + pub async fn instruction_sources(&self) -> Vec { self.codex.instruction_sources().await } + /// Returns loaded instruction sources rendered as legacy app-server path strings. + pub async fn legacy_instruction_sources(&self) -> Vec { + self.instruction_sources() + .await + .into_iter() + .map(Into::into) + .collect() + } + pub async fn config(&self) -> Arc { self.codex.session.get_config().await } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index b55c4b11d..5d579f14a 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -830,15 +830,13 @@ impl Codex { state.session_configuration.thread_config_snapshot() } - pub(crate) async fn instruction_sources(&self) -> Vec { + pub(crate) async fn instruction_sources(&self) -> Vec { let state = self.session.state.lock().await; state .session_configuration .loaded_agents_md .as_ref() - .map_or_else(Vec::new, |instructions| { - instructions.sources().cloned().collect() - }) + .map_or_else(Vec::new, |instructions| instructions.sources().collect()) } pub(crate) async fn thread_environment_selections(&self) -> Vec { diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 42b9bf87c..e0e7b2028 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -58,6 +58,7 @@ use tempfile::TempDir; use wiremock::MockServer; use crate::TempDirExt; +use crate::TestEnvironment; use crate::get_remote_test_env; use crate::load_default_config_for_test; use crate::load_default_config_for_test_with_cloud_config_bundle; @@ -179,7 +180,20 @@ pub async fn test_env() -> Result { /*sandbox*/ None, ) .await?; - let cwd = cwd_uri.to_abs_path()?; + let cwd = if remote_env == TestEnvironment::WineExec { + // TODO(anp): Convert `Config::cwd` to `LegacyAppPathString` and remove this + // compatibility projection. + // `Config::cwd` still requires `AbsolutePathBuf`. Preserve the test harness's + // Linux-absolute `/C:/...` compatibility spelling so converting it back to a + // `PathUri` recovers the remote Windows convention. Production conversions stay + // strict: `PathUri::to_abs_path` intentionally rejects foreign paths. + let path = cwd_uri.to_url().to_file_path().map_err(|()| { + anyhow!("remote test cwd URI cannot be projected onto the host: {cwd_uri}") + })?; + AbsolutePathBuf::try_from(path)? + } else { + cwd_uri.to_abs_path()? + }; Ok(TestEnv { environment, exec_server_url: Some(websocket_url), diff --git a/codex-rs/core/tests/remote_env_windows/BUILD.bazel b/codex-rs/core/tests/remote_env_windows/BUILD.bazel index 5e213ac14..9b5ce52bd 100644 --- a/codex-rs/core/tests/remote_env_windows/BUILD.bazel +++ b/codex-rs/core/tests/remote_env_windows/BUILD.bazel @@ -22,7 +22,6 @@ wine_rust_test( "//codex-rs/protocol", "//codex-rs/utils/path-uri", "@crates//:anyhow", - "@crates//:base64", "@crates//:pretty_assertions", "@crates//:serde_json", "@crates//:tempfile", 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 cff8ded73..04f891498 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 @@ -36,11 +36,13 @@ use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use codex_utils_path_uri::LegacyAppPathString; +use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; use std::collections::BTreeMap; +use std::fs; use tempfile::TempDir; use tokio::time::timeout; use wine_exec_server_test_support::WineExecServer; @@ -57,7 +59,7 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { const VERIFY_COMMAND: &str = r#"$path = Join-Path (Get-Location) 'codex-apply-patch-smoke.txt'; if (-not (Test-Path $path)) { exit 1 }; if ([IO.File]::ReadAllText($path) -ne "patched through unified exec`n") { exit 2 }; Remove-Item $path; Write-Output 'PATCH_VERIFIED'"#; WineExecServer - .scope(|exec_server_url| async move { + .scope(|exec_server_url, _wine_prefix| async move { let server = start_mock_server().await; let arguments = serde_json::to_string(&json!({ "cmd": COMMAND, @@ -245,8 +247,17 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Result<()> { + const AGENTS_INSTRUCTIONS: &str = "remote Windows workspace instructions"; + const NATIVE_CWD: &str = r"C:\windows"; + WineExecServer - .scope(|exec_server_url| async move { + .scope(|exec_server_url, wine_prefix| async move { + let agents_path = PathUri::parse("file:///C:/windows/AGENTS.md")?; + fs::write( + wine_prefix.join("drive_c").join("windows").join("AGENTS.md"), + AGENTS_INSTRUCTIONS, + )?; + let codex_home = TempDir::new()?; let server = create_mock_responses_server_repeating_assistant("done").await; write_mock_responses_config_toml( @@ -272,7 +283,7 @@ async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Resul .send_thread_start_request(ThreadStartParams { environments: Some(vec![TurnEnvironmentParams { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd: serde_json::from_value::(json!(r"C:\windows"))?, + cwd: serde_json::from_value::(json!(NATIVE_CWD))?, }]), ..Default::default() }) @@ -289,8 +300,13 @@ async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Resul assert_eq!(response.cwd, host_cwd); // TODO(anp): Derive runtime workspace roots from the selected remote environment. assert_eq!(response.runtime_workspace_roots, vec![host_cwd]); - // TODO(anp): Discover and report instruction sources from the remote filesystem. - assert_eq!(response.instruction_sources, Vec::new()); + assert_eq!( + response.instruction_sources, + vec![LegacyAppPathString::from_path_uri( + &agents_path, + PathConvention::Windows, + )?] + ); // TODO(anp): Report the implicit built-in permission profile instead of None. assert_eq!(response.active_permission_profile, None); @@ -326,6 +342,17 @@ async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Resul .find(|request| request.url.path().ends_with("/responses")) .context("turn should send a Responses request")?; let body = first_request.body_json::()?; + let remote_instructions = body["input"] + .as_array() + .into_iter() + .flatten() + .filter(|item| item.get("role").and_then(Value::as_str) == Some("user")) + .filter_map(|item| item.get("content").and_then(Value::as_array)) + .flatten() + .filter_map(|content| content.get("text").and_then(Value::as_str)) + .find(|text| text.contains(AGENTS_INSTRUCTIONS)) + .context("remote workspace instructions should be model visible")?; + assert!(remote_instructions.contains(r"# AGENTS.md instructions for C:\windows")); let environment_context = body["input"] .as_array() .into_iter() diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index 5b79e9f1e..e3a08447c 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -25,7 +25,6 @@ use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; -use core_test_support::skip_if_wine_exec; use core_test_support::test_codex::RecordingUserInstructionsProvider; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::test_codex; @@ -88,7 +87,7 @@ fn instruction_fragments(request: &responses::ResponsesRequest) -> Vec { } fn expected_instruction_fragment(cwd: &AbsolutePathBuf, contents: &str) -> String { - let cwd = cwd.as_path().display(); + let cwd = PathUri::from_abs_path(cwd).inferred_native_path_string(); format!("# AGENTS.md instructions for {cwd}\n\n\n{contents}\n") } @@ -138,8 +137,6 @@ fn request_body_contains(request: &wiremock::Request, text: &str) -> bool { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn agents_override_is_preferred_over_agents_md() -> Result<()> { - // TODO(anp): Remove after instruction-source helpers use target-native paths. - skip_if_wine_exec!(Ok(()), "requires native cross-OS instruction-source paths"); let instructions = agents_instructions(test_codex().with_workspace_setup(|cwd, fs| async move { let agents_md = cwd.join("AGENTS.md"); @@ -172,8 +169,6 @@ async fn agents_override_is_preferred_over_agents_md() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn configured_fallback_is_used_when_agents_candidate_is_directory() -> Result<()> { - // TODO(anp): Remove after instruction-source helpers use target-native paths. - skip_if_wine_exec!(Ok(()), "requires native cross-OS instruction-source paths"); let instructions = agents_instructions( test_codex() .with_config(|config| { @@ -334,8 +329,8 @@ async fn symlinked_cwd_uses_logical_parent_for_agents_discovery() -> Result<()> assert_eq!( test.codex.instruction_sources().await, vec![ - logical_root.join("AGENTS.md"), - test.config.cwd.join("AGENTS.md") + PathUri::from_abs_path(&logical_root.join("AGENTS.md")), + PathUri::from_abs_path(&test.config.cwd.join("AGENTS.md")) ] ); @@ -355,8 +350,6 @@ async fn symlinked_cwd_uses_logical_parent_for_agents_discovery() -> Result<()> #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn selected_environment_sources_match_model_visible_instructions() -> Result<()> { - // TODO(anp): Remove after instruction-source helpers use target-native paths. - skip_if_wine_exec!(Ok(()), "requires native cross-OS instruction-source paths"); let server = start_mock_server().await; let resp_mock = mount_sse_once( &server, @@ -385,7 +378,10 @@ async fn selected_environment_sources_match_model_visible_instructions() -> Resu assert_eq!( test.codex.instruction_sources().await, - vec![global_agents, project_agents] + vec![ + PathUri::from_abs_path(&global_agents), + PathUri::from_abs_path(&project_agents), + ] ); test.submit_turn("hello").await?; @@ -454,7 +450,7 @@ async fn loads_user_instructions_without_a_primary_environment() -> Result<()> { assert_eq!(provider.load_count(), 2); assert_eq!( no_environment_thread.thread.instruction_sources().await, - vec![global_source] + vec![PathUri::from_abs_path(&global_source)] ); no_environment_thread @@ -485,8 +481,6 @@ async fn loads_user_instructions_without_a_primary_environment() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Result<()> { - // TODO(anp): Remove after instruction-source helpers use target-native paths. - skip_if_wine_exec!(Ok(()), "requires native cross-OS instruction-source paths"); // Set up one global source, one project source, and two ordinary model turns. let server = responses::start_mock_server().await; let response_mock = responses::mount_sse_sequence( @@ -521,7 +515,10 @@ async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Re }); let test = builder.build_with_remote_env(&server).await?; let project_source = test.config.cwd.join(GLOBAL_AGENTS_FILENAME); - let creation_sources = vec![global_source.clone(), project_source.clone()]; + let creation_sources = vec![ + PathUri::from_abs_path(&global_source), + PathUri::from_abs_path(&project_source), + ]; // Confirm the thread records both creation-time sources in composition order. assert_eq!(test.codex.instruction_sources().await, creation_sources); @@ -597,8 +594,6 @@ async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Re #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapshot() -> Result<()> { - // TODO(anp): Remove after instruction-source helpers use target-native paths. - skip_if_wine_exec!(Ok(()), "requires native cross-OS instruction-source paths"); skip_if_no_network!(Ok(())); let Some(_remote_env) = get_remote_test_env() else { return Ok(()); @@ -672,9 +667,9 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho assert_eq!( thread.thread.instruction_sources().await, vec![ - global_source.clone(), - remote_source.clone(), - local_source.clone().try_into()?, + PathUri::from_abs_path(&global_source), + PathUri::from_abs_path(&remote_source), + PathUri::from_path(&local_source)?, ] ); @@ -700,7 +695,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho let contents = format!( "{GLOBAL_INSTRUCTIONS}\n\nfor `{REMOTE_ENVIRONMENT_ID}` with root {}\n\nremote project instructions\n\nfor `{LOCAL_ENVIRONMENT_ID}` with root {}\n\nlocal project instructions", - test.config.cwd.display(), + PathUri::from_abs_path(&test.config.cwd).inferred_native_path_string(), local_root.path().display(), ); let expected = @@ -712,7 +707,11 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho assert_eq!(provider.load_count(), 2); assert_eq!( thread.thread.instruction_sources().await, - vec![global_source, remote_source, local_source.try_into()?] + vec![ + PathUri::from_abs_path(&global_source), + PathUri::from_abs_path(&remote_source), + PathUri::from_path(&local_source)?, + ] ); Ok(()) @@ -741,7 +740,10 @@ async fn invalid_utf8_global_instructions_are_lossy() -> Result<()> { test.submit_turn("inspect lossy global instructions") .await?; - assert_eq!(test.codex.instruction_sources().await, vec![source.clone()]); + assert_eq!( + test.codex.instruction_sources().await, + vec![PathUri::from_abs_path(&source)] + ); let expected_fragment = expected_provider_only_instruction_fragment("global\u{FFFD}instructions"); assert_single_instruction_fragment(&response_mock.single_request(), &expected_fragment); @@ -784,7 +786,7 @@ async fn cold_resume_replays_rendered_instructions_but_reports_current_config_so // Assert the pre-resume thread reports the source used to create its snapshot. assert_eq!( initial.codex.instruction_sources().await, - vec![old_source.clone()], + vec![PathUri::from_abs_path(&old_source)], "initial thread reports the creation-time global source" ); initial.submit_turn("persist instructions").await?; @@ -814,7 +816,7 @@ async fn cold_resume_replays_rendered_instructions_but_reports_current_config_so // Assert the API reports the new source while model history replays the old structured prefix. assert_eq!( resumed.codex.instruction_sources().await, - vec![new_source], + vec![PathUri::from_abs_path(&new_source)], "resume reports sources from the newly loaded config" ); @@ -868,7 +870,7 @@ async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()> // Assert the parent reports the source used to create its snapshot. assert_eq!( parent.codex.instruction_sources().await, - vec![source.clone()], + vec![PathUri::from_abs_path(&source)], "parent reports the creation-time global source" ); parent.submit_turn("persist instructions").await?; @@ -903,7 +905,7 @@ async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()> // Assert the fork reports the new source before issuing its first turn. assert_eq!( forked.thread.instruction_sources().await, - vec![new_source], + vec![PathUri::from_abs_path(&new_source)], "fork config should reflect the newly loaded global source" ); @@ -1033,7 +1035,7 @@ async fn run_subagent_global_instruction_case(fork_context: bool) -> Result<()> // Assert the parent reports the creation-time source before spawning. assert_eq!( test.codex.instruction_sources().await, - vec![source.clone()], + vec![PathUri::from_abs_path(&source)], "parent reports the creation-time global source before spawning" ); test.submit_turn(SPAWN_SEED_PROMPT).await?; @@ -1076,12 +1078,12 @@ async fn run_subagent_global_instruction_case(fork_context: bool) -> Result<()> assert_single_instruction_fragment(&child_request, &expected_fragment); assert_eq!( test.codex.instruction_sources().await, - vec![source.clone()], + vec![PathUri::from_abs_path(&source)], "running parent retains the creation-time global source after spawning" ); assert_eq!( child_thread.instruction_sources().await, - vec![source], + vec![PathUri::from_abs_path(&source)], "subagent reports the parent's creation-time source" ); if fork_context { diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 3b709d54a..92ec66e16 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -25,6 +25,7 @@ use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use core_test_support::PathBufExt; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; @@ -4593,7 +4594,7 @@ async fn manual_compaction_keeps_the_creation_time_global_instructions() -> Resu // Assert the pre-compaction source list points at the creation-time file. assert_eq!( test.codex.instruction_sources().await, - vec![source.clone()], + vec![PathUri::from_abs_path(&source)], "thread reports the creation-time global source before compaction" ); @@ -4623,7 +4624,7 @@ async fn manual_compaction_keeps_the_creation_time_global_instructions() -> Resu assert_single_instruction_fragment(&requests[2], &expected_fragment); assert_eq!( test.codex.instruction_sources().await, - vec![source], + vec![PathUri::from_abs_path(&source)], "thread retains the creation-time global source after compaction" ); @@ -4673,7 +4674,7 @@ async fn mid_turn_compaction_keeps_the_creation_time_global_instructions() -> Re // Assert the pre-compaction source list points at the creation-time file. assert_eq!( test.codex.instruction_sources().await, - vec![source.clone()], + vec![PathUri::from_abs_path(&source)], "thread reports the creation-time global source before mid-turn compaction" ); @@ -4695,7 +4696,7 @@ async fn mid_turn_compaction_keeps_the_creation_time_global_instructions() -> Re assert_single_instruction_fragment(&requests[2], &expected_fragment); assert_eq!( test.codex.instruction_sources().await, - vec![source], + vec![PathUri::from_abs_path(&source)], "thread retains the creation-time global source after mid-turn compaction" ); @@ -4780,7 +4781,7 @@ async fn remote_v2_compaction_keeps_creation_time_instructions_after_same_path_m ); assert_eq!( test.codex.instruction_sources().await, - vec![source.clone()], + vec![PathUri::from_abs_path(&source)], "running thread retains the selected same-path source" ); assert_eq!( @@ -4829,7 +4830,7 @@ async fn remote_v2_compaction_keeps_creation_time_instructions_after_same_path_m ); assert_eq!( resumed.codex.instruction_sources().await, - vec![source], + vec![PathUri::from_abs_path(&source)], "cold-resumed thread reports the same rewritten source path" ); diff --git a/codex-rs/core/tests/suite/hierarchical_agents.rs b/codex-rs/core/tests/suite/hierarchical_agents.rs index f017d7d42..c8c5da94b 100644 --- a/codex-rs/core/tests/suite/hierarchical_agents.rs +++ b/codex-rs/core/tests/suite/hierarchical_agents.rs @@ -5,7 +5,6 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; -use core_test_support::skip_if_wine_exec; use core_test_support::test_codex::test_codex; const HIERARCHICAL_AGENTS_SNIPPET: &str = @@ -13,8 +12,6 @@ const HIERARCHICAL_AGENTS_SNIPPET: &str = #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn hierarchical_agents_appends_to_project_doc_in_user_instructions() { - // TODO(anp): Remove after instruction-source helpers use target-native paths. - skip_if_wine_exec!("requires native cross-OS instruction-source paths"); let server = start_mock_server().await; let resp_mock = mount_sse_once( &server, diff --git a/codex-rs/exec-server/testing/wine_exec_server.rs b/codex-rs/exec-server/testing/wine_exec_server.rs index 88857e785..a3643f866 100644 --- a/codex-rs/exec-server/testing/wine_exec_server.rs +++ b/codex-rs/exec-server/testing/wine_exec_server.rs @@ -1,6 +1,7 @@ //! Test support for running the Windows exec-server under Wine. use std::future::Future; +use std::path::PathBuf; use anyhow::Context; use anyhow::Result; @@ -12,16 +13,18 @@ use wine_test_support::WineTestCommand; pub struct WineExecServer; impl WineExecServer { - /// Starts the server, passes its WebSocket URL to `operation`, and tears it down afterward. + /// Starts the server, passes its WebSocket URL and Wine prefix to `operation`, and tears it + /// down afterward. pub async fn scope(self, operation: F) -> Result where - F: FnOnce(String) -> Fut, + F: FnOnce(String, PathBuf) -> Fut, Fut: Future>, { let executable = codex_utils_cargo_bin::cargo_bin("wine-windows-exec-server")?; let mut exec_server = WineTestCommand::new(executable) .env("CODEX_HOME", r"C:\codex-home") .spawn()?; + let wine_prefix = exec_server.prefix_path().to_path_buf(); let stdout = exec_server.take_stdout(); exec_server @@ -36,7 +39,7 @@ impl WineExecServer { break line; } }; - operation(exec_server_url).await + operation(exec_server_url, wine_prefix).await }) .await } diff --git a/codex-rs/exec-server/testing/wine_remote_test_runner.rs b/codex-rs/exec-server/testing/wine_remote_test_runner.rs index 288941567..6e73e68e9 100644 --- a/codex-rs/exec-server/testing/wine_remote_test_runner.rs +++ b/codex-rs/exec-server/testing/wine_remote_test_runner.rs @@ -33,7 +33,7 @@ async fn main() -> Result<()> { } WineExecServer - .scope(|exec_server_url| async move { + .scope(|exec_server_url, _wine_prefix| async move { let mut command = Command::new(test_binary); command .env(TEST_ENVIRONMENT_ENV_VAR, "wine-exec") diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index b000cba12..2a10f00b4 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -62,6 +62,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 } @@ -151,7 +152,6 @@ codex-cli = { workspace = true } codex-mcp = { workspace = true } core_test_support = { workspace = true } codex-utils-cargo-bin = { workspace = true } -codex-utils-path-uri = { workspace = true } assert_matches = { workspace = true } chrono = { workspace = true, features = ["serde"] } insta = { workspace = true } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index e57b26840..a3734ac0e 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -120,6 +120,7 @@ use codex_protocol::openai_models::ModelServiceTier; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -1561,7 +1562,7 @@ async fn thread_session_state_from_thread_start_response( response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.runtime_workspace_roots.clone(), - response.instruction_sources.clone(), + response.instruction_source_path_uris(), response.reasoning_effort.clone(), config, ) @@ -1602,7 +1603,7 @@ async fn thread_session_state_from_thread_resume_response( response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.runtime_workspace_roots.clone(), - response.instruction_sources.clone(), + response.instruction_source_path_uris(), response.reasoning_effort.clone(), config, ) @@ -1634,7 +1635,7 @@ async fn thread_session_state_from_thread_fork_response( response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.runtime_workspace_roots.clone(), - response.instruction_sources.clone(), + response.instruction_source_path_uris(), response.reasoning_effort.clone(), config, ) @@ -1673,7 +1674,7 @@ async fn thread_session_state_from_thread_response( active_permission_profile: Option, cwd: AbsolutePathBuf, runtime_workspace_roots: Vec, - instruction_source_paths: Vec, + instruction_source_paths: Vec, reasoning_effort: Option, config: &Config, ) -> Result { @@ -1759,6 +1760,7 @@ mod tests { use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; + use codex_utils_path_uri::LegacyAppPathString; use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -2367,7 +2369,9 @@ mod tests { test_path_buf("/tmp/project").abs(), test_path_buf("/tmp/project/extra").abs(), ], - instruction_sources: vec![test_path_buf("/tmp/project/AGENTS.md").abs()], + instruction_sources: vec![LegacyAppPathString::from_abs_path( + &test_path_buf("/tmp/project/AGENTS.md").abs(), + )], approval_policy: codex_app_server_protocol::AskForApproval::Never, approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::User, sandbox: read_only_profile @@ -2393,7 +2397,7 @@ mod tests { ); assert_eq!( started.session.instruction_source_paths, - response.instruction_sources + response.instruction_source_path_uris() ); assert_eq!(started.session.permission_profile, read_only_profile); assert_eq!(started.turns.len(), 1); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 94cdd9e03..fd3c140e3 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -163,6 +163,7 @@ use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::resume_hint; +use codex_utils_path_uri::PathUri; use codex_utils_plugins::mention_syntax::PLUGIN_TEXT_MENTION_SIGIL; use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL; use crossterm::event::KeyCode; @@ -687,7 +688,7 @@ pub(crate) struct ChatWidget { // App-server-backed command runner for status-line workspace metadata lookups. workspace_command_runner: Option, // Instruction source files loaded for the current session, supplied by app-server. - instruction_source_paths: Vec, + instruction_source_paths: Vec, // Runtime network proxy bind addresses from SessionConfigured. session_network_proxy: Option, // Shared latch so we only warn once about invalid status-line item IDs. diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 6fe4500fe..73d033de0 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -589,7 +589,9 @@ async fn ctrl_d_with_modal_open_does_not_quit() { #[tokio::test] async fn slash_init_does_not_depend_on_loaded_instruction_sources() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.instruction_source_paths = vec![chat.config.cwd.join("project-instructions.md")]; + chat.instruction_source_paths = vec![codex_utils_path_uri::PathUri::from_abs_path( + &chat.config.cwd.join("project-instructions.md"), + )]; submit_composer_text(&mut chat, "/init"); diff --git a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs index c20631354..953de6b89 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs @@ -1,5 +1,6 @@ use super::*; use assert_matches::assert_matches; +use codex_utils_path_uri::PathUri; #[tokio::test] async fn status_command_renders_immediately_and_refreshes_rate_limits_for_chatgpt_auth() { @@ -96,9 +97,23 @@ async fn status_command_uses_catalog_default_reasoning_when_config_empty() { } #[tokio::test] -async fn status_command_renders_instruction_sources_from_thread_session() { +async fn status_command_renders_native_and_foreign_instruction_sources() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.instruction_source_paths = vec![chat.config.cwd.join("AGENTS.md")]; + let (foreign_source, foreign_display) = if cfg!(windows) { + ( + PathUri::parse("file:///remote/AGENTS.md").expect("POSIX instruction source"), + "/remote/AGENTS.md", + ) + } else { + ( + PathUri::parse("file:///C:/remote/AGENTS.md").expect("Windows instruction source"), + r"C:\remote\AGENTS.md", + ) + }; + chat.instruction_source_paths = vec![ + PathUri::from_abs_path(&chat.config.cwd.join("AGENTS.md")), + foreign_source, + ]; chat.dispatch_command(SlashCommand::Status); @@ -109,8 +124,8 @@ async fn status_command_renders_instruction_sources_from_thread_session() { other => panic!("expected status output, got {other:?}"), }; assert!( - rendered.contains("Agents.md"), - "expected /status to render app-server instruction sources, got: {rendered}" + rendered.contains(&format!("AGENTS.md, {foreign_display}")), + "expected /status to show native-relative and environment-native foreign paths, got: {rendered}" ); assert!( !rendered.contains("Agents.md "), diff --git a/codex-rs/tui/src/session_state.rs b/codex-rs/tui/src/session_state.rs index 7988ba226..059e148a7 100644 --- a/codex-rs/tui/src/session_state.rs +++ b/codex-rs/tui/src/session_state.rs @@ -12,6 +12,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; #[derive(Debug, Clone, PartialEq)] pub(crate) struct SessionNetworkProxyRuntime { @@ -47,7 +48,7 @@ pub(crate) struct ThreadSessionState { pub(crate) active_permission_profile: Option, pub(crate) cwd: AbsolutePathBuf, pub(crate) runtime_workspace_roots: Vec, - pub(crate) instruction_source_paths: Vec, + pub(crate) instruction_source_paths: Vec, pub(crate) reasoning_effort: Option, pub(crate) collaboration_mode: Option>, pub(crate) personality: Option, diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index f3ceeb435..f03336a4b 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -5,7 +5,8 @@ use crate::text_formatting; use chrono::DateTime; use chrono::Local; use codex_protocol::account::PlanType; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathConvention; +use codex_utils_path_uri::PathUri; use std::path::Path; use unicode_width::UnicodeWidthStr; @@ -33,10 +34,20 @@ pub(crate) fn compose_model_display( (model_name.to_string(), details) } -pub(crate) fn compose_agents_summary(config: &Config, paths: &[AbsolutePathBuf]) -> String { +pub(crate) fn compose_agents_summary(config: &Config, paths: &[PathUri]) -> String { let mut rels: Vec = Vec::new(); - for p in paths { + for path in paths { + // TODO(anp): Rationalize instruction-source summaries with the TUI's broader foreign-path + // display strategy once other status surfaces can retain environment-native paths. + if path.infer_path_convention() != Some(PathConvention::native()) { + rels.push(path.inferred_native_path_string()); + continue; + } + let Ok(p) = path.to_abs_path() else { + rels.push(path.inferred_native_path_string()); + continue; + }; let p = p.as_path(); let file_name = p .file_name() @@ -229,7 +240,10 @@ mod tests { let config = test_config(&codex_home, &cwd).await; assert_eq!( - compose_agents_summary(&config, &[global_agents_path.abs()]), + compose_agents_summary( + &config, + &[PathUri::from_abs_path(&global_agents_path.abs())] + ), format_directory_display(&global_agents_path, /*max_width*/ None) ); } @@ -242,11 +256,33 @@ mod tests { let config = test_config(&codex_home, &cwd).await; assert_eq!( - compose_agents_summary(&config, &[override_path.abs()]), + compose_agents_summary(&config, &[PathUri::from_abs_path(&override_path.abs())]), format_directory_display(&override_path, /*max_width*/ None) ); } + #[tokio::test] + async fn compose_agents_summary_shows_relative_native_and_full_foreign_paths() { + let codex_home = TempDir::new().expect("temp codex home"); + let cwd = TempDir::new().expect("temp cwd"); + let config = test_config(&codex_home, &cwd).await; + let native_source = PathUri::from_abs_path(&config.cwd.join("AGENTS.md")); + let foreign_source = if cfg!(windows) { + PathUri::parse("file:///remote%20workspace/AGENTS.md") + .expect("POSIX instruction source") + } else { + PathUri::parse("file:///C:/remote%20workspace/AGENTS.md") + .expect("Windows instruction source") + }; + + let summary = compose_agents_summary(&config, &[native_source, foreign_source]); + if cfg!(windows) { + insta::assert_snapshot!(summary, @r"AGENTS.md, /remote workspace/AGENTS.md"); + } else { + insta::assert_snapshot!(summary, @r"AGENTS.md, C:\remote workspace\AGENTS.md"); + } + } + #[tokio::test] async fn compose_agents_summary_orders_global_before_project_agents() { let codex_home = TempDir::new().expect("temp codex home"); @@ -258,8 +294,8 @@ mod tests { let summary = compose_agents_summary( &config, &[ - global_agents_path.clone().abs(), - project_agents_path.clone().abs(), + PathUri::from_abs_path(&global_agents_path.clone().abs()), + PathUri::from_abs_path(&project_agents_path.clone().abs()), ], ); let mut paths = summary.split(", "); diff --git a/codex-rs/utils/path-uri/src/lib.rs b/codex-rs/utils/path-uri/src/lib.rs index 6bc80b556..5ef1df2fa 100644 --- a/codex-rs/utils/path-uri/src/lib.rs +++ b/codex-rs/utils/path-uri/src/lib.rs @@ -209,13 +209,28 @@ impl PathUri { PathBuf::from(self.inferred_native_path_string()) } - /// Returns the parent URI, or `None` for the URI root or an opaque fallback - /// URI created by [`Self::from_abs_path`]. + /// Returns the lexical parent without crossing the inferred native path root. + /// + /// POSIX `/`, Windows drive roots, Windows UNC share roots, and opaque fallback + /// URIs created by [`Self::from_abs_path`] have no parent. pub fn parent(&self) -> Option { - if self.encoded_path() == "/" || decode_bad_path_uri(&self.0).is_some() { + if decode_bad_path_uri(&self.0).is_some() { return None; } + let convention = self.infer_path_convention()?; + // In URI form, both a Windows drive root (`file:///C:`) and a UNC share root + // (`file://server/share`) retain one non-empty path segment. Keep that segment as the + // anchor so parent traversal cannot produce a URI that is not an absolute Windows path. + let anchor_depth = usize::from(convention == PathConvention::Windows); + let depth = self + .0 + .path_segments()? + .filter(|segment| !segment.is_empty()) + .count(); + if depth <= anchor_depth { + return None; + } let mut url = self.0.clone(); { let mut segments = match url.path_segments_mut() { @@ -227,6 +242,11 @@ impl PathUri { Some(Self(url)) } + /// Returns this URI and each lexical parent up to its inferred native path root. + pub fn ancestors(&self) -> impl Iterator { + std::iter::successors(Some(self.clone()), Self::parent) + } + /// Lexically resolves native absolute or relative path text against this URI. /// /// Path text is interpreted using the POSIX or Windows convention inferred diff --git a/codex-rs/utils/path-uri/src/tests.rs b/codex-rs/utils/path-uri/src/tests.rs index 328142dc6..075d343d0 100644 --- a/codex-rs/utils/path-uri/src/tests.rs +++ b/codex-rs/utils/path-uri/src/tests.rs @@ -522,7 +522,7 @@ fn path_buf_uses_the_inferred_native_spelling() { } #[test] -fn parent_uses_uri_hierarchy_and_preserves_authority() { +fn parent_stops_at_posix_drive_and_unc_roots() { for (input, expected) in [ ( "file:///workspace/src/lib.rs", @@ -531,12 +531,13 @@ fn parent_uses_uri_hierarchy_and_preserves_authority() { ("file:///workspace", Some("file:///")), ("file:///", None), ("file:///C:/Users", Some("file:///C:")), - ("file:///C:/", Some("file:///")), + ("file:///C:/", None), + ("file:///C:", None), ( "file://server/share/src/main.rs", Some("file://server/share/src"), ), - ("file://server/share", Some("file://server/")), + ("file://server/share", None), ] { let uri = PathUri::parse(input).expect("valid file URI"); let expected = expected.map(|value| PathUri::parse(value).expect("valid expected URI")); @@ -544,6 +545,35 @@ fn parent_uses_uri_hierarchy_and_preserves_authority() { } } +#[test] +fn ancestors_include_self_and_stop_at_native_path_roots() { + for (input, expected) in [ + ( + "file:///workspace/src", + vec!["file:///workspace/src", "file:///workspace", "file:///"], + ), + ( + "file:///C:/workspace/src", + vec![ + "file:///C:/workspace/src", + "file:///C:/workspace", + "file:///C:", + ], + ), + ( + "file://server/share/project", + vec!["file://server/share/project", "file://server/share"], + ), + ] { + let uri = PathUri::parse(input).expect("valid file URI"); + let ancestors = uri + .ancestors() + .map(|path| path.to_string()) + .collect::>(); + assert_eq!(ancestors, expected, "ancestors for {input}"); + } +} + #[test] fn join_normalizes_relative_uri_segments() { for (base, relative, expected) in [