mirror of
https://github.com/openai/codex.git
synced 2026-10-20 08:19:50 +00:00
## What changed - Return qualified skill names such as `team:deploy` and parsed `dependencies` and `policy` instead of a separate namespace and raw metadata file. Share `agents/openai.yaml` parsing and validation between skill loaders and executor discovery. - Include each standalone skill's search-root ID and path in `discoveryRoot`, while accepting responses from older executors that omit it. - Add plugin `displayName`, falling back to the manifest name or installation directory. - Materialize inline MCP declarations in `mcpConfig` with the manifest as their source path, and report incomplete discovery when declared MCP or Apps configuration files have no returned contents. ## Testing Add coverage for JSON round trips, omitted `discoveryRoot`, optional and invalid skill metadata, qualified names, display-name fallbacks, and inline MCP configuration. Verify standalone search roots survive cached discovery and sandboxed RPC responses. GitOrigin-RevId: 4124972444ad7c2fb8c6418acbf9a589d797fad2
246 lines
8.8 KiB
Rust
246 lines
8.8 KiB
Rust
//! V2 RPC metadata refresh and sandbox isolation over a real connection.
|
|
mod common;
|
|
|
|
use anyhow::Context;
|
|
use codex_exec_server::CAPABILITIES_DISCOVER_V2_METHOD;
|
|
use codex_exec_server::DiscoverV2CapabilitiesRequest;
|
|
use codex_exec_server::DiscoverV2CapabilitiesResponse;
|
|
use codex_exec_server::InitializeResponse;
|
|
use codex_exec_server::SkillDiscoveryRoot;
|
|
use codex_exec_server_protocol::JSONRPCMessage;
|
|
use codex_utils_path_uri::PathUri;
|
|
use common::exec_server::ExecServerHarness;
|
|
use common::exec_server::exec_server_with_env;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
#[cfg(any(target_os = "linux", target_os = "macos", windows))]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn v2_rpc_refreshes_metadata_and_enforces_sandbox() -> anyhow::Result<()> {
|
|
let home = tempfile::tempdir()?;
|
|
let home_path = std::fs::canonicalize(home.path())?;
|
|
let codex_home = tempfile::tempdir()?;
|
|
let codex_home_path = std::fs::canonicalize(codex_home.path())?;
|
|
let skills = codex_home_path.join("skills/demo/SKILL.md");
|
|
let plugin = codex_home_path.join("plugins/cache/market/mail/1.0.0");
|
|
write(
|
|
&plugin.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"mail"}"#,
|
|
)?;
|
|
write(
|
|
&plugin.join("skills/search/SKILL.md"),
|
|
"---\nname: search\ndescription: Search mail\n---\n",
|
|
)?;
|
|
write(
|
|
&skills,
|
|
"---\nname: demo\ndescription: original\n---\nPRIVATE INSTRUCTION BODY",
|
|
)?;
|
|
let mut server = exec_server_with_env(
|
|
[
|
|
("HOME", &home_path),
|
|
("USERPROFILE", &home_path),
|
|
("CODEX_HOME", &codex_home_path),
|
|
],
|
|
&[],
|
|
)
|
|
.await?;
|
|
// Existing clients need no discovery-specific initialize field.
|
|
let initialized: InitializeResponse = serde_json::from_value(
|
|
request(
|
|
&mut server,
|
|
"initialize",
|
|
serde_json::json!({"clientName": "v2-client"}),
|
|
)
|
|
.await?,
|
|
)?;
|
|
server
|
|
.send_notification("initialized", serde_json::json!({}))
|
|
.await?;
|
|
let info = initialized
|
|
.environment_info
|
|
.context("initialize environment info")?;
|
|
assert!(info.capabilities.capability_discovery_v2);
|
|
// Keep the requested cwd isolated from the test runner's repository.
|
|
let project = tempfile::tempdir()?;
|
|
let cwd = PathUri::from_host_native_path(project.path())?;
|
|
let params = DiscoverV2CapabilitiesRequest {
|
|
cwd: cwd.clone(),
|
|
sandbox: None,
|
|
};
|
|
let first = discover(&mut server, ¶ms).await?;
|
|
assert!(
|
|
first
|
|
.skills
|
|
.iter()
|
|
.any(|skill| skill.name == "demo" && skill.description == "original")
|
|
);
|
|
let expected_root = Some(SkillDiscoveryRoot {
|
|
id: "codex-home-skills".to_string(),
|
|
path: PathUri::from_host_native_path(codex_home_path.join("skills"))?,
|
|
});
|
|
assert_eq!(
|
|
first
|
|
.skills
|
|
.iter()
|
|
.find(|skill| skill.name == "demo")
|
|
.context("demo skill")?
|
|
.discovery_root,
|
|
expected_root,
|
|
);
|
|
assert!(!serde_json::to_string(&first)?.contains("PRIVATE INSTRUCTION BODY"));
|
|
|
|
assert!(
|
|
first
|
|
.plugins
|
|
.iter()
|
|
.any(|plugin| plugin.id == "mail@market" && plugin.skills.len() == 1)
|
|
);
|
|
|
|
// Exercise one content refresh through the real RPC and native watcher.
|
|
write(&skills, "---\nname: demo\ndescription: updated\n---\n")?;
|
|
tokio::time::timeout(std::time::Duration::from_secs(30), async {
|
|
let mut interval = tokio::time::interval(std::time::Duration::from_millis(50));
|
|
loop {
|
|
interval.tick().await;
|
|
if discover(&mut server, ¶ms)
|
|
.await?
|
|
.skills
|
|
.iter()
|
|
.any(|skill| skill.name == "demo" && skill.description == "updated")
|
|
{
|
|
break anyhow::Ok(());
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.context("waiting for discovery refresh")??;
|
|
|
|
// A restricted request must not reuse the unrestricted context's cached plugin metadata.
|
|
{
|
|
use codex_exec_server::FileSystemSandboxContext;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::permissions::FileSystemAccessMode;
|
|
use codex_protocol::permissions::FileSystemSandboxEntry;
|
|
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
|
use codex_protocol::permissions::NetworkSandboxPolicy;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
let allowed = codex_home_path.join("skills");
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry::new(
|
|
AbsolutePathBuf::from_absolute_path(&allowed)?.into(),
|
|
FileSystemAccessMode::Read,
|
|
)]);
|
|
let sandbox = FileSystemSandboxContext::from_permission_profile(
|
|
PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted),
|
|
cwd.clone(),
|
|
);
|
|
#[cfg(windows)]
|
|
let sandbox = FileSystemSandboxContext {
|
|
windows_sandbox_selection: codex_exec_server::WindowsSandboxSelection::RestrictedToken,
|
|
..sandbox
|
|
};
|
|
let restricted = DiscoverV2CapabilitiesRequest {
|
|
cwd: cwd.clone(),
|
|
sandbox: Some(sandbox),
|
|
};
|
|
// Linux (Bubblewrap) and macOS (Seatbelt) enforce the allowed directory directly.
|
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
|
{
|
|
let response = discover(&mut server, &restricted).await?;
|
|
assert!(response.plugins.is_empty());
|
|
assert_eq!(
|
|
response
|
|
.skills
|
|
.iter()
|
|
.find(|skill| skill.name == "demo")
|
|
.context("sandboxed demo skill")?
|
|
.discovery_root,
|
|
expected_root,
|
|
);
|
|
assert!(!serde_json::to_string(&response)?.contains("mail@market"));
|
|
}
|
|
// Windows restricted tokens reject split read access; a supported read-only policy succeeds.
|
|
#[cfg(windows)]
|
|
{
|
|
let Err(error) = discover(&mut server, &restricted).await else {
|
|
anyhow::bail!("unsupported read restrictions must not reuse cached metadata");
|
|
};
|
|
assert!(
|
|
error.chain().any(|cause| cause
|
|
.to_string()
|
|
.contains("cannot enforce split filesystem read restrictions")),
|
|
"{error:#}"
|
|
);
|
|
let mut sandbox = FileSystemSandboxContext::from_legacy_sandbox_policy(
|
|
codex_protocol::protocol::SandboxPolicy::new_read_only_policy(),
|
|
cwd.clone(),
|
|
)?;
|
|
sandbox.windows_sandbox_selection =
|
|
codex_exec_server::WindowsSandboxSelection::RestrictedToken;
|
|
let permitted = DiscoverV2CapabilitiesRequest {
|
|
cwd: cwd.clone(),
|
|
sandbox: Some(sandbox),
|
|
};
|
|
let response = discover(&mut server, &permitted).await?;
|
|
assert!(
|
|
response
|
|
.plugins
|
|
.iter()
|
|
.any(|plugin| plugin.id == "mail@market")
|
|
);
|
|
assert_eq!(
|
|
response
|
|
.skills
|
|
.iter()
|
|
.find(|skill| skill.name == "demo")
|
|
.context("sandboxed demo skill")?
|
|
.discovery_root,
|
|
expected_root,
|
|
);
|
|
}
|
|
}
|
|
|
|
server.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn discover(
|
|
server: &mut ExecServerHarness,
|
|
params: &DiscoverV2CapabilitiesRequest,
|
|
) -> anyhow::Result<DiscoverV2CapabilitiesResponse> {
|
|
Ok(serde_json::from_value(
|
|
request(
|
|
server,
|
|
CAPABILITIES_DISCOVER_V2_METHOD,
|
|
serde_json::to_value(params)?,
|
|
)
|
|
.await
|
|
.with_context(|| format!("discover V2 for {}", params.cwd))?,
|
|
)?)
|
|
}
|
|
|
|
async fn request(
|
|
server: &mut ExecServerHarness,
|
|
method: &str,
|
|
params: serde_json::Value,
|
|
) -> anyhow::Result<serde_json::Value> {
|
|
let id = server.send_request(method, params).await?;
|
|
let message = server
|
|
.wait_for_event(|event| {
|
|
matches!(event,
|
|
JSONRPCMessage::Response(response) if response.id == id
|
|
) || matches!(event, JSONRPCMessage::Error(error) if error.id == id)
|
|
})
|
|
.await
|
|
.with_context(|| format!("waiting for {method}"))?;
|
|
match message {
|
|
JSONRPCMessage::Response(response) => Ok(response.result),
|
|
other => anyhow::bail!("{method} failed: {other:?}"),
|
|
}
|
|
}
|
|
|
|
fn write(path: &std::path::Path, contents: &str) -> std::io::Result<()> {
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
std::fs::write(path, contents)
|
|
}
|