mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user