Compare commits

...

9 Commits

Author SHA1 Message Date
Sayan Sisodiya
0732c913fb Key MCP tool listings by ToolName 2026-04-13 19:49:03 +08:00
sayan-oai
933411dadf Merge branch 'main' into dev/sayan/namespace-refactor-2 2026-04-13 19:47:53 +08:00
Sayan Sisodiya
85729e250f update test mcp visible names 2026-04-13 19:34:04 +08:00
jif-oai
bacb92b1d7 Build remote exec env from exec-server policy (#17216)
## Summary
- add an exec-server `envPolicy` field; when present, the server starts
from its own process env and applies the shell environment policy there
- keep `env` as the exact environment for local/embedded starts, but
make it an overlay for remote unified-exec starts
- move the shell-environment-policy builder into `codex-config` so Core
and exec-server share the inherit/filter/set/include behavior
- overlay only runtime/sandbox/network deltas from Core onto the
exec-server-derived env

## Why
Remote unified exec was materializing the shell env inside Core and
forwarding the whole map to exec-server, so remote processes could
inherit the orchestrator machine's `HOME`, `PATH`, etc. This keeps the
base env on the executor while preserving Core-owned runtime additions
like `CODEX_THREAD_ID`, unified-exec defaults, network proxy env, and
sandbox marker env.

## Validation
- `just fmt`
- `git diff --check`
- `cargo test -p codex-exec-server --lib`
- `cargo test -p codex-core --lib unified_exec::process_manager::tests`
- `cargo test -p codex-core --lib exec_env::tests`
- `cargo test -p codex-core --lib exec_env_tests` (compile-only; filter
matched 0 tests)
- `cargo test -p codex-config --lib shell_environment` (compile-only;
filter matched 0 tests)
- `just bazel-lock-update`

## Known local validation issue
- `just bazel-lock-check` is not runnable in this checkout: it invokes
`./scripts/check-module-bazel-lock.sh`, which is missing.

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: pakrym-oai <pakrym@openai.com>
2026-04-13 09:59:08 +01:00
jif-oai
4ffe6c2ce6 feat: ignore keyring on 0.0.0 (#17221)
To prevent the spammy: 
<img width="424" height="172" alt="Screenshot 2026-04-09 at 13 36 16"
src="https://github.com/user-attachments/assets/b5ece9e3-c561-422f-87ec-041e7bd6813d"
/>
2026-04-13 09:58:47 +01:00
Eric Traut
6550007cca Stabilize exec-server process tests (#17605)
Problem: After #17294 switched exec-server tests to launch the top-level
`codex exec-server` command, parallel remote exec-process cases can
flake while waiting for the child server's listen URL or transport
shutdown.

Solution: Serialize remote exec-server-backed process tests and harden
the harness so spawned servers are killed on drop and shutdown waits for
the child process to exit.
2026-04-13 00:31:13 -07:00
Sayan Sisodiya
64815da52c Fix MCP resolution for split flat names 2026-04-11 19:05:04 -07:00
Sayan Sisodiya
dd29229c01 use ToolName more widely 2026-04-11 19:05:04 -07:00
Sayan Sisodiya
b4e66fb8ec register all mcps w/ namespace 2026-04-11 19:05:04 -07:00
48 changed files with 1120 additions and 485 deletions

2
codex-rs/Cargo.lock generated
View File

@@ -2098,6 +2098,7 @@ dependencies = [
"async-trait",
"base64 0.22.1",
"codex-app-server-protocol",
"codex-config",
"codex-protocol",
"codex-sandboxing",
"codex-utils-absolute-path",
@@ -2107,6 +2108,7 @@ dependencies = [
"pretty_assertions",
"serde",
"serde_json",
"serial_test",
"tempfile",
"test-case",
"thiserror 2.0.18",

View File

@@ -610,7 +610,7 @@ async fn collect_mcp_server_status_snapshot_from_manager(
);
let mut tools_by_server = HashMap::<String, HashMap<String, Tool>>::new();
for (_qualified_name, tool_info) in tools {
for (_tool_name, tool_info) in tools {
let raw_tool_name = tool_info.tool.name.to_string();
let Some(tool) = protocol_tool_from_rmcp_tool(&raw_tool_name, &tool_info.tool) else {
continue;
@@ -668,7 +668,8 @@ pub async fn collect_mcp_snapshot_from_manager_with_detail(
let tools = tools
.into_iter()
.filter_map(|(name, tool)| {
protocol_tool_from_rmcp_tool(&name, &tool.tool).map(|tool| (name, tool))
let display_name = name.display();
protocol_tool_from_rmcp_tool(&display_name, &tool.tool).map(|tool| (display_name, tool))
})
.collect::<HashMap<_, _>>();

View File

@@ -3,8 +3,8 @@
//! The [`McpConnectionManager`] owns one [`codex_rmcp_client::RmcpClient`] per
//! configured server (keyed by the *server name*). It offers convenience
//! helpers to query the available tools across *all* servers and returns them
//! in a single aggregated map using the model-visible fully-qualified tool name
//! as the key.
//! in a single aggregated map using the model-visible callable tool name as the
//! key.
use std::borrow::Cow;
use std::collections::HashMap;
@@ -36,6 +36,7 @@ use codex_async_utils::CancelErr;
use codex_async_utils::OrCancelExt;
use codex_config::Constrained;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_protocol::ToolName;
use codex_protocol::approvals::ElicitationRequest;
use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::mcp::CallToolResult;
@@ -155,6 +156,12 @@ pub struct ToolInfo {
pub connector_description: Option<String>,
}
impl ToolInfo {
pub fn callable_tool_name(&self) -> ToolName {
ToolName::namespaced(self.callable_namespace.clone(), self.callable_name.clone())
}
}
const META_OPENAI_FILE_PARAMS: &str = "openai/fileParams";
pub fn declared_openai_file_input_param_names(
@@ -903,10 +910,11 @@ impl McpConnectionManager {
failures
}
/// Returns a single map that contains all tools. Each key is the
/// fully-qualified name for the tool.
/// Returns a single map that contains all tools keyed by model-visible
/// callable name. Each key's `display()` is unique and <= 64 bytes so it
/// can be used at flat protocol/display boundaries.
#[instrument(level = "trace", skip_all)]
pub async fn list_all_tools(&self) -> HashMap<String, ToolInfo> {
pub async fn list_all_tools(&self) -> HashMap<ToolName, ToolInfo> {
let mut tools = Vec::new();
for managed_client in self.clients.values() {
let Some(server_tools) = managed_client.listed_tools().await else {
@@ -922,7 +930,7 @@ impl McpConnectionManager {
/// On success, the refreshed tools replace the cache contents and the
/// latest filtered tool map is returned directly to the caller. On
/// failure, the existing cache remains unchanged.
pub async fn hard_refresh_codex_apps_tools_cache(&self) -> Result<HashMap<String, ToolInfo>> {
pub async fn hard_refresh_codex_apps_tools_cache(&self) -> Result<HashMap<ToolName, ToolInfo>> {
let managed_client = self
.clients
.get(CODEX_APPS_MCP_SERVER_NAME)
@@ -1191,14 +1199,15 @@ impl McpConnectionManager {
.with_context(|| format!("resources/read failed for `{server}` ({uri})"))
}
pub async fn resolve_tool_info(&self, name: &str, namespace: Option<&str>) -> Option<ToolInfo> {
let qualified_name = match namespace {
Some(namespace) if name.starts_with(namespace) => name.to_string(),
Some(namespace) => format!("{namespace}{name}"),
None => name.to_string(),
};
pub async fn resolve_tool_info(&self, tool_name: &ToolName) -> Option<ToolInfo> {
let all_tools = self.list_all_tools().await;
if let Some(tool) = all_tools.get(tool_name) {
return Some(tool.clone());
}
self.list_all_tools().await.get(&qualified_name).cloned()
all_tools
.into_iter()
.find_map(|(name, tool)| (name.display() == tool_name.name).then_some(tool))
}
pub async fn notify_sandbox_state_change(&self, sandbox_state: &SandboxState) -> Result<()> {
@@ -1286,8 +1295,8 @@ fn filter_tools(tools: Vec<ToolInfo>, filter: &ToolFilter) -> Vec<ToolInfo> {
}
pub fn filter_non_codex_apps_mcp_tools_only(
mcp_tools: &HashMap<String, ToolInfo>,
) -> HashMap<String, ToolInfo> {
mcp_tools: &HashMap<ToolName, ToolInfo>,
) -> HashMap<ToolName, ToolInfo> {
mcp_tools
.iter()
.filter(|(_, tool)| tool.server_name != CODEX_APPS_MCP_SERVER_NAME)

View File

@@ -1,4 +1,5 @@
use super::*;
use codex_protocol::ToolName;
use codex_protocol::protocol::GranularApprovalConfig;
use codex_protocol::protocol::McpAuthStatus;
use pretty_assertions::assert_eq;
@@ -251,8 +252,8 @@ fn test_qualify_tools_short_non_duplicated_names() {
let qualified_tools = qualify_tools(tools);
assert_eq!(qualified_tools.len(), 2);
assert!(qualified_tools.contains_key("mcp__server1__tool1"));
assert!(qualified_tools.contains_key("mcp__server1__tool2"));
assert!(qualified_tools.contains_key(&ToolName::namespaced("mcp__server1__", "tool1")));
assert!(qualified_tools.contains_key(&ToolName::namespaced("mcp__server1__", "tool2")));
}
#[test]
@@ -266,7 +267,9 @@ fn test_qualify_tools_duplicated_names_skipped() {
// Only the first tool should remain, the second is skipped
assert_eq!(qualified_tools.len(), 1);
assert!(qualified_tools.contains_key("mcp__server1__duplicate_tool"));
assert!(
qualified_tools.contains_key(&ToolName::namespaced("mcp__server1__", "duplicate_tool"))
);
}
#[test]
@@ -288,7 +291,7 @@ fn test_qualify_tools_long_names_same_server() {
assert_eq!(qualified_tools.len(), 2);
let mut keys: Vec<_> = qualified_tools.keys().cloned().collect();
let mut keys: Vec<_> = qualified_tools.keys().map(ToolName::display).collect();
keys.sort();
assert!(keys.iter().all(|key| key.len() == 64));
@@ -308,10 +311,13 @@ fn test_qualify_tools_sanitizes_invalid_characters() {
assert_eq!(qualified_tools.len(), 1);
let (qualified_name, tool) = qualified_tools.into_iter().next().expect("one tool");
assert_eq!(qualified_name, "mcp__server_one__tool_two_three");
assert_eq!(
qualified_name,
ToolName::namespaced("mcp__server_one__", "tool_two_three")
);
assert_eq!(
format!("{}{}", tool.callable_namespace, tool.callable_name),
qualified_name
qualified_name.display()
);
// The key and callable parts are sanitized for model-visible tool calls, but
@@ -323,6 +329,7 @@ fn test_qualify_tools_sanitizes_invalid_characters() {
assert!(
qualified_name
.display()
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_'),
"qualified name must be code-mode compatible: {qualified_name:?}"
@@ -337,7 +344,10 @@ fn test_qualify_tools_keeps_hyphenated_mcp_tools_callable() {
assert_eq!(qualified_tools.len(), 1);
let (qualified_name, tool) = qualified_tools.into_iter().next().expect("one tool");
assert_eq!(qualified_name, "mcp__music_studio__get_strudel_guide");
assert_eq!(
qualified_name,
ToolName::namespaced("mcp__music_studio__", "get_strudel_guide")
);
assert_eq!(tool.callable_namespace, "mcp__music_studio__");
assert_eq!(tool.callable_name, "get_strudel_guide");
assert_eq!(tool.tool.name, "get-strudel-guide");
@@ -367,9 +377,10 @@ fn test_qualify_tools_disambiguates_sanitized_namespace_collisions() {
.collect::<HashSet<_>>();
assert_eq!(raw_servers, HashSet::from(["basic-server", "basic_server"]));
assert!(
qualified_tools
.keys()
.all(|key| key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')),
qualified_tools.keys().all(|key| key
.display()
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')),
"qualified names must be code-mode compatible: {qualified_tools:?}"
);
}
@@ -640,12 +651,77 @@ async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() {
let tools = manager.list_all_tools().await;
let tool = tools
.get("mcp__codex_apps__calendar_create_event")
.get(&ToolName::namespaced(
"mcp__codex_apps__",
"calendar_create_event",
))
.expect("tool from startup cache");
assert_eq!(tool.server_name, CODEX_APPS_MCP_SERVER_NAME);
assert_eq!(tool.callable_name, "calendar_create_event");
}
#[tokio::test]
async fn resolve_tool_info_accepts_plain_and_namespaced_tool_names() {
let startup_tools = vec![create_test_tool("rmcp", "echo")];
let pending_client = futures::future::pending::<Result<ManagedClient, StartupOutcomeError>>()
.boxed()
.shared();
let approval_policy = Constrained::allow_any(AskForApproval::OnFailure);
let sandbox_policy = Constrained::allow_any(SandboxPolicy::new_read_only_policy());
let mut manager = McpConnectionManager::new_uninitialized(&approval_policy, &sandbox_policy);
manager.clients.insert(
"rmcp".to_string(),
AsyncManagedClient {
client: pending_client,
startup_snapshot: Some(startup_tools),
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
},
);
let flat = manager
.resolve_tool_info(&ToolName::plain("mcp__rmcp__echo"))
.await
.expect("flat qualified MCP tool name should resolve");
let split = manager
.resolve_tool_info(&ToolName::namespaced("mcp__rmcp__", "echo"))
.await
.expect("split MCP tool namespace and name should resolve");
let split_with_flat_name = manager
.resolve_tool_info(&ToolName::namespaced("mcp__rmcp__", "mcp__rmcp__echo"))
.await
.expect("split namespace with flat qualified MCP tool name should resolve");
let expected = ("rmcp", "mcp__rmcp__", "echo", "echo");
assert_eq!(
(
flat.server_name.as_str(),
flat.callable_namespace.as_str(),
flat.callable_name.as_str(),
flat.tool.name.as_ref(),
),
expected
);
assert_eq!(
(
split.server_name.as_str(),
split.callable_namespace.as_str(),
split.callable_name.as_str(),
split.tool.name.as_ref(),
),
expected
);
assert_eq!(
(
split_with_flat_name.server_name.as_str(),
split_with_flat_name.callable_namespace.as_str(),
split_with_flat_name.callable_name.as_str(),
split_with_flat_name.tool.name.as_ref(),
),
expected
);
}
#[tokio::test]
async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot() {
let pending_client = futures::future::pending::<Result<ManagedClient, StartupOutcomeError>>()
@@ -722,7 +798,10 @@ async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() {
let tools = manager.list_all_tools().await;
let tool = tools
.get("mcp__codex_apps__calendar_create_event")
.get(&ToolName::namespaced(
"mcp__codex_apps__",
"calendar_create_event",
))
.expect("tool from startup cache");
assert_eq!(tool.server_name, CODEX_APPS_MCP_SERVER_NAME);
assert_eq!(tool.callable_name, "calendar_create_event");

View File

@@ -9,6 +9,7 @@ use tracing::warn;
use crate::mcp::sanitize_responses_api_tool_name;
use crate::mcp_connection_manager::ToolInfo;
use codex_protocol::ToolName;
const MCP_TOOL_NAME_DELIMITER: &str = "__";
const MAX_TOOL_NAME_LENGTH: usize = 64;
@@ -71,10 +72,10 @@ fn unique_callable_parts(
tool_name: &str,
raw_identity: &str,
used_names: &mut HashSet<String>,
) -> (String, String, String) {
let qualified_name = format!("{namespace}{tool_name}");
if qualified_name.len() <= MAX_TOOL_NAME_LENGTH && used_names.insert(qualified_name.clone()) {
return (namespace.to_string(), tool_name.to_string(), qualified_name);
) -> (String, String) {
let display_name = ToolName::namespaced(namespace, tool_name).display();
if display_name.len() <= MAX_TOOL_NAME_LENGTH && used_names.insert(display_name) {
return (namespace.to_string(), tool_name.to_string());
}
let mut attempt = 0_u32;
@@ -86,9 +87,9 @@ fn unique_callable_parts(
};
let (namespace, tool_name) =
fit_callable_parts_with_hash(namespace, tool_name, &hash_input);
let qualified_name = format!("{namespace}{tool_name}");
if used_names.insert(qualified_name.clone()) {
return (namespace, tool_name, qualified_name);
let display_name = ToolName::namespaced(&namespace, &tool_name).display();
if used_names.insert(display_name) {
return (namespace, tool_name);
}
attempt = attempt.saturating_add(1);
}
@@ -103,12 +104,12 @@ struct CallableToolCandidate {
callable_name: String,
}
/// Returns a qualified-name lookup for MCP tools.
/// Returns a callable-name lookup for MCP tools.
///
/// Raw MCP server/tool names are kept on each [`ToolInfo`] for protocol calls, while
/// `callable_namespace` / `callable_name` are sanitized and, when necessary, hashed so
/// every model-visible `mcp__namespace__tool` name is unique and <= 64 bytes.
pub(crate) fn qualify_tools<I>(tools: I) -> HashMap<String, ToolInfo>
pub(crate) fn qualify_tools<I>(tools: I) -> HashMap<ToolName, ToolInfo>
where
I: IntoIterator<Item = ToolInfo>,
{
@@ -188,7 +189,7 @@ where
let mut used_names = HashSet::new();
let mut qualified_tools = HashMap::new();
for mut candidate in candidates {
let (callable_namespace, callable_name, qualified_name) = unique_callable_parts(
let (callable_namespace, callable_name) = unique_callable_parts(
&candidate.callable_namespace,
&candidate.callable_name,
&candidate.raw_tool_identity,
@@ -196,7 +197,7 @@ where
);
candidate.tool.callable_namespace = callable_namespace;
candidate.tool.callable_name = callable_name;
qualified_tools.insert(qualified_name, candidate.tool);
qualified_tools.insert(candidate.tool.callable_tool_name(), candidate.tool);
}
qualified_tools
}

View File

@@ -14,6 +14,7 @@ pub mod profile_toml;
mod project_root_markers;
mod requirements_exec_policy;
pub mod schema;
pub mod shell_environment;
mod skills_config;
mod state;
pub mod types;

View File

@@ -0,0 +1,123 @@
use crate::types::EnvironmentVariablePattern;
use crate::types::ShellEnvironmentPolicy;
use crate::types::ShellEnvironmentPolicyInherit;
use std::collections::HashMap;
use std::collections::HashSet;
pub const CODEX_THREAD_ID_ENV_VAR: &str = "CODEX_THREAD_ID";
/// Construct a shell environment from the supplied process environment and
/// shell-environment policy.
pub fn create_env(
policy: &ShellEnvironmentPolicy,
thread_id: Option<&str>,
) -> HashMap<String, String> {
create_env_from_vars(std::env::vars(), policy, thread_id)
}
pub fn create_env_from_vars<I>(
vars: I,
policy: &ShellEnvironmentPolicy,
thread_id: Option<&str>,
) -> HashMap<String, String>
where
I: IntoIterator<Item = (String, String)>,
{
let mut env_map = populate_env(vars, policy, thread_id);
if cfg!(target_os = "windows") {
// This is a workaround to address the failures we are seeing in the
// following tests when run via Bazel on Windows:
//
// ```
// suite::shell_command::unicode_output::with_login
// suite::shell_command::unicode_output::without_login
// ```
//
// Currently, we can only reproduce these failures in CI, which makes
// iteration times long, so we include this quick fix for now to unblock
// getting the Windows Bazel build running.
if !env_map.keys().any(|k| k.eq_ignore_ascii_case("PATHEXT")) {
env_map.insert("PATHEXT".to_string(), ".COM;.EXE;.BAT;.CMD".to_string());
}
}
env_map
}
pub fn populate_env<I>(
vars: I,
policy: &ShellEnvironmentPolicy,
thread_id: Option<&str>,
) -> HashMap<String, String>
where
I: IntoIterator<Item = (String, String)>,
{
// Step 1 - determine the starting set of variables based on the
// `inherit` strategy.
let mut env_map: HashMap<String, String> = match policy.inherit {
ShellEnvironmentPolicyInherit::All => vars.into_iter().collect(),
ShellEnvironmentPolicyInherit::None => HashMap::new(),
ShellEnvironmentPolicyInherit::Core => {
let core_vars: HashSet<&str> = COMMON_CORE_VARS
.iter()
.copied()
.chain(PLATFORM_CORE_VARS.iter().copied())
.collect();
let is_core_var = |name: &str| {
if cfg!(target_os = "windows") {
core_vars
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(name))
} else {
core_vars.contains(name)
}
};
vars.into_iter().filter(|(k, _)| is_core_var(k)).collect()
}
};
// Internal helper - does `name` match any pattern in `patterns`?
let matches_any = |name: &str, patterns: &[EnvironmentVariablePattern]| -> bool {
patterns.iter().any(|pattern| pattern.matches(name))
};
// Step 2 - Apply the default exclude if not disabled.
if !policy.ignore_default_excludes {
let default_excludes = vec![
EnvironmentVariablePattern::new_case_insensitive("*KEY*"),
EnvironmentVariablePattern::new_case_insensitive("*SECRET*"),
EnvironmentVariablePattern::new_case_insensitive("*TOKEN*"),
];
env_map.retain(|k, _| !matches_any(k, &default_excludes));
}
// Step 3 - Apply custom excludes.
if !policy.exclude.is_empty() {
env_map.retain(|k, _| !matches_any(k, &policy.exclude));
}
// Step 4 - Apply user-provided overrides.
for (key, val) in &policy.r#set {
env_map.insert(key.clone(), val.clone());
}
// Step 5 - If include_only is non-empty, keep only the matching vars.
if !policy.include_only.is_empty() {
env_map.retain(|k, _| matches_any(k, &policy.include_only));
}
// Step 6 - Populate the thread ID environment variable when provided.
if let Some(thread_id) = thread_id {
env_map.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string());
}
env_map
}
const COMMON_CORE_VARS: &[&str] = &["PATH", "SHELL", "TMPDIR", "TEMP", "TMP"];
#[cfg(target_os = "windows")]
const PLATFORM_CORE_VARS: &[&str] = &["PATHEXT", "USERNAME", "USERPROFILE"];
#[cfg(unix)]
const PLATFORM_CORE_VARS: &[&str] = &["HOME", "LANG", "LC_ALL", "LC_CTYPE", "LOGNAME", "USER"];

View File

@@ -658,7 +658,7 @@ impl From<SandboxWorkspaceWrite> for codex_app_server_protocol::SandboxSettings
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum ShellEnvironmentPolicyInherit {
/// "Core" environment variables for the platform. On UNIX, this would

View File

@@ -91,6 +91,7 @@ use codex_otel::current_span_trace_id;
use codex_otel::current_span_w3c_trace_context;
use codex_otel::set_parent_from_w3c_trace_context;
use codex_protocol::ThreadId;
use codex_protocol::ToolName;
use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::approvals::NetworkPolicyAmendment;
@@ -4431,16 +4432,12 @@ impl Session {
.await
}
pub(crate) async fn resolve_mcp_tool_info(
&self,
name: &str,
namespace: Option<&str>,
) -> Option<ToolInfo> {
pub(crate) async fn resolve_mcp_tool_info(&self, tool_name: &ToolName) -> Option<ToolInfo> {
self.services
.mcp_connection_manager
.read()
.await
.resolve_tool_info(name, namespace)
.resolve_tool_info(tool_name)
.await
}

View File

@@ -37,6 +37,7 @@ use codex_protocol::protocol::ReadOnlyAccess;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::request_permissions::PermissionGrantScope;
use codex_protocol::request_permissions::RequestPermissionProfile;
use codex_tools::ToolName;
use tracing::Span;
use crate::RolloutRecorderParams;
@@ -414,10 +415,18 @@ fn make_mcp_tool(
} else {
format!("mcp__{server_name}__")
};
let callable_name = if server_name == CODEX_APPS_MCP_SERVER_NAME {
connector_id
.and_then(|connector_id| tool_name.strip_prefix(connector_id))
.unwrap_or(tool_name)
.to_string()
} else {
tool_name.to_string()
};
ToolInfo {
server_name: server_name.to_string(),
callable_name: tool_name.to_string(),
callable_name,
callable_namespace: tool_namespace,
server_instructions: None,
tool: Tool {
@@ -438,16 +447,14 @@ fn make_mcp_tool(
}
}
fn numbered_mcp_tools(count: usize) -> HashMap<String, ToolInfo> {
fn numbered_mcp_tools(count: usize) -> HashMap<ToolName, ToolInfo> {
(0..count)
.map(|index| {
let tool_name = format!("tool_{index}");
(
format!("mcp__rmcp__{tool_name}"),
make_mcp_tool(
"rmcp", &tool_name, /*connector_id*/ None, /*connector_name*/ None,
),
)
let tool = make_mcp_tool(
"rmcp", &tool_name, /*connector_id*/ None, /*connector_name*/ None,
);
(tool.callable_tool_name(), tool)
})
.collect()
}
@@ -934,9 +941,13 @@ fn mcp_tool_exposure_directly_exposes_small_effective_tool_sets() {
&tools_config,
);
let mut direct_tool_names: Vec<_> = exposure.direct_tools.keys().cloned().collect();
let mut direct_tool_names: Vec<_> = exposure
.direct_tools
.keys()
.map(ToolName::display)
.collect();
direct_tool_names.sort();
let mut expected_tool_names: Vec<_> = mcp_tools.keys().cloned().collect();
let mut expected_tool_names: Vec<_> = mcp_tools.keys().map(ToolName::display).collect();
expected_tool_names.sort();
assert_eq!(direct_tool_names, expected_tool_names);
assert!(exposure.deferred_tools.is_none());
@@ -961,9 +972,9 @@ fn mcp_tool_exposure_searches_large_effective_tool_sets() {
.deferred_tools
.as_ref()
.expect("large tool sets should be discoverable through tool_search");
let mut deferred_tool_names: Vec<_> = deferred_tools.keys().cloned().collect();
let mut deferred_tool_names: Vec<_> = deferred_tools.keys().map(ToolName::display).collect();
deferred_tool_names.sort();
let mut expected_tool_names: Vec<_> = mcp_tools.keys().cloned().collect();
let mut expected_tool_names: Vec<_> = mcp_tools.keys().map(ToolName::display).collect();
expected_tool_names.sort();
assert_eq!(deferred_tool_names, expected_tool_names);
}
@@ -973,15 +984,13 @@ fn mcp_tool_exposure_directly_exposes_explicit_apps_in_large_search_sets() {
let config = test_config();
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true);
let mut mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1);
mcp_tools.extend([(
"mcp__codex_apps__calendar_create_event".to_string(),
make_mcp_tool(
CODEX_APPS_MCP_SERVER_NAME,
"calendar_create_event",
Some("calendar"),
Some("Calendar"),
),
)]);
let calendar_tool = make_mcp_tool(
CODEX_APPS_MCP_SERVER_NAME,
"calendar_create_event",
Some("calendar"),
Some("Calendar"),
);
mcp_tools.insert(calendar_tool.callable_tool_name(), calendar_tool);
let connectors = vec![make_connector("calendar", "Calendar")];
let exposure = build_mcp_tool_exposure(
@@ -992,7 +1001,11 @@ fn mcp_tool_exposure_directly_exposes_explicit_apps_in_large_search_sets() {
&tools_config,
);
let mut tool_names: Vec<String> = exposure.direct_tools.into_keys().collect();
let mut tool_names: Vec<String> = exposure
.direct_tools
.into_keys()
.map(|name| name.display())
.collect();
tool_names.sort();
assert_eq!(
tool_names,
@@ -1006,8 +1019,11 @@ fn mcp_tool_exposure_directly_exposes_explicit_apps_in_large_search_sets() {
.deferred_tools
.as_ref()
.expect("large tool sets should be discoverable through tool_search");
assert!(deferred_tools.contains_key("mcp__codex_apps__calendar_create_event"));
assert!(deferred_tools.contains_key("mcp__rmcp__tool_0"));
assert!(deferred_tools.contains_key(&ToolName::namespaced(
"mcp__codex_apps__calendar",
"_create_event"
)));
assert!(deferred_tools.contains_key(&ToolName::namespaced("mcp__rmcp__", "tool_0")));
}
#[tokio::test]

View File

@@ -1525,7 +1525,7 @@ fn config_defaults_to_file_cli_auth_store_mode() -> std::io::Result<()> {
}
#[test]
fn config_honors_explicit_keyring_auth_store_mode() -> std::io::Result<()> {
fn config_resolves_explicit_keyring_auth_store_mode() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
cli_auth_credentials_store: Some(AuthCredentialsStoreMode::Keyring),
@@ -1540,14 +1540,17 @@ fn config_honors_explicit_keyring_auth_store_mode() -> std::io::Result<()> {
assert_eq!(
config.cli_auth_credentials_store_mode,
AuthCredentialsStoreMode::Keyring,
resolve_cli_auth_credentials_store_mode(
AuthCredentialsStoreMode::Keyring,
env!("CARGO_PKG_VERSION"),
),
);
Ok(())
}
#[test]
fn config_defaults_to_auto_oauth_store_mode() -> std::io::Result<()> {
fn config_resolves_default_oauth_store_mode() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml::default();
@@ -1559,12 +1562,66 @@ fn config_defaults_to_auto_oauth_store_mode() -> std::io::Result<()> {
assert_eq!(
config.mcp_oauth_credentials_store_mode,
OAuthCredentialsStoreMode::Auto,
resolve_mcp_oauth_credentials_store_mode(
OAuthCredentialsStoreMode::Auto,
env!("CARGO_PKG_VERSION"),
),
);
Ok(())
}
#[test]
fn local_dev_builds_force_file_cli_auth_store_modes() {
assert_eq!(
resolve_cli_auth_credentials_store_mode(
AuthCredentialsStoreMode::Keyring,
LOCAL_DEV_BUILD_VERSION,
),
AuthCredentialsStoreMode::File,
);
assert_eq!(
resolve_cli_auth_credentials_store_mode(
AuthCredentialsStoreMode::Auto,
LOCAL_DEV_BUILD_VERSION,
),
AuthCredentialsStoreMode::File,
);
assert_eq!(
resolve_cli_auth_credentials_store_mode(
AuthCredentialsStoreMode::Ephemeral,
LOCAL_DEV_BUILD_VERSION,
),
AuthCredentialsStoreMode::Ephemeral,
);
assert_eq!(
resolve_cli_auth_credentials_store_mode(AuthCredentialsStoreMode::Keyring, "1.2.3"),
AuthCredentialsStoreMode::Keyring,
);
}
#[test]
fn local_dev_builds_force_file_mcp_oauth_store_modes() {
assert_eq!(
resolve_mcp_oauth_credentials_store_mode(
OAuthCredentialsStoreMode::Keyring,
LOCAL_DEV_BUILD_VERSION,
),
OAuthCredentialsStoreMode::File,
);
assert_eq!(
resolve_mcp_oauth_credentials_store_mode(
OAuthCredentialsStoreMode::Auto,
LOCAL_DEV_BUILD_VERSION,
),
OAuthCredentialsStoreMode::File,
);
assert_eq!(
resolve_mcp_oauth_credentials_store_mode(OAuthCredentialsStoreMode::Keyring, "1.2.3"),
OAuthCredentialsStoreMode::Keyring,
);
}
#[test]
fn feedback_enabled_defaults_to_true() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
@@ -1922,7 +1979,10 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> {
)?;
assert_eq!(
final_config.mcp_oauth_credentials_store_mode,
OAuthCredentialsStoreMode::Keyring,
resolve_mcp_oauth_credentials_store_mode(
OAuthCredentialsStoreMode::Keyring,
env!("CARGO_PKG_VERSION"),
),
);
Ok(())
@@ -4514,7 +4574,10 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
cwd: fixture.cwd(),
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: Constrained::allow_any(HashMap::new()),
mcp_oauth_credentials_store_mode: Default::default(),
mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode(
Default::default(),
LOCAL_DEV_BUILD_VERSION,
),
mcp_oauth_callback_port: None,
mcp_oauth_callback_url: None,
model_providers: fixture.model_provider_map.clone(),
@@ -4660,7 +4723,10 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
cwd: fixture.cwd(),
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: Constrained::allow_any(HashMap::new()),
mcp_oauth_credentials_store_mode: Default::default(),
mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode(
Default::default(),
LOCAL_DEV_BUILD_VERSION,
),
mcp_oauth_callback_port: None,
mcp_oauth_callback_url: None,
model_providers: fixture.model_provider_map.clone(),
@@ -4804,7 +4870,10 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
cwd: fixture.cwd(),
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: Constrained::allow_any(HashMap::new()),
mcp_oauth_credentials_store_mode: Default::default(),
mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode(
Default::default(),
LOCAL_DEV_BUILD_VERSION,
),
mcp_oauth_callback_port: None,
mcp_oauth_callback_url: None,
model_providers: fixture.model_provider_map.clone(),
@@ -4934,7 +5003,10 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
cwd: fixture.cwd(),
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: Constrained::allow_any(HashMap::new()),
mcp_oauth_credentials_store_mode: Default::default(),
mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode(
Default::default(),
LOCAL_DEV_BUILD_VERSION,
),
mcp_oauth_callback_port: None,
mcp_oauth_callback_url: None,
model_providers: fixture.model_provider_map.clone(),

View File

@@ -124,6 +124,7 @@ pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB
pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option<usize> = Some(6);
pub(crate) const DEFAULT_AGENT_MAX_DEPTH: i32 = 1;
pub(crate) const DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS: Option<u64> = None;
const LOCAL_DEV_BUILD_VERSION: &str = "0.0.0";
pub const CONFIG_TOML_FILE: &str = "config.toml";
@@ -141,6 +142,32 @@ fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option<PathBuf> {
}
}
fn resolve_cli_auth_credentials_store_mode(
configured: AuthCredentialsStoreMode,
package_version: &str,
) -> AuthCredentialsStoreMode {
match (package_version, configured) {
(
LOCAL_DEV_BUILD_VERSION,
AuthCredentialsStoreMode::Keyring | AuthCredentialsStoreMode::Auto,
) => AuthCredentialsStoreMode::File,
(_, mode) => mode,
}
}
fn resolve_mcp_oauth_credentials_store_mode(
configured: OAuthCredentialsStoreMode,
package_version: &str,
) -> OAuthCredentialsStoreMode {
match (package_version, configured) {
(
LOCAL_DEV_BUILD_VERSION,
OAuthCredentialsStoreMode::Keyring | OAuthCredentialsStoreMode::Auto,
) => OAuthCredentialsStoreMode::File,
(_, mode) => mode,
}
}
#[cfg(test)]
pub(crate) fn test_config() -> Config {
let codex_home = tempfile::tempdir().expect("create temp dir");
@@ -2014,11 +2041,17 @@ impl Config {
include_environment_context,
// The config.toml omits "_mode" because it's a config file. However, "_mode"
// is important in code to differentiate the mode from the store implementation.
cli_auth_credentials_store_mode: cfg.cli_auth_credentials_store.unwrap_or_default(),
cli_auth_credentials_store_mode: resolve_cli_auth_credentials_store_mode(
cfg.cli_auth_credentials_store.unwrap_or_default(),
env!("CARGO_PKG_VERSION"),
),
mcp_servers,
// The config.toml omits "_mode" because it's a config file. However, "_mode"
// is important in code to differentiate the mode from the store implementation.
mcp_oauth_credentials_store_mode: cfg.mcp_oauth_credentials_store.unwrap_or_default(),
mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode(
cfg.mcp_oauth_credentials_store.unwrap_or_default(),
env!("CARGO_PKG_VERSION"),
),
mcp_oauth_callback_port: cfg.mcp_oauth_callback_port,
mcp_oauth_callback_url: cfg.mcp_oauth_callback_url.clone(),
model_providers,

View File

@@ -19,6 +19,7 @@ use codex_connectors::DirectoryListResponse;
use codex_login::token_data::TokenData;
use codex_protocol::protocol::SandboxPolicy;
use codex_tools::DiscoverableTool;
use codex_tools::ToolName;
use rmcp::model::ToolAnnotations;
use serde::Deserialize;
use serde::de::DeserializeOwned;
@@ -159,7 +160,7 @@ pub async fn list_cached_accessible_connectors_from_mcp_tools(
pub(crate) fn refresh_accessible_connectors_cache_from_mcp_tools(
config: &Config,
auth: Option<&CodexAuth>,
mcp_tools: &HashMap<String, ToolInfo>,
mcp_tools: &HashMap<ToolName, ToolInfo>,
) {
if !config.features.enabled(Feature::Apps) {
return;
@@ -510,7 +511,7 @@ pub fn connector_mention_slug(connector: &AppInfo) -> String {
}
pub(crate) fn accessible_connectors_from_mcp_tools(
mcp_tools: &HashMap<String, ToolInfo>,
mcp_tools: &HashMap<ToolName, ToolInfo>,
) -> Vec<AppInfo> {
// ToolInfo already carries plugin provenance, so app-level plugin sources
// can be derived here instead of requiring a separate enrichment pass.

View File

@@ -14,6 +14,7 @@ use codex_config::types::AppsDefaultConfig;
use codex_features::Feature;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::ToolInfo;
use codex_tools::ToolName;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use rmcp::model::JsonObject;
@@ -121,6 +122,13 @@ fn codex_app_tool(
}
}
fn mcp_tool_map<const N: usize>(tools: [ToolInfo; N]) -> HashMap<ToolName, ToolInfo> {
tools
.into_iter()
.map(|tool| (tool.callable_tool_name(), tool))
.collect()
}
fn with_accessible_connectors_cache_cleared<R>(f: impl FnOnce() -> R) -> R {
let previous = {
let mut cache_guard = ACCESSIBLE_CONNECTORS_CACHE
@@ -166,39 +174,30 @@ fn merge_connectors_replaces_plugin_placeholder_name_with_accessible_name() {
#[test]
fn accessible_connectors_from_mcp_tools_carries_plugin_display_names() {
let tools = HashMap::from([
(
"mcp__codex_apps__calendar_list_events".to_string(),
codex_app_tool(
"calendar_list_events",
"calendar",
/*connector_name*/ None,
&["sample", "sample"],
),
let tools = mcp_tool_map([
codex_app_tool(
"calendar_list_events",
"calendar",
/*connector_name*/ None,
&["sample", "sample"],
),
(
"mcp__codex_apps__calendar_create_event".to_string(),
codex_app_tool(
"calendar_create_event",
"calendar",
Some("Google Calendar"),
&["beta", "sample"],
),
),
(
"mcp__sample__echo".to_string(),
ToolInfo {
server_name: "sample".to_string(),
callable_name: "echo".to_string(),
callable_namespace: "sample".to_string(),
server_instructions: None,
tool: test_tool_definition("echo"),
connector_id: None,
connector_name: None,
connector_description: None,
plugin_display_names: plugin_names(&["ignored"]),
},
codex_app_tool(
"calendar_create_event",
"calendar",
Some("Google Calendar"),
&["beta", "sample"],
),
ToolInfo {
server_name: "sample".to_string(),
callable_name: "echo".to_string(),
callable_namespace: "sample".to_string(),
server_instructions: None,
tool: test_tool_definition("echo"),
connector_id: None,
connector_name: None,
connector_description: None,
plugin_display_names: plugin_names(&["ignored"]),
},
]);
let connectors = accessible_connectors_from_mcp_tools(&tools);
@@ -233,24 +232,18 @@ async fn refresh_accessible_connectors_cache_from_mcp_tools_writes_latest_instal
.expect("config should load");
let _ = config.features.set_enabled(Feature::Apps, /*enabled*/ true);
let cache_key = accessible_connectors_cache_key(&config, /*auth*/ None);
let tools = HashMap::from([
(
"mcp__codex_apps__calendar_list_events".to_string(),
codex_app_tool(
"calendar_list_events",
"calendar",
Some("Google Calendar"),
&["calendar-plugin"],
),
let tools = mcp_tool_map([
codex_app_tool(
"calendar_list_events",
"calendar",
Some("Google Calendar"),
&["calendar-plugin"],
),
(
"mcp__codex_apps__openai_hidden".to_string(),
codex_app_tool(
"openai_hidden",
"connector_openai_hidden",
Some("Hidden"),
&[],
),
codex_app_tool(
"openai_hidden",
"connector_openai_hidden",
Some("Hidden"),
&[],
),
]);
@@ -310,30 +303,27 @@ fn merge_connectors_unions_and_dedupes_plugin_display_names() {
#[test]
fn accessible_connectors_from_mcp_tools_preserves_description() {
let mcp_tools = HashMap::from([(
"mcp__codex_apps__calendar_create_event".to_string(),
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
callable_name: "calendar_create_event".to_string(),
callable_namespace: "mcp__codex_apps__calendar".to_string(),
server_instructions: None,
tool: Tool {
name: "calendar_create_event".to_string().into(),
title: None,
description: Some("Create a calendar event".into()),
input_schema: Arc::new(JsonObject::default()),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
},
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
connector_description: Some("Plan events".to_string()),
plugin_display_names: Vec::new(),
let mcp_tools = mcp_tool_map([ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
callable_name: "calendar_create_event".to_string(),
callable_namespace: "mcp__codex_apps__calendar".to_string(),
server_instructions: None,
tool: Tool {
name: "calendar_create_event".to_string().into(),
title: None,
description: Some("Create a calendar event".into()),
input_schema: Arc::new(JsonObject::default()),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
},
)]);
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
connector_description: Some("Plan events".to_string()),
plugin_display_names: Vec::new(),
}]);
assert_eq!(
accessible_connectors_from_mcp_tools(&mcp_tools),

View File

@@ -349,6 +349,7 @@ pub(crate) async fn execute_exec_request(
command,
cwd,
env,
exec_server_env_config: _,
network,
expiration,
capture_policy,

View File

@@ -1,11 +1,10 @@
#[cfg(test)]
use codex_config::types::EnvironmentVariablePattern;
use codex_config::types::ShellEnvironmentPolicy;
use codex_config::types::ShellEnvironmentPolicyInherit;
use codex_protocol::ThreadId;
use std::collections::HashMap;
use std::collections::HashSet;
pub const CODEX_THREAD_ID_ENV_VAR: &str = "CODEX_THREAD_ID";
pub use codex_config::shell_environment::CODEX_THREAD_ID_ENV_VAR;
/// Construct an environment map based on the rules in the specified policy. The
/// resulting map can be passed directly to `Command::envs()` after calling
@@ -21,9 +20,11 @@ pub fn create_env(
policy: &ShellEnvironmentPolicy,
thread_id: Option<ThreadId>,
) -> HashMap<String, String> {
create_env_from_vars(std::env::vars(), policy, thread_id)
let thread_id = thread_id.map(|thread_id| thread_id.to_string());
codex_config::shell_environment::create_env(policy, thread_id.as_deref())
}
#[cfg(all(test, target_os = "windows"))]
fn create_env_from_vars<I>(
vars: I,
policy: &ShellEnvironmentPolicy,
@@ -32,35 +33,11 @@ fn create_env_from_vars<I>(
where
I: IntoIterator<Item = (String, String)>,
{
let mut env_map = populate_env(vars, policy, thread_id);
if cfg!(target_os = "windows") {
// This is a workaround to address the failures we are seeing in the
// following tests when run via Bazel on Windows:
//
// ```
// suite::shell_command::unicode_output::with_login
// suite::shell_command::unicode_output::without_login
// ```
//
// Currently, we can only reproduce these failures in CI, which makes
// iteration times long, so we include this quick fix for now to unblock
// getting the Windows Bazel build running.
if !env_map.keys().any(|k| k.eq_ignore_ascii_case("PATHEXT")) {
env_map.insert("PATHEXT".to_string(), ".COM;.EXE;.BAT;.CMD".to_string());
}
}
env_map
let thread_id = thread_id.map(|thread_id| thread_id.to_string());
codex_config::shell_environment::create_env_from_vars(vars, policy, thread_id.as_deref())
}
const COMMON_CORE_VARS: &[&str] = &["PATH", "SHELL", "TMPDIR", "TEMP", "TMP"];
#[cfg(target_os = "windows")]
const PLATFORM_CORE_VARS: &[&str] = &["PATHEXT", "USERNAME", "USERPROFILE"];
#[cfg(unix)]
const PLATFORM_CORE_VARS: &[&str] = &["HOME", "LANG", "LC_ALL", "LC_CTYPE", "LOGNAME", "USER"];
#[cfg(test)]
fn populate_env<I>(
vars: I,
policy: &ShellEnvironmentPolicy,
@@ -69,66 +46,8 @@ fn populate_env<I>(
where
I: IntoIterator<Item = (String, String)>,
{
// Step 1 determine the starting set of variables based on the
// `inherit` strategy.
let mut env_map: HashMap<String, String> = match policy.inherit {
ShellEnvironmentPolicyInherit::All => vars.into_iter().collect(),
ShellEnvironmentPolicyInherit::None => HashMap::new(),
ShellEnvironmentPolicyInherit::Core => {
let core_vars: HashSet<&str> = COMMON_CORE_VARS
.iter()
.copied()
.chain(PLATFORM_CORE_VARS.iter().copied())
.collect();
let is_core_var = |name: &str| {
if cfg!(target_os = "windows") {
core_vars
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(name))
} else {
core_vars.contains(name)
}
};
vars.into_iter().filter(|(k, _)| is_core_var(k)).collect()
}
};
// Internal helper does `name` match **any** pattern in `patterns`?
let matches_any = |name: &str, patterns: &[EnvironmentVariablePattern]| -> bool {
patterns.iter().any(|pattern| pattern.matches(name))
};
// Step 2 Apply the default exclude if not disabled.
if !policy.ignore_default_excludes {
let default_excludes = vec![
EnvironmentVariablePattern::new_case_insensitive("*KEY*"),
EnvironmentVariablePattern::new_case_insensitive("*SECRET*"),
EnvironmentVariablePattern::new_case_insensitive("*TOKEN*"),
];
env_map.retain(|k, _| !matches_any(k, &default_excludes));
}
// Step 3 Apply custom excludes.
if !policy.exclude.is_empty() {
env_map.retain(|k, _| !matches_any(k, &policy.exclude));
}
// Step 4 Apply user-provided overrides.
for (key, val) in &policy.r#set {
env_map.insert(key.clone(), val.clone());
}
// Step 5 If include_only is non-empty, keep *only* the matching vars.
if !policy.include_only.is_empty() {
env_map.retain(|k, _| matches_any(k, &policy.include_only));
}
// Step 6 Populate the thread ID environment variable when provided.
if let Some(thread_id) = thread_id {
env_map.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string());
}
env_map
let thread_id = thread_id.map(|thread_id| thread_id.to_string());
codex_config::shell_environment::populate_env(vars, policy, thread_id.as_deref())
}
#[cfg(test)]

View File

@@ -4,6 +4,7 @@ use std::collections::HashSet;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::ToolInfo as McpToolInfo;
use codex_mcp::filter_non_codex_apps_mcp_tools_only;
use codex_tools::ToolName;
use codex_tools::ToolsConfig;
use crate::config::Config;
@@ -12,12 +13,12 @@ use crate::connectors;
pub(crate) const DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD: usize = 100;
pub(crate) struct McpToolExposure {
pub(crate) direct_tools: HashMap<String, McpToolInfo>,
pub(crate) deferred_tools: Option<HashMap<String, McpToolInfo>>,
pub(crate) direct_tools: HashMap<ToolName, McpToolInfo>,
pub(crate) deferred_tools: Option<HashMap<ToolName, McpToolInfo>>,
}
pub(crate) fn build_mcp_tool_exposure(
all_mcp_tools: &HashMap<String, McpToolInfo>,
all_mcp_tools: &HashMap<ToolName, McpToolInfo>,
connectors: Option<&[connectors::AppInfo]>,
explicitly_enabled_connectors: &[connectors::AppInfo],
config: &Config,
@@ -48,10 +49,10 @@ pub(crate) fn build_mcp_tool_exposure(
}
fn filter_codex_apps_mcp_tools(
mcp_tools: &HashMap<String, McpToolInfo>,
mcp_tools: &HashMap<ToolName, McpToolInfo>,
connectors: &[connectors::AppInfo],
config: &Config,
) -> HashMap<String, McpToolInfo> {
) -> HashMap<ToolName, McpToolInfo> {
let allowed: HashSet<&str> = connectors
.iter()
.map(|connector| connector.id.as_str())

View File

@@ -9,10 +9,11 @@ use crate::plugins::PluginCapabilitySummary;
use crate::plugins::render_explicit_plugin_instructions;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::ToolInfo;
use codex_tools::ToolName;
pub(crate) fn build_plugin_injections(
mentioned_plugins: &[PluginCapabilitySummary],
mcp_tools: &HashMap<String, ToolInfo>,
mcp_tools: &HashMap<ToolName, ToolInfo>,
available_connectors: &[connectors::AppInfo],
) -> Vec<ResponseItem> {
if mentioned_plugins.is_empty() {

View File

@@ -33,11 +33,18 @@ pub(crate) struct ExecOptions {
pub(crate) capture_policy: ExecCapturePolicy,
}
#[derive(Clone, Debug)]
pub(crate) struct ExecServerEnvConfig {
pub(crate) policy: codex_exec_server::ExecEnvPolicy,
pub(crate) local_policy_env: HashMap<String, String>,
}
#[derive(Debug)]
pub struct ExecRequest {
pub command: Vec<String>,
pub cwd: AbsolutePathBuf,
pub env: HashMap<String, String>,
pub(crate) exec_server_env_config: Option<ExecServerEnvConfig>,
pub network: Option<NetworkProxy>,
pub expiration: ExecExpiration,
pub capture_policy: ExecCapturePolicy,
@@ -72,6 +79,7 @@ impl ExecRequest {
command,
cwd,
env,
exec_server_env_config: None,
network,
expiration,
capture_policy,
@@ -121,6 +129,7 @@ impl ExecRequest {
command,
cwd,
env,
exec_server_env_config: None,
network,
expiration,
capture_policy,

View File

@@ -162,6 +162,7 @@ pub(crate) async fn execute_user_shell_command(
command: exec_command.clone(),
cwd: cwd.clone(),
env: exec_env_map,
exec_server_env_config: None,
network: turn_context.network.clone(),
// TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we
// should use that instead of an "arbitrarily large" timeout here.

View File

@@ -122,7 +122,7 @@ impl CodeModeTurnHost for CoreTurnHost {
call_nested_tool(
self.exec.clone(),
self.tool_runtime.clone(),
tool_name,
ToolName::plain(tool_name),
input,
cancellation_token,
)
@@ -274,38 +274,40 @@ async fn build_nested_router(exec: &ExecContext) -> ToolRouter {
async fn call_nested_tool(
exec: ExecContext,
tool_runtime: ToolCallRuntime,
tool_name: String,
tool_name: ToolName,
input: Option<JsonValue>,
cancellation_token: CancellationToken,
) -> Result<JsonValue, FunctionCallError> {
if tool_name == PUBLIC_TOOL_NAME {
if tool_name.namespace.is_none() && tool_name.name == PUBLIC_TOOL_NAME {
return Err(FunctionCallError::RespondToModel(format!(
"{PUBLIC_TOOL_NAME} cannot invoke itself"
)));
}
let payload = if let Some(tool_info) = exec
.session
.resolve_mcp_tool_info(&tool_name, /*namespace*/ None)
.await
{
match serialize_function_tool_arguments(&tool_name, input) {
Ok(raw_arguments) => ToolPayload::Mcp {
server: tool_info.server_name,
tool: tool_info.tool.name.to_string(),
raw_arguments,
},
Err(error) => return Err(FunctionCallError::RespondToModel(error)),
}
} else {
match build_nested_tool_payload(tool_runtime.find_spec(&tool_name), &tool_name, input) {
Ok(payload) => payload,
Err(error) => return Err(FunctionCallError::RespondToModel(error)),
}
};
let display_name = tool_name.display();
let (tool_call_name, payload) =
if let Some(tool_info) = exec.session.resolve_mcp_tool_info(&tool_name).await {
let raw_arguments = match serialize_function_tool_arguments(&display_name, input) {
Ok(raw_arguments) => raw_arguments,
Err(error) => return Err(FunctionCallError::RespondToModel(error)),
};
(
tool_info.callable_tool_name(),
ToolPayload::Mcp {
server: tool_info.server_name,
tool: tool_info.tool.name.to_string(),
raw_arguments,
},
)
} else {
match build_nested_tool_payload(tool_runtime.find_spec(&tool_name), &tool_name, input) {
Ok(payload) => (tool_name, payload),
Err(error) => return Err(FunctionCallError::RespondToModel(error)),
}
};
let call = ToolCall {
tool_name: ToolName::plain(tool_name.clone()),
tool_name: tool_call_name,
call_id: format!("{PUBLIC_TOOL_NAME}-{}", uuid::Uuid::new_v4()),
payload,
};
@@ -325,16 +327,17 @@ fn tool_kind_for_spec(spec: &ToolSpec) -> codex_code_mode::CodeModeToolKind {
fn tool_kind_for_name(
spec: Option<ToolSpec>,
tool_name: &str,
tool_name: &ToolName,
) -> Result<codex_code_mode::CodeModeToolKind, String> {
let display_name = tool_name.display();
spec.as_ref()
.map(tool_kind_for_spec)
.ok_or_else(|| format!("tool `{tool_name}` is not enabled in {PUBLIC_TOOL_NAME}"))
.ok_or_else(|| format!("tool `{display_name}` is not enabled in {PUBLIC_TOOL_NAME}"))
}
fn build_nested_tool_payload(
spec: Option<ToolSpec>,
tool_name: &str,
tool_name: &ToolName,
input: Option<JsonValue>,
) -> Result<ToolPayload, String> {
let actual_kind = tool_kind_for_name(spec, tool_name)?;
@@ -349,10 +352,11 @@ fn build_nested_tool_payload(
}
fn build_function_tool_payload(
tool_name: &str,
tool_name: &ToolName,
input: Option<JsonValue>,
) -> Result<ToolPayload, String> {
let arguments = serialize_function_tool_arguments(tool_name, input)?;
let display_name = tool_name.display();
let arguments = serialize_function_tool_arguments(&display_name, input)?;
Ok(ToolPayload::Function { arguments })
}
@@ -371,11 +375,12 @@ fn serialize_function_tool_arguments(
}
fn build_freeform_tool_payload(
tool_name: &str,
tool_name: &ToolName,
input: Option<JsonValue>,
) -> Result<ToolPayload, String> {
let display_name = tool_name.display();
match input {
Some(JsonValue::String(input)) => Ok(ToolPayload::Custom { input }),
_ => Err(format!("tool `{tool_name}` expects a string input")),
_ => Err(format!("tool `{display_name}` expects a string input")),
}
}

View File

@@ -11,18 +11,19 @@ use bm25::SearchEngineBuilder;
use codex_mcp::ToolInfo;
use codex_tools::TOOL_SEARCH_DEFAULT_LIMIT;
use codex_tools::TOOL_SEARCH_TOOL_NAME;
use codex_tools::ToolName;
use codex_tools::ToolSearchResultSource;
use codex_tools::collect_tool_search_output_tools;
pub struct ToolSearchHandler {
entries: Vec<(String, ToolInfo)>,
entries: Vec<(ToolName, ToolInfo)>,
search_engine: SearchEngine<usize>,
}
impl ToolSearchHandler {
pub fn new(tools: std::collections::HashMap<String, ToolInfo>) -> Self {
let mut entries: Vec<(String, ToolInfo)> = tools.into_iter().collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
pub fn new(tools: std::collections::HashMap<ToolName, ToolInfo>) -> Self {
let mut entries: Vec<(ToolName, ToolInfo)> = tools.into_iter().collect();
entries.sort_by_key(|entry| entry.0.display());
let documents: Vec<Document<usize>> = entries
.iter()
@@ -104,9 +105,9 @@ impl ToolHandler for ToolSearchHandler {
}
}
fn build_search_text(name: &str, info: &ToolInfo) -> String {
fn build_search_text(name: &ToolName, info: &ToolInfo) -> String {
let mut parts = vec![
name.to_string(),
name.display(),
info.callable_name.clone(),
info.tool.name.to_string(),
info.server_name.clone(),

View File

@@ -1572,29 +1572,38 @@ impl JsReplManager {
},
);
let payload = if let Some(tool_info) = exec
let requested_tool_name = codex_tools::ToolName::plain(req.tool_name.clone());
let (tool_call_name, payload) = if let Some(tool_info) = exec
.session
.resolve_mcp_tool_info(&req.tool_name, /*namespace*/ None)
.resolve_mcp_tool_info(&requested_tool_name)
.await
{
crate::tools::context::ToolPayload::Mcp {
server: tool_info.server_name,
tool: tool_info.tool.name.to_string(),
raw_arguments: req.arguments.clone(),
}
(
tool_info.callable_tool_name(),
crate::tools::context::ToolPayload::Mcp {
server: tool_info.server_name,
tool: tool_info.tool.name.to_string(),
raw_arguments: req.arguments.clone(),
},
)
} else if is_freeform_tool(&router.specs(), &req.tool_name) {
crate::tools::context::ToolPayload::Custom {
input: req.arguments.clone(),
}
(
requested_tool_name,
crate::tools::context::ToolPayload::Custom {
input: req.arguments.clone(),
},
)
} else {
crate::tools::context::ToolPayload::Function {
arguments: req.arguments.clone(),
}
(
requested_tool_name,
crate::tools::context::ToolPayload::Function {
arguments: req.arguments.clone(),
},
)
};
let tool_name = req.tool_name.clone();
let call = crate::tools::router::ToolCall {
tool_name: codex_tools::ToolName::plain(tool_name.clone()),
tool_name: tool_call_name,
call_id: req.id.clone(),
payload,
};

View File

@@ -48,7 +48,7 @@ impl ToolCallRuntime {
}
}
pub(crate) fn find_spec(&self, tool_name: &str) -> Option<ToolSpec> {
pub(crate) fn find_spec(&self, tool_name: &codex_tools::ToolName) -> Option<ToolSpec> {
self.router.find_spec(tool_name)
}

View File

@@ -39,8 +39,8 @@ pub struct ToolRouter {
}
pub(crate) struct ToolRouterParams<'a> {
pub(crate) mcp_tools: Option<HashMap<String, ToolInfo>>,
pub(crate) deferred_mcp_tools: Option<HashMap<String, ToolInfo>>,
pub(crate) mcp_tools: Option<HashMap<ToolName, ToolInfo>>,
pub(crate) deferred_mcp_tools: Option<HashMap<ToolName, ToolInfo>>,
pub(crate) discoverable_tools: Option<Vec<DiscoverableTool>>,
pub(crate) dynamic_tools: &'a [DynamicToolSpec],
}
@@ -97,10 +97,11 @@ impl ToolRouter {
self.model_visible_specs.clone()
}
pub fn find_spec(&self, tool_name: &str) -> Option<ToolSpec> {
pub fn find_spec(&self, tool_name: &ToolName) -> Option<ToolSpec> {
let display_name = tool_name.display();
self.specs
.iter()
.find(|config| config.name() == tool_name)
.find(|config| config.name() == display_name.as_str())
.map(|config| config.spec.clone())
}
@@ -126,16 +127,10 @@ impl ToolRouter {
call_id,
..
} => {
let mcp_tool = session
.resolve_mcp_tool_info(&name, namespace.as_deref())
.await;
let tool_name = match namespace {
Some(namespace) => ToolName::namespaced(namespace, name),
None => ToolName::plain(name),
};
if let Some(tool_info) = mcp_tool {
let tool_name = ToolName::new(namespace, name);
if let Some(tool_info) = session.resolve_mcp_tool_info(&tool_name).await {
Ok(Some(ToolCall {
tool_name,
tool_name: tool_info.callable_tool_name(),
call_id,
payload: ToolPayload::Mcp {
server: tool_info.server_name,

View File

@@ -126,6 +126,7 @@ pub(super) async fn try_run_zsh_fork(
command,
cwd: sandbox_cwd,
env: sandbox_env,
exec_server_env_config: _,
network: sandbox_network,
expiration: _sandbox_expiration,
capture_policy: _capture_policy,
@@ -734,6 +735,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
command: self.command.clone(),
cwd: self.cwd.clone(),
env: exec_env,
exec_server_env_config: None,
network: self.network.clone(),
expiration: ExecExpiration::Cancellation(cancel_rx),
capture_policy: ExecCapturePolicy::ShellTool,

View File

@@ -10,6 +10,7 @@ use crate::exec::ExecExpiration;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::review_approval_request;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::ExecServerEnvConfig;
use crate::sandboxing::SandboxPermissions;
use crate::shell::ShellType;
use crate::tools::network_approval::NetworkApprovalMode;
@@ -52,6 +53,7 @@ pub struct UnifiedExecRequest {
pub process_id: i32,
pub cwd: AbsolutePathBuf,
pub env: HashMap<String, String>,
pub exec_server_env_config: Option<ExecServerEnvConfig>,
pub explicit_env_overrides: HashMap<String, String>,
pub network: Option<NetworkProxy>,
pub tty: bool,
@@ -237,9 +239,10 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
expiration: ExecExpiration::DefaultTimeout,
capture_policy: ExecCapturePolicy::ShellTool,
};
let exec_env = attempt
let mut exec_env = attempt
.env_for(command, options, req.network.as_ref())
.map_err(|err| ToolError::Codex(err.into()))?;
exec_env.exec_server_env_config = req.exec_server_env_config.clone();
match zsh_fork_backend::maybe_prepare_unified_exec(
req,
attempt,
@@ -294,9 +297,10 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
expiration: ExecExpiration::DefaultTimeout,
capture_policy: ExecCapturePolicy::ShellTool,
};
let exec_env = attempt
let mut exec_env = attempt
.env_for(command, options, req.network.as_ref())
.map_err(|err| ToolError::Codex(err.into()))?;
exec_env.exec_server_env_config = req.exec_server_env_config.clone();
let Some(environment) = ctx.turn.environment.as_ref() else {
return Err(ToolError::Rejected(
"exec_command is unavailable in this session".to_string(),

View File

@@ -9,8 +9,10 @@ use codex_mcp::ToolInfo;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_tools::DiscoverableTool;
use codex_tools::ToolHandlerKind;
use codex_tools::ToolName;
use codex_tools::ToolNamespace;
use codex_tools::ToolRegistryPlanDeferredTool;
use codex_tools::ToolRegistryPlanMcpTool;
use codex_tools::ToolRegistryPlanParams;
use codex_tools::ToolUserShellType;
use codex_tools::ToolsConfig;
@@ -29,16 +31,19 @@ pub(crate) fn tool_user_shell_type(user_shell: &Shell) -> ToolUserShellType {
}
}
struct McpToolPlanInputs {
mcp_tools: HashMap<String, rmcp::model::Tool>,
tool_namespaces: HashMap<String, ToolNamespace>,
struct McpToolPlanInputs<'a> {
mcp_tools: Vec<ToolRegistryPlanMcpTool<'a>>,
tool_namespaces: HashMap<ToolName, ToolNamespace>,
}
fn map_mcp_tools_for_plan(mcp_tools: &HashMap<String, ToolInfo>) -> McpToolPlanInputs {
fn map_mcp_tools_for_plan(mcp_tools: &HashMap<ToolName, ToolInfo>) -> McpToolPlanInputs<'_> {
McpToolPlanInputs {
mcp_tools: mcp_tools
.iter()
.map(|(name, tool)| (name.clone(), tool.tool.clone()))
.map(|(name, tool)| ToolRegistryPlanMcpTool {
name: name.clone(),
tool: &tool.tool,
})
.collect(),
tool_namespaces: mcp_tools
.iter()
@@ -57,8 +62,8 @@ fn map_mcp_tools_for_plan(mcp_tools: &HashMap<String, ToolInfo>) -> McpToolPlanI
pub(crate) fn build_specs_with_discoverable_tools(
config: &ToolsConfig,
mcp_tools: Option<HashMap<String, ToolInfo>>,
deferred_mcp_tools: Option<HashMap<String, ToolInfo>>,
mcp_tools: Option<HashMap<ToolName, ToolInfo>>,
deferred_mcp_tools: Option<HashMap<ToolName, ToolInfo>>,
discoverable_tools: Option<Vec<DiscoverableTool>>,
dynamic_tools: &[DynamicToolSpec],
) -> ToolRegistryBuilder {
@@ -97,10 +102,9 @@ pub(crate) fn build_specs_with_discoverable_tools(
let mcp_tool_plan_inputs = mcp_tools.as_ref().map(map_mcp_tools_for_plan);
let deferred_mcp_tool_sources = deferred_mcp_tools.as_ref().map(|tools| {
tools
.values()
.map(|tool| ToolRegistryPlanDeferredTool {
tool_name: tool.callable_name.as_str(),
tool_namespace: tool.callable_namespace.as_str(),
.iter()
.map(|(name, tool)| ToolRegistryPlanDeferredTool {
name: name.clone(),
server_name: tool.server_name.as_str(),
connector_name: tool.connector_name.as_deref(),
connector_description: tool.connector_description.as_deref(),
@@ -114,7 +118,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
ToolRegistryPlanParams {
mcp_tools: mcp_tool_plan_inputs
.as_ref()
.map(|inputs| &inputs.mcp_tools),
.map(|inputs| inputs.mcp_tools.as_slice()),
deferred_mcp_tools: deferred_mcp_tool_sources.as_deref(),
tool_namespaces: mcp_tool_plan_inputs
.as_ref()

View File

@@ -67,6 +67,32 @@ fn mcp_tool_info(tool: rmcp::model::Tool) -> ToolInfo {
}
}
fn mcp_tool_info_with_display_name(display_name: &str, tool: rmcp::model::Tool) -> ToolInfo {
let (callable_namespace, callable_name) = display_name
.rsplit_once('/')
.map(|(namespace, callable_name)| (format!("{namespace}/"), callable_name.to_string()))
.unwrap_or_else(|| ("".to_string(), display_name.to_string()));
ToolInfo {
server_name: "test_server".to_string(),
callable_name,
callable_namespace,
server_instructions: None,
tool,
connector_id: None,
connector_name: None,
plugin_display_names: Vec::new(),
connector_description: None,
}
}
fn mcp_tool_map<const N: usize>(tools: [ToolInfo; N]) -> HashMap<ToolName, ToolInfo> {
tools
.into_iter()
.map(|tool| (tool.callable_tool_name(), tool))
.collect()
}
fn discoverable_connector(id: &str, name: &str, description: &str) -> DiscoverableTool {
let slug = name.replace(' ', "-").to_lowercase();
DiscoverableTool::Connector(Box::new(AppInfo {
@@ -222,8 +248,8 @@ fn model_info_from_models_json(slug: &str) -> ModelInfo {
/// Builds the tool registry builder while collecting tool specs for later serialization.
fn build_specs(
config: &ToolsConfig,
mcp_tools: Option<HashMap<String, ToolInfo>>,
deferred_mcp_tools: Option<HashMap<String, ToolInfo>>,
mcp_tools: Option<HashMap<ToolName, ToolInfo>>,
deferred_mcp_tools: Option<HashMap<ToolName, ToolInfo>>,
dynamic_tools: &[DynamicToolSpec],
) -> ToolRegistryBuilder {
build_specs_with_discoverable_tools(
@@ -796,24 +822,21 @@ fn search_tool_description_falls_back_to_connector_name_without_description() {
let (tools, _) = build_specs(
&tools_config,
/*mcp_tools*/ None,
Some(HashMap::from([(
"mcp__codex_apps__calendar_create_event".to_string(),
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
callable_name: "_create_event".to_string(),
callable_namespace: "mcp__codex_apps__calendar".to_string(),
server_instructions: None,
tool: mcp_tool(
"calendar_create_event",
"Create calendar event",
serde_json::json!({"type": "object"}),
),
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
plugin_display_names: Vec::new(),
connector_description: None,
},
)])),
Some(mcp_tool_map([ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
callable_name: "_create_event".to_string(),
callable_namespace: "mcp__codex_apps__calendar".to_string(),
server_instructions: None,
tool: mcp_tool(
"calendar_create_event",
"Create calendar event",
serde_json::json!({"type": "object"}),
),
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
plugin_display_names: Vec::new(),
connector_description: None,
}])),
&[],
)
.build();
@@ -847,57 +870,48 @@ fn search_tool_registers_namespaced_mcp_tool_aliases() {
let (_, registry) = build_specs(
&tools_config,
/*mcp_tools*/ None,
Some(HashMap::from([
(
"mcp__codex_apps__calendar_create_event".to_string(),
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
callable_name: "_create_event".to_string(),
callable_namespace: "mcp__codex_apps__calendar".to_string(),
server_instructions: None,
tool: mcp_tool(
"calendar-create-event",
"Create calendar event",
serde_json::json!({"type": "object"}),
),
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
connector_description: None,
plugin_display_names: Vec::new(),
},
),
(
"mcp__codex_apps__calendar_list_events".to_string(),
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
callable_name: "_list_events".to_string(),
callable_namespace: "mcp__codex_apps__calendar".to_string(),
server_instructions: None,
tool: mcp_tool(
"calendar-list-events",
"List calendar events",
serde_json::json!({"type": "object"}),
),
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
connector_description: None,
plugin_display_names: Vec::new(),
},
),
(
"mcp__rmcp__echo".to_string(),
ToolInfo {
server_name: "rmcp".to_string(),
callable_name: "echo".to_string(),
callable_namespace: "mcp__rmcp__".to_string(),
server_instructions: None,
tool: mcp_tool("echo", "Echo", serde_json::json!({"type": "object"})),
connector_id: None,
connector_name: None,
connector_description: None,
plugin_display_names: Vec::new(),
},
),
Some(mcp_tool_map([
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
callable_name: "_create_event".to_string(),
callable_namespace: "mcp__codex_apps__calendar".to_string(),
server_instructions: None,
tool: mcp_tool(
"calendar-create-event",
"Create calendar event",
serde_json::json!({"type": "object"}),
),
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
connector_description: None,
plugin_display_names: Vec::new(),
},
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
callable_name: "_list_events".to_string(),
callable_namespace: "mcp__codex_apps__calendar".to_string(),
server_instructions: None,
tool: mcp_tool(
"calendar-list-events",
"List calendar events",
serde_json::json!({"type": "object"}),
),
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
connector_description: None,
plugin_display_names: Vec::new(),
},
ToolInfo {
server_name: "rmcp".to_string(),
callable_name: "echo".to_string(),
callable_namespace: "mcp__rmcp__".to_string(),
server_instructions: None,
tool: mcp_tool("echo", "Echo", serde_json::json!({"type": "object"})),
connector_id: None,
connector_name: None,
connector_description: None,
plugin_display_names: Vec::new(),
},
])),
&[],
)
@@ -913,6 +927,40 @@ fn search_tool_registers_namespaced_mcp_tool_aliases() {
assert!(registry.has_handler(&ToolName::plain("mcp__rmcp__echo")));
}
#[test]
fn direct_mcp_tools_register_namespaced_handlers() {
let config = test_config();
let model_info = construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &features,
image_generation_tool_auth_allowed: true,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
});
let (_, registry) = build_specs(
&tools_config,
Some(mcp_tool_map([mcp_tool_info(mcp_tool(
"echo",
"Echo",
serde_json::json!({"type": "object"}),
))])),
/*deferred_mcp_tools*/ None,
&[],
)
.build();
assert!(registry.has_handler(&ToolName::namespaced("mcp__test_server__", "echo")));
assert!(!registry.has_handler(&ToolName::plain("mcp__test_server__echo")));
}
#[test]
fn test_mcp_tool_property_missing_type_defaults_to_string() {
let config = test_config();
@@ -933,9 +981,9 @@ fn test_mcp_tool_property_missing_type_defaults_to_string() {
let (tools, _) = build_specs(
&tools_config,
Some(HashMap::from([(
"dash/search".to_string(),
mcp_tool_info(mcp_tool(
Some(mcp_tool_map([mcp_tool_info_with_display_name(
"dash/search",
mcp_tool(
"search",
"Search docs",
serde_json::json!({
@@ -944,7 +992,7 @@ fn test_mcp_tool_property_missing_type_defaults_to_string() {
"query": {"description": "search query"}
}
}),
)),
),
)])),
/*deferred_mcp_tools*/ None,
&[],
@@ -993,16 +1041,16 @@ fn test_mcp_tool_preserves_integer_schema() {
let (tools, _) = build_specs(
&tools_config,
Some(HashMap::from([(
"dash/paginate".to_string(),
mcp_tool_info(mcp_tool(
Some(mcp_tool_map([mcp_tool_info_with_display_name(
"dash/paginate",
mcp_tool(
"paginate",
"Pagination",
serde_json::json!({
"type": "object",
"properties": {"page": {"type": "integer"}}
}),
)),
),
)])),
/*deferred_mcp_tools*/ None,
&[],
@@ -1052,16 +1100,16 @@ fn test_mcp_tool_array_without_items_gets_default_string_items() {
let (tools, _) = build_specs(
&tools_config,
Some(HashMap::from([(
"dash/tags".to_string(),
mcp_tool_info(mcp_tool(
Some(mcp_tool_map([mcp_tool_info_with_display_name(
"dash/tags",
mcp_tool(
"tags",
"Tags",
serde_json::json!({
"type": "object",
"properties": {"tags": {"type": "array"}}
}),
)),
),
)])),
/*deferred_mcp_tools*/ None,
&[],
@@ -1113,9 +1161,9 @@ fn test_mcp_tool_anyof_defaults_to_string() {
let (tools, _) = build_specs(
&tools_config,
Some(HashMap::from([(
"dash/value".to_string(),
mcp_tool_info(mcp_tool(
Some(mcp_tool_map([mcp_tool_info_with_display_name(
"dash/value",
mcp_tool(
"value",
"AnyOf Value",
serde_json::json!({
@@ -1124,7 +1172,7 @@ fn test_mcp_tool_anyof_defaults_to_string() {
"value": {"anyOf": [{"type": "string"}, {"type": "number"}]}
}
}),
)),
),
)])),
/*deferred_mcp_tools*/ None,
&[],
@@ -1178,9 +1226,9 @@ fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
});
let (tools, _) = build_specs(
&tools_config,
Some(HashMap::from([(
"test_server/do_something_cool".to_string(),
mcp_tool_info(mcp_tool(
Some(mcp_tool_map([mcp_tool_info_with_display_name(
"test_server/do_something_cool",
mcp_tool(
"do_something_cool",
"Do something cool",
serde_json::json!({
@@ -1206,7 +1254,7 @@ fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
}
}
}),
)),
),
)])),
/*deferred_mcp_tools*/ None,
&[],

View File

@@ -66,10 +66,11 @@ pub(crate) struct OutputHandles {
/// Transport-specific process handle used by unified exec.
enum ProcessHandle {
Local(Box<ExecCommandSession>),
Remote(Arc<dyn ExecProcess>),
ExecServer(Arc<dyn ExecProcess>),
}
/// Unified wrapper over local PTY sessions and exec-server-backed processes.
/// Unified wrapper over directly spawned PTY sessions and exec-server-backed
/// processes.
pub(crate) struct UnifiedExecProcess {
process_handle: ProcessHandle,
output_tx: broadcast::Sender<Vec<u8>>,
@@ -135,7 +136,7 @@ impl UnifiedExecProcess {
.send(data.to_vec())
.await
.map_err(|_| UnifiedExecError::WriteToStdin),
ProcessHandle::Remote(process_handle) => {
ProcessHandle::ExecServer(process_handle) => {
match process_handle.write(data.to_vec()).await {
Ok(response) => match response.status {
WriteStatus::Accepted => Ok(()),
@@ -179,7 +180,7 @@ impl UnifiedExecProcess {
let state = self.state_rx.borrow().clone();
match &self.process_handle {
ProcessHandle::Local(process_handle) => state.has_exited || process_handle.has_exited(),
ProcessHandle::Remote(_) => state.has_exited,
ProcessHandle::ExecServer(_) => state.has_exited,
}
}
@@ -189,7 +190,7 @@ impl UnifiedExecProcess {
ProcessHandle::Local(process_handle) => {
state.exit_code.or_else(|| process_handle.exit_code())
}
ProcessHandle::Remote(_) => state.exit_code,
ProcessHandle::ExecServer(_) => state.exit_code,
}
}
@@ -198,7 +199,7 @@ impl UnifiedExecProcess {
self.output_closed_notify.notify_waiters();
match &self.process_handle {
ProcessHandle::Local(process_handle) => process_handle.terminate(),
ProcessHandle::Remote(process_handle) => {
ProcessHandle::ExecServer(process_handle) => {
let process_handle = Arc::clone(process_handle);
tokio::spawn(async move {
let _ = process_handle.terminate().await;
@@ -331,14 +332,14 @@ impl UnifiedExecProcess {
Ok(managed)
}
pub(super) async fn from_remote_started(
pub(super) async fn from_exec_server_started(
started: StartedExecProcess,
sandbox_type: SandboxType,
) -> Result<Self, UnifiedExecError> {
let process_handle = ProcessHandle::Remote(Arc::clone(&started.process));
let process_handle = ProcessHandle::ExecServer(Arc::clone(&started.process));
let mut managed = Self::new(process_handle, sandbox_type, /*spawn_lifecycle*/ None);
let output_handles = managed.output_handles();
managed.output_task = Some(Self::spawn_remote_output_task(
managed.output_task = Some(Self::spawn_exec_server_output_task(
started,
output_handles,
managed.output_tx.clone(),
@@ -366,7 +367,7 @@ impl UnifiedExecProcess {
Ok(managed)
}
fn spawn_remote_output_task(
fn spawn_exec_server_output_task(
started: StartedExecProcess,
output_handles: OutputHandles,
output_tx: broadcast::Sender<Vec<u8>>,

View File

@@ -11,9 +11,11 @@ use tokio::time::Duration;
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
use crate::exec_env::CODEX_THREAD_ID_ENV_VAR;
use crate::exec_env::create_env;
use crate::exec_policy::ExecApprovalRequest;
use crate::sandboxing::ExecRequest;
use crate::sandboxing::ExecServerEnvConfig;
use crate::tools::context::ExecCommandToolOutput;
use crate::tools::events::ToolEmitter;
use crate::tools::events::ToolEventCtx;
@@ -47,6 +49,7 @@ use crate::unified_exec::process::OutputBuffer;
use crate::unified_exec::process::OutputHandles;
use crate::unified_exec::process::SpawnLifecycleHandle;
use crate::unified_exec::process::UnifiedExecProcess;
use codex_config::types::ShellEnvironmentPolicy;
use codex_protocol::protocol::ExecCommandSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_output_truncation::approx_token_count;
@@ -89,6 +92,70 @@ fn apply_unified_exec_env(mut env: HashMap<String, String>) -> HashMap<String, S
env
}
fn exec_env_policy_from_shell_policy(
policy: &ShellEnvironmentPolicy,
) -> codex_exec_server::ExecEnvPolicy {
codex_exec_server::ExecEnvPolicy {
inherit: policy.inherit.clone(),
ignore_default_excludes: policy.ignore_default_excludes,
exclude: policy
.exclude
.iter()
.map(std::string::ToString::to_string)
.collect(),
r#set: policy.r#set.clone(),
include_only: policy
.include_only
.iter()
.map(std::string::ToString::to_string)
.collect(),
}
}
fn env_overlay_for_exec_server(
request_env: &HashMap<String, String>,
local_policy_env: &HashMap<String, String>,
) -> HashMap<String, String> {
request_env
.iter()
.filter(|(key, value)| local_policy_env.get(*key) != Some(*value))
.map(|(key, value)| (key.clone(), value.clone()))
.collect()
}
fn exec_server_env_for_request(
request: &ExecRequest,
) -> (
Option<codex_exec_server::ExecEnvPolicy>,
HashMap<String, String>,
) {
if let Some(exec_server_env_config) = &request.exec_server_env_config {
(
Some(exec_server_env_config.policy.clone()),
env_overlay_for_exec_server(&request.env, &exec_server_env_config.local_policy_env),
)
} else {
(None, request.env.clone())
}
}
fn exec_server_params_for_request(
process_id: i32,
request: &ExecRequest,
tty: bool,
) -> codex_exec_server::ExecParams {
let (env_policy, env) = exec_server_env_for_request(request);
codex_exec_server::ExecParams {
process_id: exec_server_process_id(process_id).into(),
argv: request.command.clone(),
cwd: request.cwd.to_path_buf(),
env_policy,
env,
tty,
arg0: request.arg0.clone(),
}
}
/// Borrowed process state prepared for a `write_stdin` or poll operation.
struct PreparedProcessHandles {
process: Arc<UnifiedExecProcess>,
@@ -587,12 +654,7 @@ impl UnifiedExecProcessManager {
mut spawn_lifecycle: SpawnLifecycleHandle,
environment: &codex_exec_server::Environment,
) -> Result<UnifiedExecProcess, UnifiedExecError> {
let (program, args) = request
.command
.split_first()
.ok_or(UnifiedExecError::MissingCommandLine)?;
let inherited_fds = spawn_lifecycle.inherited_fds();
if environment.is_remote() {
if !inherited_fds.is_empty() {
return Err(UnifiedExecError::create_process(
@@ -602,19 +664,17 @@ impl UnifiedExecProcessManager {
let started = environment
.get_exec_backend()
.start(codex_exec_server::ExecParams {
process_id: exec_server_process_id(process_id).into(),
argv: request.command.clone(),
cwd: request.cwd.to_path_buf(),
env: request.env.clone(),
tty,
arg0: request.arg0.clone(),
})
.start(exec_server_params_for_request(process_id, request, tty))
.await
.map_err(|err| UnifiedExecError::create_process(err.to_string()))?;
return UnifiedExecProcess::from_remote_started(started, request.sandbox).await;
spawn_lifecycle.after_spawn();
return UnifiedExecProcess::from_exec_server_started(started, request.sandbox).await;
}
let (program, args) = request
.command
.split_first()
.ok_or(UnifiedExecError::MissingCommandLine)?;
let spawn_result = if tty {
codex_utils_pty::pty::spawn_process_with_inherited_fds(
program,
@@ -649,10 +709,20 @@ impl UnifiedExecProcessManager {
cwd: AbsolutePathBuf,
context: &UnifiedExecContext,
) -> Result<(UnifiedExecProcess, Option<DeferredNetworkApproval>), UnifiedExecError> {
let env = apply_unified_exec_env(create_env(
let local_policy_env = create_env(
&context.turn.shell_environment_policy,
Some(context.session.conversation_id),
));
/*thread_id*/ None,
);
let mut env = local_policy_env.clone();
env.insert(
CODEX_THREAD_ID_ENV_VAR.to_string(),
context.session.conversation_id.to_string(),
);
let env = apply_unified_exec_env(env);
let exec_server_env_config = ExecServerEnvConfig {
policy: exec_env_policy_from_shell_policy(&context.turn.shell_environment_policy),
local_policy_env,
};
let mut orchestrator = ToolOrchestrator::new();
let mut runtime = UnifiedExecRuntime::new(
self,
@@ -680,6 +750,7 @@ impl UnifiedExecProcessManager {
process_id: request.process_id,
cwd,
env,
exec_server_env_config: Some(exec_server_env_config),
explicit_env_overrides: context.turn.shell_environment_policy.r#set.clone(),
network: request.network.clone(),
tty: request.tty,

View File

@@ -34,6 +34,92 @@ fn unified_exec_env_overrides_existing_values() {
assert_eq!(env.get("PATH"), Some(&"/usr/bin".to_string()));
}
#[test]
fn env_overlay_for_exec_server_keeps_runtime_changes_only() {
let local_policy_env = HashMap::from([
("HOME".to_string(), "/client-home".to_string()),
("PATH".to_string(), "/client-path".to_string()),
("SHELL_SET".to_string(), "policy".to_string()),
]);
let request_env = HashMap::from([
("HOME".to_string(), "/client-home".to_string()),
("PATH".to_string(), "/sandbox-path".to_string()),
("SHELL_SET".to_string(), "policy".to_string()),
("CODEX_THREAD_ID".to_string(), "thread-1".to_string()),
(
"CODEX_SANDBOX_NETWORK_DISABLED".to_string(),
"1".to_string(),
),
]);
assert_eq!(
env_overlay_for_exec_server(&request_env, &local_policy_env),
HashMap::from([
("PATH".to_string(), "/sandbox-path".to_string()),
("CODEX_THREAD_ID".to_string(), "thread-1".to_string()),
(
"CODEX_SANDBOX_NETWORK_DISABLED".to_string(),
"1".to_string()
),
])
);
}
#[test]
fn exec_server_params_use_env_policy_overlay_contract() {
let request = ExecRequest {
command: vec!["bash".to_string(), "-lc".to_string(), "true".to_string()],
cwd: std::env::current_dir()
.expect("current dir")
.try_into()
.expect("absolute path"),
env: HashMap::from([
("HOME".to_string(), "/client-home".to_string()),
("PATH".to_string(), "/sandbox-path".to_string()),
("CODEX_THREAD_ID".to_string(), "thread-1".to_string()),
]),
exec_server_env_config: Some(ExecServerEnvConfig {
policy: codex_exec_server::ExecEnvPolicy {
inherit: codex_config::types::ShellEnvironmentPolicyInherit::Core,
ignore_default_excludes: false,
exclude: Vec::new(),
r#set: HashMap::new(),
include_only: Vec::new(),
},
local_policy_env: HashMap::from([
("HOME".to_string(), "/client-home".to_string()),
("PATH".to_string(), "/client-path".to_string()),
]),
}),
network: None,
expiration: crate::exec::ExecExpiration::DefaultTimeout,
capture_policy: crate::exec::ExecCapturePolicy::ShellTool,
sandbox: codex_sandboxing::SandboxType::None,
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
file_system_sandbox_policy: codex_protocol::permissions::FileSystemSandboxPolicy::from(
&codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
),
network_sandbox_policy: codex_protocol::permissions::NetworkSandboxPolicy::Restricted,
windows_sandbox_filesystem_overrides: None,
arg0: None,
};
let params =
exec_server_params_for_request(/*process_id*/ 123, &request, /*tty*/ true);
assert_eq!(params.process_id.as_str(), "123");
assert!(params.env_policy.is_some());
assert_eq!(
params.env,
HashMap::from([
("PATH".to_string(), "/sandbox-path".to_string()),
("CODEX_THREAD_ID".to_string(), "thread-1".to_string()),
])
);
}
#[test]
fn exec_server_process_id_matches_unified_exec_process_id() {
assert_eq!(exec_server_process_id(/*process_id*/ 4321), "4321");

View File

@@ -76,7 +76,7 @@ async fn remote_process(write_status: WriteStatus) -> UnifiedExecProcess {
}),
};
UnifiedExecProcess::from_remote_started(started, SandboxType::None)
UnifiedExecProcess::from_exec_server_started(started, SandboxType::None)
.await
.expect("remote process should start")
}
@@ -133,7 +133,7 @@ async fn remote_process_waits_for_early_exit_event() {
let _ = wake_tx.send(1);
});
let process = UnifiedExecProcess::from_remote_started(started, SandboxType::None)
let process = UnifiedExecProcess::from_exec_server_started(started, SandboxType::None)
.await
.expect("remote process should observe early exit");

View File

@@ -15,6 +15,7 @@ arc-swap = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-config = { workspace = true }
codex-protocol = { workspace = true }
codex-sandboxing = { workspace = true }
codex-utils-absolute-path = { workspace = true }
@@ -42,5 +43,6 @@ uuid = { workspace = true, features = ["v4"] }
anyhow = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
pretty_assertions = { workspace = true }
serial_test = { workspace = true }
tempfile = { workspace = true }
test-case = "3.3.1"

View File

@@ -343,6 +343,7 @@ mod tests {
process_id: ProcessId::from("default-env-proc"),
argv: vec!["true".to_string()],
cwd: std::env::current_dir().expect("read current dir"),
env_policy: None,
env: Default::default(),
tty: false,
arg0: None,

View File

@@ -42,6 +42,7 @@ pub use process::ExecProcess;
pub use process::StartedExecProcess;
pub use process_id::ProcessId;
pub use protocol::ExecClosedNotification;
pub use protocol::ExecEnvPolicy;
pub use protocol::ExecExitedNotification;
pub use protocol::ExecOutputDeltaNotification;
pub use protocol::ExecOutputStream;

View File

@@ -5,6 +5,9 @@ use std::time::Duration;
use async_trait::async_trait;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_config::shell_environment;
use codex_config::types::EnvironmentVariablePattern;
use codex_config::types::ShellEnvironmentPolicy;
use codex_utils_pty::ExecCommandSession;
use codex_utils_pty::TerminalSize;
use tokio::sync::Mutex;
@@ -19,6 +22,7 @@ use crate::ProcessId;
use crate::StartedExecProcess;
use crate::protocol::EXEC_CLOSED_METHOD;
use crate::protocol::ExecClosedNotification;
use crate::protocol::ExecEnvPolicy;
use crate::protocol::ExecExitedNotification;
use crate::protocol::ExecOutputDeltaNotification;
use crate::protocol::ExecOutputStream;
@@ -150,12 +154,13 @@ impl LocalProcess {
process_map.insert(process_id.clone(), ProcessEntry::Starting);
}
let env = child_env(&params);
let spawned_result = if params.tty {
codex_utils_pty::spawn_pty_process(
program,
args,
params.cwd.as_path(),
&params.env,
&env,
&params.arg0,
TerminalSize::default(),
)
@@ -165,7 +170,7 @@ impl LocalProcess {
program,
args,
params.cwd.as_path(),
&params.env,
&env,
&params.arg0,
)
.await
@@ -375,6 +380,36 @@ impl LocalProcess {
}
}
fn child_env(params: &ExecParams) -> HashMap<String, String> {
let Some(env_policy) = &params.env_policy else {
return params.env.clone();
};
let policy = shell_environment_policy(env_policy);
let mut env = shell_environment::create_env(&policy, /*thread_id*/ None);
env.extend(params.env.clone());
env
}
fn shell_environment_policy(env_policy: &ExecEnvPolicy) -> ShellEnvironmentPolicy {
ShellEnvironmentPolicy {
inherit: env_policy.inherit.clone(),
ignore_default_excludes: env_policy.ignore_default_excludes,
exclude: env_policy
.exclude
.iter()
.map(|pattern| EnvironmentVariablePattern::new_case_insensitive(pattern))
.collect(),
r#set: env_policy.r#set.clone(),
include_only: env_policy
.include_only
.iter()
.map(|pattern| EnvironmentVariablePattern::new_case_insensitive(pattern))
.collect(),
use_profile: false,
}
}
#[async_trait]
impl ExecBackend for LocalProcess {
async fn start(&self, params: ExecParams) -> Result<StartedExecProcess, ExecServerError> {
@@ -618,3 +653,56 @@ fn notification_sender(inner: &Inner) -> Option<RpcNotificationSender> {
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
#[cfg(test)]
mod tests {
use super::*;
use codex_config::types::ShellEnvironmentPolicyInherit;
fn test_exec_params(env: HashMap<String, String>) -> ExecParams {
ExecParams {
process_id: ProcessId::from("env-test"),
argv: vec!["true".to_string()],
cwd: std::path::PathBuf::from("/tmp"),
env_policy: None,
env,
tty: false,
arg0: None,
}
}
#[test]
fn child_env_defaults_to_exact_env() {
let params = test_exec_params(HashMap::from([("ONLY_THIS".to_string(), "1".to_string())]));
assert_eq!(
child_env(&params),
HashMap::from([("ONLY_THIS".to_string(), "1".to_string())])
);
}
#[test]
fn child_env_applies_policy_then_overlay() {
let mut params = test_exec_params(HashMap::from([
("OVERLAY".to_string(), "overlay".to_string()),
("POLICY_SET".to_string(), "overlay-wins".to_string()),
]));
params.env_policy = Some(ExecEnvPolicy {
inherit: ShellEnvironmentPolicyInherit::None,
ignore_default_excludes: true,
exclude: Vec::new(),
r#set: HashMap::from([("POLICY_SET".to_string(), "policy".to_string())]),
include_only: Vec::new(),
});
let mut expected = HashMap::from([
("OVERLAY".to_string(), "overlay".to_string()),
("POLICY_SET".to_string(), "overlay-wins".to_string()),
]);
if cfg!(target_os = "windows") {
expected.insert("PATHEXT".to_string(), ".COM;.EXE;.BAT;.CMD".to_string());
}
assert_eq!(child_env(&params), expected);
}
}

View File

@@ -3,6 +3,7 @@ use std::path::PathBuf;
use crate::FileSystemSandboxContext;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_config::types::ShellEnvironmentPolicyInherit;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde::Serialize;
@@ -64,11 +65,23 @@ pub struct ExecParams {
pub process_id: ProcessId,
pub argv: Vec<String>,
pub cwd: PathBuf,
#[serde(default)]
pub env_policy: Option<ExecEnvPolicy>,
pub env: HashMap<String, String>,
pub tty: bool,
pub arg0: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecEnvPolicy {
pub inherit: ShellEnvironmentPolicyInherit,
pub ignore_default_excludes: bool,
pub exclude: Vec<String>,
pub r#set: HashMap<String, String>,
pub include_only: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecResponse {

View File

@@ -27,6 +27,7 @@ fn exec_params_with_argv(process_id: &str, argv: Vec<String>) -> ExecParams {
process_id: ProcessId::from(process_id),
argv,
cwd: std::env::current_dir().expect("cwd"),
env_policy: None,
env: inherited_path_env(),
tty: false,
arg0: None,

View File

@@ -390,6 +390,7 @@ mod tests {
process_id,
argv: sleep_then_print_argv(),
cwd: std::env::current_dir().expect("cwd"),
env_policy: None,
env,
tty: false,
arg0: None,

View File

@@ -47,6 +47,7 @@ pub(crate) async fn exec_server() -> anyhow::Result<ExecServerHarness> {
child.stdin(Stdio::null());
child.stdout(Stdio::piped());
child.stderr(Stdio::inherit());
child.kill_on_drop(true);
let mut child = child.spawn()?;
let websocket_url = read_listen_url_from_stdout(&mut child).await?;
@@ -140,6 +141,9 @@ impl ExecServerHarness {
pub(crate) async fn shutdown(&mut self) -> anyhow::Result<()> {
self.child.start_kill()?;
timeout(CONNECT_TIMEOUT, self.child.wait())
.await
.map_err(|_| anyhow!("timed out waiting for exec-server shutdown"))??;
Ok(())
}

View File

@@ -51,6 +51,7 @@ async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> {
process_id: ProcessId::from("proc-1"),
argv: vec!["true".to_string()],
cwd: std::env::current_dir()?,
env_policy: /*env_policy*/ None,
env: Default::default(),
tty: false,
arg0: None,
@@ -127,6 +128,7 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> {
"sleep 0.05; printf 'session output\\n'".to_string(),
],
cwd: std::env::current_dir()?,
env_policy: /*env_policy*/ None,
env: Default::default(),
tty: false,
arg0: None,
@@ -156,6 +158,7 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> {
"import sys; line = sys.stdin.readline(); sys.stdout.write(f'from-stdin:{line}'); sys.stdout.flush()".to_string(),
],
cwd: std::env::current_dir()?,
env_policy: /*env_policy*/ None,
env: Default::default(),
tty: true,
arg0: None,
@@ -192,6 +195,7 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe(
"printf 'queued output\\n'".to_string(),
],
cwd: std::env::current_dir()?,
env_policy: /*env_policy*/ None,
env: Default::default(),
tty: false,
arg0: None,
@@ -210,6 +214,8 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe(
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
// Serialize tests that launch a real exec-server process through the full CLI.
#[serial_test::serial(remote_exec_server)]
async fn remote_exec_process_reports_transport_disconnect() -> Result<()> {
let mut context = create_process_context(/*use_remote*/ true).await?;
let session = context
@@ -222,6 +228,7 @@ async fn remote_exec_process_reports_transport_disconnect() -> Result<()> {
"sleep 10".to_string(),
],
cwd: std::env::current_dir()?,
env_policy: /*env_policy*/ None,
env: Default::default(),
tty: false,
arg0: None,
@@ -255,6 +262,8 @@ async fn remote_exec_process_reports_transport_disconnect() -> Result<()> {
#[test_case(false ; "local")]
#[test_case(true ; "remote")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
// Serialize tests that launch a real exec-server process through the full CLI.
#[serial_test::serial(remote_exec_server)]
async fn exec_process_starts_and_exits(use_remote: bool) -> Result<()> {
assert_exec_process_starts_and_exits(use_remote).await
}
@@ -262,6 +271,8 @@ async fn exec_process_starts_and_exits(use_remote: bool) -> Result<()> {
#[test_case(false ; "local")]
#[test_case(true ; "remote")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
// Serialize tests that launch a real exec-server process through the full CLI.
#[serial_test::serial(remote_exec_server)]
async fn exec_process_streams_output(use_remote: bool) -> Result<()> {
assert_exec_process_streams_output(use_remote).await
}
@@ -269,6 +280,8 @@ async fn exec_process_streams_output(use_remote: bool) -> Result<()> {
#[test_case(false ; "local")]
#[test_case(true ; "remote")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
// Serialize tests that launch a real exec-server process through the full CLI.
#[serial_test::serial(remote_exec_server)]
async fn exec_process_write_then_read(use_remote: bool) -> Result<()> {
assert_exec_process_write_then_read(use_remote).await
}
@@ -276,6 +289,8 @@ async fn exec_process_write_then_read(use_remote: bool) -> Result<()> {
#[test_case(false ; "local")]
#[test_case(true ; "remote")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
// Serialize tests that launch a real exec-server process through the full CLI.
#[serial_test::serial(remote_exec_server)]
async fn exec_process_preserves_queued_events_before_subscribe(use_remote: bool) -> Result<()> {
assert_exec_process_preserves_queued_events_before_subscribe(use_remote).await
}

View File

@@ -2,8 +2,10 @@ pub mod account;
mod agent_path;
pub mod auth;
mod thread_id;
mod tool_name;
pub use agent_path::AgentPath;
pub use thread_id::ThreadId;
pub use tool_name::ToolName;
pub mod approvals;
pub mod config_types;
pub mod dynamic_tools;

View File

@@ -7,6 +7,13 @@ pub struct ToolName {
}
impl ToolName {
pub fn new(namespace: Option<String>, name: impl Into<String>) -> Self {
Self {
name: name.into(),
namespace,
}
}
pub fn plain(name: impl Into<String>) -> Self {
Self {
name: name.into(),

View File

@@ -18,7 +18,6 @@ mod responses_api;
mod tool_config;
mod tool_definition;
mod tool_discovery;
mod tool_name;
mod tool_registry_plan;
mod tool_registry_plan_types;
mod tool_spec;
@@ -50,6 +49,7 @@ pub use code_mode::collect_code_mode_tool_definitions;
pub use code_mode::create_code_mode_tool;
pub use code_mode::create_wait_tool;
pub use code_mode::tool_spec_to_code_mode_tool_definition;
pub use codex_protocol::ToolName;
pub use dynamic_tool::parse_dynamic_tool;
pub use image_detail::can_request_original_image_detail;
pub use image_detail::normalize_output_image_detail;
@@ -113,13 +113,13 @@ pub use tool_discovery::collect_tool_suggest_entries;
pub use tool_discovery::create_tool_search_tool;
pub use tool_discovery::create_tool_suggest_tool;
pub use tool_discovery::filter_tool_suggest_discoverable_tools_for_client;
pub use tool_name::ToolName;
pub use tool_registry_plan::build_tool_registry_plan;
pub use tool_registry_plan_types::ToolHandlerKind;
pub use tool_registry_plan_types::ToolHandlerSpec;
pub use tool_registry_plan_types::ToolNamespace;
pub use tool_registry_plan_types::ToolRegistryPlan;
pub use tool_registry_plan_types::ToolRegistryPlanDeferredTool;
pub use tool_registry_plan_types::ToolRegistryPlanMcpTool;
pub use tool_registry_plan_types::ToolRegistryPlanParams;
pub use tool_spec::ConfiguredToolSpec;
pub use tool_spec::ResponsesApiWebSearchFilters;

View File

@@ -6,7 +6,6 @@ use crate::TOOL_SEARCH_DEFAULT_LIMIT;
use crate::TOOL_SEARCH_TOOL_NAME;
use crate::TOOL_SUGGEST_TOOL_NAME;
use crate::ToolHandlerKind;
use crate::ToolName;
use crate::ToolRegistryPlan;
use crate::ToolRegistryPlanParams;
use crate::ToolSearchSource;
@@ -61,7 +60,6 @@ use crate::request_user_input_tool_description;
use crate::tool_registry_plan_types::agent_type_description;
use codex_protocol::openai_models::ApplyPatchToolType;
use codex_protocol::openai_models::ConfigShellToolType;
use rmcp::model::Tool as McpTool;
use std::collections::BTreeMap;
pub fn build_tool_registry_plan(
@@ -76,9 +74,9 @@ pub fn build_tool_registry_plan(
.tool_namespaces
.into_iter()
.flatten()
.map(|(name, detail)| {
.map(|(tool_name, detail)| {
(
name.clone(),
tool_name.display(),
codex_code_mode::ToolNamespaceDescription {
name: detail.name.clone(),
description: detail.description.clone().unwrap_or_default(),
@@ -266,10 +264,7 @@ pub fn build_tool_registry_plan(
plan.register_handler(TOOL_SEARCH_TOOL_NAME, ToolHandlerKind::ToolSearch);
for tool in deferred_mcp_tools {
plan.register_handler(
ToolName::namespaced(tool.tool_namespace, tool.tool_name),
ToolHandlerKind::Mcp,
);
plan.register_handler(tool.name.clone(), ToolHandlerKind::Mcp);
}
}
@@ -471,25 +466,23 @@ pub fn build_tool_registry_plan(
}
if let Some(mcp_tools) = params.mcp_tools {
let mut entries: Vec<(String, &McpTool)> = mcp_tools
.iter()
.map(|(name, tool)| (name.clone(), tool))
.collect();
entries.sort_by(|left, right| left.0.cmp(&right.0));
let mut entries = mcp_tools.to_vec();
entries.sort_by_key(|tool| tool.name.display());
for (name, tool) in entries {
match mcp_tool_to_responses_api_tool(name.clone(), tool) {
for tool in entries {
let display_name = tool.name.display();
match mcp_tool_to_responses_api_tool(display_name.clone(), tool.tool) {
Ok(converted_tool) => {
plan.push_spec(
ToolSpec::Function(converted_tool),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.register_handler(name, ToolHandlerKind::Mcp);
plan.register_handler(tool.name, ToolHandlerKind::Mcp);
}
Err(error) => {
tracing::error!(
"Failed to convert {name:?} MCP tool to OpenAI tool: {error:?}"
"Failed to convert {display_name:?} MCP tool to OpenAI tool: {error:?}"
);
}
}

View File

@@ -11,8 +11,10 @@ use crate::ResponsesApiTool;
use crate::ResponsesApiWebSearchFilters;
use crate::ResponsesApiWebSearchUserLocation;
use crate::ToolHandlerSpec;
use crate::ToolName;
use crate::ToolNamespace;
use crate::ToolRegistryPlanDeferredTool;
use crate::ToolRegistryPlanMcpTool;
use crate::ToolsConfigParams;
use crate::WaitAgentTimeoutOptions;
use crate::mcp_call_tool_result_output_schema;
@@ -1169,15 +1171,15 @@ fn test_build_specs_mcp_tools_sorted_by_name() {
let tools_map = HashMap::from([
(
"test_server/do".to_string(),
mcp_tool("a", "a", serde_json::json!({"type": "object"})),
mcp_tool("do", "a", serde_json::json!({"type": "object"})),
),
(
"test_server/something".to_string(),
mcp_tool("b", "b", serde_json::json!({"type": "object"})),
mcp_tool("something", "b", serde_json::json!({"type": "object"})),
),
(
"test_server/cool".to_string(),
mcp_tool("c", "c", serde_json::json!({"type": "object"})),
mcp_tool("cool", "c", serde_json::json!({"type": "object"})),
),
]);
@@ -1886,14 +1888,31 @@ fn build_specs_with_optional_tool_namespaces<'a>(
config: &ToolsConfig,
mcp_tools: Option<HashMap<String, rmcp::model::Tool>>,
deferred_mcp_tools: Option<Vec<ToolRegistryPlanDeferredTool<'a>>>,
tool_namespaces: Option<HashMap<String, ToolNamespace>>,
tool_namespaces: Option<HashMap<ToolName, ToolNamespace>>,
discoverable_tools: Option<Vec<DiscoverableTool>>,
dynamic_tools: &[DynamicToolSpec],
) -> (Vec<ConfiguredToolSpec>, Vec<ToolHandlerSpec>) {
let mcp_tool_inputs = mcp_tools.as_ref().map(|mcp_tools| {
mcp_tools
.iter()
.map(|(qualified_name, tool)| {
let raw_tool_name = tool.name.as_ref();
let callable_namespace = qualified_name
.strip_suffix(raw_tool_name)
.filter(|namespace| !namespace.is_empty())
.unwrap_or("mcp__test_server__");
ToolRegistryPlanMcpTool {
name: ToolName::namespaced(callable_namespace.to_string(), raw_tool_name),
tool,
}
})
.collect::<Vec<_>>()
});
let plan = build_tool_registry_plan(
config,
ToolRegistryPlanParams {
mcp_tools: mcp_tools.as_ref(),
mcp_tools: mcp_tool_inputs.as_deref(),
deferred_mcp_tools: deferred_mcp_tools.as_deref(),
tool_namespaces: tool_namespaces.as_ref(),
discoverable_tools: discoverable_tools.as_deref(),
@@ -2018,8 +2037,7 @@ fn deferred_mcp_tool<'a>(
connector_description: Option<&'a str>,
) -> ToolRegistryPlanDeferredTool<'a> {
ToolRegistryPlanDeferredTool {
tool_name,
tool_namespace,
name: ToolName::namespaced(tool_namespace, tool_name),
server_name,
connector_name,
connector_description,

View File

@@ -6,7 +6,6 @@ use crate::ToolsConfig;
use crate::WaitAgentTimeoutOptions;
use crate::augment_tool_spec_for_code_mode;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use rmcp::model::Tool as McpTool;
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -58,9 +57,9 @@ pub struct ToolRegistryPlan {
#[derive(Debug, Clone, Copy)]
pub struct ToolRegistryPlanParams<'a> {
pub mcp_tools: Option<&'a HashMap<String, McpTool>>,
pub mcp_tools: Option<&'a [ToolRegistryPlanMcpTool<'a>]>,
pub deferred_mcp_tools: Option<&'a [ToolRegistryPlanDeferredTool<'a>]>,
pub tool_namespaces: Option<&'a HashMap<String, ToolNamespace>>,
pub tool_namespaces: Option<&'a HashMap<ToolName, ToolNamespace>>,
pub discoverable_tools: Option<&'a [DiscoverableTool]>,
pub dynamic_tools: &'a [DynamicToolSpec],
pub default_agent_type_description: &'a str,
@@ -73,10 +72,17 @@ pub struct ToolNamespace {
pub description: Option<String>,
}
#[derive(Debug, Clone, Copy)]
/// Direct MCP tool metadata needed to expose the flat Responses API tool while
/// registering its runtime handler with the canonical namespace/name identity.
#[derive(Debug, Clone)]
pub struct ToolRegistryPlanMcpTool<'a> {
pub name: ToolName,
pub tool: &'a rmcp::model::Tool,
}
#[derive(Debug, Clone)]
pub struct ToolRegistryPlanDeferredTool<'a> {
pub tool_name: &'a str,
pub tool_namespace: &'a str,
pub name: ToolName,
pub server_name: &'a str,
pub connector_name: Option<&'a str>,
pub connector_description: Option<&'a str>,