Make selected plugin roots URI-native (#28918)

## Why

Selected capability roots belong to the executor filesystem, not the
app-server host. Converting their path strings into the host's native
`Path` breaks whenever the two machines use different path conventions,
such as a Windows executor behind a Unix app-server.

This PR establishes `PathUri` as the selected-plugin boundary so the
executor remains authoritative for its paths.

## What changed

- Require `selectedCapabilityRoots[].location.path` to be a canonical
`file:` URI and deserialize it directly as `PathUri`; native path
strings are rejected.
- Update the app-server schema, generated TypeScript, examples, and
request coverage for the URI contract.
- Keep selected roots, resolved plugin locations, manifest paths, and
manifest resources as `PathUri`.
- Inspect and read plugin roots and manifests only through the selected
environment's `ExecutorFileSystem`.
- Parse executor manifests with the shared URI-native parser from #29620
instead of projecting them onto the host filesystem.
- Enforce resource containment lexically and preserve the root URI's
POSIX or Windows path convention.
- Cover foreign Windows plugin roots and URI-native manifest resources.

```text
thread/start
  selectedCapabilityRoots[].location.path = "file:///C:/plugins/demo"
                              | PathUri
                              v
                    ExecutorFileSystem
                              |
                              +--> plugin.json
                              +--> manifest resources
```

This PR stops at the shared selected-plugin representation. The next two
PRs remove the remaining host-path projections in the skill and MCP
consumers.

## Stack

1. #29614 — add lexical `PathUri` containment.
2. #29620 — share URI-native manifest path resolution.
3. **This PR** — keep selected plugin roots and resources URI-native.
4. #29626 — load executor skills without host path conversion.
5. #29628 — resolve executor MCP working directories without host path
conversion.
This commit is contained in:
jif
2026-06-23 22:51:19 +01:00
committed by GitHub
parent 01f89c8c59
commit 2e69966cd8
25 changed files with 409 additions and 197 deletions
@@ -8,9 +8,10 @@ use codex_plugin::PluginResourceLocator;
use codex_plugin::ResolvedPlugin;
use codex_plugin::ResolvedPluginLocation;
use codex_plugin::manifest::PluginManifestMcpServers;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use codex_utils_path_uri::PathUriParseError;
use std::io;
use std::path::PathBuf;
use thiserror::Error;
const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
@@ -25,14 +26,24 @@ pub(super) enum ExecutorPluginMcpProviderError {
#[error("failed to read MCP config for selected plugin `{plugin_id}` at `{path}`: {source}")]
ReadConfig {
plugin_id: String,
path: AbsolutePathBuf,
path: PathUri,
#[source]
source: io::Error,
},
#[error(
"failed to resolve MCP config path `{relative_path}` below selected plugin `{plugin_id}` at `{root}`: {source}"
)]
InvalidConfigPath {
plugin_id: String,
root: PathUri,
relative_path: &'static str,
#[source]
source: PathUriParseError,
},
#[error("failed to parse MCP config for selected plugin `{plugin_id}` at `{path}`: {source}")]
ParseConfig {
plugin_id: String,
path: AbsolutePathBuf,
path: PathUri,
#[source]
source: serde_json::Error,
},
@@ -52,7 +63,7 @@ impl ExecutorPluginMcpProvider {
async fn load_from_file_system(
plugin: &ResolvedPlugin,
plugin_root: &AbsolutePathBuf,
plugin_root: &PathUri,
file_system: &dyn ExecutorFileSystem,
) -> Result<Vec<(String, McpServerConfig)>, ExecutorPluginMcpProviderError> {
let ResolvedPluginLocation::Environment { environment_id, .. } = plugin.location();
@@ -61,10 +72,9 @@ async fn load_from_file_system(
Some(PluginManifestMcpServers::Path(PluginResourceLocator::Environment {
path, ..
})) => {
let config_uri = PathUri::from_abs_path(path);
(
file_system
.read_file_text(&config_uri, /*sandbox*/ None)
.read_file_text(path, /*sandbox*/ None)
.await
.map_err(|source| ExecutorPluginMcpProviderError::ReadConfig {
plugin_id: plugin_id.to_string(),
@@ -74,15 +84,21 @@ async fn load_from_file_system(
path.clone(),
)
}
Some(PluginManifestMcpServers::Object(object_config)) => (
object_config.clone(),
plugin_root.join(".codex-plugin/plugin.json"),
),
Some(PluginManifestMcpServers::Object(object_config)) => {
let PluginResourceLocator::Environment { path, .. } = plugin.manifest_path();
(object_config.clone(), path.clone())
}
None => {
let config_path = plugin_root.join(DEFAULT_MCP_CONFIG_FILE);
let config_uri = PathUri::from_abs_path(&config_path);
let config_path = plugin_root
.join(DEFAULT_MCP_CONFIG_FILE)
.map_err(|source| ExecutorPluginMcpProviderError::InvalidConfigPath {
plugin_id: plugin_id.to_string(),
root: plugin_root.clone(),
relative_path: DEFAULT_MCP_CONFIG_FILE,
source,
})?;
let contents = match file_system
.read_file_text(&config_uri, /*sandbox*/ None)
.read_file_text(&config_path, /*sandbox*/ None)
.await
{
Ok(contents) => contents,
@@ -100,8 +116,9 @@ async fn load_from_file_system(
(contents, config_path)
}
};
let plugin_root_path = PathBuf::from(plugin_root.inferred_native_path_string());
let parsed = parse_plugin_mcp_config(
plugin_root.as_path(),
plugin_root_path.as_path(),
&contents,
PluginMcpServerPlacement::Environment { environment_id },
)
@@ -156,7 +156,8 @@ async fn reads_declared_config_only_through_executor_file_system() {
reads: Mutex::new(Vec::new()),
};
let servers = load_from_file_system(&plugin, &plugin_root, &file_system)
let plugin_root_uri = PathUri::from_abs_path(&plugin_root);
let servers = load_from_file_system(&plugin, &plugin_root_uri, &file_system)
.await
.expect("load executor MCP config");
@@ -210,7 +211,8 @@ async fn reads_manifest_object_config_without_executor_file_system_access() {
reads: Mutex::new(Vec::new()),
};
let servers = load_from_file_system(&plugin, &plugin_root, &file_system)
let plugin_root_uri = PathUri::from_abs_path(&plugin_root);
let servers = load_from_file_system(&plugin, &plugin_root_uri, &file_system)
.await
.expect("load manifest object executor MCP config");
@@ -259,7 +261,8 @@ async fn missing_default_config_is_empty() {
reads: Mutex::new(Vec::new()),
};
let servers = load_from_file_system(&plugin, &plugin_root, &file_system)
let plugin_root_uri = PathUri::from_abs_path(&plugin_root);
let servers = load_from_file_system(&plugin, &plugin_root_uri, &file_system)
.await
.expect("missing default config should be ignored");
@@ -283,7 +286,8 @@ async fn malformed_declared_config_is_an_error() {
reads: Mutex::new(Vec::new()),
};
let err = load_from_file_system(&plugin, &plugin_root, &file_system)
let plugin_root_uri = PathUri::from_abs_path(&plugin_root);
let err = load_from_file_system(&plugin, &plugin_root_uri, &file_system)
.await
.expect_err("malformed declared config should fail");
@@ -297,20 +301,70 @@ async fn malformed_declared_config_is_an_error() {
};
assert_eq!(
(plugin_id, path),
("selected-root".to_string(), config_path.clone())
(
"selected-root".to_string(),
PathUri::from_abs_path(&config_path)
)
);
assert_eq!(reads(&file_system), vec![config_path]);
}
#[tokio::test]
async fn malformed_manifest_object_config_reports_actual_manifest_path() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let plugin_root = AbsolutePathBuf::from_absolute_path_checked(temp_dir.path().join("plugin"))
.expect("absolute plugin root");
let plugin = resolved_plugin(
&plugin_root,
Some(PluginManifestMcpServers::Object("{not-json".to_string())),
);
let file_system = SyntheticExecutorFileSystem {
config_path: plugin_root.join(DEFAULT_MCP_CONFIG_FILE),
config_contents: None,
reads: Mutex::new(Vec::new()),
};
let plugin_root_uri = PathUri::from_abs_path(&plugin_root);
let err = load_from_file_system(&plugin, &plugin_root_uri, &file_system)
.await
.expect_err("malformed manifest object config should fail");
let ExecutorPluginMcpProviderError::ParseConfig {
plugin_id,
path,
source: _,
} = err
else {
panic!("expected parse error");
};
assert_eq!(
(plugin_id, path),
(
"selected-root".to_string(),
PathUri::from_abs_path(&plugin_root.join(".claude-plugin/plugin.json"))
)
);
assert_eq!(reads(&file_system), Vec::new());
}
fn resolved_plugin(
plugin_root: &AbsolutePathBuf,
mcp_servers: Option<PluginManifestMcpServers<AbsolutePathBuf>>,
) -> ResolvedPlugin {
let plugin_root_uri = PathUri::from_abs_path(plugin_root);
let mcp_servers = mcp_servers.map(|mcp_servers| match mcp_servers {
PluginManifestMcpServers::Path(path) => {
PluginManifestMcpServers::Path(PathUri::from_abs_path(&path))
}
PluginManifestMcpServers::Object(config) => PluginManifestMcpServers::Object(config),
});
ResolvedPlugin::from_environment(
"selected-root".to_string(),
"executor-test".to_string(),
plugin_root.clone(),
plugin_root.join(".codex-plugin/plugin.json"),
plugin_root_uri.clone(),
plugin_root_uri
.join(".claude-plugin/plugin.json")
.expect("manifest URI"),
PluginManifest {
name: "demo-plugin".to_string(),
version: None,