diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index c008567b96..f35544276b 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -24,7 +24,7 @@ pub use login::run_logout; #[derive(Debug, Default, Args)] pub struct SandboxStateArgs { - /// JSON value from `codex/sandbox-state-meta` to apply directly. + /// Opaque JSON value from `codex/sandbox-state` to apply directly. #[arg( long = "sandbox-state-json", value_name = "JSON", diff --git a/codex-rs/codex-mcp/README.md b/codex-rs/codex-mcp/README.md new file mode 100644 index 0000000000..6a2adfc64a --- /dev/null +++ b/codex-rs/codex-mcp/README.md @@ -0,0 +1,37 @@ +# Codex MCP runtime + +This crate manages Codex's MCP connections, tool calls, and their captured authority. + +## Sandbox request metadata + +An MCP server advertises the experimental `codex/sandbox-state` capability to +receive sandbox metadata on tool calls. The deprecated capability name +`codex/sandbox-state-meta` opts into the same contract during migration. + +Codex supplies these entries in the request's `_meta` object: + +| Key | Value | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `codex/sandbox-state` | Opaque policy to pass to `codex sandbox --sandbox-state-json`. | +| `codex/sandbox-state-meta` | An identical copy of that policy under the deprecated name. | +| `codex/sandbox-launcher` | When available, an object with an absolute `codexExecutable` path to a verified full Codex CLI on the MCP server's host. | + +Use the launcher path to invoke `sandbox --sandbox-state-json` with the entire +state value serialized as one argument, followed by `--` and the command to run. +The state contains neither launcher discovery data nor deprecation messages. +Preserve unknown policy fields by forwarding the original value rather than +deserializing and reconstructing it. The two state names are aliases, not separate +policies; new consumers should prefer `codex/sandbox-state`. + +Launcher metadata is currently available only for local stdio servers. An HTTP +endpoint, including loopback, or a remote executor does not establish a shared +filesystem with the harness. Codex omits the launcher when its path is unknown, +not representable as UTF-8, or fails the cached capability probe. Absence does not +authorize a PATH search or unsandboxed fallback. Servers that require a launcher +must fail closed or require an explicit operator opt-out. + +The state and launcher describe the issuing call. Retain both when starting a +long-lived subprocess or caching policy-bound results; reject changed authority +before reusing them. Tool arguments supplied by the model are not authority. +See [`sandbox_metadata.rs`](src/sandbox_metadata.rs) for the wire types and +[`binding.rs`](src/binding.rs) for execution-environment selection. diff --git a/codex-rs/codex-mcp/src/binding.rs b/codex-rs/codex-mcp/src/binding.rs index ea3638dc94..a20d1a8e35 100644 --- a/codex-rs/codex-mcp/src/binding.rs +++ b/codex-rs/codex-mcp/src/binding.rs @@ -259,6 +259,20 @@ impl PreparedMcpCall { permission_profile } + /// Attach policy aliases and the server-host launcher, replacing stale authority. + /// Callers negotiate the state capability before requesting this metadata. + pub async fn augment_sandbox_metadata( + &self, + meta: &mut Option, + state: crate::SandboxState, + ) -> Result<()> { + crate::sandbox_metadata::augment_metadata( + meta, + state, + self.sandbox_codex_executable().await, + ) + } + /// Full CLI path usable by a local stdio server. An HTTP endpoint (even on /// loopback) or remote executor does not establish a shared filesystem. /// Capability probes are cached process-wide by absolute executable path. diff --git a/codex-rs/codex-mcp/src/binding_tests.rs b/codex-rs/codex-mcp/src/binding_tests.rs index 426e30d2c7..2d8d16cba4 100644 --- a/codex-rs/codex-mcp/src/binding_tests.rs +++ b/codex-rs/codex-mcp/src/binding_tests.rs @@ -488,6 +488,34 @@ async fn sandbox_executable_requires_local_stdio_and_a_full_cli() -> anyhow::Res config.mcp_server_catalog = catalog.build(); call.server_metadata.environment_id = runtime_environment.to_owned(); assert_eq!(call.sandbox_codex_executable().await, expected); + + let state = crate::SandboxState { + permission_profile: PermissionProfile::default(), + codex_linux_sandbox_exe: None, + sandbox_cwd: codex_utils_path_uri::PathUri::from_host_native_path( + std::env::current_dir()?, + )?, + use_legacy_landlock: false, + use_mxc: false, + }; + let expected_state = serde_json::to_value(&state)?; + let mut metadata = Some(serde_json::json!({ + "unrelated": "retained", + "codex/sandbox-state": {"stale": true}, + "codex/sandbox-state-meta": {"different": true}, + "codex/sandbox-launcher": {"codexExecutable": "must-not-survive"}, + })); + call.augment_sandbox_metadata(&mut metadata, state).await?; + let mut expected_metadata = serde_json::json!({ + "unrelated": "retained", + "codex/sandbox-state": expected_state, + "codex/sandbox-state-meta": expected_state, + }); + if let Some(executable) = expected { + expected_metadata["codex/sandbox-launcher"] = + serde_json::json!({"codexExecutable": executable}); + } + assert_eq!(metadata, Some(expected_metadata)); } Ok(()) } diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index 9d7ccab32f..7dd122b45a 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -22,13 +22,15 @@ pub use resource_client::McpResourcePage; pub use resource_client::McpResourceReadResult; pub use resource_client::McpResourceServerCacheKey; pub use rmcp::model::ReadResourceRequestParams; -pub use rmcp_client::MCP_SANDBOX_STATE_META_CAPABILITY; pub use runtime::McpRuntime; pub use runtime::McpRuntimeContext; pub use runtime::McpRuntimeInput; pub use runtime::McpStartupPolicy; -pub use runtime::SandboxState; pub use runtime::apply_http_headers_helper; +pub use sandbox_metadata::MCP_SANDBOX_LAUNCHER_META_KEY; +pub use sandbox_metadata::MCP_SANDBOX_STATE_CAPABILITY; +pub use sandbox_metadata::MCP_SANDBOX_STATE_META_CAPABILITY; +pub use sandbox_metadata::SandboxState; pub use tool_catalog_cache::McpToolCatalogCache; pub use tools::ToolInfo; pub use trusted_access::TrustedAccessContext; @@ -123,6 +125,7 @@ mod resource_origin; pub(crate) mod rmcp_client; pub(crate) mod runtime; mod sandbox_executable; +mod sandbox_metadata; pub(crate) mod server; mod tool_catalog_cache; pub(crate) mod tools; diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index e73030d036..ae46bd180a 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -93,9 +93,6 @@ use tracing::Instrument; use tracing::instrument; use tracing::warn; -/// MCP server capability indicating that Codex should include [`SandboxState`] -/// in tool-call request `_meta` under this key. -pub const MCP_SANDBOX_STATE_META_CAPABILITY: &str = "codex/sandbox-state-meta"; /// Experimental MCP server capability for development and testing only; production servers should /// not use it. Its `cacheable: false` property disables sharing tool definitions across connections. const MCP_TOOL_CATALOG_CACHE_CAPABILITY: &str = "codex/tool-catalog-cache"; @@ -999,8 +996,10 @@ async fn start_server_task( .capabilities .experimental .as_ref() - .and_then(|exp| exp.get(MCP_SANDBOX_STATE_META_CAPABILITY)) - .is_some(); + .is_some_and(|exp| { + exp.contains_key(crate::MCP_SANDBOX_STATE_CAPABILITY) + || exp.contains_key(crate::MCP_SANDBOX_STATE_META_CAPABILITY) + }); let codex_apps_tools_cache_context = codex_apps_tools_cache_context.map(|context| { if server_disables_tool_catalog_cache { context.without_live_scope() diff --git a/codex-rs/codex-mcp/src/runtime.rs b/codex-rs/codex-mcp/src/runtime.rs index a37f3cc9f4..0e74e2ebdf 100644 --- a/codex-rs/codex-mcp/src/runtime.rs +++ b/codex-rs/codex-mcp/src/runtime.rs @@ -34,18 +34,14 @@ use codex_protocol::capabilities::SelectedCapabilityRoot; use codex_protocol::mcp::CallToolResult; use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::mcp::McpResourceOriginCheckpoint; -use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_rmcp_client::ElicitationResponse; use codex_rmcp_client::with_http_headers_helper; -use codex_utils_path_uri::PathUri; use rmcp::model::ReadResourceRequestParams; use rmcp::model::ReadResourceResult; use rmcp::model::RequestId; -use serde::Deserialize; -use serde::Serialize; use tokio::sync::watch; use tokio_util::sync::CancellationToken; @@ -784,44 +780,6 @@ impl McpRuntime { } } -/// Opaque sandbox state forwarded unchanged to `codex sandbox --sandbox-state-json`. -/// Servers advertise `codex/sandbox-state-meta` to receive it on tool calls. -/// -/// `codexExecutable` is the one field servers may interpret: invoke that absolute -/// path with `sandbox --sandbox-state-json -- `. -/// Do not deserialize and reconstruct the opaque state in a consumer: doing so -/// can discard future policy fields. The state describes the issuing call, so a -/// long-lived subprocess must retain its launch state and must not be reused for -/// a different policy without an explicit lifecycle decision. -/// -/// MCP probes the host's existing re-exec binary once to verify this contract. -/// Standalone binaries without the capability omit it; HTTP and remote-executor -/// servers receive no host-local path even when the host has a full CLI. -/// Older clients omit the field and older CLI consumers ignore it when decoding. -/// Servers that require it must fail closed or require an explicit operator -/// opt-out, never silently search PATH or execute an unsandboxed command. -#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SandboxState { - /// Absolute path to a full Codex CLI on the MCP server's host, supporting - /// `sandbox --sandbox-state-json`. Consumers may read this field to launch - /// the CLI, but must forward the entire state unchanged. - /// - /// Absent for older clients, embeddings without a full CLI, and transports - /// or remote environments where a usable server-host executable is unknown. - /// Also omitted when the OS path cannot be represented as a JSON string. - /// Absence does not authorize an unsandboxed fallback or a PATH lookup. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub codex_executable: Option, - pub permission_profile: PermissionProfile, - pub codex_linux_sandbox_exe: Option, - pub sandbox_cwd: PathUri, - #[serde(default)] - pub use_legacy_landlock: bool, - #[serde(default)] - pub use_mxc: bool, -} - /// Runtime context used when resolving per-server MCP environments. /// /// `McpConfig` describes what servers exist. This value carries the canonical @@ -966,6 +924,8 @@ pub(crate) fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &st #[cfg(test)] mod tests { + use codex_protocol::models::PermissionProfile; + use codex_utils_path_uri::PathUri; use std::collections::HashMap; use codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID; @@ -975,7 +935,6 @@ mod tests { use codex_exec_server_test_support::environment_manager_without_environments; use codex_utils_path_uri::LegacyAppPathString; use pretty_assertions::assert_eq; - use serde_json::Value; use super::*; @@ -1180,74 +1139,6 @@ mod tests { } } - #[test] - fn sandbox_state_serializes_skip_missing_entries_as_missing_path_behavior() { - let sandbox_cwd = PathUri::from_host_native_path( - std::env::current_dir().expect("current directory should be available"), - ) - .expect("current directory should convert to a URI"); - let sandbox_state = SandboxState { - codex_executable: None, - permission_profile: PermissionProfile::workspace_write(), - codex_linux_sandbox_exe: None, - sandbox_cwd, - use_legacy_landlock: false, - use_mxc: false, - }; - - let serialized = serde_json::to_value(&sandbox_state).expect("serialize sandbox state"); - let serialized_text = serde_json::to_string(&serialized).expect("serialize JSON text"); - assert!( - !serialized_text.contains("generated_default_path"), - "MCP sandbox metadata must preserve FileSystemPath's stable wire variants" - ); - assert!( - !serialized_text.contains("generated_default_special"), - "MCP sandbox metadata must preserve FileSystemPath's stable wire variants" - ); - - let entries = serialized - .pointer("/permissionProfile/file_system/entries") - .and_then(Value::as_array) - .expect("workspace-write profile should contain filesystem entries"); - let skip_missing_entries = entries - .iter() - .filter(|entry| { - entry.get("missing_path_behavior").and_then(Value::as_str) == Some("skip") - }) - .collect::>(); - assert!( - !skip_missing_entries.is_empty(), - "skip-missing entries should be represented as optional missing_path_behavior" - ); - assert!( - skip_missing_entries.iter().all(|entry| { - matches!( - entry.pointer("/path/type").and_then(Value::as_str), - Some("path" | "special") - ) - }), - "skip-missing entries should use the stable path/special variants" - ); - - let deserialized: SandboxState = - serde_json::from_value(serialized.clone()).expect("deserialize legacy sandbox state"); - assert_eq!(deserialized, sandbox_state); - - let executable = std::env::current_exe().expect("absolute executable path"); - let mut extended = serialized; - extended["codexExecutable"] = serde_json::json!(executable); - extended["futureField"] = serde_json::json!({"preservedByOpaqueConsumers": true}); - let deserialized: SandboxState = serde_json::from_value(extended).expect("extended state"); - assert_eq!( - deserialized, - SandboxState { - codex_executable: Some(executable), - ..sandbox_state - } - ); - } - #[test] fn local_stdio_requires_local_stdio_availability() { let runtime_context = McpRuntimeContext::new( diff --git a/codex-rs/codex-mcp/src/sandbox_metadata.rs b/codex-rs/codex-mcp/src/sandbox_metadata.rs new file mode 100644 index 0000000000..c5adfa3381 --- /dev/null +++ b/codex-rs/codex-mcp/src/sandbox_metadata.rs @@ -0,0 +1,82 @@ +//! Keep opaque sandbox policy separate from the launcher used to apply it. +//! Both state names carry identical values during migration; neither contains +//! executable discovery data or deprecation messages. + +use codex_protocol::models::PermissionProfile; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Map; +use serde_json::Value; +use std::path::PathBuf; + +/// Servers advertise this experimental capability to receive opaque sandbox state +/// in tool-call `_meta`, along with a local launcher when one is available. +pub const MCP_SANDBOX_STATE_CAPABILITY: &str = "codex/sandbox-state"; +/// Deprecated capability and metadata spelling, retained as an identical alias. +/// Deprecation guidance is outside the opaque policy object. +pub const MCP_SANDBOX_STATE_META_CAPABILITY: &str = "codex/sandbox-state-meta"; +/// Tool-call metadata describing a sandbox launcher on the MCP server's host. +pub const MCP_SANDBOX_LAUNCHER_META_KEY: &str = "codex/sandbox-launcher"; + +/// Opaque policy forwarded unchanged to `codex sandbox --sandbox-state-json`. +/// +/// Servers advertise `codex/sandbox-state` (or its deprecated `-meta` alias) to +/// receive the same object under both state keys. Consumers must preserve unknown +/// fields, not deserialize and reconstruct the policy. Launcher information lives +/// separately under `codex/sandbox-launcher`; only its documented fields may be +/// interpreted. Long-lived subprocesses retain both their state and launcher and +/// must not be reused under different authority without a lifecycle decision. +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SandboxState { + pub permission_profile: PermissionProfile, + pub codex_linux_sandbox_exe: Option, + pub sandbox_cwd: PathUri, + #[serde(default)] + pub use_legacy_landlock: bool, + #[serde(default)] + pub use_mxc: bool, +} + +/// The known launcher envelope is distinct from the opaque policy. Absence never +/// authorizes searching PATH or falling back to an unsandboxed process. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SandboxLauncher { + /// A full CLI on the MCP server's host, supporting `sandbox --sandbox-state-json`. + /// Remote/HTTP servers and hosts without a verified, UTF-8 CLI path omit the envelope. + codex_executable: AbsolutePathBuf, +} + +pub(crate) fn augment_metadata( + meta: &mut Option, + state: SandboxState, + executable: Option, +) -> anyhow::Result<()> { + let state = serde_json::to_value(state)?; + let launcher = executable + .map(|codex_executable| serde_json::to_value(SandboxLauncher { codex_executable })) + .transpose()?; + let Some(metadata) = meta + .get_or_insert_with(|| Value::Object(Map::new())) + .as_object_mut() + else { + return Ok(()); + }; + + metadata.insert(MCP_SANDBOX_STATE_CAPABILITY.to_owned(), state.clone()); + metadata.insert(MCP_SANDBOX_STATE_META_CAPABILITY.to_owned(), state); + // Reserved launcher metadata must not survive from a previous/local call + // when this server's execution environment has no verified executable. + metadata.remove(MCP_SANDBOX_LAUNCHER_META_KEY); + if let Some(launcher) = launcher { + metadata.insert(MCP_SANDBOX_LAUNCHER_META_KEY.to_owned(), launcher); + } + Ok(()) +} + +#[cfg(test)] +#[path = "sandbox_metadata_tests.rs"] +mod tests; diff --git a/codex-rs/codex-mcp/src/sandbox_metadata_tests.rs b/codex-rs/codex-mcp/src/sandbox_metadata_tests.rs new file mode 100644 index 0000000000..a69a3223d2 --- /dev/null +++ b/codex-rs/codex-mcp/src/sandbox_metadata_tests.rs @@ -0,0 +1,62 @@ +//! Preserve sandbox policy wire semantics while launcher metadata evolves separately. + +use super::*; +use pretty_assertions::assert_eq; +use serde_json::Value; + +#[test] +fn sandbox_state_serializes_skip_missing_entries_as_missing_path_behavior() { + let sandbox_cwd = PathUri::from_host_native_path( + std::env::current_dir().expect("current directory should be available"), + ) + .expect("current directory should convert to a URI"); + let sandbox_state = SandboxState { + permission_profile: PermissionProfile::workspace_write(), + codex_linux_sandbox_exe: None, + sandbox_cwd, + use_legacy_landlock: false, + use_mxc: false, + }; + + let serialized = serde_json::to_value(&sandbox_state).expect("serialize sandbox state"); + let serialized_text = serde_json::to_string(&serialized).expect("serialize JSON text"); + assert!( + !serialized_text.contains("generated_default_path"), + "MCP sandbox metadata must preserve FileSystemPath's stable wire variants" + ); + assert!( + !serialized_text.contains("generated_default_special"), + "MCP sandbox metadata must preserve FileSystemPath's stable wire variants" + ); + + let entries = serialized + .pointer("/permissionProfile/file_system/entries") + .and_then(Value::as_array) + .expect("workspace-write profile should contain filesystem entries"); + let skip_missing_entries = entries + .iter() + .filter(|entry| entry.get("missing_path_behavior").and_then(Value::as_str) == Some("skip")) + .collect::>(); + assert!( + !skip_missing_entries.is_empty(), + "skip-missing entries should be represented as optional missing_path_behavior" + ); + assert!( + skip_missing_entries.iter().all(|entry| { + matches!( + entry.pointer("/path/type").and_then(Value::as_str), + Some("path" | "special") + ) + }), + "skip-missing entries should use the stable path/special variants" + ); + + let deserialized: SandboxState = + serde_json::from_value(serialized.clone()).expect("deserialize legacy sandbox state"); + assert_eq!(deserialized, sandbox_state); + + let mut extended = serialized; + extended["futureField"] = serde_json::json!({"preservedByOpaqueConsumers": true}); + let deserialized: SandboxState = serde_json::from_value(extended).expect("extended state"); + assert_eq!(deserialized, sandbox_state); +} diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index acfe3ea01a..991ad9083c 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -875,40 +875,23 @@ async fn augment_mcp_tool_request_meta_with_sandbox_state( }; // TODO(anp): Build this metadata from the server's captured // TurnEnvironment::sandbox_context instead of the runtime-wide Landlock value. - let sandbox_state = serde_json::to_value(SandboxState { - codex_executable: prepared_call - .sandbox_codex_executable() - .await - .map(AbsolutePathBuf::into_path_buf), - permission_profile: prepared_call.permission_profile().clone(), - codex_linux_sandbox_exe: prepared_call.config().codex_linux_sandbox_exe.clone(), - sandbox_cwd, - use_legacy_landlock: prepared_call.config().use_legacy_landlock, - use_mxc: prepared_call - .config() - .environment_use_mxc - .get(server_environment_id) - .copied() - .unwrap_or(false), - })?; - - match meta.as_mut() { - Some(serde_json::Value::Object(map)) => { - map.insert( - codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY.to_string(), - sandbox_state, - ); - } - Some(_) => {} - None => { - let mut map = serde_json::Map::new(); - map.insert( - codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY.to_string(), - sandbox_state, - ); - meta = Some(serde_json::Value::Object(map)); - } - } + prepared_call + .augment_sandbox_metadata( + &mut meta, + SandboxState { + permission_profile: prepared_call.permission_profile().clone(), + codex_linux_sandbox_exe: prepared_call.config().codex_linux_sandbox_exe.clone(), + sandbox_cwd, + use_legacy_landlock: prepared_call.config().use_legacy_landlock, + use_mxc: prepared_call + .config() + .environment_use_mxc + .get(server_environment_id) + .copied() + .unwrap_or(false), + }, + ) + .await?; Ok(meta) } diff --git a/codex-rs/core/tests/suite/mcp_sandbox_tests.rs b/codex-rs/core/tests/suite/mcp_sandbox_tests.rs index 0bd1cf9165..0bfb500aaf 100644 --- a/codex-rs/core/tests/suite/mcp_sandbox_tests.rs +++ b/codex-rs/core/tests/suite/mcp_sandbox_tests.rs @@ -7,6 +7,8 @@ use std::process::Stdio; use anyhow::Context; use anyhow::Result; use codex_config::McpServerConfig; +use codex_mcp::MCP_SANDBOX_LAUNCHER_META_KEY; +use codex_mcp::MCP_SANDBOX_STATE_CAPABILITY; use codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::NetworkSandboxPolicy; @@ -172,6 +174,15 @@ impl Respond for SandboxMcpServer { let state = body["params"]["_meta"] .get(MCP_SANDBOX_STATE_META_CAPABILITY) .expect("Codex must supply sandbox state in request metadata"); + // A legacy capability still receives both identical state names. + // HTTP does not establish a shared executable filesystem. + assert_eq!( + ( + body["params"]["_meta"].get(MCP_SANDBOX_STATE_CAPABILITY), + body["params"]["_meta"].get(MCP_SANDBOX_LAUNCHER_META_KEY), + ), + (Some(state), None), + ); let target_path = body["params"]["arguments"]["path"] .as_str() .expect("write_file path argument"); diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 0e0de7ba46..fb0572ae45 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -39,6 +39,8 @@ use codex_features::Feature; use codex_http_client::HttpClientBuilder; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_mcp::MCP_SANDBOX_LAUNCHER_META_KEY; +use codex_mcp::MCP_SANDBOX_STATE_CAPABILITY; use codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY; use codex_mcp::McpProtocolMode; use codex_mcp::SandboxState; @@ -2420,7 +2422,7 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta( assert_eq!(output_json["callId"], json!(call_id)); let sandbox_meta = meta - .get(MCP_SANDBOX_STATE_META_CAPABILITY) + .get(MCP_SANDBOX_STATE_CAPABILITY) .expect("sandbox state metadata should be present"); let sandbox_state: SandboxState = serde_json::from_value(sandbox_meta.clone())?; if attachment_owned_permissions { @@ -2438,20 +2440,30 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta( ); } } + let expected_state = serde_json::to_value(SandboxState { + permission_profile: owner_permission_profile + .materialize_project_roots_with_path_uris(&owner_workspace_roots), + codex_linux_sandbox_exe: fixture.config.codex_linux_sandbox_exe.clone(), + sandbox_cwd: PathUri::from_abs_path(&fixture.config.cwd), + use_legacy_landlock: false, + use_mxc: prefer_mxc && cfg!(windows) && !attachment_owned_permissions, + })?; + let expected_launcher = (remote_aware_environment_id() + == codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID) + .then_some(fixture.config.codex_self_exe.as_ref()) + .flatten() + .map(|executable| json!({"codexExecutable": executable})); assert_eq!( - sandbox_state, - SandboxState { - codex_executable: (remote_aware_environment_id() - == codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID) - .then(|| fixture.config.codex_self_exe.clone()) - .flatten(), - permission_profile: owner_permission_profile - .materialize_project_roots_with_path_uris(&owner_workspace_roots), - codex_linux_sandbox_exe: fixture.config.codex_linux_sandbox_exe.clone(), - sandbox_cwd: PathUri::from_abs_path(&fixture.config.cwd), - use_legacy_landlock: false, - use_mxc: prefer_mxc && cfg!(windows) && !attachment_owned_permissions, - } + ( + meta.get(MCP_SANDBOX_STATE_CAPABILITY), + meta.get(MCP_SANDBOX_STATE_META_CAPABILITY), + meta.get(MCP_SANDBOX_LAUNCHER_META_KEY), + ), + ( + Some(&expected_state), + Some(&expected_state), + expected_launcher.as_ref() + ), ); server.verify().await; diff --git a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs index b3d2161efc..70a5476ee9 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -47,7 +47,7 @@ struct TestToolServer { const MEMO_URI: &str = "memo://codex/example-note"; const MEMO_CONTENT: &str = "This is a sample MCP resource served by the rmcp test server."; -const SANDBOX_STATE_META_CAPABILITY: &str = "codex/sandbox-state-meta"; +const SANDBOX_STATE_CAPABILITY: &str = "codex/sandbox-state"; const SMALL_PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; const APP_ONLY_CWD_MARKER_FILE_ENV: &str = "MCP_TEST_APP_ONLY_CWD_MARKER_FILE"; const DYNAMIC_SERVER_METADATA_ENV: &str = "MCP_TEST_DYNAMIC_SERVER_METADATA"; @@ -534,7 +534,7 @@ impl ServerHandler for TestToolServer { .enable_resources() .build(); capabilities.experimental = Some(BTreeMap::from([( - SANDBOX_STATE_META_CAPABILITY.to_string(), + SANDBOX_STATE_CAPABILITY.to_string(), JsonObject::new(), )]));