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
Unverified
parent 01f89c8c59
commit 2e69966cd8
25 changed files with 409 additions and 197 deletions
+74 -7
View File
@@ -514,6 +514,11 @@ mod tests {
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
use codex_plugin::PluginProvider;
use codex_plugin::ResolvedPlugin;
use codex_plugin::manifest::PluginManifest as GenericPluginManifest;
use codex_plugin::manifest::PluginManifestHooks;
use codex_plugin::manifest::PluginManifestInterface;
use codex_plugin::manifest::PluginManifestMcpServers;
use codex_plugin::manifest::PluginManifestPaths;
use codex_protocol::capabilities::CapabilityRootLocation;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -779,14 +784,16 @@ mod tests {
"composerIcon": "./assets/icon.svg"
}"#,
);
let host_manifest = load_plugin_manifest(&plugin_root).expect("host manifest");
let plugin_root =
AbsolutePathBuf::from_absolute_path_checked(plugin_root).expect("absolute plugin root");
let plugin_root_uri = PathUri::from_abs_path(&plugin_root);
let provider =
ExecutorPluginProvider::new(Arc::new(EnvironmentManager::default_for_tests()));
let selected_root = SelectedCapabilityRoot {
id: "selected-demo".to_string(),
location: CapabilityRootLocation::Environment {
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
path: plugin_root.to_string_lossy().into_owned(),
path: plugin_root_uri.clone(),
},
};
@@ -795,17 +802,77 @@ mod tests {
.await
.expect("resolve executor plugin")
.expect("plugin descriptor");
let plugin_root =
AbsolutePathBuf::from_absolute_path_checked(plugin_root).expect("absolute plugin root");
let manifest_path = plugin_root_uri
.join(".codex-plugin/plugin.json")
.expect("manifest URI");
let manifest_contents =
fs::read_to_string(plugin_root.join(".codex-plugin/plugin.json")).expect("manifest");
let expected_manifest =
super::parse_plugin_manifest_uri(&plugin_root_uri, &manifest_path, &manifest_contents)
.expect("URI manifest");
let expected_plugin = ResolvedPlugin::from_environment(
"selected-demo".to_string(),
LOCAL_ENVIRONMENT_ID.to_string(),
plugin_root.clone(),
plugin_root.join(".codex-plugin/plugin.json"),
host_manifest,
plugin_root_uri,
manifest_path,
expected_manifest,
)
.expect("valid expected descriptor");
assert_eq!(executor_plugin, expected_plugin);
}
#[test]
fn uri_manifest_resolves_resources_below_foreign_root() {
let plugin_root =
PathUri::parse("file:///C:/plugins/demo-plugin").expect("plugin root URI");
let manifest_path = plugin_root
.join(".codex-plugin/plugin.json")
.expect("manifest URI");
let manifest = super::parse_plugin_manifest_uri(
&plugin_root,
&manifest_path,
r#"{
"name": "demo-plugin",
"skills": "./skills",
"mcpServers": "./.mcp.json",
"apps": "./apps",
"hooks": "./hooks.json",
"interface": {
"displayName": "Demo Plugin",
"composerIcon": "./assets/icon.svg"
}
}"#,
)
.expect("URI manifest");
assert_eq!(
manifest,
GenericPluginManifest {
name: "demo-plugin".to_string(),
version: None,
description: None,
keywords: Vec::new(),
paths: PluginManifestPaths {
skills: vec![plugin_root.join("skills").expect("skills URI")],
mcp_servers: Some(PluginManifestMcpServers::Path(
plugin_root.join(".mcp.json").expect("MCP URI"),
)),
apps: Some(plugin_root.join("apps").expect("apps URI")),
hooks: Some(PluginManifestHooks::Paths(vec![
plugin_root.join("hooks.json").expect("hooks URI"),
])),
},
interface: Some(PluginManifestInterface {
display_name: Some("Demo Plugin".to_string()),
composer_icon: Some(
plugin_root
.join("assets/icon.svg")
.expect("composer icon URI"),
),
..PluginManifestInterface::default()
}),
}
);
}
}
+35 -47
View File
@@ -1,4 +1,4 @@
use crate::manifest::parse_plugin_manifest;
use crate::manifest::parse_plugin_manifest_uri;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::ExecutorFileSystem;
use codex_plugin::PluginProvider;
@@ -6,23 +6,16 @@ use codex_plugin::ResolvedPlugin;
use codex_plugin::ResolvedPluginError;
use codex_protocol::capabilities::CapabilityRootLocation;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use codex_utils_path_uri::PathUriParseError;
use codex_utils_plugins::DISCOVERABLE_PLUGIN_MANIFEST_PATHS;
use std::io;
use std::path::PathBuf;
use std::sync::Arc;
use thiserror::Error;
/// Failure to resolve an environment-owned capability root as a plugin package.
#[derive(Debug, Error)]
pub enum ExecutorPluginProviderError {
#[error("selected capability root `{root_id}` has invalid path `{path}`: {message}")]
InvalidRootPath {
root_id: String,
path: String,
message: String,
},
#[error(
"selected capability root `{root_id}` references unavailable environment `{environment_id}`"
)]
@@ -33,33 +26,40 @@ pub enum ExecutorPluginProviderError {
#[error("failed to inspect selected capability root `{root_id}` at {path}: {source}")]
InspectRoot {
root_id: String,
path: AbsolutePathBuf,
path: PathUri,
#[source]
source: io::Error,
},
#[error("selected capability root `{root_id}` path {path} is not a directory")]
RootNotDirectory {
RootNotDirectory { root_id: String, path: PathUri },
#[error(
"failed to resolve plugin manifest path `{relative_path}` below selected capability root `{root_id}` at {root}: {source}"
)]
InvalidManifestPath {
root_id: String,
path: AbsolutePathBuf,
root: PathUri,
relative_path: &'static str,
#[source]
source: PathUriParseError,
},
#[error("failed to inspect plugin manifest for `{root_id}` at {path}: {source}")]
InspectManifest {
root_id: String,
path: AbsolutePathBuf,
path: PathUri,
#[source]
source: io::Error,
},
#[error("failed to read plugin manifest for `{root_id}` at {path}: {source}")]
ReadManifest {
root_id: String,
path: AbsolutePathBuf,
path: PathUri,
#[source]
source: io::Error,
},
#[error("failed to parse plugin manifest for `{root_id}` at {path}: {source}")]
ParseManifest {
root_id: String,
path: AbsolutePathBuf,
path: PathUri,
#[source]
source: serde_json::Error,
},
@@ -110,7 +110,7 @@ impl ExecutorPluginProvider {
selected_root: &SelectedCapabilityRoot,
) -> Result<Option<ResolvedExecutorPlugin>, ExecutorPluginProviderError> {
let root_id = &selected_root.id;
let plugin_root = selected_plugin_root(selected_root)?;
let plugin_root = selected_plugin_root(selected_root);
let CapabilityRootLocation::Environment { environment_id, .. } = &selected_root.location;
let environment = self
.environment_manager
@@ -142,38 +142,20 @@ impl PluginProvider for ExecutorPluginProvider {
}
}
fn selected_plugin_root(
selected_root: &SelectedCapabilityRoot,
) -> Result<AbsolutePathBuf, ExecutorPluginProviderError> {
let root_id = &selected_root.id;
fn selected_plugin_root(selected_root: &SelectedCapabilityRoot) -> PathUri {
let CapabilityRootLocation::Environment { path, .. } = &selected_root.location;
let plugin_root = PathBuf::from(path);
if !plugin_root.is_absolute() {
return Err(ExecutorPluginProviderError::InvalidRootPath {
root_id: root_id.clone(),
path: path.clone(),
message: "executor path must be absolute".to_string(),
});
}
AbsolutePathBuf::from_absolute_path_checked(plugin_root).map_err(|err| {
ExecutorPluginProviderError::InvalidRootPath {
root_id: root_id.clone(),
path: path.clone(),
message: err.to_string(),
}
})
path.clone()
}
async fn resolve_plugin_root(
selected_root: &SelectedCapabilityRoot,
plugin_root: AbsolutePathBuf,
plugin_root: PathUri,
file_system: &dyn ExecutorFileSystem,
) -> Result<Option<ResolvedPlugin>, ExecutorPluginProviderError> {
let root_id = &selected_root.id;
let CapabilityRootLocation::Environment { environment_id, .. } = &selected_root.location;
let root_uri = PathUri::from_abs_path(&plugin_root);
let root_metadata = file_system
.get_metadata(&root_uri, /*sandbox*/ None)
.get_metadata(&plugin_root, /*sandbox*/ None)
.await
.map_err(|source| ExecutorPluginProviderError::InspectRoot {
root_id: root_id.clone(),
@@ -189,14 +171,20 @@ async fn resolve_plugin_root(
let mut manifest_path = None;
for relative_path in DISCOVERABLE_PLUGIN_MANIFEST_PATHS {
let candidate = plugin_root.join(relative_path);
let candidate_uri = PathUri::from_abs_path(&candidate);
let candidate_uri = plugin_root.join(relative_path).map_err(|source| {
ExecutorPluginProviderError::InvalidManifestPath {
root_id: root_id.clone(),
root: plugin_root.clone(),
relative_path,
source,
}
})?;
match file_system
.get_metadata(&candidate_uri, /*sandbox*/ None)
.await
{
Ok(metadata) if metadata.is_file => {
manifest_path = Some((candidate, candidate_uri));
manifest_path = Some(candidate_uri);
break;
}
Ok(_) => {}
@@ -204,13 +192,13 @@ async fn resolve_plugin_root(
Err(source) => {
return Err(ExecutorPluginProviderError::InspectManifest {
root_id: root_id.clone(),
path: candidate,
path: candidate_uri,
source,
});
}
}
}
let Some((manifest_path, manifest_uri)) = manifest_path else {
let Some(manifest_uri) = manifest_path else {
return Ok(None);
};
let contents = file_system
@@ -218,14 +206,14 @@ async fn resolve_plugin_root(
.await
.map_err(|source| ExecutorPluginProviderError::ReadManifest {
root_id: root_id.clone(),
path: manifest_path.clone(),
path: manifest_uri.clone(),
source,
})?;
let manifest =
parse_plugin_manifest(&plugin_root, &manifest_path, &contents).map_err(|source| {
parse_plugin_manifest_uri(&plugin_root, &manifest_uri, &contents).map_err(|source| {
ExecutorPluginProviderError::ParseManifest {
root_id: root_id.clone(),
path: manifest_path.clone(),
path: manifest_uri.clone(),
source,
}
})?;
@@ -234,7 +222,7 @@ async fn resolve_plugin_root(
root_id.clone(),
environment_id.clone(),
plugin_root,
manifest_path,
manifest_uri,
manifest,
)
.map_err(|source| ExecutorPluginProviderError::ConstructDescriptor {
+74 -46
View File
@@ -1,7 +1,7 @@
use super::ExecutorPluginProvider;
use super::ExecutorPluginProviderError;
use super::resolve_plugin_root;
use crate::manifest::parse_plugin_manifest;
use crate::manifest::parse_plugin_manifest_uri;
use codex_exec_server::CopyOptions;
use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::EnvironmentManager;
@@ -18,7 +18,6 @@ use codex_plugin::PluginProvider;
use codex_plugin::ResolvedPlugin;
use codex_protocol::capabilities::CapabilityRootLocation;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
use std::fs;
@@ -43,13 +42,13 @@ const MANIFEST_CONTENTS: &str = r#"{
#[derive(Debug, PartialEq, Eq)]
enum FileSystemCall {
Metadata(AbsolutePathBuf),
Read(AbsolutePathBuf),
Metadata(PathUri),
Read(PathUri),
}
struct SyntheticPluginFileSystem {
plugin_root: AbsolutePathBuf,
manifest_path: AbsolutePathBuf,
plugin_root: PathUri,
manifest_path: PathUri,
calls: Mutex<Vec<FileSystemCall>>,
}
@@ -77,12 +76,11 @@ impl ExecutorFileSystem for SyntheticPluginFileSystem {
_sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, Vec<u8>> {
Box::pin(async move {
let path = path.to_abs_path()?;
self.calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(FileSystemCall::Read(path.clone()));
if path == self.manifest_path {
if path == &self.manifest_path {
Ok(MANIFEST_CONTENTS.as_bytes().to_vec())
} else {
Err(io::Error::new(io::ErrorKind::NotFound, "not found"))
@@ -122,14 +120,13 @@ impl ExecutorFileSystem for SyntheticPluginFileSystem {
_sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, FileMetadata> {
Box::pin(async move {
let path = path.to_abs_path()?;
self.calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(FileSystemCall::Metadata(path.clone()));
let (is_directory, is_file) = if path == self.plugin_root {
let (is_directory, is_file) = if path == &self.plugin_root {
(true, false)
} else if path == self.manifest_path {
} else if path == &self.manifest_path {
(false, true)
} else {
return Err(io::Error::new(io::ErrorKind::NotFound, "not found"));
@@ -185,7 +182,17 @@ fn selected_root(id: &str, environment_id: &str, path: &Path) -> SelectedCapabil
id: id.to_string(),
location: CapabilityRootLocation::Environment {
environment_id: environment_id.to_string(),
path: path.to_string_lossy().into_owned(),
path: PathUri::from_host_native_path(path).expect("path URI"),
},
}
}
fn selected_root_uri(id: &str, environment_id: &str, path: PathUri) -> SelectedCapabilityRoot {
SelectedCapabilityRoot {
id: id.to_string(),
location: CapabilityRootLocation::Environment {
environment_id: environment_id.to_string(),
path,
},
}
}
@@ -195,22 +202,20 @@ async fn plugin_root_resolution_uses_supplied_executor_file_system() {
let temp_dir = tempdir().expect("tempdir");
let plugin_root = temp_dir.path().join("executor-only-plugin");
assert!(!plugin_root.exists());
let plugin_root =
AbsolutePathBuf::from_absolute_path_checked(plugin_root).expect("absolute plugin root");
let manifest_path = plugin_root.join(".codex-plugin/plugin.json");
let parsed_manifest = parse_plugin_manifest(
plugin_root.as_path(),
manifest_path.as_path(),
MANIFEST_CONTENTS,
)
.expect("parse manifest");
let plugin_root = PathUri::from_host_native_path(&plugin_root).expect("plugin root URI");
let manifest_path = plugin_root
.join(".codex-plugin/plugin.json")
.expect("manifest URI");
let parsed_manifest =
parse_plugin_manifest_uri(&plugin_root, &manifest_path, MANIFEST_CONTENTS)
.expect("parse manifest");
let file_system = SyntheticPluginFileSystem {
plugin_root: plugin_root.clone(),
manifest_path: manifest_path.clone(),
calls: Mutex::new(Vec::new()),
};
let resolved = resolve_plugin_root(
&selected_root("selected-demo", "executor-test", plugin_root.as_path()),
&selected_root_uri("selected-demo", "executor-test", plugin_root.clone()),
plugin_root.clone(),
&file_system,
)
@@ -243,6 +248,51 @@ async fn plugin_root_resolution_uses_supplied_executor_file_system() {
);
}
#[tokio::test]
async fn plugin_root_resolution_accepts_foreign_executor_file_uri() {
let plugin_root = PathUri::parse("file:///C:/plugins/foo").expect("Windows plugin root URI");
let manifest_path = plugin_root
.join(".codex-plugin/plugin.json")
.expect("manifest URI");
let parsed_manifest =
parse_plugin_manifest_uri(&plugin_root, &manifest_path, MANIFEST_CONTENTS)
.expect("parse manifest");
let file_system = SyntheticPluginFileSystem {
plugin_root: plugin_root.clone(),
manifest_path: manifest_path.clone(),
calls: Mutex::new(Vec::new()),
};
let selected_root = selected_root_uri("selected-demo", "executor-test", plugin_root.clone());
let resolved = resolve_plugin_root(&selected_root, plugin_root.clone(), &file_system)
.await
.expect("resolve executor plugin");
assert_eq!(
resolved,
Some(
ResolvedPlugin::from_environment(
"selected-demo".to_string(),
"executor-test".to_string(),
plugin_root.clone(),
manifest_path.clone(),
parsed_manifest,
)
.expect("valid expected descriptor")
)
);
assert_eq!(
*file_system
.calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
vec![
FileSystemCall::Metadata(plugin_root),
FileSystemCall::Metadata(manifest_path.clone()),
FileSystemCall::Read(manifest_path),
]
);
}
#[tokio::test]
async fn standalone_capability_root_is_not_a_plugin() {
let temp_dir = tempdir().expect("tempdir");
@@ -292,8 +342,8 @@ async fn malformed_preferred_manifest_does_not_fall_through_to_alternate() {
MANIFEST_CONTENTS,
);
let expected_path =
AbsolutePathBuf::from_absolute_path_checked(plugin_root.join(".codex-plugin/plugin.json"))
.expect("absolute manifest path");
PathUri::from_host_native_path(plugin_root.join(".codex-plugin/plugin.json"))
.expect("manifest URI");
let provider = ExecutorPluginProvider::new(Arc::new(EnvironmentManager::default_for_tests()));
let err = provider
@@ -318,25 +368,3 @@ async fn malformed_preferred_manifest_does_not_fall_through_to_alternate() {
("selected-demo".to_string(), expected_path)
);
}
#[tokio::test]
async fn executor_root_must_be_an_explicit_absolute_path() {
let provider = ExecutorPluginProvider::new(Arc::new(EnvironmentManager::default_for_tests()));
let selected_root = SelectedCapabilityRoot {
id: "selected-demo".to_string(),
location: CapabilityRootLocation::Environment {
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
path: "~/plugins/demo".to_string(),
},
};
let err = provider
.resolve(&selected_root)
.await
.expect_err("home-relative executor path should fail");
assert_eq!(
err.to_string(),
"selected capability root `selected-demo` has invalid path `~/plugins/demo`: executor path must be absolute"
);
}