diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 9f1c46d800..88c828ba7a 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1373,7 +1373,7 @@ "cwds": { "description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces and the official curated marketplace are considered.", "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "type": "string" }, "type": [ "array", @@ -3452,13 +3452,9 @@ "WindowsSandboxSetupStartParams": { "properties": { "cwd": { - "anyOf": [ - { - "$ref": "#/definitions/AbsolutePathBuf" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "mode": { 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 80f2b226b7..936831f003 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 @@ -9993,7 +9993,7 @@ "cwds": { "description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces and the official curated marketplace are considered.", "items": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "type": "string" }, "type": [ "array", @@ -15206,13 +15206,9 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "cwd": { - "anyOf": [ - { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "mode": { 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 eb4e9d7b18..8fa00a7207 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 @@ -6816,7 +6816,7 @@ "cwds": { "description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces and the official curated marketplace are considered.", "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "type": "string" }, "type": [ "array", @@ -13061,13 +13061,9 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "cwd": { - "anyOf": [ - { - "$ref": "#/definitions/AbsolutePathBuf" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "mode": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json index 669ff92b9e..8d7940b0b5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json @@ -1,16 +1,10 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "AbsolutePathBuf": { - "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - "type": "string" - } - }, "properties": { "cwds": { "description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces and the official curated marketplace are considered.", "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "type": "string" }, "type": [ "array", diff --git a/codex-rs/app-server-protocol/schema/json/v2/WindowsSandboxSetupStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/WindowsSandboxSetupStartParams.json index ed93913cb3..00edc8b02e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/WindowsSandboxSetupStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/WindowsSandboxSetupStartParams.json @@ -1,10 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { - "AbsolutePathBuf": { - "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - "type": "string" - }, "WindowsSandboxSetupMode": { "enum": [ "elevated", @@ -15,13 +11,9 @@ }, "properties": { "cwd": { - "anyOf": [ - { - "$ref": "#/definitions/AbsolutePathBuf" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "mode": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts index 07ecee5e5f..2dd7db55f6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts @@ -1,14 +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. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; export type PluginListParams = { /** * Optional working directories used to discover repo marketplaces. When omitted, * only home-scoped marketplaces and the official curated marketplace are considered. */ -cwds?: Array | null, +cwds?: Array | null, /** * When true, reconcile the official curated marketplace against the remote plugin state * before listing marketplaces. diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxSetupStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxSetupStartParams.ts index 596c9f5b8c..cb342c24cd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxSetupStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxSetupStartParams.ts @@ -1,7 +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 { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode"; -export type WindowsSandboxSetupStartParams = { mode: WindowsSandboxSetupMode, cwd?: AbsolutePathBuf | null, }; +export type WindowsSandboxSetupStartParams = { mode: WindowsSandboxSetupMode, cwd?: string | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 3562d9cda6..1457d9f377 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3259,7 +3259,7 @@ pub struct PluginListParams { /// Optional working directories used to discover repo marketplaces. When omitted, /// only home-scoped marketplaces and the official curated marketplace are considered. #[ts(optional = nullable)] - pub cwds: Option>, + pub cwds: Option>, /// When true, reconcile the official curated marketplace against the remote plugin state /// before listing marketplaces. #[serde(default, skip_serializing_if = "std::ops::Not::not")] @@ -5456,7 +5456,7 @@ pub enum WindowsSandboxSetupMode { pub struct WindowsSandboxSetupStartParams { pub mode: WindowsSandboxSetupMode, #[ts(optional = nullable)] - pub cwd: Option, + pub cwd: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/schema_fixtures.rs b/codex-rs/app-server-protocol/src/schema_fixtures.rs index 56dcf33a4d..f5e221cb56 100644 --- a/codex-rs/app-server-protocol/src/schema_fixtures.rs +++ b/codex-rs/app-server-protocol/src/schema_fixtures.rs @@ -133,18 +133,29 @@ fn read_file_bytes(path: &Path) -> Result> { // fixture test is platform-independent. let text = String::from_utf8(bytes) .with_context(|| format!("expected UTF-8 TypeScript in {}", path.display()))?; - let text = text.replace("\r\n", "\n").replace('\r', "\n"); - // Fixture comparisons care about schema content, not whether the generator - // re-prepended the standard banner to every TypeScript file. - let text = text - .strip_prefix(GENERATED_TS_HEADER) - .unwrap_or(&text) - .to_string(); + let text = normalize_typescript_fixture_text(text); return Ok(text.into_bytes()); } Ok(bytes) } +fn normalize_typescript_fixture_text(text: String) -> String { + let text = text.replace("\r\n", "\n").replace('\r', "\n"); + // Fixture comparisons care about schema content, not whether the generator + // re-prepended the standard banner to every TypeScript file. + let text = text.strip_prefix(GENERATED_TS_HEADER).unwrap_or(&text); + let mut normalized = String::with_capacity(text.len()); + for line in text.split_inclusive('\n') { + if let Some(line) = line.strip_suffix('\n') { + normalized.push_str(line.trim_end()); + normalized.push('\n'); + } else { + normalized.push_str(line.trim_end()); + } + } + normalized +} + fn canonicalize_json(value: &Value) -> Value { match value { Value::Array(items) => { @@ -274,12 +285,11 @@ fn collect_typescript_fixture_file( return Ok(()); } - let contents = T::export_to_string().context("export TypeScript fixture content")?; + let contents = T::export_to_string() + .map(normalize_typescript_fixture_text) + .context("export TypeScript fixture content")?; let output_path = normalize_relative_fixture_path(&output_path); - files.insert( - output_path, - contents.replace("\r\n", "\n").replace('\r', "\n"), - ); + files.insert(output_path, contents); let mut visitor = TypeScriptFixtureCollector { files, @@ -354,4 +364,14 @@ mod tests { ]); assert_eq!(canonicalize_json(&value), expected); } + + #[test] + fn normalize_typescript_fixture_text_trims_trailing_whitespace() { + let text = format!("{GENERATED_TS_HEADER}type Example = {{ \r\nfield: string, \n}};\t"); + + assert_eq!( + normalize_typescript_fixture_text(text), + "type Example = {\nfield: string,\n};" + ); + } } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 6fedd73d98..13d3c3fe7a 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -189,7 +189,7 @@ Example with notification opt-out: - `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. - `mcpServerStatus/list` — enumerate configured MCP servers with their tools and auth status, plus resources/resource templates for `full` detail; supports cursor+limit pagination. If `detail` is omitted, the server defaults to `full`. - `mcpServer/resource/read` — read a resource from a thread's configured MCP server by `threadId`, `server`, and `uri`, returning text/blob resource `contents`. -- `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`. +- `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`. - `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id. - `config/read` — fetch the effective config on disk after resolving config layering. - `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home). diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 2f450afa49..ecc6f1c22f 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6024,6 +6024,7 @@ impl CodexMessageProcessor { continue; } }; + let normalized_cwd = cwd_abs.to_path_buf(); let config_layer_stack = match load_config_layers_state( &self.config.codex_home, Some(cwd_abs), @@ -6052,7 +6053,7 @@ impl CodexMessageProcessor { config.features.enabled(Feature::Plugins), ); let skills_input = codex_core::skills::SkillsLoadInput::new( - cwd.clone(), + normalized_cwd, effective_skill_roots, config_layer_stack, config.bundled_skills_enabled(), @@ -6079,7 +6080,27 @@ impl CodexMessageProcessor { cwds, force_remote_sync, } = params; - let roots = cwds.unwrap_or_default(); + let roots = match cwds + .unwrap_or_default() + .into_iter() + .map(AbsolutePathBuf::relative_to_current_dir) + .collect::, _>>() + { + Ok(roots) => roots, + Err(err) => { + self.outgoing + .send_error( + request_id, + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("invalid plugin cwd: {err}"), + data: None, + }, + ) + .await; + return; + } + }; plugins_manager.maybe_start_non_curated_plugin_cache_refresh_for_roots(&roots); let mut config = match self.load_latest_config(/*fallback_cwd*/ None).await { @@ -7865,6 +7886,25 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: WindowsSandboxSetupStartParams, ) { + let command_cwd = match params.cwd { + Some(cwd) => match AbsolutePathBuf::relative_to_current_dir(cwd) { + Ok(cwd) => cwd, + Err(err) => { + self.outgoing + .send_error( + request_id, + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("invalid windows sandbox setup cwd: {err}"), + data: None, + }, + ) + .await; + return; + } + }, + None => self.config.cwd.clone(), + }; self.outgoing .send_response( request_id.clone(), @@ -7878,7 +7918,6 @@ impl CodexMessageProcessor { }; let config = Arc::clone(&self.config); let cloud_requirements = self.current_cloud_requirements(); - let command_cwd = params.cwd.unwrap_or_else(|| config.cwd.clone()); let cli_overrides = self.current_cli_overrides(); let runtime_feature_enablement = self.current_runtime_feature_enablement(); let outgoing = Arc::clone(&self.outgoing); diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 8bc4a8598e..6ac33c59d5 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -1,3 +1,4 @@ +use std::path::PathBuf; use std::time::Duration; use anyhow::Result; @@ -67,7 +68,7 @@ async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Resul let request_id = mcp .send_plugin_list_request(PluginListParams { - cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + cwds: Some(vec![repo_root.path().to_path_buf()]), force_remote_sync: false, }) .await?; @@ -102,28 +103,58 @@ async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Resul } #[tokio::test] -async fn plugin_list_rejects_relative_cwds() -> Result<()> { +async fn plugin_list_resolves_relative_cwds() -> Result<()> { let codex_home = TempDir::new()?; + write_plugins_enabled_config(codex_home.path())?; + let repo_root = codex_home.path().join("relative-root"); + std::fs::create_dir_all(repo_root.join(".git"))?; + std::fs::create_dir_all(repo_root.join(".agents/plugins"))?; + std::fs::create_dir_all(repo_root.join("plugins/relative-plugin/.codex-plugin"))?; + std::fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "relative-marketplace", + "plugins": [ + { + "name": "relative-plugin", + "source": { + "source": "local", + "path": "./plugins/relative-plugin" + } + } + ] +}"#, + )?; + std::fs::write( + repo_root.join("plugins/relative-plugin/.codex-plugin/plugin.json"), + r#"{"name":"relative-plugin"}"#, + )?; + let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_raw_request( - "plugin/list", - Some(serde_json::json!({ - "cwds": ["relative-root"], - })), - ) + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![PathBuf::from("relative-root")]), + force_remote_sync: false, + }) .await?; - let err = timeout( + let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, - mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; + let response: PluginListResponse = to_response(response)?; - assert_eq!(err.error.code, -32600); - assert!(err.error.message.contains("Invalid request")); + assert!( + response + .marketplaces + .iter() + .any(|marketplace| marketplace.name == "relative-marketplace"), + "relative cwd marketplace should be discovered: {:?}", + response.marketplaces + ); Ok(()) } @@ -194,8 +225,8 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![ - AbsolutePathBuf::try_from(valid_repo_root.path())?, - AbsolutePathBuf::try_from(invalid_repo_root.path())?, + valid_repo_root.path().to_path_buf(), + invalid_repo_root.path().to_path_buf(), ]), force_remote_sync: false, }) @@ -350,7 +381,7 @@ enabled = false let request_id = mcp .send_plugin_list_request(PluginListParams { - cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + cwds: Some(vec![repo_root.path().to_path_buf()]), force_remote_sync: false, }) .await?; @@ -503,8 +534,8 @@ enabled = false let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![ - AbsolutePathBuf::try_from(workspace_enabled.path())?, - AbsolutePathBuf::try_from(workspace_default.path())?, + workspace_enabled.path().to_path_buf(), + workspace_default.path().to_path_buf(), ]), force_remote_sync: false, }) @@ -589,7 +620,7 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res let request_id = mcp .send_plugin_list_request(PluginListParams { - cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + cwds: Some(vec![repo_root.path().to_path_buf()]), force_remote_sync: false, }) .await?; @@ -702,7 +733,7 @@ async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> { let request_id = mcp .send_plugin_list_request(PluginListParams { - cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + cwds: Some(vec![repo_root.path().to_path_buf()]), force_remote_sync: false, }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 6bd862eb62..da593b0419 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -1,3 +1,4 @@ +use std::path::PathBuf; use std::time::Duration; use anyhow::Context; @@ -26,6 +27,14 @@ fn write_skill(root: &TempDir, name: &str) -> Result<()> { Ok(()) } +fn write_skill_at_root(root: &std::path::Path, name: &str) -> Result<()> { + let skill_dir = root.join(name); + std::fs::create_dir_all(&skill_dir)?; + let content = format!("---\nname: {name}\ndescription: {name} description\n---\n\n# Body\n"); + std::fs::write(skill_dir.join("SKILL.md"), content)?; + Ok(()) +} + #[tokio::test] async fn skills_list_includes_skills_from_per_cwd_extra_user_roots() -> Result<()> { let codex_home = TempDir::new()?; @@ -64,6 +73,41 @@ async fn skills_list_includes_skills_from_per_cwd_extra_user_roots() -> Result<( Ok(()) } +#[tokio::test] +async fn skills_list_resolves_relative_cwd_for_repo_skills() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = codex_home.path().join("relative-root"); + std::fs::create_dir_all(repo_root.join(".git"))?; + write_skill_at_root(&repo_root.join(".agents/skills"), "repo-skill")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_skills_list_request(SkillsListParams { + cwds: vec![PathBuf::from("relative-root")], + force_reload: true, + per_cwd_extra_user_roots: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let SkillsListResponse { data } = to_response(response)?; + assert_eq!(data.len(), 1); + assert_eq!(data[0].cwd, PathBuf::from("relative-root")); + assert!( + data[0] + .skills + .iter() + .any(|skill| skill.name == "repo-skill") + ); + Ok(()) +} + #[tokio::test] async fn skills_list_rejects_relative_extra_user_roots() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs b/codex-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs index a0466a459b..b8b72b5165 100644 --- a/codex-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs +++ b/codex-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs @@ -64,8 +64,9 @@ async fn windows_sandbox_setup_start_emits_completion_notification() -> Result<( } #[tokio::test] -async fn windows_sandbox_setup_start_rejects_relative_cwd() -> Result<()> { +async fn windows_sandbox_setup_start_accepts_relative_cwd() -> Result<()> { let codex_home = TempDir::new()?; + std::fs::create_dir_all(codex_home.path().join("relative-root"))?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -79,13 +80,13 @@ async fn windows_sandbox_setup_start_rejects_relative_cwd() -> Result<()> { ) .await?; - let err = timeout( + let response: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; + let start_payload: WindowsSandboxSetupStartResponse = to_response(response)?; - assert_eq!(err.error.code, -32600); - assert!(err.error.message.contains("Invalid request")); + assert!(start_payload.started); Ok(()) }