mirror of
https://github.com/openai/codex.git
synced 2026-09-28 08:43:01 +08:00
## What changed - Define `capabilities/discoverV2` request and inventory types and advertise `capabilityDiscoveryV2` in executor metadata. - Prewarm installed plugin and global skill locations at server startup, with nonfatal scan failures. - Add sandbox-scoped discovery caching with file watcher invalidation and request-time reloads when watching is unavailable. - Batch metadata reads through the filesystem sandbox helper and bound discovery responses to 4 MiB and 2,048 capabilities. - Share plugin identity and installed-version selection in `codex-core-plugin-common`, and fall back to `.app.json` when a plugin does not declare an apps configuration. The V2 discovery manager is not yet wired into RPC request dispatch. ## Testing Add coverage for inventory metadata, sandbox cache isolation, watcher invalidation, startup scan failures, response limits, and compatibility with older executor metadata. GitOrigin-RevId: a223a5bc15fdf251023c0c71f468fd92c3b2d847
1415 lines
53 KiB
Rust
1415 lines
53 KiB
Rust
mod common;
|
|
#[cfg(target_os = "linux")]
|
|
#[path = "common/fake_bwrap.rs"]
|
|
mod fake_bwrap;
|
|
|
|
#[cfg(target_os = "linux")]
|
|
use anyhow::Context as _;
|
|
use base64::Engine as _;
|
|
use base64::engine::general_purpose::STANDARD;
|
|
use codex_exec_server::CAPABILITY_ROOTS_DISCOVER_METHOD;
|
|
use codex_exec_server::CapabilityRootDiscovery;
|
|
use codex_exec_server::CapabilityRootsDiscoverParams;
|
|
use codex_exec_server::CapabilityRootsDiscoverResponse;
|
|
use codex_exec_server::FileSystemSandboxContext;
|
|
use codex_exec_server::InitializeParams;
|
|
use codex_exec_server::InitializeResponse;
|
|
use codex_exec_server::WindowsSandboxSelection;
|
|
use codex_exec_server_protocol::CapabilityRootDiscoverRequest;
|
|
use codex_exec_server_protocol::EXEC_METHOD;
|
|
#[cfg(unix)]
|
|
use codex_exec_server_protocol::EXEC_READ_METHOD;
|
|
use codex_exec_server_protocol::FS_OPEN_METHOD;
|
|
#[cfg(unix)]
|
|
use codex_exec_server_protocol::FS_READ_BLOCK_METHOD;
|
|
use codex_exec_server_protocol::FS_READ_FILE_METHOD;
|
|
#[cfg(unix)]
|
|
use codex_exec_server_protocol::FsReadBlockResponse;
|
|
use codex_exec_server_protocol::FsReadFileResponse;
|
|
use codex_exec_server_protocol::JSONRPCMessage;
|
|
use codex_exec_server_protocol::JSONRPCResponse;
|
|
#[cfg(unix)]
|
|
use codex_exec_server_protocol::ReadResponse;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::permissions::FileSystemAccessMode;
|
|
use codex_protocol::permissions::FileSystemPath;
|
|
use codex_protocol::permissions::FileSystemSandboxEntry;
|
|
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
|
use codex_protocol::permissions::FileSystemSpecialPath;
|
|
use codex_protocol::permissions::NetworkSandboxPolicy;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use codex_utils_path_uri::PathUri;
|
|
use common::exec_server::exec_server;
|
|
#[cfg(target_os = "linux")]
|
|
use common::exec_server::exec_server_with_env;
|
|
#[cfg(target_os = "linux")]
|
|
use fake_bwrap::write_fake_bwrap;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn discovers_a_complete_capability_bundle_in_one_request() -> anyhow::Result<()> {
|
|
let root = tempfile::tempdir()?;
|
|
write_file(
|
|
&root.path().join(".codex-plugin/plugin.json"),
|
|
r#"{
|
|
"name": "demo",
|
|
"interface": {"displayName": "Demo Plugin"},
|
|
"mcpServers": "./config/mcp.json",
|
|
"apps": "./config/apps.json"
|
|
}"#,
|
|
)?;
|
|
write_file(
|
|
&root.path().join(".claude-plugin/plugin.json"),
|
|
r#"{"name":"lower-priority-claude"}"#,
|
|
)?;
|
|
write_file(
|
|
&root.path().join(".cursor-plugin/plugin.json"),
|
|
r#"{"name":"lower-priority-cursor"}"#,
|
|
)?;
|
|
write_file(
|
|
&root.path().join("config/mcp.json"),
|
|
r#"{"mcpServers":{"demo":{"command":"demo-server"}}}"#,
|
|
)?;
|
|
write_file(
|
|
&root.path().join("config/apps.json"),
|
|
r#"{"apps":{"demo":{"connector_id":"connector-demo"}}}"#,
|
|
)?;
|
|
write_file(
|
|
&root.path().join(".app.json"),
|
|
r#"{"apps":{"fallback":{"id":"connector-fallback"}}}"#,
|
|
)?;
|
|
write_file(
|
|
&root.path().join("skills/deploy/SKILL.md"),
|
|
"---\nname: deploy\ndescription: Deploy the service.\n---\n\nDeploy instructions.\n",
|
|
)?;
|
|
write_file(
|
|
&root.path().join("skills/deploy/agents/openai.yaml"),
|
|
"policy:\n allow_implicit_invocation: false\n",
|
|
)?;
|
|
write_file(
|
|
&root.path().join("nested/.claude-plugin/plugin.json"),
|
|
r#"{"name":"nested"}"#,
|
|
)?;
|
|
write_file(
|
|
&root.path().join("nested/skills/audit/SKILL.md"),
|
|
"---\nname: audit\ndescription: Audit the service.\n---\n",
|
|
)?;
|
|
write_file(
|
|
&root.path().join("nested-cursor/.cursor-plugin/plugin.json"),
|
|
r#"{"name":"cursor-nested"}"#,
|
|
)?;
|
|
write_file(
|
|
&root.path().join("nested-cursor/skills/review/SKILL.md"),
|
|
"---\nname: review\ndescription: Review the service.\n---\n",
|
|
)?;
|
|
|
|
let mut server = exec_server().await?;
|
|
initialize(&mut server).await?;
|
|
let root_uri = PathUri::from_host_native_path(root.path())?;
|
|
let discovery = discover_root(&mut server, "demo@1", root_uri.clone()).await?;
|
|
|
|
assert_eq!(discovery.id, "demo@1");
|
|
assert_eq!(discovery.path, root_uri);
|
|
assert_eq!(discovery.error, None);
|
|
assert_eq!(discovery.warnings, Vec::<String>::new());
|
|
let plugin = discovery.plugin.as_ref().expect("root plugin");
|
|
assert_eq!(
|
|
plugin.manifest.path,
|
|
root_uri.join(".codex-plugin/plugin.json")?
|
|
);
|
|
assert!(plugin.manifest.contents.contains("Demo Plugin"));
|
|
assert_eq!(
|
|
plugin.mcp_config.as_ref().map(|file| &file.path),
|
|
Some(&root_uri.join("config/mcp.json")?)
|
|
);
|
|
assert_eq!(
|
|
plugin.apps_config.as_ref().map(|file| &file.path),
|
|
Some(&root_uri.join("config/apps.json")?)
|
|
);
|
|
assert_eq!(
|
|
discovery
|
|
.namespace_manifests
|
|
.iter()
|
|
.map(|file| file.path.clone())
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
root_uri.join(".codex-plugin/plugin.json")?,
|
|
root_uri.join("nested/.claude-plugin/plugin.json")?,
|
|
root_uri.join("nested-cursor/.cursor-plugin/plugin.json")?,
|
|
]
|
|
);
|
|
assert_eq!(
|
|
discovery
|
|
.skills
|
|
.iter()
|
|
.map(|skill| (
|
|
skill.instructions.path.clone(),
|
|
skill
|
|
.metadata
|
|
.as_ref()
|
|
.map(|metadata| metadata.path.clone()),
|
|
))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(root_uri.join("nested-cursor/skills/review/SKILL.md")?, None,),
|
|
(root_uri.join("nested/skills/audit/SKILL.md")?, None,),
|
|
(
|
|
root_uri.join("skills/deploy/SKILL.md")?,
|
|
Some(root_uri.join("skills/deploy/agents/openai.yaml")?),
|
|
),
|
|
]
|
|
);
|
|
|
|
// Without an apps declaration, discovery returns the default .app.json.
|
|
let mut manifest: serde_json::Value = serde_json::from_str(&plugin.manifest.contents)?;
|
|
manifest
|
|
.as_object_mut()
|
|
.ok_or_else(|| anyhow::anyhow!("manifest must be an object"))?
|
|
.remove("apps");
|
|
write_file(
|
|
&root.path().join(".codex-plugin/plugin.json"),
|
|
&serde_json::to_string(&manifest)?,
|
|
)?;
|
|
let fallback = discover_root(&mut server, "demo@1", root_uri.clone()).await?;
|
|
assert_eq!(fallback.error, None);
|
|
assert_eq!(fallback.warnings, Vec::<String>::new());
|
|
let apps = fallback
|
|
.plugin
|
|
.and_then(|plugin| plugin.apps_config)
|
|
.ok_or_else(|| anyhow::anyhow!("missing fallback apps config"))?;
|
|
assert_eq!(apps.path, root_uri.join(".app.json")?);
|
|
assert_eq!(
|
|
apps.contents,
|
|
r#"{"apps":{"fallback":{"id":"connector-fallback"}}}"#,
|
|
);
|
|
|
|
server.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn discovers_cursor_plugin_without_reading_default_mcp_for_inline_servers()
|
|
-> anyhow::Result<()> {
|
|
let root = tempfile::tempdir()?;
|
|
write_file(
|
|
&root.path().join(".cursor-plugin/plugin.json"),
|
|
r#"{"name":"cursor-demo","mcpServers":{"inline":{"command":"inline"}}}"#,
|
|
)?;
|
|
write_file(
|
|
&root.path().join(".mcp.json"),
|
|
r#"{"mcpServers":{"should-not-load":{"command":"wrong"}}}"#,
|
|
)?;
|
|
|
|
let mut server = exec_server().await?;
|
|
initialize(&mut server).await?;
|
|
let root_uri = PathUri::from_host_native_path(root.path())?;
|
|
let discovery = discover_root(&mut server, "cursor@1", root_uri.clone()).await?;
|
|
|
|
assert_eq!(discovery.error, None);
|
|
assert_eq!(discovery.warnings, Vec::<String>::new());
|
|
let plugin = discovery.plugin.expect("cursor plugin");
|
|
assert_eq!(
|
|
plugin.manifest.path,
|
|
root_uri.join(".cursor-plugin/plugin.json")?
|
|
);
|
|
assert_eq!(plugin.mcp_config, None);
|
|
assert_eq!(plugin.apps_config, None);
|
|
assert_eq!(
|
|
discovery
|
|
.namespace_manifests
|
|
.iter()
|
|
.map(|manifest| manifest.path.clone())
|
|
.collect::<Vec<_>>(),
|
|
vec![root_uri.join(".cursor-plugin/plugin.json")?]
|
|
);
|
|
|
|
server.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn executor_legacy_missing_or_null_sandbox_cwd_keeps_absolute_requests_working()
|
|
-> anyhow::Result<()> {
|
|
let workspace = tempfile::tempdir()?;
|
|
std::fs::write(workspace.path().join("note.txt"), b"contents")?;
|
|
write_file(
|
|
&workspace.path().join("skills/demo/SKILL.md"),
|
|
"---\nname: demo\ndescription: Demo.\n---\n",
|
|
)?;
|
|
let cwd = PathUri::from_host_native_path(workspace.path())?;
|
|
let path = cwd.join("note.txt")?;
|
|
let program = std::env::current_exe()?.to_string_lossy().into_owned();
|
|
// A process with sandbox intent needs an enforceable profile and an enabled Windows backend.
|
|
let process_policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry::new(
|
|
FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
FileSystemAccessMode::Read,
|
|
),
|
|
FileSystemSandboxEntry::new(
|
|
FileSystemPath::Path { path: cwd.clone() },
|
|
FileSystemAccessMode::Write,
|
|
),
|
|
]);
|
|
let mut process_sandbox = FileSystemSandboxContext::from_permission_profile(
|
|
PermissionProfile::from_runtime_permissions(&process_policy, NetworkSandboxPolicy::Enabled),
|
|
cwd.clone(),
|
|
);
|
|
process_sandbox.windows_sandbox_selection = WindowsSandboxSelection::RestrictedToken;
|
|
let sandbox =
|
|
FileSystemSandboxContext::from_permission_profile(PermissionProfile::Disabled, cwd.clone());
|
|
let requests = [
|
|
(
|
|
EXEC_METHOD,
|
|
serde_json::json!({
|
|
"processId": "sandbox-cwd", "argv": [program, "--help"], "cwd": cwd,
|
|
"env": {}, "tty": false, "sandbox": process_sandbox,
|
|
}),
|
|
"/sandbox",
|
|
),
|
|
(
|
|
FS_READ_FILE_METHOD,
|
|
serde_json::json!({ "path": path, "sandbox": sandbox }),
|
|
"/sandbox",
|
|
),
|
|
(
|
|
CAPABILITY_ROOTS_DISCOVER_METHOD,
|
|
serde_json::json!({ "roots": [{ "id": "root", "path": cwd, "sandbox": sandbox }] }),
|
|
"/roots/0/sandbox",
|
|
),
|
|
];
|
|
let mut server = exec_server().await?;
|
|
initialize(&mut server).await?;
|
|
|
|
for (method, request, sandbox_pointer) in requests {
|
|
for (index, legacy_cwd) in [None, Some(serde_json::Value::Null)]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
let mut request = request.clone();
|
|
if method == EXEC_METHOD {
|
|
request["processId"] = serde_json::json!(format!("sandbox-cwd-{index}"));
|
|
}
|
|
let sandbox = request
|
|
.pointer_mut(sandbox_pointer)
|
|
.and_then(serde_json::Value::as_object_mut)
|
|
.expect("sandbox request");
|
|
if let Some(legacy_cwd) = legacy_cwd {
|
|
sandbox.insert("cwd".to_string(), legacy_cwd);
|
|
} else {
|
|
sandbox.remove("cwd");
|
|
}
|
|
sandbox.remove("workspaceRoots");
|
|
let request_id = server.send_request(method, request).await?;
|
|
let response = server
|
|
.wait_for_event(|event| match event {
|
|
JSONRPCMessage::Response(response) => response.id == request_id,
|
|
JSONRPCMessage::Error(error) => error.id == request_id,
|
|
JSONRPCMessage::Request(_) | JSONRPCMessage::Notification(_) => false,
|
|
})
|
|
.await?;
|
|
let JSONRPCMessage::Response(response) = response else {
|
|
anyhow::bail!("expected legacy {method} to succeed, got {response:?}");
|
|
};
|
|
if method == FS_READ_FILE_METHOD {
|
|
let read: FsReadFileResponse = serde_json::from_value(response.result)?;
|
|
assert_eq!(STANDARD.decode(read.data_base64)?, b"contents");
|
|
} else if method == CAPABILITY_ROOTS_DISCOVER_METHOD {
|
|
let discovery: CapabilityRootsDiscoverResponse =
|
|
serde_json::from_value(response.result)?;
|
|
assert_eq!(
|
|
discovery.roots[0]
|
|
.skills
|
|
.iter()
|
|
.map(|skill| &skill.instructions.path)
|
|
.collect::<Vec<_>>(),
|
|
vec![&cwd.join("skills/demo/SKILL.md")?],
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
server.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Old filesystem clients cannot replace the original cwd for rules whose meaning depends on it.
|
|
#[tokio::test]
|
|
async fn executor_legacy_filesystem_rejects_missing_cwd_for_dynamic_permissions()
|
|
-> anyhow::Result<()> {
|
|
let workspace = tempfile::tempdir()?;
|
|
let cwd = PathUri::from_host_native_path(workspace.path())?;
|
|
let mut server = exec_server().await?;
|
|
initialize(&mut server).await?;
|
|
|
|
for path in [
|
|
FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::ProjectRoots { subpath: None },
|
|
},
|
|
FileSystemPath::GlobPattern {
|
|
pattern: "secrets/**".to_string(),
|
|
},
|
|
] {
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry::new(
|
|
path,
|
|
FileSystemAccessMode::Deny,
|
|
)]);
|
|
let sandbox = FileSystemSandboxContext::from_permission_profile(
|
|
PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted),
|
|
cwd.clone(),
|
|
);
|
|
for legacy_cwd in [None, Some(serde_json::Value::Null)] {
|
|
let mut sandbox = serde_json::to_value(&sandbox)?;
|
|
let fields = sandbox.as_object_mut().expect("sandbox object");
|
|
if let Some(legacy_cwd) = legacy_cwd {
|
|
fields.insert("cwd".to_string(), legacy_cwd);
|
|
} else {
|
|
fields.remove("cwd");
|
|
}
|
|
fields.remove("workspaceRoots");
|
|
for (method, request) in [
|
|
(
|
|
FS_READ_FILE_METHOD,
|
|
serde_json::json!({ "path": cwd, "sandbox": sandbox }),
|
|
),
|
|
(
|
|
FS_OPEN_METHOD,
|
|
serde_json::json!({ "handleId": "denied", "path": cwd, "sandbox": sandbox }),
|
|
),
|
|
(
|
|
CAPABILITY_ROOTS_DISCOVER_METHOD,
|
|
serde_json::json!({ "roots": [{ "id": "root", "path": cwd, "sandbox": sandbox }] }),
|
|
),
|
|
] {
|
|
let request_id = server.send_request(method, request).await?;
|
|
let response = server
|
|
.wait_for_event(|event| match event {
|
|
JSONRPCMessage::Response(response) => response.id == request_id,
|
|
JSONRPCMessage::Error(error) => error.id == request_id,
|
|
JSONRPCMessage::Request(_) | JSONRPCMessage::Notification(_) => false,
|
|
})
|
|
.await?;
|
|
let JSONRPCMessage::Error(error) = response else {
|
|
anyhow::bail!(
|
|
"expected legacy {method} to reject dynamic permissions, got {response:?}"
|
|
);
|
|
};
|
|
assert_eq!(
|
|
(error.error.code, error.error.message.as_str()),
|
|
(
|
|
-32602,
|
|
"file system sandbox context with dynamic permissions requires cwd"
|
|
),
|
|
"{method}",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
server.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Providing cwd lets project roots and relative rules be enforced against the original directory.
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn executor_legacy_filesystem_accepts_cwd_for_dynamic_permissions() -> anyhow::Result<()> {
|
|
let workspace = tempfile::tempdir()?;
|
|
write_file(&workspace.path().join("allowed.txt"), "allowed")?;
|
|
write_file(&workspace.path().join("secret.txt"), "secret")?;
|
|
let cwd = PathUri::from_host_native_path(workspace.path())?;
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry::new(
|
|
FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
FileSystemAccessMode::Read,
|
|
),
|
|
FileSystemSandboxEntry::new(
|
|
FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::ProjectRoots { subpath: None },
|
|
},
|
|
FileSystemAccessMode::Write,
|
|
),
|
|
FileSystemSandboxEntry::new(
|
|
FileSystemPath::GlobPattern {
|
|
pattern: "secret*".to_string(),
|
|
},
|
|
FileSystemAccessMode::Deny,
|
|
),
|
|
]);
|
|
let sandbox = FileSystemSandboxContext::from_permission_profile(
|
|
PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted),
|
|
cwd.clone(),
|
|
);
|
|
let mut server = exec_server().await?;
|
|
initialize(&mut server).await?;
|
|
let request_id = server
|
|
.send_request(
|
|
FS_READ_FILE_METHOD,
|
|
serde_json::json!({"path": cwd.join("secret.txt")?}),
|
|
)
|
|
.await?;
|
|
let response = server
|
|
.wait_for_event(|event| match event {
|
|
JSONRPCMessage::Response(response) => response.id == request_id,
|
|
JSONRPCMessage::Error(error) => error.id == request_id,
|
|
JSONRPCMessage::Request(_) | JSONRPCMessage::Notification(_) => false,
|
|
})
|
|
.await?;
|
|
let JSONRPCMessage::Response(response) = response else {
|
|
anyhow::bail!(
|
|
"expected the denied path to be readable without a sandbox, got {response:?}"
|
|
);
|
|
};
|
|
let read: FsReadFileResponse = serde_json::from_value(response.result)?;
|
|
assert_eq!(STANDARD.decode(read.data_base64)?, b"secret");
|
|
|
|
for (name, permitted) in [("allowed.txt", true), ("secret.txt", false)] {
|
|
let request_id = server
|
|
.send_request(
|
|
FS_READ_FILE_METHOD,
|
|
serde_json::json!({
|
|
"path": cwd.join(name)?, "sandbox": sandbox,
|
|
}),
|
|
)
|
|
.await?;
|
|
let response = server
|
|
.wait_for_event(|event| match event {
|
|
JSONRPCMessage::Response(response) => response.id == request_id,
|
|
JSONRPCMessage::Error(error) => error.id == request_id,
|
|
JSONRPCMessage::Request(_) | JSONRPCMessage::Notification(_) => false,
|
|
})
|
|
.await?;
|
|
match response {
|
|
JSONRPCMessage::Response(response) if permitted => {
|
|
let read: FsReadFileResponse = serde_json::from_value(response.result)?;
|
|
assert_eq!(STANDARD.decode(read.data_base64)?, b"allowed");
|
|
}
|
|
JSONRPCMessage::Error(error) if !permitted => {
|
|
assert_eq!(error.error.code, -32600, "{error:?}");
|
|
assert!(
|
|
matches!(
|
|
error.error.message.as_str(),
|
|
"Permission denied (os error 13)" | "Operation not permitted (os error 1)"
|
|
),
|
|
"expected a filesystem permission denial: {error:?}"
|
|
);
|
|
}
|
|
response => anyhow::bail!("unexpected filesystem result for {name}: {response:?}"),
|
|
}
|
|
}
|
|
server.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// A present policy context can omit cwd: legacy cwd still anchors denials, and only static
|
|
/// filesystem policies can use the executor default when both cwd fields are absent.
|
|
#[tokio::test]
|
|
async fn executor_legacy_nested_policy_context_cwd_uses_available_fallbacks() -> anyhow::Result<()>
|
|
{
|
|
let workspace = tempfile::tempdir()?;
|
|
write_file(&workspace.path().join("allowed.txt"), "allowed")?;
|
|
write_file(&workspace.path().join("secret.txt"), "secret")?;
|
|
let cwd = PathUri::from_host_native_path(workspace.path())?;
|
|
let static_permissions = if cfg!(unix) {
|
|
PermissionProfile::read_only()
|
|
} else {
|
|
// The direct Windows test fixture has no enabled platform sandbox.
|
|
PermissionProfile::Disabled
|
|
};
|
|
let static_sandbox =
|
|
FileSystemSandboxContext::from_permission_profile(static_permissions, cwd.clone());
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry::new(
|
|
FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
FileSystemAccessMode::Read,
|
|
),
|
|
FileSystemSandboxEntry::new(
|
|
FileSystemPath::GlobPattern {
|
|
pattern: "secret*".to_string(),
|
|
},
|
|
FileSystemAccessMode::Deny,
|
|
),
|
|
]);
|
|
let dynamic_sandbox = FileSystemSandboxContext::from_permission_profile(
|
|
PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted),
|
|
cwd.clone(),
|
|
);
|
|
let mut server = exec_server().await?;
|
|
initialize(&mut server).await?;
|
|
|
|
for (shape, policy_context) in [
|
|
("missing", serde_json::json!({"workspaceRoots": [cwd]})),
|
|
(
|
|
"null",
|
|
serde_json::json!({"cwd": null, "workspaceRoots": [cwd]}),
|
|
),
|
|
] {
|
|
for (case, sandbox, legacy_cwd, name, expected) in [
|
|
(
|
|
"static allowed without legacy cwd",
|
|
&static_sandbox,
|
|
None::<&PathUri>,
|
|
"allowed.txt",
|
|
Ok(b"allowed".as_slice()),
|
|
),
|
|
(
|
|
"static secret without legacy cwd",
|
|
&static_sandbox,
|
|
None,
|
|
"secret.txt",
|
|
Ok(b"secret".as_slice()),
|
|
),
|
|
(
|
|
"dynamic without legacy cwd",
|
|
&dynamic_sandbox,
|
|
None,
|
|
"allowed.txt",
|
|
Err(-32602),
|
|
),
|
|
// The default Windows test backend cannot enforce read denials at runtime.
|
|
#[cfg(unix)]
|
|
(
|
|
"dynamic allowed with legacy cwd",
|
|
&dynamic_sandbox,
|
|
Some(&cwd),
|
|
"allowed.txt",
|
|
Ok(b"allowed".as_slice()),
|
|
),
|
|
#[cfg(unix)]
|
|
(
|
|
"dynamic secret with legacy cwd",
|
|
&dynamic_sandbox,
|
|
Some(&cwd),
|
|
"secret.txt",
|
|
Err(-32600),
|
|
),
|
|
] {
|
|
let mut sandbox = serde_json::to_value(sandbox)?;
|
|
let fields = sandbox.as_object_mut().expect("sandbox object");
|
|
fields.insert("policyContext".to_string(), policy_context.clone());
|
|
if let Some(legacy_cwd) = legacy_cwd {
|
|
fields.insert("cwd".to_string(), serde_json::to_value(legacy_cwd)?);
|
|
} else {
|
|
fields.remove("cwd");
|
|
}
|
|
fields.remove("workspaceRoots");
|
|
let request_id = server
|
|
.send_request(
|
|
FS_READ_FILE_METHOD,
|
|
serde_json::json!({"path": cwd.join(name)?, "sandbox": sandbox}),
|
|
)
|
|
.await?;
|
|
let response = server
|
|
.wait_for_event(|event| match event {
|
|
JSONRPCMessage::Response(response) => response.id == request_id,
|
|
JSONRPCMessage::Error(error) => error.id == request_id,
|
|
JSONRPCMessage::Request(_) | JSONRPCMessage::Notification(_) => false,
|
|
})
|
|
.await?;
|
|
match (response, expected) {
|
|
(JSONRPCMessage::Response(response), Ok(contents)) => {
|
|
let read: FsReadFileResponse = serde_json::from_value(response.result)?;
|
|
assert_eq!(
|
|
STANDARD.decode(read.data_base64)?,
|
|
contents,
|
|
"{shape}: {case}"
|
|
);
|
|
}
|
|
(JSONRPCMessage::Error(error), Err(-32602)) => assert_eq!(
|
|
(error.error.code, error.error.message.as_str()),
|
|
(
|
|
-32602,
|
|
"file system sandbox context with dynamic permissions requires cwd"
|
|
),
|
|
"{shape}: {case}",
|
|
),
|
|
(JSONRPCMessage::Error(error), Err(-32600)) => {
|
|
assert_eq!(error.error.code, -32600, "{shape}: {case}: {error:?}");
|
|
assert!(
|
|
matches!(
|
|
error.error.message.as_str(),
|
|
"Permission denied (os error 13)"
|
|
| "Operation not permitted (os error 1)"
|
|
),
|
|
"{shape}: {case}: {error:?}",
|
|
);
|
|
}
|
|
(response, expected) => anyhow::bail!(
|
|
"unexpected {shape} nested cwd result for {case}: expected {expected:?}, got {response:?}"
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
server.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn executor_legacy_exec_uses_process_cwd_for_relative_denials() -> anyhow::Result<()> {
|
|
let workspace = tempfile::tempdir()?;
|
|
std::fs::write(workspace.path().join("allowed.txt"), b"allowed")?;
|
|
std::fs::write(workspace.path().join("secret.txt"), b"secret")?;
|
|
let cwd = PathUri::from_host_native_path(workspace.path())?;
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry::new(
|
|
FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
FileSystemAccessMode::Read,
|
|
),
|
|
FileSystemSandboxEntry::new(
|
|
FileSystemPath::GlobPattern {
|
|
pattern: "secret*".to_string(),
|
|
},
|
|
FileSystemAccessMode::Deny,
|
|
),
|
|
]);
|
|
let sandbox = FileSystemSandboxContext::from_permission_profile(
|
|
PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted),
|
|
cwd.clone(),
|
|
);
|
|
let process_env =
|
|
std::collections::HashMap::from([("PATH".to_string(), std::env::var("PATH")?)]);
|
|
#[cfg(target_os = "linux")]
|
|
let process_env = {
|
|
let mut process_env = process_env;
|
|
// Bazel-provided bubblewrap lives in runfiles, not necessarily on the process PATH.
|
|
for name in [
|
|
"CARGO_BIN_EXE_bwrap",
|
|
"RUNFILES_DIR",
|
|
"RUNFILES_MANIFEST_FILE",
|
|
"TEST_SRCDIR",
|
|
"TEST_WORKSPACE",
|
|
] {
|
|
if let Ok(value) = std::env::var(name) {
|
|
process_env.insert(name.to_string(), value);
|
|
}
|
|
}
|
|
process_env
|
|
};
|
|
let mut server = exec_server().await?;
|
|
initialize(&mut server).await?;
|
|
for (index, legacy_cwd) in [None, Some(serde_json::Value::Null)]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
let mut sandbox = serde_json::to_value(&sandbox)?;
|
|
let fields = sandbox.as_object_mut().expect("sandbox object");
|
|
if let Some(legacy_cwd) = legacy_cwd {
|
|
fields.insert("cwd".to_string(), legacy_cwd);
|
|
} else {
|
|
fields.remove("cwd");
|
|
}
|
|
let process = format!("legacy-relative-{index}");
|
|
let request_id = server.send_request(EXEC_METHOD, serde_json::json!({
|
|
"processId": process, "argv": ["/bin/sh", "-c", "cat allowed.txt && if cat secret.txt 2>/dev/null; then exit 9; fi"], "cwd": cwd,
|
|
"env": process_env, "tty": false, "sandbox": sandbox,
|
|
})).await?;
|
|
let start = server
|
|
.wait_for_event(|event| match event {
|
|
JSONRPCMessage::Response(response) => response.id == request_id,
|
|
JSONRPCMessage::Error(error) => error.id == request_id,
|
|
JSONRPCMessage::Request(_) | JSONRPCMessage::Notification(_) => false,
|
|
})
|
|
.await?;
|
|
if !matches!(start, JSONRPCMessage::Response(_)) {
|
|
anyhow::bail!("expected legacy process to start, got {start:?}");
|
|
}
|
|
let mut after_seq = None;
|
|
let mut output = Vec::new();
|
|
let (exit_code, failure, sandbox_denied) = loop {
|
|
let request_id = server
|
|
.send_request(
|
|
EXEC_READ_METHOD,
|
|
serde_json::json!({
|
|
"processId": process, "afterSeq": after_seq, "waitMs": 1000,
|
|
}),
|
|
)
|
|
.await?;
|
|
let response = server
|
|
.wait_for_event(|event| match event {
|
|
JSONRPCMessage::Response(response) => response.id == request_id,
|
|
JSONRPCMessage::Error(error) => error.id == request_id,
|
|
JSONRPCMessage::Request(_) | JSONRPCMessage::Notification(_) => false,
|
|
})
|
|
.await?;
|
|
let JSONRPCMessage::Response(response) = response else {
|
|
anyhow::bail!("expected legacy process output, got {response:?}");
|
|
};
|
|
let response: ReadResponse = serde_json::from_value(response.result)?;
|
|
output.extend(response.chunks.into_iter().flat_map(|chunk| chunk.chunk.0));
|
|
after_seq = response.next_seq.checked_sub(1).or(after_seq);
|
|
if response.closed {
|
|
break (
|
|
response.exit_code,
|
|
response.failure,
|
|
response.sandbox_denied,
|
|
);
|
|
}
|
|
};
|
|
assert_eq!(
|
|
(exit_code, String::from_utf8(output)?),
|
|
(Some(0), "allowed".to_string()),
|
|
"legacy process failed: {failure:?}; sandbox denied: {sandbox_denied}"
|
|
);
|
|
}
|
|
server.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn executor_legacy_filesystem_cwd_keeps_absolute_read_allow_and_deny_rules()
|
|
-> anyhow::Result<()> {
|
|
let workspace = tempfile::tempdir()?;
|
|
let allowed = workspace.path().join("allowed");
|
|
let denied = workspace.path().join("denied");
|
|
for (root, name) in [(&allowed, "allowed"), (&denied, "denied")] {
|
|
write_file(&root.join("note.txt"), "contents")?;
|
|
write_file(
|
|
&root.join(format!("skills/{name}/SKILL.md")),
|
|
&format!("---\nname: {name}\ndescription: {name}.\n---\n"),
|
|
)?;
|
|
}
|
|
let allowed = PathUri::from_host_native_path(allowed)?;
|
|
let denied = PathUri::from_host_native_path(denied)?;
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry::new(
|
|
FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
FileSystemAccessMode::Read,
|
|
),
|
|
FileSystemSandboxEntry::new(
|
|
FileSystemPath::Path {
|
|
path: denied.clone(),
|
|
},
|
|
FileSystemAccessMode::Deny,
|
|
),
|
|
]);
|
|
let sandbox = FileSystemSandboxContext::from_permission_profile(
|
|
PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted),
|
|
PathUri::from_host_native_path(workspace.path())?,
|
|
);
|
|
let mut server = exec_server().await?;
|
|
initialize(&mut server).await?;
|
|
|
|
let request_id = server
|
|
.send_request(
|
|
FS_READ_FILE_METHOD,
|
|
serde_json::json!({"path": denied.join("note.txt")?}),
|
|
)
|
|
.await?;
|
|
let response = server
|
|
.wait_for_event(|event| match event {
|
|
JSONRPCMessage::Response(response) => response.id == request_id,
|
|
JSONRPCMessage::Error(error) => error.id == request_id,
|
|
JSONRPCMessage::Request(_) | JSONRPCMessage::Notification(_) => false,
|
|
})
|
|
.await?;
|
|
let JSONRPCMessage::Response(response) = response else {
|
|
anyhow::bail!(
|
|
"expected the denied path to be readable without a sandbox, got {response:?}"
|
|
);
|
|
};
|
|
let read: FsReadFileResponse = serde_json::from_value(response.result)?;
|
|
assert_eq!(STANDARD.decode(read.data_base64)?, b"contents");
|
|
|
|
for (index, legacy_cwd) in [None, Some(serde_json::Value::Null)]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
let mut sandbox = serde_json::to_value(&sandbox)?;
|
|
let fields = sandbox.as_object_mut().expect("sandbox object");
|
|
if let Some(legacy_cwd) = legacy_cwd {
|
|
fields.insert("cwd".to_string(), legacy_cwd);
|
|
} else {
|
|
fields.remove("cwd");
|
|
}
|
|
fields.remove("workspaceRoots");
|
|
for (root, permitted) in [(&allowed, true), (&denied, false)] {
|
|
let handle = format!("legacy-{index}-{permitted}");
|
|
for (method, request) in [
|
|
(
|
|
FS_READ_FILE_METHOD,
|
|
serde_json::json!({ "path": root.join("note.txt")?, "sandbox": sandbox }),
|
|
),
|
|
(
|
|
FS_OPEN_METHOD,
|
|
serde_json::json!({ "handleId": handle, "path": root.join("note.txt")?, "sandbox": sandbox }),
|
|
),
|
|
] {
|
|
let request_id = server.send_request(method, request).await?;
|
|
let response = server
|
|
.wait_for_event(|event| match event {
|
|
JSONRPCMessage::Response(response) => response.id == request_id,
|
|
JSONRPCMessage::Error(error) => error.id == request_id,
|
|
JSONRPCMessage::Request(_) | JSONRPCMessage::Notification(_) => false,
|
|
})
|
|
.await?;
|
|
match response {
|
|
JSONRPCMessage::Response(response) if permitted => {
|
|
if method == FS_READ_FILE_METHOD {
|
|
let read: FsReadFileResponse = serde_json::from_value(response.result)?;
|
|
assert_eq!(STANDARD.decode(read.data_base64)?, b"contents");
|
|
} else {
|
|
let request_id = server.send_request(FS_READ_BLOCK_METHOD, serde_json::json!({ "handleId": handle, "offset": 0, "len": 16 })).await?;
|
|
let block = server
|
|
.wait_for_event(|event| match event {
|
|
JSONRPCMessage::Response(response) => response.id == request_id,
|
|
JSONRPCMessage::Error(error) => error.id == request_id,
|
|
JSONRPCMessage::Request(_)
|
|
| JSONRPCMessage::Notification(_) => false,
|
|
})
|
|
.await?;
|
|
let JSONRPCMessage::Response(block) = block else {
|
|
anyhow::bail!("expected to read the legacy stream, got {block:?}");
|
|
};
|
|
let block: FsReadBlockResponse = serde_json::from_value(block.result)?;
|
|
assert_eq!(block.chunk.0, b"contents");
|
|
}
|
|
}
|
|
JSONRPCMessage::Error(error) if !permitted => {
|
|
assert_eq!(error.error.code, -32600, "{method}: {error:?}");
|
|
assert!(
|
|
matches!(
|
|
error.error.message.as_str(),
|
|
"Permission denied (os error 13)"
|
|
| "Operation not permitted (os error 1)"
|
|
),
|
|
"expected a filesystem permission denial for {method}: {error:?}"
|
|
);
|
|
}
|
|
response => {
|
|
anyhow::bail!("unexpected legacy {method} result for {root}: {response:?}")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let request_id = server
|
|
.send_request(
|
|
CAPABILITY_ROOTS_DISCOVER_METHOD,
|
|
serde_json::json!({ "roots": [
|
|
{ "id": "allowed", "path": allowed, "sandbox": sandbox },
|
|
{ "id": "denied", "path": denied, "sandbox": sandbox },
|
|
] }),
|
|
)
|
|
.await?;
|
|
let discovery = server
|
|
.wait_for_event(|event| match event {
|
|
JSONRPCMessage::Response(response) => response.id == request_id,
|
|
JSONRPCMessage::Error(error) => error.id == request_id,
|
|
JSONRPCMessage::Request(_) | JSONRPCMessage::Notification(_) => false,
|
|
})
|
|
.await?;
|
|
let JSONRPCMessage::Response(discovery) = discovery else {
|
|
anyhow::bail!("expected legacy capability discovery, got {discovery:?}");
|
|
};
|
|
let discovery: CapabilityRootsDiscoverResponse = serde_json::from_value(discovery.result)?;
|
|
assert_eq!(
|
|
discovery
|
|
.roots
|
|
.iter()
|
|
.map(|root| (
|
|
root.id.as_str(),
|
|
root.skills
|
|
.iter()
|
|
.map(|skill| &skill.instructions.path)
|
|
.collect::<Vec<_>>()
|
|
))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
("allowed", vec![&allowed.join("skills/allowed/SKILL.md")?]),
|
|
("denied", vec![]),
|
|
]
|
|
);
|
|
}
|
|
|
|
server.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
// Exercise one helper request with multiple roots, including the response budget before IPC.
|
|
#[tokio::test]
|
|
async fn v2_helper_batches_roots_and_bounds_its_response() -> anyhow::Result<()> {
|
|
use tokio::io::AsyncWriteExt;
|
|
|
|
let workspace = tempfile::tempdir()?;
|
|
let mut locations = Vec::new();
|
|
// Five individually valid configs exceed the shared 4 MiB response budget.
|
|
let config = serde_json::to_string(&serde_json::json!({"padding": "x".repeat(900 * 1024)}))?;
|
|
for index in 0..5 {
|
|
let root = workspace.path().join(format!("plugin-{index}"));
|
|
write_file(
|
|
&root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"demo"}"#,
|
|
)?;
|
|
write_file(&root.join(".mcp.json"), &config)?;
|
|
write_file(
|
|
&root.join("skills/deploy/SKILL.md"),
|
|
"---\nname: deploy\ndescription: Deploy.\n---\n",
|
|
)?;
|
|
locations.push(serde_json::json!({
|
|
"root": PathUri::from_host_native_path(root)?,
|
|
"plugin": {
|
|
"id": format!("plugin-{index}@test"),
|
|
"version": "local",
|
|
"remote_plugin_id": null,
|
|
},
|
|
}));
|
|
}
|
|
let small_root = workspace.path().join("plugin-small");
|
|
write_file(
|
|
&small_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"small"}"#,
|
|
)?;
|
|
write_file(
|
|
&small_root.join("skills/deploy/SKILL.md"),
|
|
"---\nname: deploy\ndescription: Deploy.\n---\n",
|
|
)?;
|
|
locations.push(serde_json::json!({
|
|
"root": PathUri::from_host_native_path(small_root)?,
|
|
"plugin": {
|
|
"id": "plugin-small@test",
|
|
"version": "local",
|
|
"remote_plugin_id": null,
|
|
},
|
|
}));
|
|
let request = serde_json::json!({
|
|
"operation": "capabilities/loadDiscoveries",
|
|
"params": {"locations": locations, "warnings": []},
|
|
});
|
|
let (helper_binary, _) = common::current_test_binary_helper_paths()?;
|
|
let mut child = tokio::process::Command::new(helper_binary)
|
|
.arg(codex_exec_server::CODEX_FS_HELPER_ARG1)
|
|
.stdin(std::process::Stdio::piped())
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::piped())
|
|
.kill_on_drop(true)
|
|
.spawn()?;
|
|
let mut stdin = child
|
|
.stdin
|
|
.take()
|
|
.ok_or_else(|| anyhow::anyhow!("helper stdin missing"))?;
|
|
stdin.write_all(format!("{request}\n").as_bytes()).await?;
|
|
drop(stdin);
|
|
let output = tokio::time::timeout(
|
|
std::time::Duration::from_secs(/*secs*/ 30),
|
|
child.wait_with_output(),
|
|
)
|
|
.await??;
|
|
assert!(
|
|
output.status.success(),
|
|
"helper failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
let wire: serde_json::Value = serde_json::from_slice(&output.stdout)?;
|
|
assert_eq!(wire["status"], "ok", "helper response: {wire}");
|
|
assert_eq!(wire["payload"]["operation"], "capabilities/loadDiscoveries");
|
|
let response: codex_exec_server_protocol::DiscoverV2CapabilitiesResponse =
|
|
serde_json::from_value(wire["payload"]["response"].clone())?;
|
|
assert_eq!(
|
|
response
|
|
.plugins
|
|
.iter()
|
|
.map(|plugin| plugin.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
"plugin-0@test",
|
|
"plugin-1@test",
|
|
"plugin-2@test",
|
|
"plugin-3@test",
|
|
"plugin-small@test",
|
|
],
|
|
);
|
|
assert!(
|
|
response
|
|
.plugins
|
|
.iter()
|
|
.all(|plugin| plugin.skills.len() == 1)
|
|
);
|
|
assert_eq!(
|
|
response.warnings,
|
|
vec!["capability discovery size limit reached"]
|
|
);
|
|
assert!(serde_json::to_vec(&response)?.len() <= 4 * 1024 * 1024);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn sandboxed_discovery_batches_roots_without_combining_different_permissions()
|
|
-> anyhow::Result<()> {
|
|
#[cfg(windows)]
|
|
crate::skip_if_mxc_unavailable!(Ok(()));
|
|
let workspace = tempfile::tempdir()?;
|
|
let first_root = workspace.path().join("first");
|
|
let second_root = workspace.path().join("second");
|
|
write_file(
|
|
&first_root.join("skills/first/SKILL.md"),
|
|
"---\nname: first\ndescription: First skill.\n---\n",
|
|
)?;
|
|
write_file(
|
|
&second_root.join("skills/second/SKILL.md"),
|
|
"---\nname: second\ndescription: Second skill.\n---\n",
|
|
)?;
|
|
|
|
let workspace_uri = PathUri::from_host_native_path(workspace.path())?;
|
|
let first_uri = PathUri::from_host_native_path(&first_root)?;
|
|
let second_uri = PathUri::from_host_native_path(&second_root)?;
|
|
let read_workspace = FileSystemSandboxEntry::new(
|
|
AbsolutePathBuf::from_absolute_path(workspace.path())?.into(),
|
|
FileSystemAccessMode::Read,
|
|
);
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![read_workspace]);
|
|
let shared_sandbox = FileSystemSandboxContext::from_permission_profile(
|
|
PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted),
|
|
workspace_uri,
|
|
);
|
|
let shared_sandbox = with_native_sandbox(shared_sandbox);
|
|
|
|
#[cfg(target_os = "linux")]
|
|
let fake_bwrap_directory = tempfile::tempdir()?;
|
|
#[cfg(target_os = "linux")]
|
|
let (mut server, fake_bwrap) = {
|
|
let fake_bin_dir = fake_bwrap_directory.path().to_path_buf();
|
|
let fake_bwrap = write_fake_bwrap(&fake_bin_dir)?;
|
|
let mut path_entries = vec![fake_bin_dir];
|
|
if let Some(path) = std::env::var_os("PATH") {
|
|
path_entries.extend(std::env::split_paths(&path));
|
|
}
|
|
let helper_path = std::env::join_paths(path_entries)?;
|
|
(
|
|
exec_server_with_env([("PATH", helper_path.as_os_str())], &[]).await?,
|
|
fake_bwrap,
|
|
)
|
|
};
|
|
#[cfg(not(target_os = "linux"))]
|
|
let mut server = exec_server().await?;
|
|
initialize(&mut server).await?;
|
|
let response = discover_roots(
|
|
&mut server,
|
|
vec![
|
|
CapabilityRootDiscoverRequest {
|
|
id: "first".to_string(),
|
|
path: first_uri.clone(),
|
|
sandbox: Some(shared_sandbox.clone()),
|
|
},
|
|
CapabilityRootDiscoverRequest {
|
|
id: "second".to_string(),
|
|
path: second_uri.clone(),
|
|
sandbox: Some(shared_sandbox.clone()),
|
|
},
|
|
],
|
|
)
|
|
.await?;
|
|
assert_eq!(
|
|
response
|
|
.roots
|
|
.into_iter()
|
|
.map(|root| (
|
|
root.id,
|
|
root.path,
|
|
root.skills
|
|
.into_iter()
|
|
.map(|skill| skill.instructions.path)
|
|
.collect::<Vec<_>>(),
|
|
root.error,
|
|
))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(
|
|
"first".to_string(),
|
|
first_uri.clone(),
|
|
vec![first_uri.join("skills/first/SKILL.md")?],
|
|
None,
|
|
),
|
|
(
|
|
"second".to_string(),
|
|
second_uri.clone(),
|
|
vec![second_uri.join("skills/second/SKILL.md")?],
|
|
None,
|
|
),
|
|
]
|
|
);
|
|
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
let launch_log = fake_bwrap.with_file_name("bwrap.log");
|
|
let launch_count = std::fs::read_to_string(&launch_log)
|
|
.with_context(|| format!("expected fake bwrap launch log at {}", launch_log.display()))?
|
|
.lines()
|
|
.count();
|
|
assert_eq!(launch_count, 1);
|
|
|
|
std::fs::write(fake_bwrap.with_file_name("bwrap.fail-once"), "")?;
|
|
let fallback = discover_roots(
|
|
&mut server,
|
|
vec![
|
|
CapabilityRootDiscoverRequest {
|
|
id: "fallback-first".to_string(),
|
|
path: first_uri.clone(),
|
|
sandbox: Some(shared_sandbox.clone()),
|
|
},
|
|
CapabilityRootDiscoverRequest {
|
|
id: "fallback-second".to_string(),
|
|
path: second_uri.clone(),
|
|
sandbox: Some(shared_sandbox.clone()),
|
|
},
|
|
],
|
|
)
|
|
.await?;
|
|
assert_eq!(
|
|
fallback
|
|
.roots
|
|
.into_iter()
|
|
.map(|root| (root.id, root.skills.len(), root.error))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
("fallback-first".to_string(), 1, None),
|
|
("fallback-second".to_string(), 1, None),
|
|
]
|
|
);
|
|
assert!(std::fs::read_to_string(&launch_log)?.lines().count() > 2);
|
|
|
|
server.shutdown().await?;
|
|
server = exec_server().await?;
|
|
initialize(&mut server).await?;
|
|
}
|
|
|
|
let read_first_root = FileSystemSandboxEntry::new(
|
|
AbsolutePathBuf::from_absolute_path(&first_root)?.into(),
|
|
FileSystemAccessMode::Read,
|
|
);
|
|
let first_policy = FileSystemSandboxPolicy::restricted(vec![read_first_root]);
|
|
let first_only_sandbox = FileSystemSandboxContext::from_permission_profile(
|
|
PermissionProfile::from_runtime_permissions(
|
|
&first_policy,
|
|
NetworkSandboxPolicy::Restricted,
|
|
),
|
|
first_uri.clone(),
|
|
);
|
|
let first_only_sandbox = with_native_sandbox(first_only_sandbox);
|
|
let read_second_root = FileSystemSandboxEntry::new(
|
|
AbsolutePathBuf::from_absolute_path(&second_root)?.into(),
|
|
FileSystemAccessMode::Read,
|
|
);
|
|
let second_policy = FileSystemSandboxPolicy::restricted(vec![read_second_root]);
|
|
let second_only_sandbox = FileSystemSandboxContext::from_permission_profile(
|
|
PermissionProfile::from_runtime_permissions(
|
|
&second_policy,
|
|
NetworkSandboxPolicy::Restricted,
|
|
),
|
|
second_uri.clone(),
|
|
);
|
|
let second_only_sandbox = with_native_sandbox(second_only_sandbox);
|
|
let response = discover_roots(
|
|
&mut server,
|
|
vec![
|
|
CapabilityRootDiscoverRequest {
|
|
id: "first-isolated".to_string(),
|
|
path: first_uri,
|
|
sandbox: Some(first_only_sandbox),
|
|
},
|
|
CapabilityRootDiscoverRequest {
|
|
id: "second-isolated".to_string(),
|
|
path: second_uri,
|
|
sandbox: Some(second_only_sandbox),
|
|
},
|
|
],
|
|
)
|
|
.await?;
|
|
assert_eq!(
|
|
response
|
|
.roots
|
|
.into_iter()
|
|
.map(|root| (root.id, root.skills.len(), root.error))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
("first-isolated".to_string(), 1, None),
|
|
("second-isolated".to_string(), 1, None),
|
|
]
|
|
);
|
|
|
|
server.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn sandboxed_discovery_follows_only_permitted_external_symlinks() -> anyhow::Result<()> {
|
|
let root = tempfile::tempdir()?;
|
|
let external = tempfile::tempdir()?;
|
|
write_file(
|
|
&root.path().join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"linked-plugin","mcpServers":"./external-mcp.json"}"#,
|
|
)?;
|
|
write_file(
|
|
&external.path().join("mcp.json"),
|
|
r#"{"mcpServers":{"linked":{"command":"linked-server"}}}"#,
|
|
)?;
|
|
write_file(
|
|
&external.path().join("skill/SKILL.md"),
|
|
"---\nname: linked\ndescription: Linked external skill.\n---\n",
|
|
)?;
|
|
std::fs::create_dir_all(root.path().join("skills"))?;
|
|
std::os::unix::fs::symlink(
|
|
external.path().join("skill"),
|
|
root.path().join("skills/linked"),
|
|
)?;
|
|
std::os::unix::fs::symlink(
|
|
external.path().join("mcp.json"),
|
|
root.path().join("external-mcp.json"),
|
|
)?;
|
|
|
|
let mut server = exec_server().await?;
|
|
initialize(&mut server).await?;
|
|
let root_uri = PathUri::from_host_native_path(root.path())?;
|
|
let root_path = AbsolutePathBuf::from_absolute_path(root.path())?;
|
|
let external_root = AbsolutePathBuf::from_absolute_path(external.path())?;
|
|
let path_entry =
|
|
|path: AbsolutePathBuf, access| FileSystemSandboxEntry::new(path.into(), access);
|
|
let read_root = path_entry(root_path, FileSystemAccessMode::Read);
|
|
let read_external = path_entry(external_root.clone(), FileSystemAccessMode::Read);
|
|
let deny_external_skill = path_entry(external_root.join("skill"), FileSystemAccessMode::Deny);
|
|
let cases = [
|
|
(
|
|
"permitted symlinks",
|
|
vec![read_root.clone(), read_external.clone()],
|
|
true,
|
|
true,
|
|
),
|
|
(
|
|
"denied external root",
|
|
vec![read_root.clone()],
|
|
false,
|
|
false,
|
|
),
|
|
(
|
|
"denied external skill",
|
|
vec![read_root, read_external, deny_external_skill],
|
|
false,
|
|
true,
|
|
),
|
|
];
|
|
|
|
for (scenario, entries, has_skill, has_mcp) in cases {
|
|
let policy = FileSystemSandboxPolicy::restricted(entries);
|
|
let sandbox = FileSystemSandboxContext::from_permission_profile(
|
|
PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted),
|
|
root_uri.clone(),
|
|
);
|
|
let discovery =
|
|
discover_root_with_sandbox(&mut server, "linked", root_uri.clone(), Some(sandbox))
|
|
.await?;
|
|
|
|
assert_eq!(discovery.error, None, "{scenario}");
|
|
assert_eq!(discovery.skills.len(), usize::from(has_skill), "{scenario}");
|
|
assert_eq!(
|
|
discovery
|
|
.plugin
|
|
.and_then(|plugin| plugin.mcp_config)
|
|
.is_some_and(|config| config.contents.contains("linked-server")),
|
|
has_mcp,
|
|
"{scenario}"
|
|
);
|
|
}
|
|
|
|
server.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn discover_root(
|
|
server: &mut common::exec_server::ExecServerHarness,
|
|
id: &str,
|
|
path: PathUri,
|
|
) -> anyhow::Result<CapabilityRootDiscovery> {
|
|
discover_root_with_sandbox(server, id, path, /*sandbox*/ None).await
|
|
}
|
|
|
|
async fn discover_root_with_sandbox(
|
|
server: &mut common::exec_server::ExecServerHarness,
|
|
id: &str,
|
|
path: PathUri,
|
|
sandbox: Option<FileSystemSandboxContext>,
|
|
) -> anyhow::Result<CapabilityRootDiscovery> {
|
|
let response = discover_roots(
|
|
server,
|
|
vec![CapabilityRootDiscoverRequest {
|
|
id: id.to_string(),
|
|
path,
|
|
sandbox,
|
|
}],
|
|
)
|
|
.await?;
|
|
let [discovery] = response.roots.as_slice() else {
|
|
anyhow::bail!("expected exactly one discovered root");
|
|
};
|
|
Ok(discovery.clone())
|
|
}
|
|
|
|
async fn discover_roots(
|
|
server: &mut common::exec_server::ExecServerHarness,
|
|
roots: Vec<CapabilityRootDiscoverRequest>,
|
|
) -> anyhow::Result<CapabilityRootsDiscoverResponse> {
|
|
let request_id = server
|
|
.send_request(
|
|
CAPABILITY_ROOTS_DISCOVER_METHOD,
|
|
serde_json::to_value(CapabilityRootsDiscoverParams { roots })?,
|
|
)
|
|
.await?;
|
|
let response = server.next_event().await?;
|
|
let JSONRPCMessage::Response(JSONRPCResponse { id, result }) = response else {
|
|
anyhow::bail!("expected discovery response, received {response:?}");
|
|
};
|
|
assert_eq!(id, request_id);
|
|
Ok(serde_json::from_value(result)?)
|
|
}
|
|
|
|
async fn initialize(server: &mut common::exec_server::ExecServerHarness) -> anyhow::Result<()> {
|
|
let initialize_id = server
|
|
.send_request(
|
|
"initialize",
|
|
serde_json::to_value(InitializeParams {
|
|
client_name: "capability-discovery-test".to_string(),
|
|
resume_session_id: None,
|
|
})?,
|
|
)
|
|
.await?;
|
|
let response = server
|
|
.wait_for_event(|event| {
|
|
matches!(event, JSONRPCMessage::Response(response) if response.id == initialize_id)
|
|
})
|
|
.await?;
|
|
let JSONRPCMessage::Response(JSONRPCResponse { result, .. }) = response else {
|
|
unreachable!("wait predicate only accepts a response");
|
|
};
|
|
let _: InitializeResponse = serde_json::from_value(result)?;
|
|
server
|
|
.send_notification("initialized", serde_json::json!({}))
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_file(path: &std::path::Path, contents: &str) -> anyhow::Result<()> {
|
|
let parent = path
|
|
.parent()
|
|
.ok_or_else(|| anyhow::anyhow!("test file should have a parent"))?;
|
|
std::fs::create_dir_all(parent)?;
|
|
std::fs::write(path, contents)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn with_native_sandbox(mut sandbox: FileSystemSandboxContext) -> FileSystemSandboxContext {
|
|
if cfg!(windows) {
|
|
sandbox.windows_sandbox_selection = WindowsSandboxSelection::Mxc;
|
|
}
|
|
sandbox
|
|
}
|