Merge branch 'main' into etraut/stream-lifecycle-diagnostics

This commit is contained in:
Eric Traut
2026-04-26 21:45:56 -07:00
committed by GitHub
244 changed files with 6432 additions and 5114 deletions
+8 -4
View File
@@ -2286,15 +2286,20 @@ version = "0.0.0"
dependencies = [
"anyhow",
"async-trait",
"base64 0.22.1",
"codex-app-server-protocol",
"codex-exec-server",
"codex-execpolicy",
"codex-features",
"codex-git-utils",
"codex-model-provider-info",
"codex-network-proxy",
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-path",
"core-foundation 0.9.4",
"dns-lookup",
"dunce",
"futures",
"gethostname",
"libc",
@@ -2318,6 +2323,7 @@ dependencies = [
"tracing",
"wildmatch",
"winapi-util",
"windows-sys 0.52.0",
]
[[package]]
@@ -2398,7 +2404,6 @@ dependencies = [
"codex-utils-string",
"codex-utils-template",
"codex-windows-sandbox",
"core-foundation 0.9.4",
"core_test_support",
"csv",
"ctor 0.6.3",
@@ -2449,7 +2454,6 @@ dependencies = [
"walkdir",
"which 8.0.0",
"whoami",
"windows-sys 0.52.0",
"wiremock",
"zstd 0.13.3",
]
@@ -2559,6 +2563,7 @@ dependencies = [
"codex-apply-patch",
"codex-arg0",
"codex-cloud-requirements",
"codex-config",
"codex-core",
"codex-feedback",
"codex-git-utils",
@@ -2602,7 +2607,6 @@ dependencies = [
"bytes",
"codex-app-server-protocol",
"codex-client",
"codex-config",
"codex-protocol",
"codex-sandboxing",
"codex-test-binary-support",
@@ -2780,7 +2784,6 @@ version = "0.0.0"
dependencies = [
"cc",
"clap",
"codex-config",
"codex-core",
"codex-protocol",
"codex-sandboxing",
@@ -3117,6 +3120,7 @@ dependencies = [
"tracing",
"ts-rs",
"uuid",
"wildmatch",
]
[[package]]
@@ -161,7 +161,7 @@ fn sample_thread_start_response(thread_id: &str, ephemeral: bool, model: &str) -
}
fn sample_permission_profile() -> AppServerPermissionProfile {
CorePermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::DangerFullAccess).into()
CorePermissionProfile::Disabled.into()
}
fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata {
@@ -315,7 +315,10 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact {
session_source: SessionSource::Exec,
model: "gpt-5".to_string(),
model_provider: "openai".to_string(),
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: CorePermissionProfile::from_legacy_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
permission_profile_cwd: PathBuf::from("/tmp"),
reasoning_effort: None,
reasoning_summary: None,
service_tier: None,
+3 -2
View File
@@ -13,12 +13,12 @@ use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookSource;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SkillScope;
use codex_protocol::protocol::SubAgentSource;
@@ -62,7 +62,8 @@ pub struct TurnResolvedConfigFact {
pub session_source: SessionSource,
pub model: String,
pub model_provider: String,
pub sandbox_policy: SandboxPolicy,
pub permission_profile: PermissionProfile,
pub permission_profile_cwd: PathBuf,
pub reasoning_effort: Option<ReasoningEffort>,
pub reasoning_summary: Option<ReasoningSummary>,
pub service_tier: Option<ServiceTier>,
+50 -9
View File
@@ -61,7 +61,7 @@ use codex_login::default_client::originator;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SkillScope;
use codex_protocol::protocol::TokenUsage;
@@ -884,7 +884,8 @@ fn codex_turn_event_params(
session_source: _session_source,
model,
model_provider,
sandbox_policy,
permission_profile,
permission_profile_cwd,
reasoning_effort,
reasoning_summary,
service_tier,
@@ -909,7 +910,10 @@ fn codex_turn_event_params(
parent_thread_id: thread_metadata.parent_thread_id.clone(),
model: Some(model),
model_provider,
sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)),
sandbox_policy: Some(sandbox_policy_mode(
&permission_profile,
permission_profile_cwd.as_path(),
)),
reasoning_effort: reasoning_effort.map(|value| value.to_string()),
reasoning_summary: reasoning_summary_mode(reasoning_summary),
service_tier: service_tier
@@ -954,12 +958,27 @@ fn codex_turn_event_params(
}
}
fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str {
match sandbox_policy {
SandboxPolicy::DangerFullAccess => "full_access",
SandboxPolicy::ReadOnly { .. } => "read_only",
SandboxPolicy::WorkspaceWrite { .. } => "workspace_write",
SandboxPolicy::ExternalSandbox { .. } => "external_sandbox",
fn sandbox_policy_mode(permission_profile: &PermissionProfile, cwd: &Path) -> &'static str {
match permission_profile {
PermissionProfile::Disabled => "full_access",
PermissionProfile::External { .. } => "external_sandbox",
PermissionProfile::Managed { .. } => {
let file_system_policy = permission_profile.file_system_sandbox_policy();
if file_system_policy.has_full_disk_write_access() {
if permission_profile.network_sandbox_policy().is_enabled() {
"full_access"
} else {
"external_sandbox"
}
} else if file_system_policy
.get_writable_roots_with_cwd(cwd)
.is_empty()
{
"read_only"
} else {
"workspace_write"
}
}
}
}
@@ -1050,3 +1069,25 @@ pub(crate) fn normalize_path_for_skill_id(
_ => resolved_path.to_string_lossy().replace('\\', "/"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::models::SandboxEnforcement;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
#[test]
fn managed_full_disk_with_restricted_network_reports_external_sandbox() {
let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement(
SandboxEnforcement::Managed,
&FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Restricted,
);
assert_eq!(
sandbox_policy_mode(&permission_profile, Path::new("/")),
"external_sandbox"
);
}
}
+2 -2
View File
@@ -41,12 +41,12 @@ use codex_app_server_protocol::Result as JsonRpcResult;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudRequirementsLoader;
use codex_config::LoaderOverrides;
use codex_config::NoopThreadConfigLoader;
use codex_config::RemoteThreadConfigLoader;
use codex_config::ThreadConfigLoader;
use codex_core::config::Config;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::LoaderOverrides;
pub use codex_exec_server::EnvironmentManager;
pub use codex_exec_server::EnvironmentManagerArgs;
pub use codex_exec_server::ExecServerRuntimePaths;
+1 -7
View File
@@ -2292,12 +2292,6 @@
},
"type": "array"
},
"end_turn": {
"type": [
"boolean",
"null"
]
},
"id": {
"type": [
"string",
@@ -6130,4 +6124,4 @@
}
],
"title": "ClientRequest"
}
}
@@ -12739,12 +12739,6 @@
},
"type": "array"
},
"end_turn": {
"type": [
"boolean",
"null"
]
},
"id": {
"type": [
"string",
@@ -17758,4 +17752,4 @@
},
"title": "CodexAppServerProtocol",
"type": "object"
}
}
@@ -9413,12 +9413,6 @@
},
"type": "array"
},
"end_turn": {
"type": [
"boolean",
"null"
]
},
"id": {
"type": [
"string",
@@ -15643,4 +15637,4 @@
},
"title": "CodexAppServerProtocolV2",
"type": "object"
}
}
@@ -345,12 +345,6 @@
},
"type": "array"
},
"end_turn": {
"type": [
"boolean",
"null"
]
},
"id": {
"type": [
"string",
@@ -928,4 +922,4 @@
],
"title": "RawResponseItemCompletedNotification",
"type": "object"
}
}
@@ -756,12 +756,6 @@
},
"type": "array"
},
"end_turn": {
"type": [
"boolean",
"null"
]
},
"id": {
"type": [
"string",
@@ -1458,4 +1452,4 @@
],
"title": "ThreadResumeParams",
"type": "object"
}
}
@@ -11,7 +11,7 @@ import type { ReasoningItemContent } from "./ReasoningItemContent";
import type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSummary";
import type { WebSearchAction } from "./WebSearchAction";
export type ResponseItem = { "type": "message", role: string, content: Array<ContentItem>, end_turn?: boolean, phase?: MessagePhase, } | { "type": "reasoning", summary: Array<ReasoningItemReasoningSummary>, content?: Array<ReasoningItemContent>, encrypted_content: string | null, } | { "type": "local_shell_call",
export type ResponseItem = { "type": "message", role: string, content: Array<ContentItem>, phase?: MessagePhase, } | { "type": "reasoning", summary: Array<ReasoningItemReasoningSummary>, content?: Array<ReasoningItemContent>, encrypted_content: string | null, } | { "type": "local_shell_call",
/**
* Set when using the Responses API.
*/
@@ -3096,7 +3096,6 @@ mod tests {
content: vec![codex_protocol::models::ContentItem::InputText {
text: "plain text".into(),
}],
end_turn: None,
phase: None,
}),
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
@@ -230,6 +230,9 @@ use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCre
use codex_backend_client::Client as BackendClient;
use codex_chatgpt::connectors;
use codex_chatgpt::workspace_settings;
use codex_config::CloudRequirementsLoadError;
use codex_config::CloudRequirementsLoadErrorCode;
use codex_config::loader::project_trust_key;
use codex_config::types::McpServerTransportConfig;
use codex_core::CodexThread;
use codex_core::CodexThreadTurnContextOverrides;
@@ -248,9 +251,6 @@ use codex_core::config::NetworkProxyAuditMetadata;
use codex_core::config::ThreadStoreConfig;
use codex_core::config::edit::ConfigEdit;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config_loader::CloudRequirementsLoadError;
use codex_core::config_loader::CloudRequirementsLoadErrorCode;
use codex_core::config_loader::project_trust_key;
use codex_core::exec::ExecCapturePolicy;
use codex_core::exec::ExecExpiration;
use codex_core::exec::ExecParams;
@@ -2208,7 +2208,7 @@ impl CodexMessageProcessor {
let started_network_proxy = match self.config.permissions.network.as_ref() {
Some(spec) => match spec
.start_proxy(
self.config.permissions.sandbox_policy.get(),
self.config.permissions.permission_profile.get(),
/*policy_decider*/ None,
/*blocked_request_observer*/ None,
managed_network_requirements_enabled,
@@ -2272,44 +2272,30 @@ impl CodexMessageProcessor {
arg0: None,
};
let (
effective_policy,
effective_file_system_sandbox_policy,
effective_network_sandbox_policy,
) = if let Some(permission_profile) = permission_profile {
let effective_permission_profile = if let Some(permission_profile) = permission_profile {
let permission_profile =
codex_protocol::models::PermissionProfile::from(permission_profile);
let sandbox_policy = match permission_profile.to_legacy_sandbox_policy(&sandbox_cwd) {
Ok(sandbox_policy) => sandbox_policy,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("invalid permission profile: {err}"),
data: None,
};
self.outgoing.send_error(request, error).await;
return;
}
};
let (mut file_system_sandbox_policy, network_sandbox_policy) =
permission_profile.to_runtime_permissions();
let configured_file_system_sandbox_policy =
self.config.permissions.file_system_sandbox_policy();
Self::preserve_configured_deny_read_restrictions(
&mut file_system_sandbox_policy,
&configured_file_system_sandbox_policy,
);
let effective_permission_profile =
codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement(
permission_profile.enforcement(),
&file_system_sandbox_policy,
network_sandbox_policy,
);
match self
.config
.permissions
.sandbox_policy
.can_set(&sandbox_policy)
.permission_profile
.can_set(&effective_permission_profile)
{
Ok(()) => {
let (mut file_system_sandbox_policy, network_sandbox_policy) =
permission_profile.to_runtime_permissions();
Self::preserve_configured_deny_read_restrictions(
&mut file_system_sandbox_policy,
&self.config.permissions.file_system_sandbox_policy,
);
(
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
)
}
Ok(()) => effective_permission_profile,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
@@ -2321,13 +2307,39 @@ impl CodexMessageProcessor {
}
}
} else if let Some(policy) = sandbox_policy.map(|policy| policy.to_core()) {
match self.config.permissions.sandbox_policy.can_set(&policy) {
match self
.config
.permissions
.can_set_legacy_sandbox_policy(&policy, &sandbox_cwd)
{
Ok(()) => {
let file_system_sandbox_policy =
codex_protocol::permissions::FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&policy, &sandbox_cwd);
let network_sandbox_policy =
codex_protocol::permissions::NetworkSandboxPolicy::from(&policy);
(policy, file_system_sandbox_policy, network_sandbox_policy)
let permission_profile =
codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement(
codex_protocol::models::SandboxEnforcement::from_legacy_sandbox_policy(
&policy,
),
&file_system_sandbox_policy,
network_sandbox_policy,
);
if let Err(err) = self
.config
.permissions
.permission_profile
.can_set(&permission_profile)
{
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("invalid sandbox policy: {err}"),
data: None,
};
self.outgoing.send_error(request, error).await;
return;
}
permission_profile
}
Err(err) => {
let error = JSONRPCErrorError {
@@ -2340,11 +2352,7 @@ impl CodexMessageProcessor {
}
}
} else {
(
self.config.permissions.sandbox_policy.get().clone(),
self.config.permissions.file_system_sandbox_policy.clone(),
self.config.permissions.network_sandbox_policy,
)
self.config.permissions.permission_profile()
};
let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone();
@@ -2363,9 +2371,7 @@ impl CodexMessageProcessor {
match codex_core::exec::build_exec_request(
exec_params,
&effective_policy,
&effective_file_system_sandbox_policy,
effective_network_sandbox_policy,
&effective_permission_profile,
&sandbox_cwd,
&codex_linux_sandbox_exe,
use_legacy_landlock,
@@ -2652,16 +2658,14 @@ impl CodexMessageProcessor {
// should still be considered "trusted" in this case.
let requested_permissions_trust_project =
requested_permissions_trust_project(&typesafe_overrides, config.cwd.as_path());
let effective_permissions_trust_project = permission_profile_trusts_project(
&config.permissions.permission_profile(),
config.cwd.as_path(),
);
if requested_cwd.is_some()
&& config.active_project.trust_level.is_none()
&& (requested_permissions_trust_project
|| matches!(
config.permissions.sandbox_policy.get(),
codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. }
| codex_protocol::protocol::SandboxPolicy::DangerFullAccess
| codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. }
))
&& (requested_permissions_trust_project || effective_permissions_trust_project)
{
let trust_target = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &config.cwd)
.await
@@ -8705,7 +8709,9 @@ impl CodexMessageProcessor {
Ok(config) => {
let setup_request = WindowsSandboxSetupRequest {
mode,
policy: config.permissions.sandbox_policy.get().clone(),
policy: config
.permissions
.legacy_sandbox_policy(config.cwd.as_path()),
policy_cwd: config.cwd.to_path_buf(),
command_cwd,
env_map: std::env::vars().collect(),
@@ -10160,18 +10166,20 @@ fn requested_permissions_trust_project(overrides: &ConfigOverrides, cwd: &Path)
overrides
.permission_profile
.as_ref()
.is_some_and(|profile| {
profile
.to_legacy_sandbox_policy(cwd)
.is_ok_and(|sandbox_policy| {
matches!(
sandbox_policy,
codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. }
| codex_protocol::protocol::SandboxPolicy::DangerFullAccess
| codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. }
)
})
})
.is_some_and(|profile| permission_profile_trusts_project(profile, cwd))
}
fn permission_profile_trusts_project(
profile: &codex_protocol::models::PermissionProfile,
cwd: &Path,
) -> bool {
match profile {
codex_protocol::models::PermissionProfile::Disabled
| codex_protocol::models::PermissionProfile::External { .. } => true,
codex_protocol::models::PermissionProfile::Managed { .. } => profile
.file_system_sandbox_policy()
.can_write_path_with_cwd(cwd, cwd),
}
}
fn parse_datetime(timestamp: Option<&str>) -> Option<DateTime<Utc>> {
@@ -10456,11 +10464,11 @@ mod tests {
use chrono::Utc;
use codex_app_server_protocol::ServerRequestPayload;
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_config::CloudRequirementsLoader;
use codex_config::LoaderOverrides;
use codex_config::SessionThreadConfig;
use codex_config::StaticThreadConfigLoader;
use codex_config::ThreadConfigSource;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::LoaderOverrides;
use codex_model_provider_info::ModelProviderInfo;
use codex_model_provider_info::WireApi;
use codex_protocol::ThreadId;
@@ -10468,6 +10476,7 @@ mod tests {
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
@@ -10672,16 +10681,10 @@ mod tests {
#[test]
fn thread_response_permission_profile_preserves_enforcement() {
let full_access_profile =
codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy(
&SandboxPolicy::DangerFullAccess,
);
let external_profile =
codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy(
&SandboxPolicy::ExternalSandbox {
network_access: codex_protocol::protocol::NetworkAccess::Restricted,
},
);
let full_access_profile = codex_protocol::models::PermissionProfile::Disabled;
let external_profile = codex_protocol::models::PermissionProfile::External {
network: codex_protocol::permissions::NetworkSandboxPolicy::Restricted,
};
assert_eq!(
thread_response_permission_profile(external_profile.clone()),
@@ -10696,17 +10699,24 @@ mod tests {
#[test]
fn requested_permissions_trust_project_uses_permission_profile_intent() {
let cwd = test_path_buf("/tmp/project").abs();
let full_access_profile =
codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy(
&SandboxPolicy::DangerFullAccess,
);
let workspace_write_profile =
codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy(
&SandboxPolicy::new_workspace_write_policy(),
);
let read_only_profile =
codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
let full_access_profile = codex_protocol::models::PermissionProfile::Disabled;
let workspace_write_profile = codex_protocol::models::PermissionProfile::workspace_write();
let read_only_profile = codex_protocol::models::PermissionProfile::read_only();
let split_write_profile =
codex_protocol::models::PermissionProfile::from_runtime_permissions(
&FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: cwd.clone() },
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: "/tmp/project/**/*.env".to_string(),
},
access: FileSystemAccessMode::None,
},
]),
NetworkSandboxPolicy::Restricted,
);
assert!(requested_permissions_trust_project(
@@ -10723,6 +10733,13 @@ mod tests {
},
cwd.as_path()
));
assert!(requested_permissions_trust_project(
&ConfigOverrides {
permission_profile: Some(split_write_profile),
..Default::default()
},
cwd.as_path()
));
assert!(!requested_permissions_trust_project(
&ConfigOverrides {
permission_profile: Some(read_only_profile),
@@ -10915,10 +10932,7 @@ mod tests {
approval_policy: codex_protocol::protocol::AskForApproval::OnRequest,
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User,
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
permission_profile:
codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy(
&codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
),
permission_profile: codex_protocol::models::PermissionProfile::Disabled,
cwd,
ephemeral: false,
reasoning_effort: None,
+20 -53
View File
@@ -34,9 +34,9 @@ use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::sync::watch;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_PARAMS_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::error_code::internal_error;
use crate::error_code::invalid_params;
use crate::error_code::invalid_request;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
@@ -158,7 +158,7 @@ impl CommandExecManager {
} = params;
if process_id.is_none() && (tty || stream_stdin || stream_stdout_stderr) {
return Err(invalid_request(
"command/exec tty or streaming requires a client-supplied processId".to_string(),
"command/exec tty or streaming requires a client-supplied processId",
));
}
let process_id = process_id.map_or_else(
@@ -178,12 +178,12 @@ impl CommandExecManager {
if matches!(exec_request.sandbox, SandboxType::WindowsRestrictedToken) {
if tty || stream_stdin || stream_stdout_stderr {
return Err(invalid_request(
"streaming command/exec is not supported with windows sandbox".to_string(),
"streaming command/exec is not supported with windows sandbox",
));
}
if output_bytes_cap != Some(DEFAULT_OUTPUT_BYTES_CAP) {
return Err(invalid_request(
"custom outputBytesCap is not supported with windows sandbox".to_string(),
"custom outputBytesCap is not supported with windows sandbox",
));
}
if let InternalProcessId::Client(_) = &process_id {
@@ -249,7 +249,7 @@ impl CommandExecManager {
let sessions = Arc::clone(&self.sessions);
let (program, args) = command
.split_first()
.ok_or_else(|| invalid_request("command must not be empty".to_string()))?;
.ok_or_else(|| invalid_request("command must not be empty"))?;
{
let mut sessions = self.sessions.lock().await;
if sessions.contains_key(&process_key) {
@@ -312,7 +312,7 @@ impl CommandExecManager {
) -> Result<CommandExecWriteResponse, JSONRPCErrorError> {
if params.delta_base64.is_none() && !params.close_stdin {
return Err(invalid_params(
"command/exec/write requires deltaBase64 or closeStdin".to_string(),
"command/exec/write requires deltaBase64 or closeStdin",
));
}
@@ -421,7 +421,7 @@ impl CommandExecManager {
};
let CommandExecSession::Active { control_tx } = session else {
return Err(invalid_request(
"command/exec/write, command/exec/terminate, and command/exec/resize are not supported for windows sandbox processes".to_string(),
"command/exec/write, command/exec/terminate, and command/exec/resize are not supported for windows sandbox processes",
));
};
let (response_tx, response_rx) = oneshot::channel();
@@ -635,7 +635,7 @@ async fn handle_process_write(
) -> Result<(), JSONRPCErrorError> {
if !stream_stdin {
return Err(invalid_request(
"stdin streaming is not enabled for this command/exec".to_string(),
"stdin streaming is not enabled for this command/exec",
));
}
if !delta.is_empty() {
@@ -643,7 +643,7 @@ async fn handle_process_write(
.writer_sender()
.send(delta)
.await
.map_err(|_| invalid_request("stdin is already closed".to_string()))?;
.map_err(|_| invalid_request("stdin is already closed"))?;
}
if close_stdin {
session.close_stdin();
@@ -665,7 +665,7 @@ pub(crate) fn terminal_size_from_protocol(
) -> Result<TerminalSize, JSONRPCErrorError> {
if size.rows == 0 || size.cols == 0 {
return Err(invalid_params(
"command/exec size rows and cols must be greater than 0".to_string(),
"command/exec size rows and cols must be greater than 0",
));
}
Ok(TerminalSize {
@@ -681,38 +681,13 @@ fn command_no_longer_running_error(process_id: &InternalProcessId) -> JSONRPCErr
))
}
fn invalid_request(message: String) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message,
data: None,
}
}
fn invalid_params(message: String) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INVALID_PARAMS_ERROR_CODE,
message,
data: None,
}
}
fn internal_error(message: String) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message,
data: None,
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::models::PermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
#[cfg(not(target_os = "windows"))]
@@ -729,12 +704,10 @@ mod tests {
use crate::outgoing_message::OutgoingMessage;
fn windows_sandbox_exec_request() -> ExecRequest {
let sandbox_policy = SandboxPolicy::ReadOnly {
network_access: false,
};
let cwd = AbsolutePathBuf::current_dir().expect("current dir");
ExecRequest::new(
vec!["cmd".to_string()],
AbsolutePathBuf::current_dir().expect("current dir"),
cwd,
HashMap::new(),
/*network*/ None,
ExecExpiration::DefaultTimeout,
@@ -742,9 +715,7 @@ mod tests {
SandboxType::WindowsRestrictedToken,
WindowsSandboxLevel::Disabled,
/*windows_sandbox_private_desktop*/ false,
sandbox_policy.clone(),
FileSystemSandboxPolicy::from(&sandbox_policy),
NetworkSandboxPolicy::from(&sandbox_policy),
PermissionProfile::read_only(),
/*arg0*/ None,
)
}
@@ -834,9 +805,7 @@ mod tests {
connection_id: ConnectionId(8),
request_id: codex_app_server_protocol::RequestId::Integer(100),
};
let sandbox_policy = SandboxPolicy::ReadOnly {
network_access: false,
};
let cwd = AbsolutePathBuf::current_dir().expect("current dir");
manager
.start(StartCommandExecParams {
@@ -845,7 +814,7 @@ mod tests {
process_id: Some("proc-100".to_string()),
exec_request: ExecRequest::new(
vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()],
AbsolutePathBuf::current_dir().expect("current dir"),
cwd.clone(),
HashMap::new(),
/*network*/ None,
ExecExpiration::Cancellation(CancellationToken::new()),
@@ -853,9 +822,7 @@ mod tests {
SandboxType::None,
WindowsSandboxLevel::Disabled,
/*windows_sandbox_private_desktop*/ false,
sandbox_policy.clone(),
FileSystemSandboxPolicy::from(&sandbox_policy),
NetworkSandboxPolicy::from(&sandbox_policy),
PermissionProfile::read_only(),
/*arg0*/ None,
),
started_network_proxy: None,
+42 -69
View File
@@ -1,7 +1,8 @@
use crate::config_manager::ConfigManager;
use crate::config_manager_service::ConfigManagerError;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use async_trait::async_trait;
use codex_analytics::AnalyticsEventsClient;
use codex_app_server_protocol::ConfigBatchWriteParams;
@@ -22,15 +23,15 @@ use codex_app_server_protocol::NetworkDomainPermission;
use codex_app_server_protocol::NetworkRequirements;
use codex_app_server_protocol::NetworkUnixSocketPermission;
use codex_app_server_protocol::SandboxMode;
use codex_config::ConfigRequirementsToml;
use codex_config::HookEventsToml;
use codex_config::HookHandlerConfig as CoreHookHandlerConfig;
use codex_config::ManagedHooksRequirementsToml;
use codex_config::MatcherGroup as CoreMatcherGroup;
use codex_config::ResidencyRequirement as CoreResidencyRequirement;
use codex_config::SandboxModeRequirement as CoreSandboxModeRequirement;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::config_loader::ConfigRequirementsToml;
use codex_core::config_loader::HookEventsToml;
use codex_core::config_loader::HookHandlerConfig as CoreHookHandlerConfig;
use codex_core::config_loader::ManagedHooksRequirementsToml;
use codex_core::config_loader::MatcherGroup as CoreMatcherGroup;
use codex_core::config_loader::ResidencyRequirement as CoreResidencyRequirement;
use codex_core::config_loader::SandboxModeRequirement as CoreSandboxModeRequirement;
use codex_core::plugins::PluginId;
use codex_core_plugins::loader::installed_plugin_telemetry_metadata;
use codex_core_plugins::toggles::collect_plugin_enabled_candidates;
@@ -99,10 +100,10 @@ impl ConfigApi {
self.config_manager
.load_latest_config(fallback_cwd)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to resolve feature override precedence: {err}"),
data: None,
.map_err(|err| {
internal_error(format!(
"failed to resolve feature override precedence: {err}"
))
})
}
@@ -197,14 +198,10 @@ impl ConfigApi {
continue;
}
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!(
"unsupported feature enablement `{key}`: currently supported features are {}",
SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ")
),
data: None,
});
return Err(invalid_request(format!(
"unsupported feature enablement `{key}`: currently supported features are {}",
SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ")
)));
}
let message = if let Some(feature) = feature_for_key(key) {
@@ -215,11 +212,7 @@ impl ConfigApi {
} else {
format!("invalid feature enablement `{key}`")
};
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message,
data: None,
});
return Err(invalid_request(message));
}
if enablement.is_empty() {
@@ -232,11 +225,7 @@ impl ConfigApi {
.iter()
.map(|(name, enabled)| (name.clone(), *enabled)),
)
.map_err(|_| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: "failed to update feature enablement".to_string(),
data: None,
})?;
.map_err(|_| internal_error("failed to update feature enablement"))?;
self.load_latest_config(/*fallback_cwd*/ None).await?;
self.user_config_reloader.reload_user_config().await;
@@ -388,20 +377,20 @@ fn map_residency_requirement_to_api(
}
fn map_network_requirements_to_api(
network: codex_core::config_loader::NetworkRequirementsToml,
network: codex_config::NetworkRequirementsToml,
) -> NetworkRequirements {
let allowed_domains = network
.domains
.as_ref()
.and_then(codex_core::config_loader::NetworkDomainPermissionsToml::allowed_domains);
.and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains);
let denied_domains = network
.domains
.as_ref()
.and_then(codex_core::config_loader::NetworkDomainPermissionsToml::denied_domains);
.and_then(codex_config::NetworkDomainPermissionsToml::denied_domains);
let allow_unix_sockets = network
.unix_sockets
.as_ref()
.map(codex_core::config_loader::NetworkUnixSocketPermissionsToml::allow_unix_sockets)
.map(codex_config::NetworkUnixSocketPermissionsToml::allow_unix_sockets)
.filter(|entries| !entries.is_empty());
NetworkRequirements {
@@ -438,28 +427,20 @@ fn map_network_requirements_to_api(
}
fn map_network_domain_permission_to_api(
permission: codex_core::config_loader::NetworkDomainPermissionToml,
permission: codex_config::NetworkDomainPermissionToml,
) -> NetworkDomainPermission {
match permission {
codex_core::config_loader::NetworkDomainPermissionToml::Allow => {
NetworkDomainPermission::Allow
}
codex_core::config_loader::NetworkDomainPermissionToml::Deny => {
NetworkDomainPermission::Deny
}
codex_config::NetworkDomainPermissionToml::Allow => NetworkDomainPermission::Allow,
codex_config::NetworkDomainPermissionToml::Deny => NetworkDomainPermission::Deny,
}
}
fn map_network_unix_socket_permission_to_api(
permission: codex_core::config_loader::NetworkUnixSocketPermissionToml,
permission: codex_config::NetworkUnixSocketPermissionToml,
) -> NetworkUnixSocketPermission {
match permission {
codex_core::config_loader::NetworkUnixSocketPermissionToml::Allow => {
NetworkUnixSocketPermission::Allow
}
codex_core::config_loader::NetworkUnixSocketPermissionToml::None => {
NetworkUnixSocketPermission::None
}
codex_config::NetworkUnixSocketPermissionToml::Allow => NetworkUnixSocketPermission::Allow,
codex_config::NetworkUnixSocketPermissionToml::None => NetworkUnixSocketPermission::None,
}
}
@@ -468,11 +449,7 @@ fn map_error(err: ConfigManagerError) -> JSONRPCErrorError {
return config_write_error(code, err.to_string());
}
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: err.to_string(),
data: None,
}
internal_error(err.to_string())
}
fn config_write_error(code: ConfigWriteErrorCode, message: impl Into<String>) -> JSONRPCErrorError {
@@ -491,13 +468,13 @@ mod tests {
use crate::config_manager::apply_runtime_feature_enablement;
use codex_analytics::AnalyticsEventsClient;
use codex_arg0::Arg0DispatchPaths;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::LoaderOverrides;
use codex_core::config_loader::NetworkDomainPermissionToml as CoreNetworkDomainPermissionToml;
use codex_core::config_loader::NetworkDomainPermissionsToml as CoreNetworkDomainPermissionsToml;
use codex_core::config_loader::NetworkRequirementsToml as CoreNetworkRequirementsToml;
use codex_core::config_loader::NetworkUnixSocketPermissionToml as CoreNetworkUnixSocketPermissionToml;
use codex_core::config_loader::NetworkUnixSocketPermissionsToml as CoreNetworkUnixSocketPermissionsToml;
use codex_config::CloudRequirementsLoader;
use codex_config::LoaderOverrides;
use codex_config::NetworkDomainPermissionToml as CoreNetworkDomainPermissionToml;
use codex_config::NetworkDomainPermissionsToml as CoreNetworkDomainPermissionsToml;
use codex_config::NetworkRequirementsToml as CoreNetworkRequirementsToml;
use codex_config::NetworkUnixSocketPermissionToml as CoreNetworkUnixSocketPermissionToml;
use codex_config::NetworkUnixSocketPermissionsToml as CoreNetworkUnixSocketPermissionsToml;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_login::CodexAuth;
@@ -539,11 +516,9 @@ mod tests {
CoreSandboxModeRequirement::ExternalSandbox,
]),
remote_sandbox_config: None,
allowed_web_search_modes: Some(vec![
codex_core::config_loader::WebSearchModeRequirement::Cached,
]),
allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]),
guardian_policy_config: None,
feature_requirements: Some(codex_core::config_loader::FeatureRequirementsToml {
feature_requirements: Some(codex_config::FeatureRequirementsToml {
entries: std::collections::BTreeMap::from([
("apps".to_string(), false),
("personality".to_string(), true),
@@ -809,11 +784,9 @@ mod tests {
)])
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(ConfigRequirementsToml {
feature_requirements: Some(
codex_core::config_loader::FeatureRequirementsToml {
entries: BTreeMap::from([("apps".to_string(), false)]),
},
),
feature_requirements: Some(codex_config::FeatureRequirementsToml {
entries: BTreeMap::from([("apps".to_string(), false)]),
}),
..Default::default()
}))
}))
+5 -33
View File
@@ -1,12 +1,12 @@
use codex_arg0::Arg0DispatchPaths;
use codex_cloud_requirements::cloud_requirements_loader;
use codex_config::CloudRequirementsLoader;
use codex_config::ConfigLayerStack;
use codex_config::LoaderOverrides;
use codex_config::ThreadConfigLoader;
use codex_config::loader::load_config_layers_state;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::ConfigLayerStack;
use codex_core::config_loader::LoaderOverrides;
use codex_core::config_loader::load_config_layers_state;
use codex_exec_server::LOCAL_FS;
use codex_features::feature_for_key;
use codex_login::AuthManager;
@@ -33,7 +33,6 @@ pub(crate) struct ConfigManager {
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
arg0_paths: Arg0DispatchPaths,
thread_config_loader: Arc<RwLock<Arc<dyn ThreadConfigLoader>>>,
host_name: Option<String>,
}
impl ConfigManager {
@@ -44,27 +43,6 @@ impl ConfigManager {
cloud_requirements: CloudRequirementsLoader,
arg0_paths: Arg0DispatchPaths,
thread_config_loader: Arc<dyn ThreadConfigLoader>,
) -> Self {
Self::new_with_host_name(
codex_home,
cli_overrides,
loader_overrides,
cloud_requirements,
arg0_paths,
thread_config_loader,
codex_config::host_name(),
)
}
#[allow(clippy::too_many_arguments)]
fn new_with_host_name(
codex_home: PathBuf,
cli_overrides: Vec<(String, TomlValue)>,
loader_overrides: LoaderOverrides,
cloud_requirements: CloudRequirementsLoader,
arg0_paths: Arg0DispatchPaths,
thread_config_loader: Arc<dyn ThreadConfigLoader>,
host_name: Option<String>,
) -> Self {
Self {
codex_home,
@@ -74,7 +52,6 @@ impl ConfigManager {
cloud_requirements: Arc::new(RwLock::new(cloud_requirements)),
arg0_paths,
thread_config_loader: Arc::new(RwLock::new(thread_config_loader)),
host_name,
}
}
@@ -229,7 +206,6 @@ impl ConfigManager {
.fallback_cwd(fallback_cwd)
.cloud_requirements(self.current_cloud_requirements())
.thread_config_loader(self.current_thread_config_loader())
.host_name(self.host_name.clone())
.build()
.await?;
self.apply_runtime_feature_enablement(&mut config);
@@ -257,7 +233,6 @@ impl ConfigManager {
self.loader_overrides.clone(),
self.current_cloud_requirements(),
thread_config_loader.as_ref(),
self.host_name.as_deref(),
)
.await
}
@@ -285,16 +260,14 @@ impl ConfigManager {
cli_overrides: Vec<(String, TomlValue)>,
loader_overrides: LoaderOverrides,
cloud_requirements: CloudRequirementsLoader,
host_name: Option<String>,
) -> Self {
Self::new_with_host_name(
Self::new(
codex_home,
cli_overrides,
loader_overrides,
cloud_requirements,
Arg0DispatchPaths::default(),
Arc::new(codex_config::NoopThreadConfigLoader),
host_name,
)
}
@@ -305,7 +278,6 @@ impl ConfigManager {
Vec::new(),
LoaderOverrides::without_managed_config_for_tests(),
CloudRequirementsLoader::default(),
/*host_name*/ None,
)
}
}
@@ -12,16 +12,16 @@ use codex_app_server_protocol::MergeStrategy;
use codex_app_server_protocol::OverriddenMetadata;
use codex_app_server_protocol::WriteStatus;
use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use codex_config::ConfigRequirementsToml;
use codex_config::config_toml::ConfigToml;
use codex_config::merge_toml_values;
use codex_core::config::deserialize_config_toml_with_base;
use codex_core::config::edit::ConfigEdit;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config::validate_feature_requirements_for_config_toml;
use codex_core::config_loader::ConfigLayerEntry;
use codex_core::config_loader::ConfigLayerStack;
use codex_core::config_loader::ConfigLayerStackOrdering;
use codex_core::config_loader::ConfigRequirementsToml;
use codex_core::config_loader::merge_toml_values;
use codex_core::path_utils;
use codex_core::path_utils::SymlinkWritePaths;
use codex_core::path_utils::resolve_symlink_write_paths;
@@ -4,9 +4,9 @@ use codex_app_server_protocol::AppConfig;
use codex_app_server_protocol::AppToolApproval;
use codex_app_server_protocol::AppsConfig;
use codex_app_server_protocol::AskForApproval;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::FeatureRequirementsToml;
use codex_core::config_loader::LoaderOverrides;
use codex_config::CloudRequirementsLoader;
use codex_config::FeatureRequirementsToml;
use codex_config::LoaderOverrides;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
@@ -226,7 +226,6 @@ async fn read_includes_origins_and_layers() {
vec![],
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
CloudRequirementsLoader::default(),
/*host_name*/ None,
);
let response = service
@@ -305,7 +304,6 @@ writable_roots = ["~/code"]
vec![],
loader_overrides,
CloudRequirementsLoader::default(),
/*host_name*/ None,
);
let response = service
@@ -346,7 +344,6 @@ async fn write_value_reports_override() {
vec![],
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
CloudRequirementsLoader::default(),
/*host_name*/ None,
);
let result = service
@@ -446,7 +443,6 @@ async fn invalid_user_value_rejected_even_if_overridden_by_managed() {
vec![],
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
CloudRequirementsLoader::default(),
/*host_name*/ None,
);
let error = service
@@ -514,7 +510,6 @@ async fn write_value_rejects_feature_requirement_conflict() {
..Default::default()
}))
}),
/*host_name*/ None,
);
let error = service
@@ -561,7 +556,6 @@ async fn write_value_rejects_profile_feature_requirement_conflict() {
..Default::default()
}))
}),
/*host_name*/ None,
);
let error = service
@@ -612,7 +606,6 @@ async fn read_reports_managed_overrides_user_and_session_flags() {
cli_overrides,
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
CloudRequirementsLoader::default(),
/*host_name*/ None,
);
let response = service
@@ -666,7 +659,6 @@ async fn write_value_reports_managed_override() {
vec![],
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
CloudRequirementsLoader::default(),
/*host_name*/ None,
);
let result = service
+7 -10
View File
@@ -1,5 +1,5 @@
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use async_trait::async_trait;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
@@ -302,16 +302,13 @@ fn protection_class_from_store(
}
fn map_device_key_error(error: DeviceKeyError) -> JSONRPCErrorError {
let code = match error {
match &error {
DeviceKeyError::DegradedProtectionNotAllowed { .. }
| DeviceKeyError::HardwareBackedKeysUnavailable
| DeviceKeyError::KeyNotFound
| DeviceKeyError::InvalidPayload(_) => INVALID_REQUEST_ERROR_CODE,
DeviceKeyError::Platform(_) | DeviceKeyError::Crypto(_) => INTERNAL_ERROR_CODE,
};
JSONRPCErrorError {
code,
message: error.to_string(),
data: None,
| DeviceKeyError::InvalidPayload(_) => invalid_request(error.to_string()),
DeviceKeyError::Platform(_) | DeviceKeyError::Crypto(_) => {
internal_error(error.to_string())
}
}
}
+22
View File
@@ -1,5 +1,27 @@
use codex_app_server_protocol::JSONRPCErrorError;
pub(crate) const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
pub const INVALID_PARAMS_ERROR_CODE: i64 = -32602;
pub(crate) const INTERNAL_ERROR_CODE: i64 = -32603;
pub(crate) const OVERLOADED_ERROR_CODE: i64 = -32001;
pub const INPUT_TOO_LARGE_ERROR_CODE: &str = "input_too_large";
pub(crate) fn invalid_request(message: impl Into<String>) -> JSONRPCErrorError {
error(INVALID_REQUEST_ERROR_CODE, message)
}
pub(crate) fn invalid_params(message: impl Into<String>) -> JSONRPCErrorError {
error(INVALID_PARAMS_ERROR_CODE, message)
}
pub(crate) fn internal_error(message: impl Into<String>) -> JSONRPCErrorError {
error(INTERNAL_ERROR_CODE, message)
}
fn error(code: i64, message: impl Into<String>) -> JSONRPCErrorError {
JSONRPCErrorError {
code,
message: message.into(),
data: None,
}
}
@@ -3,7 +3,7 @@ use crate::config::external_agent_config::ExternalAgentConfigMigrationItem as Co
use crate::config::external_agent_config::ExternalAgentConfigMigrationItemType as CoreMigrationItemType;
use crate::config::external_agent_config::ExternalAgentConfigService;
use crate::config::external_agent_config::PendingPluginImport;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::internal_error;
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
use codex_app_server_protocol::ExternalAgentConfigDetectResponse;
use codex_app_server_protocol::ExternalAgentConfigImportParams;
@@ -12,7 +12,6 @@ use codex_app_server_protocol::ExternalAgentConfigMigrationItemType;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::MigrationDetails;
use codex_app_server_protocol::PluginsMigration;
use std::io;
use std::path::PathBuf;
#[derive(Clone)]
@@ -38,7 +37,7 @@ impl ExternalAgentConfigApi {
cwds: params.cwds,
})
.await
.map_err(map_io_error)?;
.map_err(|err| internal_error(err.to_string()))?;
Ok(ExternalAgentConfigDetectResponse {
items: items
@@ -125,7 +124,7 @@ impl ExternalAgentConfigApi {
.collect(),
)
.await
.map_err(map_io_error)
.map_err(|err| internal_error(err.to_string()))
}
pub(crate) async fn complete_pending_plugin_import(
@@ -139,14 +138,6 @@ impl ExternalAgentConfigApi {
)
.await
.map(|_| ())
.map_err(map_io_error)
}
}
fn map_io_error(err: io::Error) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: err.to_string(),
data: None,
.map_err(|err| internal_error(err.to_string()))
}
}
+3 -15
View File
@@ -1,5 +1,5 @@
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use codex_app_server_protocol::FsCopyParams;
@@ -158,22 +158,10 @@ impl FsApi {
}
}
pub(crate) fn invalid_request(message: impl Into<String>) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: message.into(),
data: None,
}
}
pub(crate) fn map_fs_error(err: io::Error) -> JSONRPCErrorError {
if err.kind() == io::ErrorKind::InvalidInput {
invalid_request(err.to_string())
} else {
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: err.to_string(),
data: None,
}
internal_error(err.to_string())
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
use crate::fs_api::invalid_request;
use crate::error_code::invalid_request;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::OutgoingMessageSender;
use codex_app_server_protocol::FsChangedNotification;
+2 -2
View File
@@ -77,10 +77,10 @@ use codex_app_server_protocol::Result;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudRequirementsLoader;
use codex_config::LoaderOverrides;
use codex_config::ThreadConfigLoader;
use codex_core::config::Config;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::LoaderOverrides;
use codex_exec_server::EnvironmentManager;
use codex_feedback::CodexFeedback;
use codex_login::AuthManager;
+5 -5
View File
@@ -1,12 +1,12 @@
#![deny(clippy::print_stdout, clippy::print_stderr)]
use codex_arg0::Arg0DispatchPaths;
use codex_config::ConfigLayerStackOrdering;
use codex_config::LoaderOverrides;
use codex_config::NoopThreadConfigLoader;
use codex_config::RemoteThreadConfigLoader;
use codex_config::ThreadConfigLoader;
use codex_core::config::Config;
use codex_core::config_loader::ConfigLayerStackOrdering;
use codex_core::config_loader::LoaderOverrides;
use codex_exec_server::EnvironmentManagerArgs;
use codex_features::Feature;
use codex_login::AuthManager;
@@ -42,11 +42,11 @@ use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::TextPosition as AppTextPosition;
use codex_app_server_protocol::TextRange as AppTextRange;
use codex_config::ConfigLoadError;
use codex_config::TextRange as CoreTextRange;
use codex_core::ExecPolicyError;
use codex_core::check_execpolicy_for_warnings;
use codex_core::config::find_codex_home;
use codex_core::config_loader::ConfigLoadError;
use codex_core::config_loader::TextRange as CoreTextRange;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::ExecServerRuntimePaths;
use codex_feedback::CodexFeedback;
@@ -518,7 +518,7 @@ pub async fn run_main_with_transport_options(
});
}
if let Some(warning) =
codex_core::config::system_bwrap_warning(config.permissions.sandbox_policy.get())
codex_core::config::system_bwrap_warning(config.permissions.permission_profile.get())
{
config_warnings.push(ConfigWarningNotification {
summary: warning,
+1 -1
View File
@@ -6,7 +6,7 @@ use codex_app_server::PluginStartupTasks;
use codex_app_server::run_main_with_transport_options;
use codex_arg0::Arg0DispatchPaths;
use codex_arg0::arg0_dispatch_or_else;
use codex_core::config_loader::LoaderOverrides;
use codex_config::LoaderOverrides;
use codex_protocol::protocol::SessionSource;
use codex_utils_cli::CliConfigOverrides;
use std::path::PathBuf;
+188 -414
View File
@@ -10,7 +10,7 @@ use crate::codex_message_processor::CodexMessageProcessorArgs;
use crate::config_api::ConfigApi;
use crate::config_manager::ConfigManager;
use crate::device_key_api::DeviceKeyApi;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::error_code::invalid_request;
use crate::external_agent_config_api::ExternalAgentConfigApi;
use crate::fs_api::FsApi;
use crate::fs_watch::FsWatchManager;
@@ -34,7 +34,6 @@ use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientNotification;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::DeviceKeyCreateParams;
@@ -42,20 +41,10 @@ use codex_app_server_protocol::DeviceKeyPublicParams;
use codex_app_server_protocol::DeviceKeySignParams;
use codex_app_server_protocol::ExperimentalApi;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification;
use codex_app_server_protocol::ExternalAgentConfigImportParams;
use codex_app_server_protocol::ExternalAgentConfigImportResponse;
use codex_app_server_protocol::ExternalAgentConfigMigrationItemType;
use codex_app_server_protocol::FsCopyParams;
use codex_app_server_protocol::FsCreateDirectoryParams;
use codex_app_server_protocol::FsGetMetadataParams;
use codex_app_server_protocol::FsReadDirectoryParams;
use codex_app_server_protocol::FsReadFileParams;
use codex_app_server_protocol::FsRemoveParams;
use codex_app_server_protocol::FsUnwatchParams;
use codex_app_server_protocol::FsWatchParams;
use codex_app_server_protocol::FsWriteFileParams;
use codex_app_server_protocol::InitializeResponse;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCErrorError;
@@ -390,43 +379,28 @@ impl MessageProcessor {
Arc::clone(&self.outgoing),
request_context.clone(),
async {
let request_json = match serde_json::to_value(&request) {
Ok(request_json) => request_json,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("Invalid request: {err}"),
data: None,
};
self.outgoing.send_error(request_id.clone(), error).await;
return;
}
};
let codex_request = match serde_json::from_value::<ClientRequest>(request_json) {
Ok(codex_request) => codex_request,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("Invalid request: {err}"),
data: None,
};
self.outgoing.send_error(request_id.clone(), error).await;
return;
}
};
// Websocket callers finalize outbound readiness in lib.rs after mirroring
// session state into outbound state and sending initialize notifications to
// this specific connection. Passing `None` avoids marking the connection
// ready too early from inside the shared request handler.
self.handle_client_request(
request_id.clone(),
codex_request,
Arc::clone(&session),
/*outbound_initialized*/ None,
request_context.clone(),
)
let result = async {
let request_json = serde_json::to_value(&request)
.map_err(|err| invalid_request(format!("Invalid request: {err}")))?;
let codex_request = serde_json::from_value::<ClientRequest>(request_json)
.map_err(|err| invalid_request(format!("Invalid request: {err}")))?;
// Websocket callers finalize outbound readiness in lib.rs after mirroring
// session state into outbound state and sending initialize notifications to
// this specific connection. Passing `None` avoids marking the connection
// ready too early from inside the shared request handler.
self.handle_client_request(
request_id.clone(),
codex_request,
Arc::clone(&session),
/*outbound_initialized*/ None,
request_context.clone(),
)
.await
}
.await;
if let Err(error) = result {
self.outgoing.send_error(request_id.clone(), error).await;
}
},
)
.await;
@@ -463,14 +437,18 @@ impl MessageProcessor {
// In-process clients do not have the websocket transport loop that performs
// post-initialize bookkeeping, so they still finalize outbound readiness in
// the shared request handler.
self.handle_client_request(
request_id.clone(),
request,
Arc::clone(&session),
Some(outbound_initialized),
request_context.clone(),
)
.await;
let result = self
.handle_client_request(
request_id.clone(),
request,
Arc::clone(&session),
Some(outbound_initialized),
request_context.clone(),
)
.await;
if let Err(error) = result {
self.outgoing.send_error(request_id.clone(), error).await;
}
},
)
.await;
@@ -598,7 +576,7 @@ impl MessageProcessor {
// lib.rs can deliver connection-scoped initialize notifications first.
outbound_initialized: Option<&AtomicBool>,
request_context: RequestContext,
) {
) -> Result<(), JSONRPCErrorError> {
let connection_id = connection_request_id.connection_id;
if let ClientRequest::Initialize { request_id, params } = codex_request {
// Handle Initialize internally so CodexMessageProcessor does not have to concern
@@ -608,13 +586,7 @@ impl MessageProcessor {
request_id,
};
if session.initialized() {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "Already initialized".to_string(),
data: None,
};
self.outgoing.send_error(connection_request_id, error).await;
return;
return Err(invalid_request("Already initialized"));
}
// TODO(maxj): Revisit capability scoping for `experimental_api_enabled`.
@@ -642,17 +614,9 @@ impl MessageProcessor {
// Validate before committing; set_default_originator validates while
// mutating process-global metadata.
if HeaderValue::from_str(&name).is_err() {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!(
"Invalid clientInfo.name: '{name}'. Must be a valid HTTP header value."
),
data: None,
};
self.outgoing
.send_error(connection_request_id.clone(), error)
.await;
return;
return Err(invalid_request(format!(
"Invalid clientInfo.name: '{name}'. Must be a valid HTTP header value."
)));
}
let originator = name.clone();
let user_agent_suffix = format!("{name}; {version}");
@@ -668,13 +632,7 @@ impl MessageProcessor {
})
.is_err()
{
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "Already initialized".to_string(),
data: None,
};
self.outgoing.send_error(connection_request_id, error).await;
return;
return Err(invalid_request("Already initialized"));
}
// Only the request that wins session initialization may mutate
@@ -729,7 +687,7 @@ impl MessageProcessor {
.connection_initialized(connection_id)
.await;
}
return;
return Ok(());
}
self.dispatch_initialized_client_request(
@@ -738,7 +696,7 @@ impl MessageProcessor {
session,
request_context,
)
.await;
.await
}
async fn dispatch_initialized_client_request(
@@ -747,27 +705,15 @@ impl MessageProcessor {
codex_request: ClientRequest,
session: Arc<ConnectionSessionState>,
request_context: RequestContext,
) {
) -> Result<(), JSONRPCErrorError> {
if !session.initialized() {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "Not initialized".to_string(),
data: None,
};
self.outgoing.send_error(connection_request_id, error).await;
return;
return Err(invalid_request("Not initialized"));
}
if let Some(reason) = codex_request.experimental_reason()
&& !session.experimental_api_enabled()
{
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: experimental_required_message(reason),
data: None,
};
self.outgoing.send_error(connection_request_id, error).await;
return;
return Err(invalid_request(experimental_required_message(reason)));
}
let connection_id = connection_request_id.connection_id;
if self.config.features.enabled(Feature::GeneralAnalytics)
@@ -793,7 +739,7 @@ impl MessageProcessor {
client_version,
device_key_requests_allowed,
)
.await;
.await
}
async fn handle_initialized_client_request(
@@ -804,66 +750,48 @@ impl MessageProcessor {
app_server_client_name: Option<String>,
client_version: Option<String>,
device_key_requests_allowed: bool,
) {
) -> Result<(), JSONRPCErrorError> {
let connection_id = connection_request_id.connection_id;
let request_id_for_connection = |request_id| ConnectionRequestId {
connection_id,
request_id,
};
match codex_request {
ClientRequest::ConfigRead { request_id, params } => {
self.handle_config_read(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
self.outgoing
.send_result(
request_id_for_connection(request_id),
self.config_api.read(params).await,
)
.await;
}
ClientRequest::ExternalAgentConfigDetect { request_id, params } => {
self.handle_external_agent_config_detect(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
self.outgoing
.send_result(
request_id_for_connection(request_id),
self.external_agent_config_api.detect(params).await,
)
.await;
}
ClientRequest::ExternalAgentConfigImport { request_id, params } => {
self.handle_external_agent_config_import(
ConnectionRequestId {
connection_id,
request_id,
},
request_id_for_connection(request_id),
params,
)
.await;
.await?;
}
ClientRequest::ConfigValueWrite { request_id, params } => {
self.handle_config_value_write(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
self.handle_config_value_write(request_id_for_connection(request_id), params)
.await;
}
ClientRequest::ConfigBatchWrite { request_id, params } => {
self.handle_config_batch_write(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
self.handle_config_batch_write(request_id_for_connection(request_id), params)
.await;
}
ClientRequest::ExperimentalFeatureEnablementSet { request_id, params } => {
self.handle_experimental_feature_enablement_set(
ConnectionRequestId {
connection_id,
request_id,
},
request_id_for_connection(request_id),
params,
)
.await;
@@ -872,133 +800,105 @@ impl MessageProcessor {
request_id,
params: _,
} => {
self.handle_config_requirements_read(ConnectionRequestId {
connection_id,
request_id,
})
.await;
self.outgoing
.send_result(
request_id_for_connection(request_id),
self.config_api.config_requirements_read().await,
)
.await;
}
ClientRequest::DeviceKeyCreate { request_id, params } => {
self.handle_device_key_create(
ConnectionRequestId {
connection_id,
request_id,
},
request_id_for_connection(request_id),
params,
device_key_requests_allowed,
);
}
ClientRequest::DeviceKeyPublic { request_id, params } => {
self.handle_device_key_public(
ConnectionRequestId {
connection_id,
request_id,
},
request_id_for_connection(request_id),
params,
device_key_requests_allowed,
);
}
ClientRequest::DeviceKeySign { request_id, params } => {
self.handle_device_key_sign(
ConnectionRequestId {
connection_id,
request_id,
},
request_id_for_connection(request_id),
params,
device_key_requests_allowed,
);
}
ClientRequest::FsReadFile { request_id, params } => {
self.handle_fs_read_file(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
self.outgoing
.send_result(
request_id_for_connection(request_id),
self.fs_api.read_file(params).await,
)
.await;
}
ClientRequest::FsWriteFile { request_id, params } => {
self.handle_fs_write_file(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
self.outgoing
.send_result(
request_id_for_connection(request_id),
self.fs_api.write_file(params).await,
)
.await;
}
ClientRequest::FsCreateDirectory { request_id, params } => {
self.handle_fs_create_directory(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
self.outgoing
.send_result(
request_id_for_connection(request_id),
self.fs_api.create_directory(params).await,
)
.await;
}
ClientRequest::FsGetMetadata { request_id, params } => {
self.handle_fs_get_metadata(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
self.outgoing
.send_result(
request_id_for_connection(request_id),
self.fs_api.get_metadata(params).await,
)
.await;
}
ClientRequest::FsReadDirectory { request_id, params } => {
self.handle_fs_read_directory(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
self.outgoing
.send_result(
request_id_for_connection(request_id),
self.fs_api.read_directory(params).await,
)
.await;
}
ClientRequest::FsRemove { request_id, params } => {
self.handle_fs_remove(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
self.outgoing
.send_result(
request_id_for_connection(request_id),
self.fs_api.remove(params).await,
)
.await;
}
ClientRequest::FsCopy { request_id, params } => {
self.handle_fs_copy(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
self.outgoing
.send_result(
request_id_for_connection(request_id),
self.fs_api.copy(params).await,
)
.await;
}
ClientRequest::FsWatch { request_id, params } => {
self.handle_fs_watch(
ConnectionRequestId {
connection_id,
request_id,
},
connection_id,
params,
)
.await;
self.outgoing
.send_result(
request_id_for_connection(request_id),
self.fs_watch_manager.watch(connection_id, params).await,
)
.await;
}
ClientRequest::FsUnwatch { request_id, params } => {
self.handle_fs_unwatch(
ConnectionRequestId {
connection_id,
request_id,
},
connection_id,
params,
)
.await;
self.outgoing
.send_result(
request_id_for_connection(request_id),
self.fs_watch_manager.unwatch(connection_id, params).await,
)
.await;
}
other => {
// Box the delegated future so this wrapper's async state machine does not
@@ -1016,13 +916,7 @@ impl MessageProcessor {
.await;
}
}
}
async fn handle_config_read(&self, request_id: ConnectionRequestId, params: ConfigReadParams) {
match self.config_api.read(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
Ok(())
}
async fn handle_config_value_write(
@@ -1167,13 +1061,6 @@ impl MessageProcessor {
}
}
async fn handle_config_requirements_read(&self, request_id: ConnectionRequestId) {
match self.config_api.config_requirements_read().await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
fn handle_device_key_create(
&self,
request_id: ConnectionRequestId,
@@ -1230,193 +1117,80 @@ impl MessageProcessor {
let device_key_api = self.device_key_api.clone();
let outgoing = Arc::clone(&self.outgoing);
tokio::spawn(async move {
if !device_key_requests_allowed {
outgoing
.send_error(
request_id,
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("{method} is not available over remote transports"),
data: None,
},
)
.await;
return;
}
match run_request(device_key_api).await {
Ok(response) => outgoing.send_response(request_id, response).await,
Err(error) => outgoing.send_error(request_id, error).await,
let result = async {
if !device_key_requests_allowed {
return Err(invalid_request(format!(
"{method} is not available over remote transports"
)));
}
run_request(device_key_api).await
}
.await;
outgoing.send_result(request_id, result).await;
});
}
async fn handle_external_agent_config_detect(
&self,
request_id: ConnectionRequestId,
params: ExternalAgentConfigDetectParams,
) {
match self.external_agent_config_api.detect(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_external_agent_config_import(
&self,
request_id: ConnectionRequestId,
params: ExternalAgentConfigImportParams,
) {
) -> Result<(), JSONRPCErrorError> {
let has_plugin_imports = params.migration_items.iter().any(|item| {
matches!(
item.item_type,
ExternalAgentConfigMigrationItemType::Plugins
)
});
match self.external_agent_config_api.import(params).await {
Ok(pending_plugin_imports) => {
if has_plugin_imports {
self.handle_config_mutation().await;
}
self.outgoing
.send_response(request_id, ExternalAgentConfigImportResponse {})
.await;
if !has_plugin_imports {
return;
}
let pending_plugin_imports = self.external_agent_config_api.import(params).await?;
if has_plugin_imports {
self.handle_config_mutation().await;
}
self.outgoing
.send_response(request_id, ExternalAgentConfigImportResponse {})
.await;
if pending_plugin_imports.is_empty() {
self.outgoing
.send_server_notification(
ServerNotification::ExternalAgentConfigImportCompleted(
ExternalAgentConfigImportCompletedNotification {},
),
)
.await;
return;
}
if !has_plugin_imports {
return Ok(());
}
let external_agent_config_api = self.external_agent_config_api.clone();
let outgoing = Arc::clone(&self.outgoing);
let thread_manager = Arc::clone(&self.thread_manager);
tokio::spawn(async move {
for pending_plugin_import in pending_plugin_imports {
match external_agent_config_api
.complete_pending_plugin_import(pending_plugin_import)
.await
{
Ok(()) => {}
Err(error) => {
tracing::warn!(
error = %error.message,
"external agent config plugin import failed"
);
}
}
if pending_plugin_imports.is_empty() {
self.outgoing
.send_server_notification(ServerNotification::ExternalAgentConfigImportCompleted(
ExternalAgentConfigImportCompletedNotification {},
))
.await;
return Ok(());
}
let external_agent_config_api = self.external_agent_config_api.clone();
let outgoing = Arc::clone(&self.outgoing);
let thread_manager = Arc::clone(&self.thread_manager);
tokio::spawn(async move {
for pending_plugin_import in pending_plugin_imports {
match external_agent_config_api
.complete_pending_plugin_import(pending_plugin_import)
.await
{
Ok(()) => {}
Err(error) => {
tracing::warn!(
error = %error.message,
"external agent config plugin import failed"
);
}
thread_manager.plugins_manager().clear_cache();
thread_manager.skills_manager().clear_cache();
outgoing
.send_server_notification(
ServerNotification::ExternalAgentConfigImportCompleted(
ExternalAgentConfigImportCompletedNotification {},
),
)
.await;
});
}
}
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
thread_manager.plugins_manager().clear_cache();
thread_manager.skills_manager().clear_cache();
outgoing
.send_server_notification(ServerNotification::ExternalAgentConfigImportCompleted(
ExternalAgentConfigImportCompletedNotification {},
))
.await;
});
async fn handle_fs_read_file(&self, request_id: ConnectionRequestId, params: FsReadFileParams) {
match self.fs_api.read_file(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_fs_write_file(
&self,
request_id: ConnectionRequestId,
params: FsWriteFileParams,
) {
match self.fs_api.write_file(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_fs_create_directory(
&self,
request_id: ConnectionRequestId,
params: FsCreateDirectoryParams,
) {
match self.fs_api.create_directory(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_fs_get_metadata(
&self,
request_id: ConnectionRequestId,
params: FsGetMetadataParams,
) {
match self.fs_api.get_metadata(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_fs_read_directory(
&self,
request_id: ConnectionRequestId,
params: FsReadDirectoryParams,
) {
match self.fs_api.read_directory(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_fs_remove(&self, request_id: ConnectionRequestId, params: FsRemoveParams) {
match self.fs_api.remove(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_fs_copy(&self, request_id: ConnectionRequestId, params: FsCopyParams) {
match self.fs_api.copy(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_fs_watch(
&self,
request_id: ConnectionRequestId,
connection_id: ConnectionId,
params: FsWatchParams,
) {
match self.fs_watch_manager.watch(connection_id, params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_fs_unwatch(
&self,
request_id: ConnectionRequestId,
connection_id: ConnectionId,
params: FsUnwatchParams,
) {
match self.fs_watch_manager.unwatch(connection_id, params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
Ok(())
}
}
@@ -27,10 +27,10 @@ use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudRequirementsLoader;
use codex_config::LoaderOverrides;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::LoaderOverrides;
use codex_exec_server::EnvironmentManager;
use codex_feedback::CodexFeedback;
use codex_login::AuthManager;
+19 -8
View File
@@ -22,6 +22,7 @@ use tracing::Span;
use tracing::warn;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::internal_error;
use crate::server_request_error::TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON;
#[cfg(test)]
@@ -196,7 +197,7 @@ impl ThreadScopedOutgoingMessageSender {
pub(crate) async fn send_error(
&self,
request_id: ConnectionRequestId,
error: JSONRPCErrorError,
error: impl Into<JSONRPCErrorError>,
) {
self.outgoing.send_error(request_id, error).await;
}
@@ -493,11 +494,7 @@ impl OutgoingMessageSender {
self.send_error_inner(
request_context,
request_id,
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to serialize response: {err}"),
data: None,
},
internal_error(format!("failed to serialize response: {err}")),
)
.await;
}
@@ -571,13 +568,27 @@ impl OutgoingMessageSender {
pub(crate) async fn send_error(
&self,
request_id: ConnectionRequestId,
error: JSONRPCErrorError,
error: impl Into<JSONRPCErrorError>,
) {
let request_context = self.take_request_context(&request_id).await;
self.send_error_inner(request_context, request_id, error)
self.send_error_inner(request_context, request_id, error.into())
.await;
}
pub(crate) async fn send_result<T, E>(
&self,
request_id: ConnectionRequestId,
result: std::result::Result<T, E>,
) where
T: Serialize,
E: Into<JSONRPCErrorError>,
{
match result {
Ok(response) => self.send_response(request_id, response).await,
Err(error) => self.send_error(request_id, error).await,
}
}
async fn send_error_inner(
&self,
request_context: Option<RequestContext>,
@@ -134,7 +134,6 @@ async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<()
content: vec![ContentItem::OutputText {
text: "REMOTE_COMPACT_SUMMARY".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Compaction {
@@ -16,9 +16,9 @@ use codex_app_server_protocol::ExperimentalFeatureStage;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_config::LoaderOverrides;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::config::ConfigBuilder;
use codex_core::config_loader::LoaderOverrides;
use codex_features::FEATURES;
use codex_features::Stage;
use pretty_assertions::assert_eq;
@@ -17,6 +17,9 @@ use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
#[cfg(windows)]
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(25);
#[cfg(not(windows))]
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const INSTALLED_MARKETPLACES_DIR: &str = ".tmp/marketplaces";
@@ -63,13 +66,14 @@ fn commit_marketplace_marker(root: &Path, marker: &str) -> Result<String> {
fn configured_git_marketplace_update<'a>(
source: &'a str,
last_revision: Option<&'a str>,
ref_name: Option<&'a str>,
) -> MarketplaceConfigUpdate<'a> {
MarketplaceConfigUpdate {
last_updated: "2026-04-13T00:00:00Z",
last_revision,
source_type: "git",
source,
ref_name: None,
ref_name,
sparse_paths: &[],
}
}
@@ -90,12 +94,13 @@ fn record_git_marketplace(
marketplace_name: &str,
source: &Path,
last_revision: &str,
ref_name: Option<&str>,
) -> Result<()> {
let source = source.display().to_string();
record_user_marketplace(
codex_home,
marketplace_name,
&configured_git_marketplace_update(&source, Some(last_revision)),
&configured_git_marketplace_update(&source, Some(last_revision), ref_name),
)?;
Ok(())
}
@@ -153,12 +158,14 @@ async fn marketplace_upgrade_all_configured_git_marketplaces() -> Result<()> {
"debug",
debug_source.path(),
&debug_old_revision,
Some(&debug_new_revision),
)?;
record_git_marketplace(
codex_home.path(),
"tools",
tools_source.path(),
&tools_old_revision,
Some(&tools_new_revision),
)?;
disable_plugin_startup_tasks(codex_home.path())?;
@@ -205,12 +212,14 @@ async fn marketplace_upgrade_named_marketplace_only() -> Result<()> {
"debug",
debug_source.path(),
&debug_old_revision,
/*ref_name*/ None,
)?;
record_git_marketplace(
codex_home.path(),
"tools",
tools_source.path(),
&tools_old_revision,
/*ref_name*/ None,
)?;
disable_plugin_startup_tasks(codex_home.path())?;
@@ -246,7 +255,13 @@ async fn marketplace_upgrade_returns_empty_roots_when_already_up_to_date() -> Re
let source = TempDir::new()?;
let old_revision = init_marketplace_repo(source.path(), "debug", "debug old")?;
commit_marketplace_marker(source.path(), "debug new")?;
record_git_marketplace(codex_home.path(), "debug", source.path(), &old_revision)?;
record_git_marketplace(
codex_home.path(),
"debug",
source.path(),
&old_revision,
/*ref_name*/ None,
)?;
disable_plugin_startup_tasks(codex_home.path())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
@@ -20,10 +20,10 @@ use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudRequirementsLoader;
use codex_config::LoaderOverrides;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::config::ConfigBuilder;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::LoaderOverrides;
use codex_exec_server::EnvironmentManager;
use codex_feedback::CodexFeedback;
use codex_protocol::protocol::SessionSource;
@@ -33,10 +33,10 @@ use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudRequirementsLoader;
use codex_config::LoaderOverrides;
use codex_config::NoopThreadConfigLoader;
use codex_core::config::ConfigBuilder;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::LoaderOverrides;
use codex_exec_server::EnvironmentManager;
use codex_feedback::CodexFeedback;
use codex_protocol::protocol::SessionSource;
@@ -59,7 +59,6 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history() -> Resu
content: vec![ContentItem::OutputText {
text: injected_text.to_string(),
}],
end_turn: None,
phase: None,
};
@@ -195,7 +194,6 @@ async fn thread_inject_items_adds_raw_response_items_after_a_turn() -> Result<()
content: vec![ContentItem::OutputText {
text: "Injected after first turn".to_string(),
}],
end_turn: None,
phase: None,
};
let injected_value = serde_json::to_value(&injected_item)?;
@@ -1660,7 +1660,6 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> {
content: vec![ContentItem::InputText {
text: "history override".to_string(),
}],
end_turn: None,
phase: None,
}]),
..Default::default()
@@ -2616,7 +2615,6 @@ async fn thread_resume_supports_history_and_overrides() -> Result<()> {
content: vec![ContentItem::InputText {
text: history_text.to_string(),
}],
end_turn: None,
phase: None,
}];
@@ -20,9 +20,9 @@ use codex_app_server_protocol::ThreadStartedNotification;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadStatusChangedNotification;
use codex_app_server_protocol::TurnEnvironmentParams;
use codex_config::loader::project_trust_key;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::config::set_project_trust_level;
use codex_core::config_loader::project_trust_key;
use codex_exec_server::LOCAL_FS;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
@@ -749,13 +749,17 @@ async fn turn_start_rejects_combined_oversized_text_input() -> Result<()> {
#[tokio::test]
async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() -> Result<()> {
let codex_home = TempDir::new()?;
let unsupported_write_root = TempDir::new()?;
let disallowed_write_root = TempDir::new()?;
create_config_toml(
codex_home.path(),
"http://localhost/unused",
"never",
&BTreeMap::from([(Feature::Personality, true)]),
)?;
std::fs::write(
codex_home.path().join("managed_config.toml"),
"sandbox_mode = \"read-only\"\n",
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
@@ -772,7 +776,7 @@ async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() ->
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let unsupported_write_root = AbsolutePathBuf::from_absolute_path(unsupported_write_root.path())
let disallowed_write_root = AbsolutePathBuf::from_absolute_path(disallowed_write_root.path())
.expect("tempdir path should be absolute");
let turn_req = mcp
@@ -787,7 +791,7 @@ async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() ->
file_system: PermissionProfileFileSystemPermissions::Restricted {
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: unsupported_write_root,
path: disallowed_write_root,
},
access: FileSystemAccessMode::Write,
}],
@@ -806,9 +810,9 @@ async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() ->
assert_eq!(err.error.code, INVALID_REQUEST_ERROR_CODE);
assert!(err.error.message.contains("invalid turn context override"));
assert!(
err.error
.message
.contains("filesystem writes outside the workspace root")
err.error.message.contains("allowed set [ReadOnly]"),
"unexpected error message: {}",
err.error.message
);
let turn_started = tokio::time::timeout(
std::time::Duration::from_millis(250),
+24 -17
View File
@@ -171,7 +171,7 @@ async fn run_command_under_sandbox(
let network_proxy = match config.permissions.network.as_ref() {
Some(spec) => Some(
spec.start_proxy(
config.permissions.sandbox_policy.get(),
config.permissions.permission_profile.get(),
/*policy_decider*/ None,
/*blocked_request_observer*/ None,
managed_network_requirements_enabled,
@@ -189,22 +189,23 @@ async fn run_command_under_sandbox(
let mut child = match sandbox_type {
#[cfg(target_os = "macos")]
SandboxType::Seatbelt => {
let file_system_sandbox_policy = config.permissions.file_system_sandbox_policy();
let network_sandbox_policy = config.permissions.network_sandbox_policy();
let args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams {
command,
file_system_sandbox_policy: &config.permissions.file_system_sandbox_policy,
network_sandbox_policy: config.permissions.network_sandbox_policy,
file_system_sandbox_policy: &file_system_sandbox_policy,
network_sandbox_policy,
sandbox_policy_cwd: sandbox_policy_cwd.as_path(),
enforce_managed_network: false,
network: network.as_ref(),
extra_allow_unix_sockets: allow_unix_sockets,
});
let network_policy = config.permissions.network_sandbox_policy;
spawn_debug_sandbox_child(
PathBuf::from("/usr/bin/sandbox-exec"),
args,
/*arg0*/ None,
cwd.to_path_buf(),
network_policy,
network_sandbox_policy,
env,
|env_map| {
env_map.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
@@ -221,23 +222,26 @@ async fn run_command_under_sandbox(
.codex_linux_sandbox_exe
.expect("codex-linux-sandbox executable not found");
let use_legacy_landlock = config.features.use_legacy_landlock();
let file_system_sandbox_policy = config.permissions.file_system_sandbox_policy();
let network_sandbox_policy = config.permissions.network_sandbox_policy();
let args = create_linux_sandbox_command_args_for_policies(
command,
cwd.as_path(),
config.permissions.sandbox_policy.get(),
&config.permissions.file_system_sandbox_policy,
config.permissions.network_sandbox_policy,
&config
.permissions
.legacy_sandbox_policy(sandbox_policy_cwd.as_path()),
&file_system_sandbox_policy,
network_sandbox_policy,
sandbox_policy_cwd.as_path(),
use_legacy_landlock,
/*allow_network_for_proxy*/ false,
);
let network_policy = config.permissions.network_sandbox_policy;
spawn_debug_sandbox_child(
codex_linux_sandbox_exe,
args,
Some("codex-linux-sandbox"),
cwd.to_path_buf(),
network_policy,
network_sandbox_policy,
env,
|env_map| {
if let Some(network) = network.as_ref() {
@@ -288,7 +292,10 @@ async fn run_command_under_windows_session(
use codex_windows_sandbox::spawn_windows_sandbox_session_elevated;
use codex_windows_sandbox::spawn_windows_sandbox_session_legacy;
let policy_str = match serde_json::to_string(config.permissions.sandbox_policy.get()) {
let sandbox_policy = config
.permissions
.legacy_sandbox_policy(sandbox_policy_cwd.as_path());
let policy_str = match serde_json::to_string(&sandbox_policy) {
Ok(policy_str) => policy_str,
Err(err) => {
eprintln!("windows sandbox failed to serialize policy: {err}");
@@ -715,17 +722,17 @@ mod tests {
assert!(config_uses_permission_profiles(&config));
assert!(
profile_config.permissions.file_system_sandbox_policy
!= legacy_config.permissions.file_system_sandbox_policy,
profile_config.permissions.file_system_sandbox_policy()
!= legacy_config.permissions.file_system_sandbox_policy(),
"test fixture should distinguish profile syntax from legacy sandbox_mode"
);
assert_eq!(
config.permissions.file_system_sandbox_policy,
profile_config.permissions.file_system_sandbox_policy,
config.permissions.file_system_sandbox_policy(),
profile_config.permissions.file_system_sandbox_policy(),
);
assert_ne!(
config.permissions.file_system_sandbox_policy,
legacy_config.permissions.file_system_sandbox_policy,
config.permissions.file_system_sandbox_policy(),
legacy_config.permissions.file_system_sandbox_policy(),
);
Ok(())
+2 -2
View File
@@ -830,7 +830,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
codex_app_server::run_main_with_transport(
arg0_paths.clone(),
root_config_overrides,
codex_core::config_loader::LoaderOverrides::default(),
codex_config::LoaderOverrides::default(),
analytics_default_enabled,
transport,
codex_protocol::protocol::SessionSource::VSCode,
@@ -1551,7 +1551,7 @@ async fn run_interactive_tui(
codex_tui::run_main(
interactive,
arg0_paths,
codex_core::config_loader::LoaderOverrides::default(),
codex_config::LoaderOverrides::default(),
normalized_remote,
remote_auth_token,
)
+6 -6
View File
@@ -15,11 +15,11 @@ use chrono::DateTime;
use chrono::Duration as ChronoDuration;
use chrono::Utc;
use codex_backend_client::Client as BackendClient;
use codex_config::CloudRequirementsLoadError;
use codex_config::CloudRequirementsLoadErrorCode;
use codex_config::CloudRequirementsLoader;
use codex_config::ConfigRequirementsToml;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::config_loader::CloudRequirementsLoadError;
use codex_core::config_loader::CloudRequirementsLoadErrorCode;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::ConfigRequirementsToml;
use codex_core::util::backoff;
use codex_login::AuthManager;
use codex_login::CodexAuth;
@@ -1314,10 +1314,10 @@ enabled = false
assert_eq!(
result,
Some(ConfigRequirementsToml {
apps: Some(codex_core::config_loader::AppsRequirementsToml {
apps: Some(codex_config::AppsRequirementsToml {
apps: BTreeMap::from([(
"connector_5f3c8c41a1e54ad7a76272c89e2554fa".to_string(),
codex_core::config_loader::AppRequirementToml {
codex_config::AppRequirementToml {
enabled: Some(false),
},
)]),
-1
View File
@@ -423,7 +423,6 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> {
id: Some("msg_1".into()),
role: "user".into(),
content: vec![ContentItem::InputText { text: "hi".into() }],
end_turn: None,
phase: None,
}],
tools: Vec::new(),
+258
View File
@@ -0,0 +1,258 @@
//! Codex Apps support for the built-in apps MCP server.
//!
//! This module owns the pieces that are unique to ChatGPT-hosted app
//! connectors: cache scoping by authenticated user, disk cache reads/writes,
//! connector allow-list filtering, and the normalization that turns app
//! connector/tool metadata into model-visible MCP callable names.
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Instant;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::runtime::emit_duration;
use crate::tools::MCP_TOOLS_CACHE_WRITE_DURATION_METRIC;
use crate::tools::ToolInfo;
use codex_login::CodexAuth;
use codex_utils_plugins::mcp_connector::is_connector_id_allowed;
use codex_utils_plugins::mcp_connector::sanitize_name;
use serde::Deserialize;
use serde::Serialize;
use sha1::Digest;
use sha1::Sha1;
pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 2;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodexAppsToolsCacheKey {
pub(crate) account_id: Option<String>,
pub(crate) chatgpt_user_id: Option<String>,
pub(crate) is_workspace_account: bool,
}
pub fn codex_apps_tools_cache_key(auth: Option<&CodexAuth>) -> CodexAppsToolsCacheKey {
CodexAppsToolsCacheKey {
account_id: auth.and_then(CodexAuth::get_account_id),
chatgpt_user_id: auth.and_then(CodexAuth::get_chatgpt_user_id),
is_workspace_account: auth.is_some_and(CodexAuth::is_workspace_account),
}
}
pub fn filter_non_codex_apps_mcp_tools_only(
mcp_tools: &HashMap<String, ToolInfo>,
) -> HashMap<String, ToolInfo> {
mcp_tools
.iter()
.filter(|(_, tool)| tool.server_name != CODEX_APPS_MCP_SERVER_NAME)
.map(|(name, tool)| (name.clone(), tool.clone()))
.collect()
}
#[derive(Clone)]
pub(crate) struct CodexAppsToolsCacheContext {
pub(crate) codex_home: PathBuf,
pub(crate) user_key: CodexAppsToolsCacheKey,
}
impl CodexAppsToolsCacheContext {
pub(crate) fn cache_path(&self) -> PathBuf {
let user_key_json = serde_json::to_string(&self.user_key).unwrap_or_default();
let user_key_hash = sha1_hex(&user_key_json);
self.codex_home
.join(CODEX_APPS_TOOLS_CACHE_DIR)
.join(format!("{user_key_hash}.json"))
}
}
pub(crate) enum CachedCodexAppsToolsLoad {
Hit(Vec<ToolInfo>),
Missing,
Invalid,
}
pub(crate) fn normalize_codex_apps_tool_title(
server_name: &str,
connector_name: Option<&str>,
value: &str,
) -> String {
if server_name != CODEX_APPS_MCP_SERVER_NAME {
return value.to_string();
}
let Some(connector_name) = connector_name
.map(str::trim)
.filter(|name| !name.is_empty())
else {
return value.to_string();
};
let prefix = format!("{connector_name}_");
if let Some(stripped) = value.strip_prefix(&prefix)
&& !stripped.is_empty()
{
return stripped.to_string();
}
value.to_string()
}
pub(crate) fn normalize_codex_apps_callable_name(
server_name: &str,
tool_name: &str,
connector_id: Option<&str>,
connector_name: Option<&str>,
) -> String {
if server_name != CODEX_APPS_MCP_SERVER_NAME {
return tool_name.to_string();
}
let tool_name = sanitize_name(tool_name);
if let Some(connector_name) = connector_name
.map(str::trim)
.map(sanitize_name)
.filter(|name| !name.is_empty())
&& let Some(stripped) = tool_name.strip_prefix(&connector_name)
&& !stripped.is_empty()
{
return stripped.to_string();
}
if let Some(connector_id) = connector_id
.map(str::trim)
.map(sanitize_name)
.filter(|name| !name.is_empty())
&& let Some(stripped) = tool_name.strip_prefix(&connector_id)
&& !stripped.is_empty()
{
return stripped.to_string();
}
tool_name
}
pub(crate) fn normalize_codex_apps_callable_namespace(
server_name: &str,
connector_name: Option<&str>,
) -> String {
if server_name == CODEX_APPS_MCP_SERVER_NAME
&& let Some(connector_name) = connector_name
{
format!("mcp__{}__{}", server_name, sanitize_name(connector_name))
} else {
format!("mcp__{server_name}__")
}
}
pub(crate) fn write_cached_codex_apps_tools_if_needed(
server_name: &str,
cache_context: Option<&CodexAppsToolsCacheContext>,
tools: &[ToolInfo],
) {
if server_name != CODEX_APPS_MCP_SERVER_NAME {
return;
}
if let Some(cache_context) = cache_context {
let cache_write_start = Instant::now();
write_cached_codex_apps_tools(cache_context, tools);
emit_duration(
MCP_TOOLS_CACHE_WRITE_DURATION_METRIC,
cache_write_start.elapsed(),
&[],
);
}
}
pub(crate) fn load_startup_cached_codex_apps_tools_snapshot(
server_name: &str,
cache_context: Option<&CodexAppsToolsCacheContext>,
) -> Option<Vec<ToolInfo>> {
if server_name != CODEX_APPS_MCP_SERVER_NAME {
return None;
}
let cache_context = cache_context?;
match load_cached_codex_apps_tools(cache_context) {
CachedCodexAppsToolsLoad::Hit(tools) => Some(tools),
CachedCodexAppsToolsLoad::Missing | CachedCodexAppsToolsLoad::Invalid => None,
}
}
#[cfg(test)]
pub(crate) fn read_cached_codex_apps_tools(
cache_context: &CodexAppsToolsCacheContext,
) -> Option<Vec<ToolInfo>> {
match load_cached_codex_apps_tools(cache_context) {
CachedCodexAppsToolsLoad::Hit(tools) => Some(tools),
CachedCodexAppsToolsLoad::Missing | CachedCodexAppsToolsLoad::Invalid => None,
}
}
pub(crate) fn load_cached_codex_apps_tools(
cache_context: &CodexAppsToolsCacheContext,
) -> CachedCodexAppsToolsLoad {
let cache_path = cache_context.cache_path();
let bytes = match std::fs::read(cache_path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return CachedCodexAppsToolsLoad::Missing;
}
Err(_) => return CachedCodexAppsToolsLoad::Invalid,
};
let cache: CodexAppsToolsDiskCache = match serde_json::from_slice(&bytes) {
Ok(cache) => cache,
Err(_) => return CachedCodexAppsToolsLoad::Invalid,
};
if cache.schema_version != CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION {
return CachedCodexAppsToolsLoad::Invalid;
}
CachedCodexAppsToolsLoad::Hit(filter_disallowed_codex_apps_tools(cache.tools))
}
pub(crate) fn write_cached_codex_apps_tools(
cache_context: &CodexAppsToolsCacheContext,
tools: &[ToolInfo],
) {
let cache_path = cache_context.cache_path();
if let Some(parent) = cache_path.parent()
&& std::fs::create_dir_all(parent).is_err()
{
return;
}
let tools = filter_disallowed_codex_apps_tools(tools.to_vec());
let Ok(bytes) = serde_json::to_vec_pretty(&CodexAppsToolsDiskCache {
schema_version: CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION,
tools,
}) else {
return;
};
let _ = std::fs::write(cache_path, bytes);
}
pub(crate) fn filter_disallowed_codex_apps_tools(tools: Vec<ToolInfo>) -> Vec<ToolInfo> {
tools
.into_iter()
.filter(|tool| {
tool.connector_id
.as_deref()
.is_none_or(is_connector_id_allowed)
})
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CodexAppsToolsDiskCache {
schema_version: u8,
tools: Vec<ToolInfo>,
}
const CODEX_APPS_TOOLS_CACHE_DIR: &str = "cache/codex_apps_tools";
fn sha1_hex(s: &str) -> String {
let mut hasher = Sha1::new();
hasher.update(s.as_bytes());
let sha1 = hasher.finalize();
format!("{sha1:x}")
}
@@ -0,0 +1,700 @@
//! Aggregates MCP server connections for Codex.
//!
//! [`McpConnectionManager`] owns the set of running async RMCP clients keyed by
//! MCP server name. It coordinates startup status events, keeps server origin
//! metadata, aggregates tools/resources/templates across servers, routes tool
//! calls to the right client, and exposes the public manager API used by
//! `codex-core`.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use crate::McpAuthStatusEntry;
use crate::codex_apps::CodexAppsToolsCacheContext;
use crate::codex_apps::CodexAppsToolsCacheKey;
use crate::codex_apps::write_cached_codex_apps_tools_if_needed;
use crate::elicitation::ElicitationRequestManager;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp::ToolPluginProvenance;
use crate::rmcp_client::AsyncManagedClient;
use crate::rmcp_client::DEFAULT_STARTUP_TIMEOUT;
use crate::rmcp_client::MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC;
use crate::rmcp_client::MCP_TOOLS_LIST_DURATION_METRIC;
use crate::rmcp_client::ManagedClient;
use crate::rmcp_client::StartupOutcomeError;
use crate::rmcp_client::list_tools_for_client_uncached;
use crate::runtime::McpRuntimeEnvironment;
use crate::runtime::emit_duration;
use crate::tools::ToolInfo;
use crate::tools::filter_tools;
use crate::tools::qualify_tools;
use crate::tools::tool_with_model_visible_input_schema;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use async_channel::Sender;
use codex_config::Constrained;
use codex_config::McpServerConfig;
use codex_config::McpServerTransportConfig;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_login::CodexAuth;
use codex_protocol::ToolName;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::McpStartupCompleteEvent;
use codex_protocol::protocol::McpStartupFailure;
use codex_protocol::protocol::McpStartupStatus;
use codex_protocol::protocol::McpStartupUpdateEvent;
use codex_rmcp_client::ElicitationResponse;
use rmcp::model::ListResourceTemplatesResult;
use rmcp::model::ListResourcesResult;
use rmcp::model::PaginatedRequestParams;
use rmcp::model::ReadResourceRequestParams;
use rmcp::model::ReadResourceResult;
use rmcp::model::RequestId;
use rmcp::model::Resource;
use rmcp::model::ResourceTemplate;
use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
use tracing::instrument;
use tracing::warn;
use url::Url;
/// A thin wrapper around a set of running [`RmcpClient`] instances.
pub struct McpConnectionManager {
clients: HashMap<String, AsyncManagedClient>,
server_origins: HashMap<String, String>,
elicitation_requests: ElicitationRequestManager,
}
impl McpConnectionManager {
pub fn new_uninitialized(
approval_policy: &Constrained<AskForApproval>,
permission_profile: &Constrained<PermissionProfile>,
) -> Self {
Self {
clients: HashMap::new(),
server_origins: HashMap::new(),
elicitation_requests: ElicitationRequestManager::new(
approval_policy.value(),
permission_profile.get().clone(),
),
}
}
pub fn has_servers(&self) -> bool {
!self.clients.is_empty()
}
pub fn server_origin(&self, server_name: &str) -> Option<&str> {
self.server_origins.get(server_name).map(String::as_str)
}
pub fn set_approval_policy(&self, approval_policy: &Constrained<AskForApproval>) {
if let Ok(mut policy) = self.elicitation_requests.approval_policy.lock() {
*policy = approval_policy.value();
}
}
pub fn set_permission_profile(&self, permission_profile: PermissionProfile) {
if let Ok(mut profile) = self.elicitation_requests.permission_profile.lock() {
*profile = permission_profile;
}
}
#[allow(clippy::new_ret_no_self, clippy::too_many_arguments)]
pub async fn new(
mcp_servers: &HashMap<String, McpServerConfig>,
store_mode: OAuthCredentialsStoreMode,
auth_entries: HashMap<String, McpAuthStatusEntry>,
approval_policy: &Constrained<AskForApproval>,
submit_id: String,
tx_event: Sender<Event>,
initial_permission_profile: PermissionProfile,
runtime_environment: McpRuntimeEnvironment,
codex_home: PathBuf,
codex_apps_tools_cache_key: CodexAppsToolsCacheKey,
tool_plugin_provenance: ToolPluginProvenance,
auth: Option<&CodexAuth>,
) -> (Self, CancellationToken) {
let cancel_token = CancellationToken::new();
let mut clients = HashMap::new();
let mut server_origins = HashMap::new();
let mut join_set = JoinSet::new();
let elicitation_requests =
ElicitationRequestManager::new(approval_policy.value(), initial_permission_profile);
let tool_plugin_provenance = Arc::new(tool_plugin_provenance);
let startup_submit_id = submit_id.clone();
let codex_apps_auth_provider = auth
.filter(|auth| auth.uses_codex_backend())
.map(codex_model_provider::auth_provider_from_auth);
let mcp_servers = mcp_servers.clone();
for (server_name, cfg) in mcp_servers.into_iter().filter(|(_, cfg)| cfg.enabled) {
if let Some(origin) = transport_origin(&cfg.transport) {
server_origins.insert(server_name.clone(), origin);
}
let cancel_token = cancel_token.child_token();
let _ = emit_update(
startup_submit_id.as_str(),
&tx_event,
McpStartupUpdateEvent {
server: server_name.clone(),
status: McpStartupStatus::Starting,
},
)
.await;
let codex_apps_tools_cache_context = if server_name == CODEX_APPS_MCP_SERVER_NAME {
Some(CodexAppsToolsCacheContext {
codex_home: codex_home.clone(),
user_key: codex_apps_tools_cache_key.clone(),
})
} else {
None
};
let uses_env_bearer_token = match &cfg.transport {
McpServerTransportConfig::StreamableHttp {
bearer_token_env_var,
..
} => bearer_token_env_var.is_some(),
McpServerTransportConfig::Stdio { .. } => false,
};
let runtime_auth_provider =
if server_name == CODEX_APPS_MCP_SERVER_NAME && !uses_env_bearer_token {
codex_apps_auth_provider.clone()
} else {
None
};
let async_managed_client = AsyncManagedClient::new(
server_name.clone(),
cfg,
store_mode,
cancel_token.clone(),
tx_event.clone(),
elicitation_requests.clone(),
codex_apps_tools_cache_context,
Arc::clone(&tool_plugin_provenance),
runtime_environment.clone(),
runtime_auth_provider,
);
clients.insert(server_name.clone(), async_managed_client.clone());
let tx_event = tx_event.clone();
let submit_id = startup_submit_id.clone();
let auth_entry = auth_entries.get(&server_name).cloned();
join_set.spawn(async move {
let mut outcome = async_managed_client.client().await;
if cancel_token.is_cancelled() {
outcome = Err(StartupOutcomeError::Cancelled);
}
let status = match &outcome {
Ok(_) => McpStartupStatus::Ready,
Err(StartupOutcomeError::Cancelled) => McpStartupStatus::Cancelled,
Err(error) => {
let error_str = mcp_init_error_display(
server_name.as_str(),
auth_entry.as_ref(),
error,
);
McpStartupStatus::Failed { error: error_str }
}
};
let _ = emit_update(
submit_id.as_str(),
&tx_event,
McpStartupUpdateEvent {
server: server_name.clone(),
status,
},
)
.await;
(server_name, outcome)
});
}
let manager = Self {
clients,
server_origins,
elicitation_requests: elicitation_requests.clone(),
};
tokio::spawn(async move {
let outcomes = join_set.join_all().await;
let mut summary = McpStartupCompleteEvent::default();
for (server_name, outcome) in outcomes {
match outcome {
Ok(_) => summary.ready.push(server_name),
Err(StartupOutcomeError::Cancelled) => summary.cancelled.push(server_name),
Err(StartupOutcomeError::Failed { error }) => {
summary.failed.push(McpStartupFailure {
server: server_name,
error,
})
}
}
}
let _ = tx_event
.send(Event {
id: startup_submit_id,
msg: EventMsg::McpStartupComplete(summary),
})
.await;
});
(manager, cancel_token)
}
pub async fn resolve_elicitation(
&self,
server_name: String,
id: RequestId,
response: ElicitationResponse,
) -> Result<()> {
self.elicitation_requests
.resolve(server_name, id, response)
.await
}
pub async fn wait_for_server_ready(&self, server_name: &str, timeout: Duration) -> bool {
let Some(async_managed_client) = self.clients.get(server_name) else {
return false;
};
match tokio::time::timeout(timeout, async_managed_client.client()).await {
Ok(Ok(_)) => true,
Ok(Err(_)) | Err(_) => false,
}
}
pub async fn required_startup_failures(
&self,
required_servers: &[String],
) -> Vec<McpStartupFailure> {
let mut failures = Vec::new();
for server_name in required_servers {
let Some(async_managed_client) = self.clients.get(server_name).cloned() else {
failures.push(McpStartupFailure {
server: server_name.clone(),
error: format!("required MCP server `{server_name}` was not initialized"),
});
continue;
};
match async_managed_client.client().await {
Ok(_) => {}
Err(error) => failures.push(McpStartupFailure {
server: server_name.clone(),
error: startup_outcome_error_message(error),
}),
}
}
failures
}
/// Returns a single map that contains all tools. Each key is the
/// fully-qualified name for the tool.
#[instrument(level = "trace", skip_all)]
pub async fn list_all_tools(&self) -> HashMap<String, ToolInfo> {
let mut tools = Vec::new();
for managed_client in self.clients.values() {
let Some(server_tools) = managed_client.listed_tools().await else {
continue;
};
tools.extend(server_tools);
}
qualify_tools(tools)
}
/// Force-refresh codex apps tools by bypassing the in-process cache.
///
/// 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>> {
let managed_client = self
.clients
.get(CODEX_APPS_MCP_SERVER_NAME)
.ok_or_else(|| anyhow!("unknown MCP server '{CODEX_APPS_MCP_SERVER_NAME}'"))?
.client()
.await
.context("failed to get client")?;
let list_start = Instant::now();
let fetch_start = Instant::now();
let tools = list_tools_for_client_uncached(
CODEX_APPS_MCP_SERVER_NAME,
&managed_client.client,
managed_client.tool_timeout,
managed_client.server_instructions.as_deref(),
)
.await
.with_context(|| {
format!("failed to refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}'")
})?;
emit_duration(
MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC,
fetch_start.elapsed(),
&[],
);
write_cached_codex_apps_tools_if_needed(
CODEX_APPS_MCP_SERVER_NAME,
managed_client.codex_apps_tools_cache_context.as_ref(),
&tools,
);
emit_duration(
MCP_TOOLS_LIST_DURATION_METRIC,
list_start.elapsed(),
&[("cache", "miss")],
);
let tools = filter_tools(tools, &managed_client.tool_filter)
.into_iter()
.map(|mut tool| {
tool.tool = tool_with_model_visible_input_schema(&tool.tool);
tool
});
Ok(qualify_tools(tools))
}
/// Returns a single map that contains all resources. Each key is the
/// server name and the value is a vector of resources.
pub async fn list_all_resources(&self) -> HashMap<String, Vec<Resource>> {
let mut join_set = JoinSet::new();
let clients_snapshot = &self.clients;
for (server_name, async_managed_client) in clients_snapshot {
let server_name = server_name.clone();
let Ok(managed_client) = async_managed_client.client().await else {
continue;
};
let timeout = managed_client.tool_timeout;
let client = managed_client.client.clone();
join_set.spawn(async move {
let mut collected: Vec<Resource> = Vec::new();
let mut cursor: Option<String> = None;
loop {
let params = cursor.as_ref().map(|next| PaginatedRequestParams {
meta: None,
cursor: Some(next.clone()),
});
let response = match client.list_resources(params, timeout).await {
Ok(result) => result,
Err(err) => return (server_name, Err(err)),
};
collected.extend(response.resources);
match response.next_cursor {
Some(next) => {
if cursor.as_ref() == Some(&next) {
return (
server_name,
Err(anyhow!("resources/list returned duplicate cursor")),
);
}
cursor = Some(next);
}
None => return (server_name, Ok(collected)),
}
}
});
}
let mut aggregated: HashMap<String, Vec<Resource>> = HashMap::new();
while let Some(join_res) = join_set.join_next().await {
match join_res {
Ok((server_name, Ok(resources))) => {
aggregated.insert(server_name, resources);
}
Ok((server_name, Err(err))) => {
warn!("Failed to list resources for MCP server '{server_name}': {err:#}");
}
Err(err) => {
warn!("Task panic when listing resources for MCP server: {err:#}");
}
}
}
aggregated
}
/// Returns a single map that contains all resource templates. Each key is the
/// server name and the value is a vector of resource templates.
pub async fn list_all_resource_templates(&self) -> HashMap<String, Vec<ResourceTemplate>> {
let mut join_set = JoinSet::new();
let clients_snapshot = &self.clients;
for (server_name, async_managed_client) in clients_snapshot {
let server_name_cloned = server_name.clone();
let Ok(managed_client) = async_managed_client.client().await else {
continue;
};
let client = managed_client.client.clone();
let timeout = managed_client.tool_timeout;
join_set.spawn(async move {
let mut collected: Vec<ResourceTemplate> = Vec::new();
let mut cursor: Option<String> = None;
loop {
let params = cursor.as_ref().map(|next| PaginatedRequestParams {
meta: None,
cursor: Some(next.clone()),
});
let response = match client.list_resource_templates(params, timeout).await {
Ok(result) => result,
Err(err) => return (server_name_cloned, Err(err)),
};
collected.extend(response.resource_templates);
match response.next_cursor {
Some(next) => {
if cursor.as_ref() == Some(&next) {
return (
server_name_cloned,
Err(anyhow!(
"resources/templates/list returned duplicate cursor"
)),
);
}
cursor = Some(next);
}
None => return (server_name_cloned, Ok(collected)),
}
}
});
}
let mut aggregated: HashMap<String, Vec<ResourceTemplate>> = HashMap::new();
while let Some(join_res) = join_set.join_next().await {
match join_res {
Ok((server_name, Ok(templates))) => {
aggregated.insert(server_name, templates);
}
Ok((server_name, Err(err))) => {
warn!(
"Failed to list resource templates for MCP server '{server_name}': {err:#}"
);
}
Err(err) => {
warn!("Task panic when listing resource templates for MCP server: {err:#}");
}
}
}
aggregated
}
/// Invoke the tool indicated by the (server, tool) pair.
pub async fn call_tool(
&self,
server: &str,
tool: &str,
arguments: Option<serde_json::Value>,
meta: Option<serde_json::Value>,
) -> Result<CallToolResult> {
let client = self.client_by_name(server).await?;
if !client.tool_filter.allows(tool) {
return Err(anyhow!(
"tool '{tool}' is disabled for MCP server '{server}'"
));
}
let result: rmcp::model::CallToolResult = client
.client
.call_tool(tool.to_string(), arguments, meta, client.tool_timeout)
.await
.with_context(|| format!("tool call failed for `{server}/{tool}`"))?;
let content = result
.content
.into_iter()
.map(|content| {
serde_json::to_value(content)
.unwrap_or_else(|_| serde_json::Value::String("<content>".to_string()))
})
.collect();
Ok(CallToolResult {
content,
structured_content: result.structured_content,
is_error: result.is_error,
meta: result.meta.and_then(|meta| serde_json::to_value(meta).ok()),
})
}
pub async fn server_supports_sandbox_state_meta_capability(
&self,
server: &str,
) -> Result<bool> {
Ok(self
.client_by_name(server)
.await?
.server_supports_sandbox_state_meta_capability)
}
/// List resources from the specified server.
pub async fn list_resources(
&self,
server: &str,
params: Option<PaginatedRequestParams>,
) -> Result<ListResourcesResult> {
let managed = self.client_by_name(server).await?;
let timeout = managed.tool_timeout;
managed
.client
.list_resources(params, timeout)
.await
.with_context(|| format!("resources/list failed for `{server}`"))
}
/// List resource templates from the specified server.
pub async fn list_resource_templates(
&self,
server: &str,
params: Option<PaginatedRequestParams>,
) -> Result<ListResourceTemplatesResult> {
let managed = self.client_by_name(server).await?;
let client = managed.client.clone();
let timeout = managed.tool_timeout;
client
.list_resource_templates(params, timeout)
.await
.with_context(|| format!("resources/templates/list failed for `{server}`"))
}
/// Read a resource from the specified server.
pub async fn read_resource(
&self,
server: &str,
params: ReadResourceRequestParams,
) -> Result<ReadResourceResult> {
let managed = self.client_by_name(server).await?;
let client = managed.client.clone();
let timeout = managed.tool_timeout;
let uri = params.uri.clone();
client
.read_resource(params, timeout)
.await
.with_context(|| format!("resources/read failed for `{server}` ({uri})"))
}
pub async fn resolve_tool_info(&self, tool_name: &ToolName) -> Option<ToolInfo> {
let all_tools = self.list_all_tools().await;
all_tools
.into_values()
.find(|tool| tool.canonical_tool_name() == *tool_name)
}
async fn client_by_name(&self, name: &str) -> Result<ManagedClient> {
self.clients
.get(name)
.ok_or_else(|| anyhow!("unknown MCP server '{name}'"))?
.client()
.await
.context("failed to get client")
}
}
async fn emit_update(
submit_id: &str,
tx_event: &Sender<Event>,
update: McpStartupUpdateEvent,
) -> Result<(), async_channel::SendError<Event>> {
tx_event
.send(Event {
id: submit_id.to_string(),
msg: EventMsg::McpStartupUpdate(update),
})
.await
}
fn transport_origin(transport: &McpServerTransportConfig) -> Option<String> {
match transport {
McpServerTransportConfig::StreamableHttp { url, .. } => {
let parsed = Url::parse(url).ok()?;
Some(parsed.origin().ascii_serialization())
}
McpServerTransportConfig::Stdio { .. } => Some("stdio".to_string()),
}
}
fn mcp_init_error_display(
server_name: &str,
entry: Option<&McpAuthStatusEntry>,
err: &StartupOutcomeError,
) -> String {
if let Some(McpServerTransportConfig::StreamableHttp {
url,
bearer_token_env_var,
http_headers,
..
}) = &entry.map(|entry| &entry.config.transport)
&& url == "https://api.githubcopilot.com/mcp/"
&& bearer_token_env_var.is_none()
&& http_headers.as_ref().map(HashMap::is_empty).unwrap_or(true)
{
format!(
"GitHub MCP does not support OAuth. Log in by adding a personal access token (https://github.com/settings/personal-access-tokens) to your environment and config.toml:\n[mcp_servers.{server_name}]\nbearer_token_env_var = CODEX_GITHUB_PERSONAL_ACCESS_TOKEN"
)
} else if is_mcp_client_auth_required_error(err) {
format!(
"The {server_name} MCP server is not logged in. Run `codex mcp login {server_name}`."
)
} else if is_mcp_client_startup_timeout_error(err) {
let startup_timeout_secs = match entry {
Some(entry) => match entry.config.startup_timeout_sec {
Some(timeout) => timeout,
None => DEFAULT_STARTUP_TIMEOUT,
},
None => DEFAULT_STARTUP_TIMEOUT,
}
.as_secs();
format!(
"MCP client for `{server_name}` timed out after {startup_timeout_secs} seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.{server_name}]\nstartup_timeout_sec = XX"
)
} else {
format!("MCP client for `{server_name}` failed to start: {err:#}")
}
}
fn startup_outcome_error_message(error: StartupOutcomeError) -> String {
match error {
StartupOutcomeError::Cancelled => "MCP startup cancelled".to_string(),
StartupOutcomeError::Failed { error } => error,
}
}
fn is_mcp_client_auth_required_error(error: &StartupOutcomeError) -> bool {
match error {
StartupOutcomeError::Failed { error } => error.contains("Auth required"),
_ => false,
}
}
fn is_mcp_client_startup_timeout_error(error: &StartupOutcomeError) -> bool {
match error {
StartupOutcomeError::Failed { error } => {
error.contains("request timed out")
|| error.contains("timed out handshaking with MCP server")
}
_ => false,
}
}
#[cfg(test)]
#[path = "connection_manager_tests.rs"]
mod tests;
@@ -1,11 +1,36 @@
use super::*;
use crate::codex_apps::CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION;
use crate::codex_apps::CodexAppsToolsCacheContext;
use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot;
use crate::codex_apps::read_cached_codex_apps_tools;
use crate::codex_apps::write_cached_codex_apps_tools;
use crate::declared_openai_file_input_param_names;
use crate::elicitation::ElicitationRequestManager;
use crate::elicitation::elicitation_is_rejected_by_policy;
use crate::rmcp_client::AsyncManagedClient;
use crate::rmcp_client::ManagedClient;
use crate::rmcp_client::StartupOutcomeError;
use crate::rmcp_client::elicitation_capability_for_server;
use crate::tools::ToolFilter;
use crate::tools::ToolInfo;
use crate::tools::filter_tools;
use crate::tools::qualify_tools;
use crate::tools::tool_with_model_visible_input_schema;
use codex_config::Constrained;
use codex_protocol::ToolName;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::GranularApprovalConfig;
use codex_protocol::protocol::McpAuthStatus;
use futures::FutureExt;
use pretty_assertions::assert_eq;
use rmcp::model::CreateElicitationRequestParams;
use rmcp::model::ElicitationAction;
use rmcp::model::ElicitationCapability;
use rmcp::model::FormElicitationCapability;
use rmcp::model::JsonObject;
use rmcp::model::Meta;
use rmcp::model::NumberOrString;
use rmcp::model::Tool;
use std::collections::HashSet;
use std::sync::Arc;
use tempfile::tempdir;
@@ -179,9 +204,9 @@ fn elicitation_granular_policy_respects_never_and_config() {
}
#[tokio::test]
async fn full_access_auto_accepts_elicitation_with_empty_form_schema() {
async fn disabled_permissions_auto_accept_elicitation_with_empty_form_schema() {
let manager =
ElicitationRequestManager::new(AskForApproval::Never, SandboxPolicy::DangerFullAccess);
ElicitationRequestManager::new(AskForApproval::Never, PermissionProfile::Disabled);
let (tx_event, _rx_event) = async_channel::bounded(1);
let sender = manager.make_sender("server".to_string(), tx_event);
@@ -209,9 +234,9 @@ async fn full_access_auto_accepts_elicitation_with_empty_form_schema() {
}
#[tokio::test]
async fn full_access_does_not_auto_accept_elicitation_with_requested_fields() {
async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fields() {
let manager =
ElicitationRequestManager::new(AskForApproval::Never, SandboxPolicy::DangerFullAccess);
ElicitationRequestManager::new(AskForApproval::Never, PermissionProfile::Disabled);
let (tx_event, _rx_event) = async_channel::bounded(1);
let sender = manager.make_sender("server".to_string(), tx_event);
@@ -627,8 +652,9 @@ async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() {
.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);
let permission_profile = Constrained::allow_any(PermissionProfile::default());
let mut manager =
McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile);
manager.clients.insert(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
AsyncManagedClient {
@@ -654,8 +680,9 @@ async fn resolve_tool_info_accepts_canonical_namespaced_tool_names() {
.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);
let permission_profile = Constrained::allow_any(PermissionProfile::default());
let mut manager =
McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile);
manager.clients.insert(
"rmcp".to_string(),
AsyncManagedClient {
@@ -689,8 +716,9 @@ async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot(
.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);
let permission_profile = Constrained::allow_any(PermissionProfile::default());
let mut manager =
McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile);
manager.clients.insert(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
AsyncManagedClient {
@@ -712,8 +740,9 @@ async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty(
.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);
let permission_profile = Constrained::allow_any(PermissionProfile::default());
let mut manager =
McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile);
manager.clients.insert(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
AsyncManagedClient {
@@ -744,8 +773,9 @@ async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() {
.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);
let permission_profile = Constrained::allow_any(PermissionProfile::default());
let mut manager =
McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile);
let startup_complete = Arc::new(std::sync::atomic::AtomicBool::new(true));
manager.clients.insert(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
+190
View File
@@ -0,0 +1,190 @@
//! MCP elicitation request tracking and policy handling.
//!
//! RMCP clients call into this module when a server asks Codex to elicit data
//! from the user. It decides whether the request can be automatically accepted,
//! must be declined by policy, or should be surfaced as a Codex protocol event
//! and later resolved through the stored responder.
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use crate::mcp::mcp_permission_prompt_is_auto_approved;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use async_channel::Sender;
use codex_protocol::approvals::ElicitationRequest;
use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::mcp::RequestId as ProtocolRequestId;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_rmcp_client::ElicitationResponse;
use codex_rmcp_client::SendElicitation;
use futures::future::FutureExt;
use rmcp::model::CreateElicitationRequestParams;
use rmcp::model::ElicitationAction;
use rmcp::model::RequestId;
use tokio::sync::Mutex;
use tokio::sync::oneshot;
#[derive(Clone)]
pub(crate) struct ElicitationRequestManager {
requests: Arc<Mutex<ResponderMap>>,
pub(crate) approval_policy: Arc<StdMutex<AskForApproval>>,
pub(crate) permission_profile: Arc<StdMutex<PermissionProfile>>,
}
impl ElicitationRequestManager {
pub(crate) fn new(
approval_policy: AskForApproval,
permission_profile: PermissionProfile,
) -> Self {
Self {
requests: Arc::new(Mutex::new(HashMap::new())),
approval_policy: Arc::new(StdMutex::new(approval_policy)),
permission_profile: Arc::new(StdMutex::new(permission_profile)),
}
}
pub(crate) async fn resolve(
&self,
server_name: String,
id: RequestId,
response: ElicitationResponse,
) -> Result<()> {
self.requests
.lock()
.await
.remove(&(server_name, id))
.ok_or_else(|| anyhow!("elicitation request not found"))?
.send(response)
.map_err(|e| anyhow!("failed to send elicitation response: {e:?}"))
}
pub(crate) fn make_sender(
&self,
server_name: String,
tx_event: Sender<Event>,
) -> SendElicitation {
let elicitation_requests = self.requests.clone();
let approval_policy = self.approval_policy.clone();
let permission_profile = self.permission_profile.clone();
Box::new(move |id, elicitation| {
let elicitation_requests = elicitation_requests.clone();
let tx_event = tx_event.clone();
let server_name = server_name.clone();
let approval_policy = approval_policy.clone();
let permission_profile = permission_profile.clone();
async move {
let approval_policy = approval_policy
.lock()
.map(|policy| *policy)
.unwrap_or(AskForApproval::Never);
let permission_profile = permission_profile
.lock()
.map(|profile| profile.clone())
.unwrap_or_default();
if mcp_permission_prompt_is_auto_approved(approval_policy, &permission_profile)
&& can_auto_accept_elicitation(&elicitation)
{
return Ok(ElicitationResponse {
action: ElicitationAction::Accept,
content: Some(serde_json::json!({})),
meta: None,
});
}
if elicitation_is_rejected_by_policy(approval_policy) {
return Ok(ElicitationResponse {
action: ElicitationAction::Decline,
content: None,
meta: None,
});
}
let request = match elicitation {
CreateElicitationRequestParams::FormElicitationParams {
meta,
message,
requested_schema,
} => ElicitationRequest::Form {
meta: meta
.map(serde_json::to_value)
.transpose()
.context("failed to serialize MCP elicitation metadata")?,
message,
requested_schema: serde_json::to_value(requested_schema)
.context("failed to serialize MCP elicitation schema")?,
},
CreateElicitationRequestParams::UrlElicitationParams {
meta,
message,
url,
elicitation_id,
} => ElicitationRequest::Url {
meta: meta
.map(serde_json::to_value)
.transpose()
.context("failed to serialize MCP elicitation metadata")?,
message,
url,
elicitation_id,
},
};
let (tx, rx) = oneshot::channel();
{
let mut lock = elicitation_requests.lock().await;
lock.insert((server_name.clone(), id.clone()), tx);
}
let _ = tx_event
.send(Event {
id: "mcp_elicitation_request".to_string(),
msg: EventMsg::ElicitationRequest(ElicitationRequestEvent {
turn_id: None,
server_name,
id: match id.clone() {
rmcp::model::NumberOrString::String(value) => {
ProtocolRequestId::String(value.to_string())
}
rmcp::model::NumberOrString::Number(value) => {
ProtocolRequestId::Integer(value)
}
},
request,
}),
})
.await;
rx.await
.context("elicitation request channel closed unexpectedly")
}
.boxed()
})
}
}
pub(crate) fn elicitation_is_rejected_by_policy(approval_policy: AskForApproval) -> bool {
match approval_policy {
AskForApproval::Never => true,
AskForApproval::OnFailure => false,
AskForApproval::OnRequest => false,
AskForApproval::UnlessTrusted => false,
AskForApproval::Granular(granular_config) => !granular_config.allows_mcp_elicitations(),
}
}
type ResponderMap = HashMap<(String, RequestId), oneshot::Sender<ElicitationResponse>>;
fn can_auto_accept_elicitation(elicitation: &CreateElicitationRequestParams) -> bool {
match elicitation {
CreateElicitationRequestParams::FormElicitationParams {
requested_schema, ..
} => {
// Auto-accept confirm/approval elicitations without schema requirements.
requested_schema.properties.is_empty()
}
CreateElicitationRequestParams::UrlElicitationParams { .. } => false,
}
}
+15 -11
View File
@@ -1,15 +1,15 @@
pub use mcp_connection_manager::MCP_SANDBOX_STATE_META_CAPABILITY;
pub use mcp_connection_manager::McpConnectionManager;
pub use mcp_connection_manager::McpRuntimeEnvironment;
pub use mcp_connection_manager::SandboxState;
pub use mcp_connection_manager::ToolInfo;
pub use connection_manager::McpConnectionManager;
pub use rmcp_client::MCP_SANDBOX_STATE_META_CAPABILITY;
pub use runtime::McpRuntimeEnvironment;
pub use runtime::SandboxState;
pub use tools::ToolInfo;
pub use mcp::CODEX_APPS_MCP_SERVER_NAME;
pub use mcp::McpConfig;
pub use mcp::ToolPluginProvenance;
pub use mcp_connection_manager::CodexAppsToolsCacheKey;
pub use mcp_connection_manager::codex_apps_tools_cache_key;
pub use codex_apps::CodexAppsToolsCacheKey;
pub use codex_apps::codex_apps_tools_cache_key;
pub use mcp::configured_mcp_servers;
pub use mcp::effective_mcp_servers;
@@ -33,11 +33,15 @@ pub use mcp::oauth_login_support;
pub use mcp::resolve_oauth_scopes;
pub use mcp::should_retry_without_scopes;
pub use codex_apps::filter_non_codex_apps_mcp_tools_only;
pub use mcp::mcp_permission_prompt_is_auto_approved;
pub use mcp::qualified_mcp_tool_name_prefix;
pub use mcp_connection_manager::declared_openai_file_input_param_names;
pub use mcp_connection_manager::filter_non_codex_apps_mcp_tools_only;
pub use tools::declared_openai_file_input_param_names;
pub(crate) mod codex_apps;
pub(crate) mod connection_manager;
pub(crate) mod elicitation;
pub(crate) mod mcp;
pub(crate) mod mcp_connection_manager;
pub(crate) mod mcp_tool_names;
pub(crate) mod rmcp_client;
pub(crate) mod runtime;
pub(crate) mod tools;
+17 -12
View File
@@ -26,17 +26,17 @@ use codex_plugin::PluginCapabilitySummary;
use codex_protocol::mcp::Resource;
use codex_protocol::mcp::ResourceTemplate;
use codex_protocol::mcp::Tool;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::McpAuthStatus;
use codex_protocol::protocol::McpListToolsResponseEvent;
use codex_protocol::protocol::SandboxPolicy;
use rmcp::model::ReadResourceRequestParams;
use rmcp::model::ReadResourceResult;
use serde_json::Value;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::mcp_connection_manager::McpRuntimeEnvironment;
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
use crate::codex_apps::codex_apps_tools_cache_key;
use crate::connection_manager::McpConnectionManager;
use crate::runtime::McpRuntimeEnvironment;
pub const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps";
const MCP_TOOL_NAME_PREFIX: &str = "mcp";
@@ -66,13 +66,18 @@ pub fn qualified_mcp_tool_name_prefix(server_name: &str) -> String {
/// of being shown to the user.
pub fn mcp_permission_prompt_is_auto_approved(
approval_policy: AskForApproval,
sandbox_policy: &SandboxPolicy,
permission_profile: &PermissionProfile,
) -> bool {
approval_policy == AskForApproval::Never
&& matches!(
sandbox_policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
)
if approval_policy != AskForApproval::Never {
return false;
}
match permission_profile {
PermissionProfile::Disabled | PermissionProfile::External { .. } => true,
PermissionProfile::Managed { file_system, .. } => {
file_system.to_sandbox_policy().has_full_disk_write_access()
}
}
}
/// MCP runtime settings derived from `codex_core::config::Config`.
@@ -229,7 +234,7 @@ pub async fn read_mcp_resource(
&config.approval_policy,
String::new(),
tx_event,
SandboxPolicy::new_read_only_policy(),
PermissionProfile::default(),
runtime_environment,
config.codex_home.clone(),
codex_apps_tools_cache_key(auth),
@@ -294,7 +299,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
&config.approval_policy,
submit_id,
tx_event,
SandboxPolicy::new_read_only_policy(),
PermissionProfile::default(),
runtime_environment,
config.codex_home.clone(),
codex_apps_tools_cache_key(auth),
+32
View File
@@ -3,6 +3,9 @@ use codex_config::Constrained;
use codex_login::CodexAuth;
use codex_plugin::AppConnectorId;
use codex_plugin::PluginCapabilitySummary;
use codex_protocol::models::ManagedFileSystemPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
@@ -33,6 +36,35 @@ fn qualified_mcp_tool_name_prefix_sanitizes_server_names_without_lowercasing() {
);
}
#[test]
fn mcp_prompt_auto_approval_honors_unrestricted_managed_profiles() {
assert!(mcp_permission_prompt_is_auto_approved(
AskForApproval::Never,
&PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Unrestricted,
network: NetworkSandboxPolicy::Enabled,
},
));
assert!(mcp_permission_prompt_is_auto_approved(
AskForApproval::Never,
&PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Unrestricted,
network: NetworkSandboxPolicy::Restricted,
},
));
assert!(!mcp_permission_prompt_is_auto_approved(
AskForApproval::Never,
&PermissionProfile::read_only(),
));
assert!(!mcp_permission_prompt_is_auto_approved(
AskForApproval::OnRequest,
&PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Unrestricted,
network: NetworkSandboxPolicy::Enabled,
},
));
}
#[test]
fn tool_plugin_provenance_collects_app_and_mcp_sources() {
let provenance = ToolPluginProvenance::from_capability_summaries(&[
File diff suppressed because it is too large Load Diff
+591
View File
@@ -0,0 +1,591 @@
//! RMCP client lifecycle for MCP server connections.
//!
//! This module owns startup of individual RMCP clients: building the transport,
//! initializing the server, listing raw tools, applying per-server tool filters,
//! and exposing cached startup snapshots while a client is still connecting.
//! Higher-level aggregation and resource/tool APIs live in
//! [`crate::connection_manager`].
use std::borrow::Cow;
use std::collections::HashMap;
use std::env;
use std::ffi::OsString;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::time::Duration;
use std::time::Instant;
use crate::codex_apps::CachedCodexAppsToolsLoad;
use crate::codex_apps::CodexAppsToolsCacheContext;
use crate::codex_apps::filter_disallowed_codex_apps_tools;
use crate::codex_apps::load_cached_codex_apps_tools;
use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot;
use crate::codex_apps::normalize_codex_apps_callable_name;
use crate::codex_apps::normalize_codex_apps_callable_namespace;
use crate::codex_apps::normalize_codex_apps_tool_title;
use crate::codex_apps::write_cached_codex_apps_tools_if_needed;
use crate::elicitation::ElicitationRequestManager;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp::ToolPluginProvenance;
use crate::runtime::McpRuntimeEnvironment;
use crate::runtime::emit_duration;
use crate::tools::ToolFilter;
use crate::tools::ToolInfo;
use crate::tools::filter_tools;
use crate::tools::tool_with_model_visible_input_schema;
use anyhow::Result;
use anyhow::anyhow;
use async_channel::Sender;
use codex_api::SharedAuthProvider;
use codex_async_utils::CancelErr;
use codex_async_utils::OrCancelExt;
use codex_config::McpServerConfig;
use codex_config::McpServerTransportConfig;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_exec_server::HttpClient;
use codex_exec_server::ReqwestHttpClient;
use codex_protocol::protocol::Event;
use codex_rmcp_client::ExecutorStdioServerLauncher;
use codex_rmcp_client::LocalStdioServerLauncher;
use codex_rmcp_client::RmcpClient;
use codex_rmcp_client::StdioServerLauncher;
use futures::future::BoxFuture;
use futures::future::FutureExt;
use futures::future::Shared;
use rmcp::model::ClientCapabilities;
use rmcp::model::ElicitationCapability;
use rmcp::model::FormElicitationCapability;
use rmcp::model::Implementation;
use rmcp::model::InitializeRequestParams;
use rmcp::model::ProtocolVersion;
use tokio_util::sync::CancellationToken;
/// MCP server capability indicating that Codex should include [`SandboxState`]
/// in tool-call request `_meta` under this key.
pub const MCP_SANDBOX_STATE_META_CAPABILITY: &str = "codex/sandbox-state-meta";
pub(crate) const MCP_TOOLS_LIST_DURATION_METRIC: &str = "codex.mcp.tools.list.duration_ms";
pub(crate) const MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC: &str =
"codex.mcp.tools.fetch_uncached.duration_ms";
pub(crate) const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(120);
#[derive(Clone)]
pub(crate) struct ManagedClient {
pub(crate) client: Arc<RmcpClient>,
pub(crate) tools: Vec<ToolInfo>,
pub(crate) tool_filter: ToolFilter,
pub(crate) tool_timeout: Option<Duration>,
pub(crate) server_instructions: Option<String>,
pub(crate) server_supports_sandbox_state_meta_capability: bool,
pub(crate) codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
}
impl ManagedClient {
fn listed_tools(&self) -> Vec<ToolInfo> {
let total_start = Instant::now();
if let Some(cache_context) = self.codex_apps_tools_cache_context.as_ref()
&& let CachedCodexAppsToolsLoad::Hit(tools) =
load_cached_codex_apps_tools(cache_context)
{
emit_duration(
MCP_TOOLS_LIST_DURATION_METRIC,
total_start.elapsed(),
&[("cache", "hit")],
);
return filter_tools(tools, &self.tool_filter);
}
if self.codex_apps_tools_cache_context.is_some() {
emit_duration(
MCP_TOOLS_LIST_DURATION_METRIC,
total_start.elapsed(),
&[("cache", "miss")],
);
}
self.tools.clone()
}
}
#[derive(Clone)]
pub(crate) struct AsyncManagedClient {
pub(crate) client: Shared<BoxFuture<'static, Result<ManagedClient, StartupOutcomeError>>>,
pub(crate) startup_snapshot: Option<Vec<ToolInfo>>,
pub(crate) startup_complete: Arc<AtomicBool>,
pub(crate) tool_plugin_provenance: Arc<ToolPluginProvenance>,
}
impl AsyncManagedClient {
// Keep this constructor flat so the startup inputs remain readable at the
// single call site instead of introducing a one-off params wrapper.
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
server_name: String,
config: McpServerConfig,
store_mode: OAuthCredentialsStoreMode,
cancel_token: CancellationToken,
tx_event: Sender<Event>,
elicitation_requests: ElicitationRequestManager,
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
tool_plugin_provenance: Arc<ToolPluginProvenance>,
runtime_environment: McpRuntimeEnvironment,
runtime_auth_provider: Option<SharedAuthProvider>,
) -> Self {
let tool_filter = ToolFilter::from_config(&config);
let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot(
&server_name,
codex_apps_tools_cache_context.as_ref(),
)
.map(|tools| filter_tools(tools, &tool_filter));
let startup_tool_filter = tool_filter;
let startup_complete = Arc::new(AtomicBool::new(false));
let startup_complete_for_fut = Arc::clone(&startup_complete);
let fut = async move {
let outcome = async {
if let Err(error) = validate_mcp_server_name(&server_name) {
return Err(error.into());
}
let client = Arc::new(
make_rmcp_client(
&server_name,
config.clone(),
store_mode,
runtime_environment,
runtime_auth_provider,
)
.await?,
);
match start_server_task(
server_name,
client,
StartServerTaskParams {
startup_timeout: config
.startup_timeout_sec
.or(Some(DEFAULT_STARTUP_TIMEOUT)),
tool_timeout: config.tool_timeout_sec.unwrap_or(DEFAULT_TOOL_TIMEOUT),
tool_filter: startup_tool_filter,
tx_event,
elicitation_requests,
codex_apps_tools_cache_context,
},
)
.or_cancel(&cancel_token)
.await
{
Ok(result) => result,
Err(CancelErr::Cancelled) => Err(StartupOutcomeError::Cancelled),
}
}
.await;
startup_complete_for_fut.store(true, Ordering::Release);
outcome
};
let client = fut.boxed().shared();
if startup_snapshot.is_some() {
let startup_task = client.clone();
tokio::spawn(async move {
let _ = startup_task.await;
});
}
Self {
client,
startup_snapshot,
startup_complete,
tool_plugin_provenance,
}
}
pub(crate) async fn client(&self) -> Result<ManagedClient, StartupOutcomeError> {
self.client.clone().await
}
fn startup_snapshot_while_initializing(&self) -> Option<Vec<ToolInfo>> {
if !self.startup_complete.load(Ordering::Acquire) {
return self.startup_snapshot.clone();
}
None
}
pub(crate) async fn listed_tools(&self) -> Option<Vec<ToolInfo>> {
let annotate_tools = |tools: Vec<ToolInfo>| {
let mut tools = tools;
for tool in &mut tools {
if tool.server_name == CODEX_APPS_MCP_SERVER_NAME {
tool.tool = tool_with_model_visible_input_schema(&tool.tool);
}
let plugin_names = match tool.connector_id.as_deref() {
Some(connector_id) => self
.tool_plugin_provenance
.plugin_display_names_for_connector_id(connector_id),
None => self
.tool_plugin_provenance
.plugin_display_names_for_mcp_server_name(tool.server_name.as_str()),
};
tool.plugin_display_names = plugin_names.to_vec();
if plugin_names.is_empty() {
continue;
}
let plugin_source_note = if plugin_names.len() == 1 {
format!("This tool is part of plugin `{}`.", plugin_names[0])
} else {
format!(
"This tool is part of plugins {}.",
plugin_names
.iter()
.map(|plugin_name| format!("`{plugin_name}`"))
.collect::<Vec<_>>()
.join(", ")
)
};
let description = tool
.tool
.description
.as_deref()
.map(str::trim)
.unwrap_or("");
let annotated_description = if description.is_empty() {
plugin_source_note
} else if matches!(description.chars().last(), Some('.' | '!' | '?')) {
format!("{description} {plugin_source_note}")
} else {
format!("{description}. {plugin_source_note}")
};
tool.tool.description = Some(Cow::Owned(annotated_description));
}
tools
};
// Keep cache payloads raw; plugin provenance is resolved per-session at read time.
let tools = if let Some(startup_tools) = self.startup_snapshot_while_initializing() {
Some(startup_tools)
} else {
match self.client().await {
Ok(client) => Some(client.listed_tools()),
Err(_) => self.startup_snapshot.clone(),
}
};
tools.map(annotate_tools)
}
}
#[derive(Debug, Clone, thiserror::Error)]
pub(crate) enum StartupOutcomeError {
#[error("MCP startup cancelled")]
Cancelled,
// We can't store the original error here because anyhow::Error doesn't implement
// `Clone`.
#[error("MCP startup failed: {error}")]
Failed { error: String },
}
impl From<anyhow::Error> for StartupOutcomeError {
fn from(error: anyhow::Error) -> Self {
Self::Failed {
error: error.to_string(),
}
}
}
pub(crate) fn elicitation_capability_for_server(
_server_name: &str,
) -> Option<ElicitationCapability> {
// https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#capabilities
// indicates this should be an empty object.
Some(ElicitationCapability {
form: Some(FormElicitationCapability {
schema_validation: None,
}),
url: None,
})
}
pub(crate) async fn list_tools_for_client_uncached(
server_name: &str,
client: &Arc<RmcpClient>,
timeout: Option<Duration>,
server_instructions: Option<&str>,
) -> Result<Vec<ToolInfo>> {
let resp = client
.list_tools_with_connector_ids(/*params*/ None, timeout)
.await?;
let tools = resp
.tools
.into_iter()
.map(|tool| {
let callable_name = normalize_codex_apps_callable_name(
server_name,
&tool.tool.name,
tool.connector_id.as_deref(),
tool.connector_name.as_deref(),
);
let callable_namespace = normalize_codex_apps_callable_namespace(
server_name,
tool.connector_name.as_deref(),
);
let connector_name = tool.connector_name;
let connector_description = tool.connector_description;
let mut tool_def = tool.tool;
if let Some(title) = tool_def.title.as_deref() {
let normalized_title =
normalize_codex_apps_tool_title(server_name, connector_name.as_deref(), title);
if tool_def.title.as_deref() != Some(normalized_title.as_str()) {
tool_def.title = Some(normalized_title);
}
}
ToolInfo {
server_name: server_name.to_owned(),
callable_name,
callable_namespace,
server_instructions: server_instructions.map(str::to_string),
tool: tool_def,
connector_id: tool.connector_id,
connector_name,
plugin_display_names: Vec::new(),
connector_description,
}
})
.collect();
if server_name == CODEX_APPS_MCP_SERVER_NAME {
return Ok(filter_disallowed_codex_apps_tools(tools));
}
Ok(tools)
}
fn resolve_bearer_token(
server_name: &str,
bearer_token_env_var: Option<&str>,
) -> Result<Option<String>> {
let Some(env_var) = bearer_token_env_var else {
return Ok(None);
};
match env::var(env_var) {
Ok(value) => {
if value.is_empty() {
Err(anyhow!(
"Environment variable {env_var} for MCP server '{server_name}' is empty"
))
} else {
Ok(Some(value))
}
}
Err(env::VarError::NotPresent) => Err(anyhow!(
"Environment variable {env_var} for MCP server '{server_name}' is not set"
)),
Err(env::VarError::NotUnicode(_)) => Err(anyhow!(
"Environment variable {env_var} for MCP server '{server_name}' contains invalid Unicode"
)),
}
}
fn validate_mcp_server_name(server_name: &str) -> Result<()> {
let re = regex_lite::Regex::new(r"^[a-zA-Z0-9_-]+$")?;
if !re.is_match(server_name) {
return Err(anyhow!(
"Invalid MCP server name '{server_name}': must match pattern {pattern}",
pattern = re.as_str()
));
}
Ok(())
}
async fn start_server_task(
server_name: String,
client: Arc<RmcpClient>,
params: StartServerTaskParams,
) -> Result<ManagedClient, StartupOutcomeError> {
let StartServerTaskParams {
startup_timeout,
tool_timeout,
tool_filter,
tx_event,
elicitation_requests,
codex_apps_tools_cache_context,
} = params;
let elicitation = elicitation_capability_for_server(&server_name);
let params = InitializeRequestParams {
meta: None,
capabilities: ClientCapabilities {
experimental: None,
extensions: None,
roots: None,
sampling: None,
elicitation,
tasks: None,
},
client_info: Implementation {
name: "codex-mcp-client".to_owned(),
version: env!("CARGO_PKG_VERSION").to_owned(),
title: Some("Codex".into()),
description: None,
icons: None,
website_url: None,
},
protocol_version: ProtocolVersion::V_2025_06_18,
};
let send_elicitation = elicitation_requests.make_sender(server_name.clone(), tx_event);
let initialize_result = client
.initialize(params, startup_timeout, send_elicitation)
.await
.map_err(StartupOutcomeError::from)?;
let server_supports_sandbox_state_meta_capability = initialize_result
.capabilities
.experimental
.as_ref()
.and_then(|exp| exp.get(MCP_SANDBOX_STATE_META_CAPABILITY))
.is_some();
let list_start = Instant::now();
let fetch_start = Instant::now();
let tools = list_tools_for_client_uncached(
&server_name,
&client,
startup_timeout,
initialize_result.instructions.as_deref(),
)
.await
.map_err(StartupOutcomeError::from)?;
emit_duration(
MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC,
fetch_start.elapsed(),
&[],
);
write_cached_codex_apps_tools_if_needed(
&server_name,
codex_apps_tools_cache_context.as_ref(),
&tools,
);
if server_name == CODEX_APPS_MCP_SERVER_NAME {
emit_duration(
MCP_TOOLS_LIST_DURATION_METRIC,
list_start.elapsed(),
&[("cache", "miss")],
);
}
let tools = filter_tools(tools, &tool_filter);
let managed = ManagedClient {
client: Arc::clone(&client),
tools,
tool_timeout: Some(tool_timeout),
tool_filter,
server_instructions: initialize_result.instructions,
server_supports_sandbox_state_meta_capability,
codex_apps_tools_cache_context,
};
Ok(managed)
}
struct StartServerTaskParams {
startup_timeout: Option<Duration>, // TODO: cancel_token should handle this.
tool_timeout: Duration,
tool_filter: ToolFilter,
tx_event: Sender<Event>,
elicitation_requests: ElicitationRequestManager,
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
}
async fn make_rmcp_client(
server_name: &str,
config: McpServerConfig,
store_mode: OAuthCredentialsStoreMode,
runtime_environment: McpRuntimeEnvironment,
runtime_auth_provider: Option<SharedAuthProvider>,
) -> Result<RmcpClient, StartupOutcomeError> {
let McpServerConfig {
transport,
experimental_environment,
..
} = config;
let remote_environment = match experimental_environment.as_deref() {
None | Some("local") => false,
Some("remote") => {
if !runtime_environment.environment().is_remote() {
return Err(StartupOutcomeError::from(anyhow!(
"remote MCP server `{server_name}` requires a remote environment"
)));
}
true
}
Some(environment) => {
return Err(StartupOutcomeError::from(anyhow!(
"unsupported experimental_environment `{environment}` for MCP server `{server_name}`"
)));
}
};
match transport {
McpServerTransportConfig::Stdio {
command,
args,
env,
env_vars,
cwd,
} => {
let command_os: OsString = command.into();
let args_os: Vec<OsString> = args.into_iter().map(Into::into).collect();
let env_os = env.map(|env| {
env.into_iter()
.map(|(key, value)| (key.into(), value.into()))
.collect::<HashMap<_, _>>()
});
let launcher = if remote_environment {
Arc::new(ExecutorStdioServerLauncher::new(
runtime_environment.environment().get_exec_backend(),
runtime_environment.fallback_cwd(),
))
} else {
Arc::new(LocalStdioServerLauncher::new(
runtime_environment.fallback_cwd(),
)) as Arc<dyn StdioServerLauncher>
};
// `RmcpClient` always sees a launched MCP stdio server. The
// launcher hides whether that means a local child process or an
// executor process whose stdin/stdout bytes cross the process API.
RmcpClient::new_stdio_client(command_os, args_os, env_os, &env_vars, cwd, launcher)
.await
.map_err(|err| StartupOutcomeError::from(anyhow!(err)))
}
McpServerTransportConfig::StreamableHttp {
url,
http_headers,
env_http_headers,
bearer_token_env_var,
} => {
let http_client: Arc<dyn HttpClient> = if remote_environment {
runtime_environment.environment().get_http_client()
} else {
Arc::new(ReqwestHttpClient)
};
let resolved_bearer_token =
match resolve_bearer_token(server_name, bearer_token_env_var.as_deref()) {
Ok(token) => token,
Err(error) => return Err(error.into()),
};
RmcpClient::new_streamable_http_client(
server_name,
&url,
resolved_bearer_token,
http_headers,
env_http_headers,
store_mode,
http_client,
runtime_auth_provider,
)
.await
.map_err(StartupOutcomeError::from)
}
}
}
+66
View File
@@ -0,0 +1,66 @@
//! Runtime support for Model Context Protocol (MCP) servers.
//!
//! This module contains data that describes the runtime environment in which MCP
//! servers execute, plus the sandbox state payload sent to capable servers and a
//! tiny shared metrics helper. Transport startup and orchestration live in
//! [`crate::rmcp_client`] and [`crate::connection_manager`].
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use codex_exec_server::Environment;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::SandboxPolicy;
use serde::Deserialize;
use serde::Serialize;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxState {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permission_profile: Option<PermissionProfile>,
pub sandbox_policy: SandboxPolicy,
pub codex_linux_sandbox_exe: Option<PathBuf>,
pub sandbox_cwd: PathBuf,
#[serde(default)]
pub use_legacy_landlock: bool,
}
/// Runtime placement information used when starting MCP server transports.
///
/// `McpConfig` describes what servers exist. This value describes where those
/// servers should run for the current caller. Keep it explicit at manager
/// construction time so status/snapshot paths and real sessions make the same
/// local-vs-remote decision. `fallback_cwd` is not a per-server override; it is
/// used when a stdio server omits `cwd` and the launcher needs a concrete
/// process working directory.
#[derive(Clone)]
pub struct McpRuntimeEnvironment {
environment: Arc<Environment>,
fallback_cwd: PathBuf,
}
impl McpRuntimeEnvironment {
pub fn new(environment: Arc<Environment>, fallback_cwd: PathBuf) -> Self {
Self {
environment,
fallback_cwd,
}
}
pub(crate) fn environment(&self) -> Arc<Environment> {
Arc::clone(&self.environment)
}
pub(crate) fn fallback_cwd(&self) -> PathBuf {
self.fallback_cwd.clone()
}
}
pub(crate) fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &str)]) {
if let Some(metrics) = codex_otel::global() {
let _ = metrics.record_duration(metric, duration, tags);
}
}
@@ -1,18 +1,134 @@
//! Allocates model-visible MCP tool names while preserving raw MCP identities.
//! MCP tool metadata, filtering, schema shaping, and name qualification.
//!
//! Raw MCP tool identities must be preserved for protocol calls, while
//! model-visible tool names must be sanitized, deduplicated, and kept within API
//! limits. This module owns that translation as well as the shared [`ToolInfo`]
//! type and helpers that adjust tool schemas before exposing them to the model.
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use codex_config::McpServerConfig;
use codex_protocol::ToolName;
use rmcp::model::Tool;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Map;
use serde_json::Value as JsonValue;
use sha1::Digest;
use sha1::Sha1;
use tracing::warn;
use crate::mcp::sanitize_responses_api_tool_name;
use crate::mcp_connection_manager::ToolInfo;
const MCP_TOOL_NAME_DELIMITER: &str = "__";
const MAX_TOOL_NAME_LENGTH: usize = 64;
const CALLABLE_NAME_HASH_LEN: usize = 12;
pub(crate) const MCP_TOOLS_CACHE_WRITE_DURATION_METRIC: &str =
"codex.mcp.tools.cache_write.duration_ms";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolInfo {
/// Raw MCP server name used for routing the tool call.
pub server_name: String,
/// Model-visible tool name used in Responses API tool declarations.
#[serde(rename = "tool_name", alias = "callable_name")]
pub callable_name: String,
/// Model-visible namespace used for deferred tool loading.
#[serde(rename = "tool_namespace", alias = "callable_namespace")]
pub callable_namespace: String,
/// Instructions from the MCP server initialize result.
#[serde(default)]
pub server_instructions: Option<String>,
/// Raw MCP tool definition; `tool.name` is sent back to the MCP server.
pub tool: Tool,
pub connector_id: Option<String>,
pub connector_name: Option<String>,
#[serde(default)]
pub plugin_display_names: Vec<String>,
pub connector_description: Option<String>,
}
impl ToolInfo {
pub fn canonical_tool_name(&self) -> ToolName {
ToolName::namespaced(self.callable_namespace.clone(), self.callable_name.clone())
}
}
pub fn declared_openai_file_input_param_names(
meta: Option<&Map<String, JsonValue>>,
) -> Vec<String> {
let Some(meta) = meta else {
return Vec::new();
};
meta.get(META_OPENAI_FILE_PARAMS)
.and_then(JsonValue::as_array)
.into_iter()
.flatten()
.filter_map(JsonValue::as_str)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect()
}
/// A tool is allowed to be used if both are true:
/// 1. enabled is None (no allowlist is set) or the tool is explicitly enabled.
/// 2. The tool is not explicitly disabled.
#[derive(Default, Clone)]
pub(crate) struct ToolFilter {
pub(crate) enabled: Option<HashSet<String>>,
pub(crate) disabled: HashSet<String>,
}
impl ToolFilter {
pub(crate) fn from_config(cfg: &McpServerConfig) -> Self {
let enabled = cfg
.enabled_tools
.as_ref()
.map(|tools| tools.iter().cloned().collect::<HashSet<_>>());
let disabled = cfg
.disabled_tools
.as_ref()
.map(|tools| tools.iter().cloned().collect::<HashSet<_>>())
.unwrap_or_default();
Self { enabled, disabled }
}
pub(crate) fn allows(&self, tool_name: &str) -> bool {
if let Some(enabled) = &self.enabled
&& !enabled.contains(tool_name)
{
return false;
}
!self.disabled.contains(tool_name)
}
}
/// Returns the model-visible view of a tool while preserving the raw metadata
/// used by execution. Keep cache entries raw and call this at manager return
/// boundaries.
pub(crate) fn tool_with_model_visible_input_schema(tool: &Tool) -> Tool {
let file_params = declared_openai_file_input_param_names(tool.meta.as_deref());
if file_params.is_empty() {
return tool.clone();
}
let mut tool = tool.clone();
let mut input_schema = JsonValue::Object(tool.input_schema.as_ref().clone());
mask_input_schema_for_file_path_params(&mut input_schema, &file_params);
if let JsonValue::Object(input_schema) = input_schema {
tool.input_schema = Arc::new(input_schema);
}
tool
}
pub(crate) fn filter_tools(tools: Vec<ToolInfo>, filter: &ToolFilter) -> Vec<ToolInfo> {
tools
.into_iter()
.filter(|tool| filter.allows(&tool.tool.name))
.collect()
}
/// Returns a qualified-name lookup for MCP tools.
///
@@ -121,6 +237,57 @@ struct CallableToolCandidate {
callable_name: String,
}
const MCP_TOOL_NAME_DELIMITER: &str = "__";
const MAX_TOOL_NAME_LENGTH: usize = 64;
const CALLABLE_NAME_HASH_LEN: usize = 12;
const META_OPENAI_FILE_PARAMS: &str = "openai/fileParams";
fn mask_input_schema_for_file_path_params(input_schema: &mut JsonValue, file_params: &[String]) {
let Some(properties) = input_schema
.as_object_mut()
.and_then(|schema| schema.get_mut("properties"))
.and_then(JsonValue::as_object_mut)
else {
return;
};
for field_name in file_params {
let Some(property_schema) = properties.get_mut(field_name) else {
continue;
};
mask_input_property_schema(property_schema);
}
}
fn mask_input_property_schema(schema: &mut JsonValue) {
let Some(object) = schema.as_object_mut() else {
return;
};
let mut description = object
.get("description")
.and_then(JsonValue::as_str)
.map(str::to_string)
.unwrap_or_default();
let guidance = "This parameter expects an absolute local file path. If you want to upload a file, provide the absolute path to that file here.";
if description.is_empty() {
description = guidance.to_string();
} else if !description.contains(guidance) {
description = format!("{description} {guidance}");
}
let is_array = object.get("type").and_then(JsonValue::as_str) == Some("array")
|| object.get("items").is_some();
object.clear();
object.insert("description".to_string(), JsonValue::String(description));
if is_array {
object.insert("type".to_string(), JsonValue::String("array".to_string()));
object.insert("items".to_string(), serde_json::json!({ "type": "string" }));
} else {
object.insert("type".to_string(), JsonValue::String("string".to_string()));
}
}
fn sha1_hex(s: &str) -> String {
let mut hasher = Sha1::new();
hasher.update(s.as_bytes());
+12
View File
@@ -14,14 +14,18 @@ workspace = true
[dependencies]
anyhow = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-exec-server = { workspace = true }
codex-execpolicy = { workspace = true }
codex-features = { workspace = true }
codex-git-utils = { workspace = true }
codex-model-provider-info = { workspace = true }
codex-network-proxy = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-path = { workspace = true }
dunce = { workspace = true }
futures = { workspace = true, features = ["alloc", "std"] }
gethostname = { workspace = true }
multimap = { workspace = true }
@@ -44,8 +48,16 @@ wildmatch = { workspace = true }
dns-lookup = { workspace = true }
libc = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.9"
[target.'cfg(target_os = "windows")'.dependencies]
winapi-util = { workspace = true }
windows-sys = { version = "0.52", features = [
"Win32_Foundation",
"Win32_System_Com",
"Win32_UI_Shell",
] }
[dev-dependencies]
pretty_assertions = { workspace = true }
+2 -2
View File
@@ -842,10 +842,10 @@ pub enum ResidencyRequirement {
impl ConfigRequirementsToml {
pub fn apply_remote_sandbox_config(&mut self, hostname: Option<&str>) {
let Some(hostname) = hostname.and_then(normalize_hostname) else {
let Some(remote_sandbox_config) = self.remote_sandbox_config.as_ref() else {
return;
};
let Some(remote_sandbox_config) = self.remote_sandbox_config.as_ref() else {
let Some(hostname) = hostname.and_then(normalize_hostname) else {
return;
};
let Some(matched_config) = remote_sandbox_config
+1 -1
View File
@@ -7,6 +7,7 @@ mod fingerprint;
mod hook_config;
mod host_name;
mod key_aliases;
pub mod loader;
mod marketplace_edit;
mod mcp_edit;
mod mcp_types;
@@ -17,7 +18,6 @@ pub mod profile_toml;
mod project_root_markers;
mod requirements_exec_policy;
pub mod schema;
pub mod shell_environment;
mod skills_config;
mod state;
mod thread_config;
@@ -1,4 +1,4 @@
# `codex-core` config loader
# `codex-config` loader
This module is the canonical place to **load and describe Codex configuration layers** (user config, CLI/session overrides, managed config, and MDM-managed preferences) and to produce:
@@ -8,9 +8,9 @@ This module is the canonical place to **load and describe Codex configuration la
## Public surface
Exported from `codex_core::config_loader`:
Exported from `codex_config::loader`:
- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, overrides, cloud_requirements, thread_config_loader, host_name) -> ConfigLayerStack`
- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, overrides, cloud_requirements, thread_config_loader) -> ConfigLayerStack`
- `ConfigLayerStack`
- `effective_config() -> toml::Value`
- `origins() -> HashMap<String, ConfigLayerMetadata>`
@@ -41,8 +41,10 @@ computing the effective config and origins metadata. This is what
Most callers want the effective config plus metadata:
```rust
use codex_core::config_loader::{CloudRequirementsLoader, LoaderOverrides, load_config_layers_state};
use codex_config::NoopThreadConfigLoader;
use codex_config::CloudRequirementsLoader;
use codex_config::LoaderOverrides;
use codex_config::loader::load_config_layers_state;
use codex_exec_server::LOCAL_FS;
use codex_utils_absolute_path::AbsolutePathBuf;
use toml::Value as TomlValue;
@@ -57,7 +59,6 @@ let layers = load_config_layers_state(
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&NoopThreadConfigLoader,
/*host_name*/ None,
).await?;
let effective = layers.effective_config();
@@ -1,10 +1,10 @@
use super::LoaderOverrides;
#[cfg(target_os = "macos")]
use super::macos::ManagedAdminConfigLayer;
#[cfg(target_os = "macos")]
use super::macos::load_managed_admin_config_layer;
use codex_config::config_error_from_toml;
use codex_config::io_error_from_config_error;
use crate::diagnostics::config_error_from_toml;
use crate::diagnostics::io_error_from_config_error;
use crate::state::LoaderOverrides;
use codex_exec_server::ExecutorFileSystem;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::io;
@@ -1,7 +1,7 @@
use super::ConfigRequirementsToml;
use super::ConfigRequirementsWithSources;
use super::RequirementSource;
use super::merge_requirements_with_remote_sandbox_config;
use crate::config_requirements::ConfigRequirementsToml;
use crate::config_requirements::ConfigRequirementsWithSources;
use crate::config_requirements::RequirementSource;
use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use core_foundation::base::TCFType;
@@ -65,7 +65,6 @@ fn load_managed_admin_config() -> io::Result<Option<ManagedAdminConfigLayer>> {
pub(crate) async fn load_managed_admin_requirements_toml(
target: &mut ConfigRequirementsWithSources,
override_base64: Option<&str>,
host_name: Option<&str>,
) -> io::Result<()> {
if let Some(encoded) = override_base64 {
let trimmed = encoded.trim();
@@ -77,7 +76,6 @@ pub(crate) async fn load_managed_admin_requirements_toml(
target,
managed_preferences_requirements_source(),
parse_managed_requirements_base64(trimmed)?,
host_name,
);
return Ok(());
}
@@ -89,7 +87,6 @@ pub(crate) async fn load_managed_admin_requirements_toml(
target,
managed_preferences_requirements_source(),
requirements,
host_name,
);
}
Ok(())
@@ -2,17 +2,29 @@ mod layer_io;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(test)]
mod tests;
use crate::config_loader::layer_io::LoadedConfigLayers;
use self::layer_io::LoadedConfigLayers;
use crate::CONFIG_TOML_FILE;
use crate::cloud_requirements::CloudRequirementsLoader;
use crate::config_requirements::ConfigRequirementsToml;
use crate::config_requirements::ConfigRequirementsWithSources;
use crate::config_requirements::RequirementSource;
use crate::config_requirements::SandboxModeRequirement;
use crate::config_toml::ConfigToml;
use crate::config_toml::ProjectConfig;
use crate::diagnostics::ConfigError;
use crate::diagnostics::config_error_from_toml;
use crate::diagnostics::first_layer_config_error_from_entries as typed_first_layer_config_error_from_entries;
use crate::diagnostics::io_error_from_config_error;
use crate::merge::merge_toml_values;
use crate::overrides::build_cli_overrides_layer;
use crate::project_root_markers::default_project_root_markers;
use crate::project_root_markers::project_root_markers_from_config;
use crate::state::ConfigLayerEntry;
use crate::state::ConfigLayerStack;
use crate::state::LoaderOverrides;
use crate::thread_config::ThreadConfigContext;
use crate::thread_config::ThreadConfigLoader;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigRequirementsWithSources;
use codex_config::ThreadConfigContext;
use codex_config::ThreadConfigLoader;
use codex_config::config_toml::ConfigToml;
use codex_config::config_toml::ProjectConfig;
use codex_exec_server::ExecutorFileSystem;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_protocol::config_types::ApprovalsReviewer;
@@ -29,71 +41,14 @@ use std::path::Path;
use std::path::PathBuf;
use toml::Value as TomlValue;
pub use codex_config::AppRequirementToml;
pub use codex_config::AppsRequirementsToml;
pub use codex_config::CloudRequirementsLoadError;
pub use codex_config::CloudRequirementsLoadErrorCode;
pub use codex_config::CloudRequirementsLoader;
pub use codex_config::ConfigError;
pub use codex_config::ConfigLayerEntry;
pub use codex_config::ConfigLayerStack;
pub use codex_config::ConfigLayerStackOrdering;
pub use codex_config::ConfigLoadError;
pub use codex_config::ConfigRequirements;
pub use codex_config::ConfigRequirementsToml;
pub use codex_config::ConstrainedWithSource;
pub use codex_config::FeatureRequirementsToml;
pub use codex_config::FilesystemConstraints;
pub use codex_config::FilesystemDenyReadPattern;
pub use codex_config::HookEventsToml;
pub use codex_config::HookHandlerConfig;
pub use codex_config::LoaderOverrides;
pub use codex_config::ManagedHooksRequirementsToml;
pub use codex_config::MatcherGroup;
pub use codex_config::McpServerIdentity;
pub use codex_config::McpServerRequirement;
pub use codex_config::NetworkConstraints;
pub use codex_config::NetworkDomainPermissionToml;
pub use codex_config::NetworkDomainPermissionsToml;
pub use codex_config::NetworkRequirementsToml;
pub use codex_config::NetworkUnixSocketPermissionToml;
pub use codex_config::NetworkUnixSocketPermissionsToml;
pub use codex_config::RemoteSandboxConfigToml;
pub use codex_config::RequirementSource;
pub use codex_config::ResidencyRequirement;
pub use codex_config::SandboxModeRequirement;
pub use codex_config::Sourced;
pub use codex_config::TextPosition;
pub use codex_config::TextRange;
pub use codex_config::WebSearchModeRequirement;
pub(crate) use codex_config::build_cli_overrides_layer;
pub(crate) use codex_config::config_error_from_toml;
pub use codex_config::default_project_root_markers;
pub use codex_config::format_config_error;
pub use codex_config::format_config_error_with_source;
pub(crate) use codex_config::io_error_from_config_error;
pub use codex_config::merge_toml_values;
pub use codex_config::project_root_markers_from_config;
#[cfg(test)]
pub(crate) use codex_config::version_for_toml;
/// On Unix systems, load default settings from this file path, if present.
/// Note that /etc/codex/ is treated as a "config folder," so subfolders such
/// as skills/ and rules/ will also be honored.
pub const SYSTEM_CONFIG_TOML_FILE_UNIX: &str = "/etc/codex/config.toml";
#[cfg(unix)]
const SYSTEM_CONFIG_TOML_FILE_UNIX: &str = "/etc/codex/config.toml";
#[cfg(windows)]
const DEFAULT_PROGRAM_DATA_DIR_WINDOWS: &str = r"C:\ProgramData";
pub(crate) async fn first_layer_config_error(layers: &ConfigLayerStack) -> Option<ConfigError> {
codex_config::first_layer_config_error::<ConfigToml>(layers, CONFIG_TOML_FILE).await
}
pub(crate) async fn first_layer_config_error_from_entries(
layers: &[ConfigLayerEntry],
) -> Option<ConfigError> {
codex_config::first_layer_config_error_from_entries::<ConfigToml>(layers, CONFIG_TOML_FILE)
.await
async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> Option<ConfigError> {
typed_first_layer_config_error_from_entries::<ConfigToml>(layers, CONFIG_TOML_FILE).await
}
/// To build up the set of admin-enforced constraints, we build up from multiple
@@ -136,7 +91,6 @@ pub async fn load_config_layers_state(
overrides: LoaderOverrides,
cloud_requirements: CloudRequirementsLoader,
thread_config_loader: &dyn ThreadConfigLoader,
host_name: Option<&str>,
) -> io::Result<ConfigLayerStack> {
let ignore_user_config = overrides.ignore_user_config;
let ignore_user_and_project_exec_policy_rules =
@@ -148,7 +102,6 @@ pub async fn load_config_layers_state(
&mut config_requirements_toml,
RequirementSource::CloudRequirements,
requirements,
host_name,
);
}
@@ -158,28 +111,20 @@ pub async fn load_config_layers_state(
overrides
.macos_managed_config_requirements_base64
.as_deref(),
host_name,
)
.await?;
// Honor the system requirements.toml location.
let requirements_toml_file = system_requirements_toml_file()?;
load_requirements_toml(
fs,
&mut config_requirements_toml,
&requirements_toml_file,
host_name,
)
.await?;
let requirements_toml_file = system_requirements_toml_file_with_overrides(&overrides)?;
load_requirements_toml(fs, &mut config_requirements_toml, &requirements_toml_file).await?;
// Make a best-effort to support the legacy `managed_config.toml` as a
// requirements specification.
let loaded_config_layers =
layer_io::load_config_layers_internal(fs, codex_home, overrides).await?;
layer_io::load_config_layers_internal(fs, codex_home, overrides.clone()).await?;
load_requirements_from_legacy_scheme(
&mut config_requirements_toml,
loaded_config_layers.clone(),
host_name,
)
.await?;
@@ -210,7 +155,7 @@ pub async fn load_config_layers_state(
// Include an entry for the "system" config folder, loading its config.toml,
// if it exists.
let system_config_toml_file = system_config_toml_file()?;
let system_config_toml_file = system_config_toml_file_with_overrides(&overrides)?;
let system_layer =
load_config_toml_for_required_layer(fs, &system_config_toml_file, |config_toml| {
ConfigLayerEntry::new(
@@ -428,11 +373,11 @@ async fn load_config_toml_for_required_layer(
/// If available, apply requirements from the platform system
/// `requirements.toml` location to `config_requirements_toml` by filling in
/// any unset fields.
async fn load_requirements_toml(
#[doc(hidden)]
pub async fn load_requirements_toml(
fs: &dyn ExecutorFileSystem,
config_requirements_toml: &mut ConfigRequirementsWithSources,
requirements_toml_file: &AbsolutePathBuf,
host_name: Option<&str>,
) -> io::Result<()> {
match fs
.read_file_text(requirements_toml_file, /*sandbox*/ None)
@@ -465,7 +410,6 @@ async fn load_requirements_toml(
file: requirements_toml_file.clone(),
},
requirements_config,
host_name,
);
}
Err(e) => {
@@ -494,16 +438,34 @@ fn system_requirements_toml_file() -> io::Result<AbsolutePathBuf> {
windows_system_requirements_toml_file()
}
fn system_requirements_toml_file_with_overrides(
overrides: &LoaderOverrides,
) -> io::Result<AbsolutePathBuf> {
match &overrides.system_requirements_path {
Some(path) => AbsolutePathBuf::from_absolute_path(path),
None => system_requirements_toml_file(),
}
}
#[cfg(unix)]
fn system_config_toml_file() -> io::Result<AbsolutePathBuf> {
pub fn system_config_toml_file() -> io::Result<AbsolutePathBuf> {
AbsolutePathBuf::from_absolute_path(Path::new(SYSTEM_CONFIG_TOML_FILE_UNIX))
}
#[cfg(windows)]
fn system_config_toml_file() -> io::Result<AbsolutePathBuf> {
pub fn system_config_toml_file() -> io::Result<AbsolutePathBuf> {
windows_system_config_toml_file()
}
fn system_config_toml_file_with_overrides(
overrides: &LoaderOverrides,
) -> io::Result<AbsolutePathBuf> {
match &overrides.system_config_path {
Some(path) => AbsolutePathBuf::from_absolute_path(path),
None => system_config_toml_file(),
}
}
#[cfg(windows)]
fn windows_codex_system_dir() -> PathBuf {
let program_data = windows_program_data_dir_from_known_folder().unwrap_or_else(|err| {
@@ -580,7 +542,6 @@ fn windows_program_data_dir_from_known_folder() -> io::Result<PathBuf> {
async fn load_requirements_from_legacy_scheme(
config_requirements_toml: &mut ConfigRequirementsWithSources,
loaded_config_layers: LoadedConfigLayers,
host_name: Option<&str>,
) -> io::Result<()> {
// In this implementation, earlier layers cannot be overwritten by later
// layers, so list managed_config_from_mdm first because it has the highest
@@ -617,7 +578,6 @@ async fn load_requirements_from_legacy_scheme(
config_requirements_toml,
source,
ConfigRequirementsToml::from(legacy_config),
host_name,
);
}
@@ -628,9 +588,11 @@ pub(super) fn merge_requirements_with_remote_sandbox_config(
target: &mut ConfigRequirementsWithSources,
source: RequirementSource,
mut requirements: ConfigRequirementsToml,
host_name: Option<&str>,
) {
requirements.apply_remote_sandbox_config(host_name);
if requirements.remote_sandbox_config.is_some() {
let host_name = crate::host_name();
requirements.apply_remote_sandbox_config(host_name.as_deref());
}
target.merge_unset_fields(source, requirements);
}
@@ -844,7 +806,8 @@ fn project_trust_for_lookup_key(
///
/// This ensures that multiple config layers can be merged together correctly
/// even if they were loaded from different directories.
pub(crate) fn resolve_relative_paths_in_config_toml(
#[doc(hidden)]
pub fn resolve_relative_paths_in_config_toml(
value_from_config_toml: TomlValue,
base_dir: &Path,
) -> io::Result<TomlValue> {
+14 -10
View File
@@ -18,6 +18,8 @@ use toml::Value as TomlValue;
#[derive(Debug, Default, Clone)]
pub struct LoaderOverrides {
pub managed_config_path: Option<PathBuf>,
pub system_config_path: Option<PathBuf>,
pub system_requirements_path: Option<PathBuf>,
pub ignore_user_config: bool,
pub ignore_user_and_project_exec_policy_rules: bool,
//TODO(gt): Add a macos_ prefix to this field and remove the target_os check.
@@ -31,11 +33,17 @@ impl LoaderOverrides {
///
/// This is intended for tests that should load only repo-controlled config fixtures.
pub fn without_managed_config_for_tests() -> Self {
Self::with_managed_config_path_for_tests(
std::env::temp_dir()
.join("codex-config-tests")
.join("managed_config.toml"),
)
let base = std::env::temp_dir().join("codex-config-tests");
Self {
managed_config_path: Some(base.join("managed_config.toml")),
system_config_path: Some(base.join("config.toml")),
system_requirements_path: Some(base.join("requirements.toml")),
ignore_user_config: false,
ignore_user_and_project_exec_policy_rules: false,
#[cfg(target_os = "macos")]
managed_preferences_base64: Some(String::new()),
macos_managed_config_requirements_base64: Some(String::new()),
}
}
/// Returns overrides with host MDM disabled and managed config loaded from `managed_config_path`.
@@ -44,11 +52,7 @@ impl LoaderOverrides {
pub fn with_managed_config_path_for_tests(managed_config_path: PathBuf) -> Self {
Self {
managed_config_path: Some(managed_config_path),
ignore_user_config: false,
ignore_user_and_project_exec_policy_rules: false,
#[cfg(target_os = "macos")]
managed_preferences_base64: Some(String::new()),
macos_managed_config_requirements_base64: Some(String::new()),
..Self::without_managed_config_for_tests()
}
}
}
+3 -60
View File
@@ -12,15 +12,17 @@ pub use crate::mcp_types::McpServerTransportConfig;
pub use crate::mcp_types::RawMcpServerConfig;
pub use codex_protocol::config_types::AltScreenMode;
pub use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::EnvironmentVariablePattern;
pub use codex_protocol::config_types::ModeKind;
pub use codex_protocol::config_types::Personality;
pub use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::ShellEnvironmentPolicy;
use codex_protocol::config_types::ShellEnvironmentPolicyInherit;
pub use codex_protocol::config_types::WebSearchMode;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::fmt;
use wildmatch::WildMatchPattern;
use schemars::JsonSchema;
use serde::Deserialize;
@@ -707,21 +709,6 @@ impl From<SandboxWorkspaceWrite> for codex_app_server_protocol::SandboxSettings
}
}
#[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
/// include HOME, LOGNAME, PATH, SHELL, and USER, among others.
Core,
/// Inherits the full environment from the parent process.
#[default]
All,
/// Do not inherit any environment variables from the parent process.
None,
}
/// Policy for building the `env` when spawning a process via either the
/// `shell` or `local_shell` tool.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
@@ -742,37 +729,6 @@ pub struct ShellEnvironmentPolicyToml {
pub experimental_use_profile: Option<bool>,
}
pub type EnvironmentVariablePattern = WildMatchPattern<'*', '?'>;
/// Deriving the `env` based on this policy works as follows:
/// 1. Create an initial map based on the `inherit` policy.
/// 2. If `ignore_default_excludes` is false, filter the map using the default
/// exclude pattern(s), which are: `"*KEY*"`, `"*SECRET*"`, and `"*TOKEN*"`.
/// 3. If `exclude` is not empty, filter the map using the provided patterns.
/// 4. Insert any entries from `r#set` into the map.
/// 5. If non-empty, filter the map using the `include_only` patterns.
#[derive(Debug, Clone, PartialEq)]
pub struct ShellEnvironmentPolicy {
/// Starting point when building the environment.
pub inherit: ShellEnvironmentPolicyInherit,
/// True to skip the check to exclude default environment variables that
/// contain "KEY", "SECRET", or "TOKEN" in their name. Defaults to true.
pub ignore_default_excludes: bool,
/// Environment variable names to exclude from the environment.
pub exclude: Vec<EnvironmentVariablePattern>,
/// (key, value) pairs to insert in the environment.
pub r#set: HashMap<String, String>,
/// Environment variable names to retain in the environment.
pub include_only: Vec<EnvironmentVariablePattern>,
/// If true, the shell profile will be used to run the command.
pub use_profile: bool,
}
impl From<ShellEnvironmentPolicyToml> for ShellEnvironmentPolicy {
fn from(toml: ShellEnvironmentPolicyToml) -> Self {
// Default to inheriting the full environment when not specified.
@@ -804,19 +760,6 @@ impl From<ShellEnvironmentPolicyToml> for ShellEnvironmentPolicy {
}
}
impl Default for ShellEnvironmentPolicy {
fn default() -> Self {
Self {
inherit: ShellEnvironmentPolicyInherit::All,
ignore_default_excludes: true,
exclude: Vec::new(),
r#set: HashMap::new(),
include_only: Vec::new(),
use_profile: false,
}
}
}
#[cfg(test)]
#[path = "types_tests.rs"]
mod tests;
+1 -1
View File
@@ -46,7 +46,7 @@ codex_rust_crate(
"//:AGENTS.md",
],
test_shard_counts = {
"core-all-test": 8,
"core-all-test": 16,
"core-unit-tests": 8,
},
test_tags = ["no-sandbox"],
-10
View File
@@ -120,9 +120,6 @@ uuid = { workspace = true, features = ["serde", "v4", "v5"] }
which = { workspace = true }
whoami = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.9"
# Build OpenSSL from source for musl builds.
[target.x86_64-unknown-linux-musl.dependencies]
openssl-sys = { workspace = true, features = ["vendored"] }
@@ -131,13 +128,6 @@ openssl-sys = { workspace = true, features = ["vendored"] }
[target.aarch64-unknown-linux-musl.dependencies]
openssl-sys = { workspace = true, features = ["vendored"] }
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.52", features = [
"Win32_Foundation",
"Win32_System_Com",
"Win32_UI_Shell",
] }
[target.'cfg(unix)'.dependencies]
codex-shell-escalation = { workspace = true }
-3
View File
@@ -66,7 +66,6 @@ fn assistant_message(text: &str, phase: Option<MessagePhase>) -> ResponseItem {
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
end_turn: None,
phase,
}
}
@@ -519,7 +518,6 @@ async fn append_message_records_assistant_message() {
content: vec![ContentItem::InputText {
text: message.to_string(),
}],
end_turn: None,
phase: None,
},
)
@@ -678,7 +676,6 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
content: vec![ContentItem::InputText {
text: "parent seed context".to_string(),
}],
end_turn: None,
phase: None,
},
assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)),
+4 -4
View File
@@ -11,13 +11,13 @@ use crate::config::Config;
use crate::config::ConfigOverrides;
use crate::config::agent_roles::parse_agent_role_file_contents;
use crate::config::deserialize_config_toml_with_base;
use crate::config_loader::ConfigLayerEntry;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigLayerStackOrdering;
use crate::config_loader::resolve_relative_paths_in_config_toml;
use anyhow::anyhow;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use codex_config::config_toml::ConfigToml;
use codex_config::loader::resolve_relative_paths_in_config_toml;
use codex_exec_server::LOCAL_FS;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
+2 -2
View File
@@ -2,9 +2,9 @@ use super::*;
use crate::SkillsManager;
use crate::config::CONFIG_TOML_FILE;
use crate::config::ConfigBuilder;
use crate::config_loader::ConfigLayerStackOrdering;
use crate::plugins::PluginsManager;
use crate::skills_load_input_from_config;
use codex_config::ConfigLayerStackOrdering;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::Verbosity;
use codex_protocol::openai_models::ReasoningEffort;
@@ -574,7 +574,7 @@ writable_roots = ["./sandbox-root"]
false
);
match &*config.permissions.sandbox_policy {
match &config.legacy_sandbox_policy() {
SandboxPolicy::WorkspaceWrite { network_access, .. } => {
assert_eq!(*network_access, true);
}
+4 -4
View File
@@ -16,11 +16,11 @@
//! 3. We do **not** walk past the project root.
use crate::config::Config;
use crate::config_loader::ConfigLayerStackOrdering;
use crate::config_loader::default_project_root_markers;
use crate::config_loader::merge_toml_values;
use crate::config_loader::project_root_markers_from_config;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::ConfigLayerStackOrdering;
use codex_config::default_project_root_markers;
use codex_config::merge_toml_values;
use codex_config::project_root_markers_from_config;
use codex_exec_server::Environment;
use codex_exec_server::ExecutorFileSystem;
use codex_features::Feature;
+1 -1
View File
@@ -38,7 +38,7 @@ pub(crate) async fn apply_patch(
match assess_patch_safety(
&action,
turn_context.approval_policy.value(),
turn_context.sandbox_policy.get(),
&turn_context.permission_profile(),
file_system_sandbox_policy,
&turn_context.cwd,
turn_context.windows_sandbox_level,
-7
View File
@@ -65,7 +65,6 @@ async fn build_arc_monitor_request_includes_relevant_history_and_null_policies()
content: vec![ContentItem::InputText {
text: "first request".to_string(),
}],
end_turn: None,
phase: None,
}],
&turn_context,
@@ -94,7 +93,6 @@ async fn build_arc_monitor_request_includes_relevant_history_and_null_policies()
content: vec![ContentItem::OutputText {
text: "commentary".to_string(),
}],
end_turn: None,
phase: Some(MessagePhase::Commentary),
}],
&turn_context,
@@ -108,7 +106,6 @@ async fn build_arc_monitor_request_includes_relevant_history_and_null_policies()
content: vec![ContentItem::OutputText {
text: "final response".to_string(),
}],
end_turn: None,
phase: Some(MessagePhase::FinalAnswer),
}],
&turn_context,
@@ -122,7 +119,6 @@ async fn build_arc_monitor_request_includes_relevant_history_and_null_policies()
content: vec![ContentItem::InputText {
text: "latest request".to_string(),
}],
end_turn: None,
phase: None,
}],
&turn_context,
@@ -277,7 +273,6 @@ async fn monitor_action_posts_expected_arc_request() {
content: vec![ContentItem::InputText {
text: "please run the tool".to_string(),
}],
end_turn: None,
phase: None,
}],
&turn_context,
@@ -358,7 +353,6 @@ async fn monitor_action_uses_env_url_and_token_overrides() {
content: vec![ContentItem::InputText {
text: "please run the tool".to_string(),
}],
end_turn: None,
phase: None,
}],
&turn_context,
@@ -428,7 +422,6 @@ async fn monitor_action_rejects_legacy_response_fields() {
content: vec![ContentItem::InputText {
text: "please run the tool".to_string(),
}],
end_turn: None,
phase: None,
}],
&turn_context,
-1
View File
@@ -278,7 +278,6 @@ impl CodexThread {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText { text: message }],
end_turn: None,
phase: None,
};
let pending_item = match pending_message_input_item(&message) {
-2
View File
@@ -509,7 +509,6 @@ fn build_compacted_history_with_limit(
content: vec![ContentItem::InputText {
text: message.clone(),
}],
end_turn: None,
phase: None,
});
}
@@ -524,7 +523,6 @@ fn build_compacted_history_with_limit(
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText { text: summary_text }],
end_turn: None,
phase: None,
});
-35
View File
@@ -63,7 +63,6 @@ fn collect_user_messages_extracts_user_text_only() {
content: vec![ContentItem::OutputText {
text: "ignored".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -72,7 +71,6 @@ fn collect_user_messages_extracts_user_text_only() {
content: vec![ContentItem::InputText {
text: "first".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Other,
@@ -97,7 +95,6 @@ do things
</INSTRUCTIONS>"#
.to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -106,7 +103,6 @@ do things
content: vec![ContentItem::InputText {
text: "<ENVIRONMENT_CONTEXT>cwd=/tmp</ENVIRONMENT_CONTEXT>".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -115,7 +111,6 @@ do things
content: vec![ContentItem::InputText {
text: "real user message".to_string(),
}],
end_turn: None,
phase: None,
},
];
@@ -223,7 +218,6 @@ async fn process_compacted_history_replaces_developer_messages() {
content: vec![ContentItem::InputText {
text: "stale permissions".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -232,7 +226,6 @@ async fn process_compacted_history_replaces_developer_messages() {
content: vec![ContentItem::InputText {
text: "summary".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -241,7 +234,6 @@ async fn process_compacted_history_replaces_developer_messages() {
content: vec![ContentItem::InputText {
text: "stale personality".to_string(),
}],
end_turn: None,
phase: None,
},
];
@@ -256,7 +248,6 @@ async fn process_compacted_history_replaces_developer_messages() {
content: vec![ContentItem::InputText {
text: "summary".to_string(),
}],
end_turn: None,
phase: None,
});
assert_eq!(refreshed, expected);
@@ -270,7 +261,6 @@ async fn process_compacted_history_reinjects_full_initial_context() {
content: vec![ContentItem::InputText {
text: "summary".to_string(),
}],
end_turn: None,
phase: None,
}];
let (refreshed, mut expected) = process_compacted_history_with_test_session(
@@ -284,7 +274,6 @@ async fn process_compacted_history_reinjects_full_initial_context() {
content: vec![ContentItem::InputText {
text: "summary".to_string(),
}],
end_turn: None,
phase: None,
});
assert_eq!(refreshed, expected);
@@ -304,7 +293,6 @@ keep me updated
</INSTRUCTIONS>"#
.to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -317,7 +305,6 @@ keep me updated
</environment_context>"#
.to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -330,7 +317,6 @@ keep me updated
</turn_aborted>"#
.to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -339,7 +325,6 @@ keep me updated
content: vec![ContentItem::InputText {
text: "summary".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -348,7 +333,6 @@ keep me updated
content: vec![ContentItem::InputText {
text: "stale developer instructions".to_string(),
}],
end_turn: None,
phase: None,
},
];
@@ -363,7 +347,6 @@ keep me updated
content: vec![ContentItem::InputText {
text: "summary".to_string(),
}],
end_turn: None,
phase: None,
});
assert_eq!(refreshed, expected);
@@ -378,7 +361,6 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message
content: vec![ContentItem::InputText {
text: "older user".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -387,7 +369,6 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message
content: vec![ContentItem::InputText {
text: format!("{SUMMARY_PREFIX}\nsummary text"),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -396,7 +377,6 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message
content: vec![ContentItem::InputText {
text: "latest user".to_string(),
}],
end_turn: None,
phase: None,
},
];
@@ -413,7 +393,6 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message
content: vec![ContentItem::InputText {
text: "older user".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -422,7 +401,6 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message
content: vec![ContentItem::InputText {
text: format!("{SUMMARY_PREFIX}\nsummary text"),
}],
end_turn: None,
phase: None,
},
];
@@ -433,7 +411,6 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message
content: vec![ContentItem::InputText {
text: "latest user".to_string(),
}],
end_turn: None,
phase: None,
});
assert_eq!(refreshed, expected);
@@ -447,7 +424,6 @@ async fn process_compacted_history_reinjects_model_switch_message() {
content: vec![ContentItem::InputText {
text: "summary".to_string(),
}],
end_turn: None,
phase: None,
}];
let previous_turn_settings = PreviousTurnSettings {
@@ -477,7 +453,6 @@ async fn process_compacted_history_reinjects_model_switch_message() {
content: vec![ContentItem::InputText {
text: "summary".to_string(),
}],
end_turn: None,
phase: None,
});
assert_eq!(refreshed, expected);
@@ -492,7 +467,6 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last()
content: vec![ContentItem::InputText {
text: "older user".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -501,7 +475,6 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last()
content: vec![ContentItem::InputText {
text: "latest user".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -510,7 +483,6 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last()
content: vec![ContentItem::InputText {
text: format!("{SUMMARY_PREFIX}\nsummary text"),
}],
end_turn: None,
phase: None,
},
];
@@ -520,7 +492,6 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last()
content: vec![ContentItem::InputText {
text: "fresh permissions".to_string(),
}],
end_turn: None,
phase: None,
}];
@@ -533,7 +504,6 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last()
content: vec![ContentItem::InputText {
text: "older user".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -542,7 +512,6 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last()
content: vec![ContentItem::InputText {
text: "fresh permissions".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -551,7 +520,6 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last()
content: vec![ContentItem::InputText {
text: "latest user".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -560,7 +528,6 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last()
content: vec![ContentItem::InputText {
text: format!("{SUMMARY_PREFIX}\nsummary text"),
}],
end_turn: None,
phase: None,
},
];
@@ -578,7 +545,6 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_compaction_last
content: vec![ContentItem::InputText {
text: "fresh permissions".to_string(),
}],
end_turn: None,
phase: None,
}];
@@ -591,7 +557,6 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_compaction_last
content: vec![ContentItem::InputText {
text: "fresh permissions".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Compaction {
+2 -2
View File
@@ -1,6 +1,6 @@
use super::AgentRoleConfig;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigLayerStackOrdering;
use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use codex_config::config_toml::AgentRoleToml;
use codex_config::config_toml::AgentsToml;
use codex_config::config_toml::ConfigToml;
@@ -1,29 +1,34 @@
use super::LoaderOverrides;
use super::load_config_layers_state;
use crate::config::ConfigBuilder;
use crate::config::ConfigOverrides;
use crate::config::ConstraintError;
use crate::config_loader::CloudRequirementsLoadError;
use crate::config_loader::CloudRequirementsLoader;
use crate::config_loader::ConfigLayerEntry;
use crate::config_loader::ConfigLoadError;
use crate::config_loader::ConfigRequirements;
use crate::config_loader::ConfigRequirementsToml;
use crate::config_loader::ConfigRequirementsWithSources;
use crate::config_loader::FilesystemDenyReadPattern;
use crate::config_loader::RequirementSource;
use crate::config_loader::load_requirements_toml;
use crate::config_loader::version_for_toml;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::CONFIG_TOML_FILE;
use codex_config::CloudRequirementsLoadError;
use codex_config::CloudRequirementsLoader;
use codex_config::ConfigError;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerStackOrdering;
use codex_config::ConfigLoadError;
use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_config::ConfigRequirementsWithSources;
use codex_config::FilesystemDenyReadPattern;
use codex_config::LoaderOverrides;
use codex_config::RequirementSource;
use codex_config::SessionThreadConfig;
use codex_config::StaticThreadConfigLoader;
use codex_config::ThreadConfigSource;
use codex_config::config_error_from_toml;
use codex_config::config_toml::ConfigToml;
use codex_config::config_toml::ProjectConfig;
use codex_config::loader::load_config_layers_state;
use codex_config::loader::load_requirements_toml;
use codex_config::version_for_toml;
use codex_exec_server::LOCAL_FS;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::protocol::AskForApproval;
#[cfg(target_os = "macos")]
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
@@ -33,7 +38,7 @@ use std::path::Path;
use tempfile::tempdir;
use toml::Value as TomlValue;
fn config_error_from_io(err: &std::io::Error) -> &super::ConfigError {
fn config_error_from_io(err: &std::io::Error) -> &ConfigError {
err.get_ref()
.and_then(|err| err.downcast_ref::<ConfigLoadError>())
.map(ConfigLoadError::config_error)
@@ -103,15 +108,13 @@ async fn returns_config_error_for_invalid_user_config_toml() {
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await
.expect_err("expected error");
let config_error = config_error_from_io(&err);
let expected_toml_error = toml::from_str::<TomlValue>(contents).expect_err("parse error");
let expected_config_error =
super::config_error_from_toml(&config_path, contents, expected_toml_error);
let expected_config_error = config_error_from_toml(&config_path, contents, expected_toml_error);
assert_eq!(config_error, &expected_config_error);
}
@@ -136,7 +139,6 @@ async fn ignore_user_config_keeps_empty_user_layer() -> std::io::Result<()> {
},
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await?;
@@ -168,7 +170,6 @@ async fn ignore_rules_marks_config_stack_for_exec_policy_rule_skip() -> std::io:
},
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await?;
@@ -194,7 +195,6 @@ async fn returns_config_error_for_invalid_managed_config_toml() {
overrides,
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await
.expect_err("expected error");
@@ -202,7 +202,7 @@ async fn returns_config_error_for_invalid_managed_config_toml() {
let config_error = config_error_from_io(&err);
let expected_toml_error = toml::from_str::<TomlValue>(contents).expect_err("parse error");
let expected_config_error =
super::config_error_from_toml(&managed_path, contents, expected_toml_error);
config_error_from_toml(&managed_path, contents, expected_toml_error);
assert_eq!(config_error, &expected_config_error);
}
@@ -281,7 +281,6 @@ extra = true
overrides,
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await
.expect("load config");
@@ -316,7 +315,6 @@ async fn returns_empty_when_all_layers_missing() {
overrides,
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await
.expect("load layers");
@@ -325,7 +323,7 @@ async fn returns_empty_when_all_layers_missing() {
.expect("expected a user layer even when CODEX_HOME/config.toml does not exist");
assert_eq!(
&ConfigLayerEntry {
name: super::ConfigLayerSource::User {
name: ConfigLayerSource::User {
file: AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, tmp.path())
},
config: TomlValue::Table(toml::map::Map::new()),
@@ -350,7 +348,7 @@ async fn returns_empty_when_all_layers_missing() {
let num_system_layers = layers
.layers_high_to_low()
.iter()
.filter(|layer| matches!(layer.name, super::ConfigLayerSource::System { .. }))
.filter(|layer| matches!(layer.name, ConfigLayerSource::System { .. }))
.count();
assert_eq!(
num_system_layers, 1,
@@ -374,18 +372,24 @@ async fn includes_thread_config_layers_in_stack() -> anyhow::Result<()> {
let cwd_dir = tmp.path().join("project");
tokio::fs::create_dir_all(&cwd_dir).await?;
let cwd = AbsolutePathBuf::from_absolute_path(&cwd_dir)?;
let overrides = LoaderOverrides::without_managed_config_for_tests();
let expected_system_config = AbsolutePathBuf::from_absolute_path(
overrides
.system_config_path
.as_ref()
.expect("test overrides should include a system config path"),
)?;
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
tmp.path(),
Some(cwd),
&[("features.plugins".to_string(), TomlValue::Boolean(true))],
LoaderOverrides::without_managed_config_for_tests(),
overrides,
CloudRequirementsLoader::default(),
&StaticThreadConfigLoader::new(vec![ThreadConfigSource::Session(SessionThreadConfig {
features: BTreeMap::from([("plugins".to_string(), false)]),
..Default::default()
})]),
/*host_name*/ None,
)
.await?;
@@ -397,13 +401,13 @@ async fn includes_thread_config_layers_in_stack() -> anyhow::Result<()> {
assert_eq!(
layer_sources,
vec![
super::ConfigLayerSource::SessionFlags,
super::ConfigLayerSource::SessionFlags,
super::ConfigLayerSource::User {
ConfigLayerSource::SessionFlags,
ConfigLayerSource::SessionFlags,
ConfigLayerSource::User {
file: AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, tmp.path()),
},
super::ConfigLayerSource::System {
file: super::system_config_toml_file()?,
ConfigLayerSource::System {
file: expected_system_config,
},
]
);
@@ -462,7 +466,6 @@ flag = false
overrides,
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await
.expect("load config");
@@ -482,7 +485,7 @@ flag = false
.find(|layer| {
matches!(
layer.name,
super::ConfigLayerSource::LegacyManagedConfigTomlFromMdm
ConfigLayerSource::LegacyManagedConfigTomlFromMdm
)
})
.expect("mdm layer");
@@ -523,7 +526,7 @@ writable_roots = ["~/code"]
.await?;
let expected_root = AbsolutePathBuf::from_absolute_path(home.join("code"))?;
match config.permissions.sandbox_policy.get() {
match &config.legacy_sandbox_policy() {
SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
assert_eq!(
writable_roots
@@ -566,7 +569,6 @@ allowed_sandbox_modes = ["read-only"]
loader_overrides,
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await?;
@@ -629,7 +631,6 @@ allowed_approval_policies = ["never"]
loader_overrides,
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await?;
@@ -671,7 +672,6 @@ personality = true
LOCAL_FS.as_ref(),
&mut config_requirements_toml,
&requirements_file,
/*host_name*/ None,
)
.await?;
@@ -687,14 +687,14 @@ personality = true
.allowed_web_search_modes
.as_deref()
.cloned(),
Some(vec![crate::config_loader::WebSearchModeRequirement::Cached])
Some(vec![codex_config::WebSearchModeRequirement::Cached])
);
assert_eq!(
config_requirements_toml
.feature_requirements
.as_ref()
.map(|requirements| requirements.value.clone()),
Some(crate::config_loader::FeatureRequirementsToml {
Some(codex_config::FeatureRequirementsToml {
entries: BTreeMap::from([("personality".to_string(), true)]),
})
);
@@ -733,14 +733,14 @@ personality = true
);
assert_eq!(
config_requirements.enforce_residency.value(),
Some(crate::config_loader::ResidencyRequirement::Us)
Some(codex_config::ResidencyRequirement::Us)
);
assert_eq!(
config_requirements
.feature_requirements
.as_ref()
.map(|requirements| requirements.value.clone()),
Some(crate::config_loader::FeatureRequirementsToml {
Some(codex_config::FeatureRequirementsToml {
entries: BTreeMap::from([("personality".to_string(), true)]),
})
);
@@ -787,7 +787,6 @@ allowed_approval_policies = ["on-request"]
}))
}),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await?;
@@ -847,7 +846,6 @@ allowed_approval_policies = ["on-request"]
LOCAL_FS.as_ref(),
&mut config_requirements_toml,
&AbsolutePathBuf::try_from(requirements_file)?,
/*host_name*/ None,
)
.await?;
@@ -869,54 +867,6 @@ allowed_approval_policies = ["on-request"]
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn system_remote_sandbox_config_keeps_cloud_sandbox_modes() -> anyhow::Result<()> {
let tmp = tempdir()?;
let requirements_file = tmp.path().join("requirements.toml");
tokio::fs::write(
&requirements_file,
r#"
[[remote_sandbox_config]]
hostname_patterns = ["runner-*.ci.example.com"]
allowed_sandbox_modes = ["read-only", "workspace-write"]
"#,
)
.await?;
let cloud_source = RequirementSource::CloudRequirements;
let mut config_requirements_toml = ConfigRequirementsWithSources::default();
config_requirements_toml.merge_unset_fields(
cloud_source.clone(),
toml::from_str(
r#"
allowed_sandbox_modes = ["read-only"]
"#,
)?,
);
load_requirements_toml(
LOCAL_FS.as_ref(),
&mut config_requirements_toml,
&AbsolutePathBuf::try_from(requirements_file)?,
Some("runner-01.ci.example.com"),
)
.await?;
let config_requirements: ConfigRequirements = config_requirements_toml.try_into()?;
assert_eq!(
config_requirements
.sandbox_policy
.can_set(&SandboxPolicy::new_workspace_write_policy()),
Err(ConstraintError::InvalidValue {
field_name: "sandbox_mode",
candidate: "WorkspaceWrite".into(),
allowed: "[ReadOnly]".into(),
requirement_source: cloud_source,
})
);
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn load_requirements_toml_resolves_deny_read_against_parent() -> anyhow::Result<()> {
let tmp = tempdir()?;
@@ -938,7 +888,6 @@ deny_read = ["./sensitive", "../shared/secret.txt"]
LOCAL_FS.as_ref(),
&mut config_requirements_toml,
&requirements_file,
/*host_name*/ None,
)
.await?;
@@ -993,7 +942,6 @@ deny_read = ["./sensitive/**/*.txt"]
LOCAL_FS.as_ref(),
&mut config_requirements_toml,
&requirements_file,
/*host_name*/ None,
)
.await?;
@@ -1062,7 +1010,6 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()>
LoaderOverrides::default(),
cloud_requirements,
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await?;
@@ -1125,7 +1072,6 @@ async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result
LoaderOverrides::default(),
cloud_requirements,
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await?;
@@ -1142,53 +1088,6 @@ async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result
Ok(())
}
#[tokio::test]
async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow::Result<()> {
let tmp = tempdir()?;
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
let requirements: ConfigRequirementsToml = toml::from_str(
r#"
allowed_sandbox_modes = ["read-only"]
[[remote_sandbox_config]]
hostname_patterns = ["runner-*.ci.example.com"]
allowed_sandbox_modes = ["read-only", "workspace-write"]
"#,
)?;
let cloud_requirements = CloudRequirementsLoader::new(async move { Ok(Some(requirements)) });
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
Some(cwd),
&[] as &[(String, TomlValue)],
LoaderOverrides::default(),
cloud_requirements,
&codex_config::NoopThreadConfigLoader,
Some("runner-01.ci.example.com"),
)
.await?;
assert_eq!(
layers.requirements_toml().allowed_sandbox_modes,
Some(vec![
crate::config_loader::SandboxModeRequirement::ReadOnly,
crate::config_loader::SandboxModeRequirement::WorkspaceWrite,
])
);
assert!(
layers
.requirements()
.sandbox_policy
.can_set(&SandboxPolicy::new_workspace_write_policy())
.is_ok()
);
Ok(())
}
#[tokio::test]
async fn load_config_layers_fails_when_cloud_requirements_loader_fails() -> anyhow::Result<()> {
let tmp = tempdir()?;
@@ -1210,7 +1109,6 @@ async fn load_config_layers_fails_when_cloud_requirements_loader_fails() -> anyh
))
}),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await
.expect_err("cloud requirements failure should fail closed");
@@ -1259,7 +1157,6 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> {
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await?;
@@ -1267,7 +1164,7 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> {
.layers_high_to_low()
.into_iter()
.filter_map(|layer| match &layer.name {
super::ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder),
ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder),
_ => None,
})
.collect();
@@ -1406,18 +1303,17 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await?;
let project_layers: Vec<_> = layers
.layers_high_to_low()
.into_iter()
.filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. }))
.filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. }))
.collect();
assert_eq!(
vec![&ConfigLayerEntry {
name: super::ConfigLayerSource::Project {
name: ConfigLayerSource::Project {
dot_codex_folder: AbsolutePathBuf::from_absolute_path(project_root.join(".codex"))?,
},
config: TomlValue::Table(toml::map::Map::new()),
@@ -1448,17 +1344,16 @@ async fn codex_home_is_not_loaded_as_project_layer_from_home_dir() -> std::io::R
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await?;
let project_layers: Vec<_> = layers
.get_layers(
super::ConfigLayerStackOrdering::HighestPrecedenceFirst,
ConfigLayerStackOrdering::HighestPrecedenceFirst,
/*include_disabled*/ true,
)
.into_iter()
.filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. }))
.filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. }))
.collect();
let expected: Vec<&ConfigLayerEntry> = Vec::new();
assert_eq!(expected, project_layers);
@@ -1507,23 +1402,22 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await?;
let project_layers: Vec<_> = layers
.get_layers(
super::ConfigLayerStackOrdering::HighestPrecedenceFirst,
ConfigLayerStackOrdering::HighestPrecedenceFirst,
/*include_disabled*/ true,
)
.into_iter()
.filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. }))
.filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. }))
.collect();
let child_config: TomlValue = toml::from_str("foo = \"child\"\n").expect("parse child config");
assert_eq!(
vec![&ConfigLayerEntry {
name: super::ConfigLayerSource::Project {
name: ConfigLayerSource::Project {
dot_codex_folder: AbsolutePathBuf::from_absolute_path(&nested_dot_codex)?,
},
config: child_config.clone(),
@@ -1580,16 +1474,15 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result<
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await?;
let project_layers_untrusted: Vec<_> = layers_untrusted
.get_layers(
super::ConfigLayerStackOrdering::HighestPrecedenceFirst,
ConfigLayerStackOrdering::HighestPrecedenceFirst,
/*include_disabled*/ true,
)
.into_iter()
.filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. }))
.filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. }))
.collect();
assert_eq!(project_layers_untrusted.len(), 1);
assert!(
@@ -1621,16 +1514,15 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result<
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await?;
let project_layers_unknown: Vec<_> = layers_unknown
.get_layers(
super::ConfigLayerStackOrdering::HighestPrecedenceFirst,
ConfigLayerStackOrdering::HighestPrecedenceFirst,
/*include_disabled*/ true,
)
.into_iter()
.filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. }))
.filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. }))
.collect();
assert_eq!(project_layers_unknown.len(), 1);
assert!(
@@ -1689,17 +1581,16 @@ async fn project_trust_does_not_match_configured_alias_for_canonical_cwd() -> st
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await?;
let project_layers: Vec<_> = layers
.get_layers(
super::ConfigLayerStackOrdering::HighestPrecedenceFirst,
ConfigLayerStackOrdering::HighestPrecedenceFirst,
/*include_disabled*/ true,
)
.into_iter()
.filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. }))
.filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. }))
.collect();
assert_eq!(project_layers.len(), 1);
assert!(
@@ -1844,16 +1735,15 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io::
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await?;
let project_layers: Vec<_> = layers
.get_layers(
super::ConfigLayerStackOrdering::HighestPrecedenceFirst,
ConfigLayerStackOrdering::HighestPrecedenceFirst,
/*include_disabled*/ true,
)
.into_iter()
.filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. }))
.filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. }))
.collect();
assert_eq!(
project_layers.len(),
@@ -1914,16 +1804,15 @@ async fn project_layer_without_config_toml_is_disabled_when_untrusted_or_unknown
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await?;
let project_layers: Vec<_> = layers
.get_layers(
super::ConfigLayerStackOrdering::HighestPrecedenceFirst,
ConfigLayerStackOrdering::HighestPrecedenceFirst,
/*include_disabled*/ true,
)
.into_iter()
.filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. }))
.filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. }))
.collect();
assert_eq!(
project_layers.len(),
@@ -1976,7 +1865,6 @@ async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await?;
@@ -2021,7 +1909,6 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<()
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await?;
@@ -2029,7 +1916,7 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<()
.layers_high_to_low()
.into_iter()
.filter_map(|layer| match &layer.name {
super::ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder),
ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder),
_ => None,
})
.collect();
@@ -2051,14 +1938,14 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<()
}
mod requirements_exec_policy_tests {
use crate::config_loader::ConfigLayerEntry;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigRequirements;
use crate::config_loader::ConfigRequirementsToml;
use crate::config_loader::ConfigRequirementsWithSources;
use crate::config_loader::RequirementSource;
use crate::exec_policy::load_exec_policy;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerStack;
use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_config::ConfigRequirementsWithSources;
use codex_config::RequirementSource;
use codex_config::RequirementsExecPolicyDecisionToml;
use codex_config::RequirementsExecPolicyParseError;
use codex_config::RequirementsExecPolicyPatternTokenToml;
+337 -121
View File
@@ -4,11 +4,10 @@ use crate::config::ThreadStoreConfig;
use crate::config::edit::ConfigEdit;
use crate::config::edit::ConfigEditsBuilder;
use crate::config::edit::apply_blocking;
use crate::config_loader::RequirementSource;
use crate::config_loader::project_trust_key;
use crate::plugins::PluginsManager;
use assert_matches::assert_matches;
use codex_config::CONFIG_TOML_FILE;
use codex_config::RequirementSource;
use codex_config::config_toml::AgentRoleToml;
use codex_config::config_toml::AgentsToml;
use codex_config::config_toml::AutoReviewToml;
@@ -21,6 +20,7 @@ use codex_config::config_toml::RealtimeTransport;
use codex_config::config_toml::RealtimeWsMode;
use codex_config::config_toml::RealtimeWsVersion;
use codex_config::config_toml::ToolsToml;
use codex_config::loader::project_trust_key;
use codex_config::permissions_toml::FilesystemPermissionToml;
use codex_config::permissions_toml::FilesystemPermissionsToml;
use codex_config::permissions_toml::NetworkDomainPermissionToml;
@@ -56,13 +56,16 @@ use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID;
use codex_model_provider_info::WireApi;
use codex_models_manager::bundled_models_response;
use codex_protocol::models::ManagedFileSystemPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::SandboxEnforcement;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::NetworkAccess;
use codex_protocol::protocol::RealtimeVoice;
use codex_protocol::protocol::SandboxPolicy;
use serde::Deserialize;
@@ -774,7 +777,7 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::
let memories_root = codex_home.path().join("memories").abs();
assert_eq!(
config.permissions.file_system_sandbox_policy,
config.permissions.file_system_sandbox_policy(),
FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
@@ -803,7 +806,7 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::
]),
);
assert_eq!(
config.permissions.sandbox_policy.get(),
&config.legacy_sandbox_policy(),
&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![memories_root],
network_access: false,
@@ -812,7 +815,7 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::
}
);
assert_eq!(
config.permissions.network_sandbox_policy,
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Restricted
);
Ok(())
@@ -837,12 +840,151 @@ async fn permission_profile_override_populates_runtime_permissions() -> std::io:
assert_eq!(config.permissions.permission_profile(), permission_profile);
assert_eq!(
config.permissions.sandbox_policy.get(),
&config.legacy_sandbox_policy(),
&SandboxPolicy::DangerFullAccess
);
Ok(())
}
#[tokio::test]
async fn permission_profile_override_preserves_managed_unrestricted_filesystem()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let permission_profile = PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Unrestricted,
network: NetworkSandboxPolicy::Restricted,
};
let config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
permission_profile: Some(permission_profile.clone()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(config.permissions.permission_profile(), permission_profile);
assert_eq!(
&config.legacy_sandbox_policy(),
&SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
}
);
Ok(())
}
#[tokio::test]
async fn managed_unrestricted_permission_profile_still_enables_network_requirements()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let permission_profile = PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Unrestricted,
network: NetworkSandboxPolicy::Enabled,
};
let mut config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
permission_profile: Some(permission_profile),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(
&config.legacy_sandbox_policy(),
&SandboxPolicy::DangerFullAccess,
"the legacy projection is intentionally lossy for managed unrestricted profiles"
);
let layers = config
.config_layer_stack
.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ true,
)
.into_iter()
.cloned()
.collect();
let mut requirements = config.config_layer_stack.requirements().clone();
requirements.network = Some(Sourced::new(
codex_config::NetworkConstraints {
enabled: Some(true),
..Default::default()
},
RequirementSource::CloudRequirements,
));
let mut requirements_toml = config.config_layer_stack.requirements_toml().clone();
requirements_toml.network = Some(codex_config::NetworkRequirementsToml {
enabled: Some(true),
..Default::default()
});
config.config_layer_stack = ConfigLayerStack::new(layers, requirements, requirements_toml)
.expect("config layer stack with network requirements");
assert!(config.managed_network_requirements_enabled());
Ok(())
}
#[tokio::test]
async fn permission_profile_override_applies_runtime_roots_to_legacy_projection()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let permission_profile = PermissionProfile::from_runtime_permissions(
&FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
},
access: FileSystemAccessMode::Write,
},
]),
NetworkSandboxPolicy::Restricted,
);
let config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
permission_profile: Some(permission_profile),
..Default::default()
},
codex_home.abs(),
)
.await?;
let memories_root = codex_home.path().join("memories").abs();
assert!(
config
.permissions
.file_system_sandbox_policy()
.can_write_path_with_cwd(memories_root.as_path(), cwd.path())
);
assert_eq!(
&config.legacy_sandbox_policy(),
&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![memories_root],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
}
);
Ok(())
}
#[tokio::test]
async fn permission_profile_override_preserves_configured_network_proxy() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
@@ -941,7 +1083,7 @@ async fn project_root_glob_none_compiles_to_filesystem_pattern_entry() -> std::i
assert_eq!(
config
.permissions
.file_system_sandbox_policy
.file_system_sandbox_policy()
.glob_scan_max_depth,
Some(2)
);
@@ -951,7 +1093,7 @@ async fn project_root_glob_none_compiles_to_filesystem_pattern_entry() -> std::i
assert!(
config
.permissions
.file_system_sandbox_policy
.file_system_sandbox_policy()
.entries
.contains(&FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
@@ -963,7 +1105,7 @@ async fn project_root_glob_none_compiles_to_filesystem_pattern_entry() -> std::i
assert!(
!config
.permissions
.file_system_sandbox_policy
.file_system_sandbox_policy()
.entries
.iter()
.any(|entry| matches!(
@@ -1020,13 +1162,16 @@ async fn permissions_profiles_require_default_permissions() -> std::io::Result<(
}
#[tokio::test]
async fn permissions_profiles_reject_writes_outside_workspace_root() -> std::io::Result<()> {
async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let external_write_path = if cfg!(windows) { r"C:\temp" } else { "/tmp" };
let external_write_dir = TempDir::new()?;
let external_write_path =
AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(external_write_dir.path())?)?;
let err = Config::load_from_base_config_with_overrides(
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("workspace".to_string()),
permissions: Some(PermissionsToml {
@@ -1036,7 +1181,7 @@ async fn permissions_profiles_reject_writes_outside_workspace_root() -> std::io:
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
entries: BTreeMap::from([(
external_write_path.to_string(),
external_write_path.to_string_lossy().into_owned(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Write),
)]),
}),
@@ -1052,14 +1197,25 @@ async fn permissions_profiles_reject_writes_outside_workspace_root() -> std::io:
},
codex_home.abs(),
)
.await
.expect_err("writes outside the workspace root should be rejected");
.await?;
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
let memories_root = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(
codex_home.path().join("memories"),
)?)?;
assert!(
err.to_string()
.contains("filesystem writes outside the workspace root"),
"{err}"
config
.permissions
.file_system_sandbox_policy()
.can_write_path_with_cwd(external_write_path.as_path(), cwd.path())
);
assert_eq!(
&config.legacy_sandbox_policy(),
&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![external_write_path, memories_root],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
}
);
Ok(())
}
@@ -1149,7 +1305,7 @@ async fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<(
.await?;
assert_eq!(
config.permissions.file_system_sandbox_policy,
config.permissions.file_system_sandbox_policy(),
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::unknown(
@@ -1161,7 +1317,7 @@ async fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<(
}]),
);
assert_eq!(
config.permissions.sandbox_policy.get(),
&config.legacy_sandbox_policy(),
&SandboxPolicy::ReadOnly {
network_access: false,
}
@@ -1195,7 +1351,7 @@ async fn permissions_profiles_allow_unknown_special_paths_with_nested_entries()
.await?;
assert_eq!(
config.permissions.file_system_sandbox_policy,
config.permissions.file_system_sandbox_policy(),
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::unknown(":future_special_path", Some("docs".into())),
@@ -1222,11 +1378,11 @@ async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io
.await?;
assert_eq!(
config.permissions.file_system_sandbox_policy,
config.permissions.file_system_sandbox_policy(),
FileSystemSandboxPolicy::restricted(Vec::new())
);
assert_eq!(
config.permissions.sandbox_policy.get(),
&config.legacy_sandbox_policy(),
&SandboxPolicy::ReadOnly {
network_access: false,
}
@@ -1253,7 +1409,7 @@ async fn permissions_profiles_allow_empty_filesystem_with_warning() -> std::io::
.await?;
assert_eq!(
config.permissions.file_system_sandbox_policy,
config.permissions.file_system_sandbox_policy(),
FileSystemSandboxPolicy::restricted(Vec::new())
);
assert!(
@@ -1350,16 +1506,10 @@ async fn permissions_profiles_allow_network_enablement() -> std::io::Result<()>
.await?;
assert!(
config.permissions.network_sandbox_policy.is_enabled(),
config.permissions.network_sandbox_policy().is_enabled(),
"expected network sandbox policy to be enabled",
);
assert!(
config
.permissions
.sandbox_policy
.get()
.has_full_network_access()
);
assert!(config.legacy_sandbox_policy().has_full_network_access());
Ok(())
}
@@ -1643,22 +1793,22 @@ exclude_slash_tmp = true
)
.await?;
let sandbox_policy = config.permissions.sandbox_policy.get();
let sandbox_policy = &config.legacy_sandbox_policy();
assert_eq!(
config.permissions.file_system_sandbox_policy,
config.permissions.file_system_sandbox_policy(),
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(sandbox_policy, cwd.path()),
"case `{name}` should preserve filesystem semantics from legacy config"
);
assert_eq!(
config.permissions.network_sandbox_policy,
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::from(sandbox_policy),
"case `{name}` should preserve network semantics from legacy config"
);
assert_eq!(
config
.permissions
.file_system_sandbox_policy
.to_legacy_sandbox_policy(config.permissions.network_sandbox_policy, cwd.path())
.file_system_sandbox_policy()
.to_legacy_sandbox_policy(config.permissions.network_sandbox_policy(), cwd.path())
.unwrap_or_else(|err| panic!("case `{name}` should round-trip: {err}")),
sandbox_policy.clone(),
"case `{name}` should round-trip through split policies without drift"
@@ -1826,12 +1976,12 @@ async fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result<
let expected_backend = backend.abs();
if cfg!(target_os = "windows") {
match config.permissions.sandbox_policy.get() {
match &config.legacy_sandbox_policy() {
SandboxPolicy::ReadOnly { .. } => {}
other => panic!("expected read-only policy on Windows, got {other:?}"),
}
} else {
match config.permissions.sandbox_policy.get() {
match &config.legacy_sandbox_policy() {
SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
assert_eq!(
writable_roots
@@ -1889,7 +2039,7 @@ async fn workspace_write_always_includes_memories_root_once() -> std::io::Result
.await?;
if cfg!(target_os = "windows") {
match config.permissions.sandbox_policy.get() {
match &config.legacy_sandbox_policy() {
SandboxPolicy::ReadOnly { .. } => {}
other => panic!("expected read-only policy on Windows, got {other:?}"),
}
@@ -1900,7 +2050,7 @@ async fn workspace_write_always_includes_memories_root_once() -> std::io::Result
memories_root.display()
);
let expected_memories_root = memories_root.abs();
match config.permissions.sandbox_policy.get() {
match &config.legacy_sandbox_policy() {
SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
assert_eq!(
writable_roots
@@ -2103,24 +2253,25 @@ fn web_search_mode_disabled_overrides_legacy_request() {
#[test]
fn web_search_mode_for_turn_uses_preference_for_read_only() {
let web_search_mode = Constrained::allow_any(WebSearchMode::Cached);
let mode =
resolve_web_search_mode_for_turn(&web_search_mode, &SandboxPolicy::new_read_only_policy());
let permission_profile =
PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::new_read_only_policy());
let mode = resolve_web_search_mode_for_turn(&web_search_mode, &permission_profile);
assert_eq!(mode, WebSearchMode::Cached);
}
#[test]
fn web_search_mode_for_turn_prefers_live_for_danger_full_access() {
fn web_search_mode_for_turn_prefers_live_for_disabled_permissions() {
let web_search_mode = Constrained::allow_any(WebSearchMode::Cached);
let mode = resolve_web_search_mode_for_turn(&web_search_mode, &SandboxPolicy::DangerFullAccess);
let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled);
assert_eq!(mode, WebSearchMode::Live);
}
#[test]
fn web_search_mode_for_turn_respects_disabled_for_danger_full_access() {
fn web_search_mode_for_turn_respects_disabled_for_disabled_permissions() {
let web_search_mode = Constrained::allow_any(WebSearchMode::Disabled);
let mode = resolve_web_search_mode_for_turn(&web_search_mode, &SandboxPolicy::DangerFullAccess);
let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled);
assert_eq!(mode, WebSearchMode::Disabled);
}
@@ -2140,7 +2291,7 @@ fn web_search_mode_for_turn_falls_back_when_live_is_disallowed() -> anyhow::Resu
})
}
})?;
let mode = resolve_web_search_mode_for_turn(&web_search_mode, &SandboxPolicy::DangerFullAccess);
let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled);
assert_eq!(mode, WebSearchMode::Cached);
Ok(())
@@ -2218,7 +2369,7 @@ async fn profile_sandbox_mode_overrides_base() -> std::io::Result<()> {
.await?;
assert!(matches!(
config.permissions.sandbox_policy.get(),
&config.legacy_sandbox_policy(),
&SandboxPolicy::DangerFullAccess
));
@@ -2252,12 +2403,12 @@ async fn cli_override_takes_precedence_over_profile_sandbox_mode() -> std::io::R
if cfg!(target_os = "windows") {
assert!(matches!(
config.permissions.sandbox_policy.get(),
&config.legacy_sandbox_policy(),
SandboxPolicy::ReadOnly { .. }
));
} else {
assert!(matches!(
config.permissions.sandbox_policy.get(),
&config.legacy_sandbox_policy(),
SandboxPolicy::WorkspaceWrite { .. }
));
}
@@ -2381,7 +2532,6 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> {
overrides,
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await?;
let cfg =
@@ -2517,7 +2667,6 @@ async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> {
overrides,
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await?;
@@ -3826,7 +3975,7 @@ async fn load_config_uses_requirements_guardian_policy_config() -> std::io::Resu
let config_layer_stack = ConfigLayerStack::new(
Vec::new(),
Default::default(),
crate::config_loader::ConfigRequirementsToml {
codex_config::ConfigRequirementsToml {
guardian_policy_config: Some(
" Use the workspace-managed guardian policy. ".to_string(),
),
@@ -3907,7 +4056,7 @@ async fn requirements_guardian_policy_beats_auto_review() -> std::io::Result<()>
let config_layer_stack = ConfigLayerStack::new(
Vec::new(),
Default::default(),
crate::config_loader::ConfigRequirementsToml {
codex_config::ConfigRequirementsToml {
guardian_policy_config: Some("Use the managed guardian policy.".to_string()),
..Default::default()
},
@@ -3971,7 +4120,7 @@ async fn load_config_ignores_empty_requirements_guardian_policy_config() -> std:
let config_layer_stack = ConfigLayerStack::new(
Vec::new(),
Default::default(),
crate::config_loader::ConfigRequirementsToml {
codex_config::ConfigRequirementsToml {
guardian_policy_config: Some(" ".to_string()),
..Default::default()
},
@@ -4103,15 +4252,15 @@ config_file = "./agents/researcher.toml"
"#,
)
.expect("agent role layer config should parse");
let config_layer_stack = crate::config_loader::ConfigLayerStack::new(
vec![crate::config_loader::ConfigLayerEntry::new(
let config_layer_stack = codex_config::ConfigLayerStack::new(
vec![codex_config::ConfigLayerEntry::new(
codex_app_server_protocol::ConfigLayerSource::User {
file: codex_home.path().join(CONFIG_TOML_FILE).abs(),
},
layer_config,
)],
Default::default(),
crate::config_loader::ConfigRequirementsToml::default(),
codex_config::ConfigRequirementsToml::default(),
)
.map_err(std::io::Error::other)?;
@@ -5292,11 +5441,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
model_provider: fixture.openai_provider.clone(),
permissions: Permissions {
approval_policy: Constrained::allow_any(AskForApproval::Never),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(
&SandboxPolicy::new_read_only_policy(),
),
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
permission_profile: Constrained::allow_any(PermissionProfile::read_only()),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
@@ -5489,11 +5634,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
model_provider: fixture.openai_custom_provider.clone(),
permissions: Permissions {
approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(
&SandboxPolicy::new_read_only_policy(),
),
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
permission_profile: Constrained::allow_any(PermissionProfile::read_only()),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
@@ -5640,11 +5781,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
model_provider: fixture.openai_provider.clone(),
permissions: Permissions {
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(
&SandboxPolicy::new_read_only_policy(),
),
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
permission_profile: Constrained::allow_any(PermissionProfile::read_only()),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
@@ -5776,11 +5913,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
model_provider: fixture.openai_provider.clone(),
permissions: Permissions {
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(
&SandboxPolicy::new_read_only_policy(),
),
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
permission_profile: Constrained::allow_any(PermissionProfile::read_only()),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
@@ -5892,14 +6025,12 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset()
{
let fixture = create_test_fixture()?;
let requirements_toml = crate::config_loader::ConfigRequirementsToml {
let requirements_toml = codex_config::ConfigRequirementsToml {
allowed_approval_policies: None,
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: Some(vec![
crate::config_loader::WebSearchModeRequirement::Cached,
]),
allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]),
feature_requirements: None,
hooks: None,
mcp_servers: None,
@@ -5910,7 +6041,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset()
permissions: None,
guardian_policy_config: None,
};
let requirement_source = crate::config_loader::RequirementSource::Unknown;
let requirement_source = codex_config::RequirementSource::Unknown;
let requirement_source_for_error = requirement_source.clone();
let allowed = vec![WebSearchMode::Disabled, WebSearchMode::Cached];
let constrained = Constrained::new(WebSearchMode::Cached, move |candidate| {
@@ -5925,15 +6056,15 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset()
})
}
})?;
let requirements = crate::config_loader::ConfigRequirements {
web_search_mode: crate::config_loader::ConstrainedWithSource::new(
let requirements = codex_config::ConfigRequirements {
web_search_mode: codex_config::ConstrainedWithSource::new(
constrained,
Some(requirement_source),
),
..Default::default()
};
let config_layer_stack =
crate::config_loader::ConfigLayerStack::new(Vec::new(), requirements, requirements_toml)
codex_config::ConfigLayerStack::new(Vec::new(), requirements, requirements_toml)
.expect("config layer stack");
let config = Config::load_config_with_layer_stack(
@@ -6519,7 +6650,7 @@ async fn test_untrusted_project_gets_unless_trusted_approval_policy() -> anyhow:
if cfg!(target_os = "windows") {
assert!(
matches!(
config.permissions.sandbox_policy.get(),
&config.legacy_sandbox_policy(),
SandboxPolicy::ReadOnly { .. }
),
"Expected ReadOnly on Windows"
@@ -6527,7 +6658,7 @@ async fn test_untrusted_project_gets_unless_trusted_approval_policy() -> anyhow:
} else {
assert!(
matches!(
config.permissions.sandbox_policy.get(),
&config.legacy_sandbox_policy(),
SandboxPolicy::WorkspaceWrite { .. }
),
"Expected WorkspaceWrite sandbox for untrusted project"
@@ -6545,17 +6676,15 @@ async fn requirements_disallowing_default_sandbox_falls_back_to_required_default
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(crate::config_loader::ConfigRequirementsToml {
allowed_sandbox_modes: Some(vec![
crate::config_loader::SandboxModeRequirement::ReadOnly,
]),
Ok(Some(codex_config::ConfigRequirementsToml {
allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]),
..Default::default()
}))
}))
.build()
.await?;
assert_eq!(
*config.permissions.sandbox_policy.get(),
config.legacy_sandbox_policy(),
SandboxPolicy::new_read_only_policy()
);
Ok(())
@@ -6570,10 +6699,10 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s
"#,
)?;
let requirements = crate::config_loader::ConfigRequirementsToml {
let requirements = codex_config::ConfigRequirementsToml {
allowed_approval_policies: None,
allowed_approvals_reviewers: None,
allowed_sandbox_modes: Some(vec![crate::config_loader::SandboxModeRequirement::ReadOnly]),
allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]),
remote_sandbox_config: None,
allowed_web_search_modes: None,
feature_requirements: None,
@@ -6596,12 +6725,99 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s
.build()
.await?;
assert_eq!(
*config.permissions.sandbox_policy.get(),
config.legacy_sandbox_policy(),
SandboxPolicy::new_read_only_policy()
);
Ok(())
}
#[tokio::test]
async fn permission_profile_override_falls_back_when_disallowed_by_requirements()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let requirements = codex_config::ConfigRequirementsToml {
allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]),
..Default::default()
};
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.harness_overrides(ConfigOverrides {
permission_profile: Some(PermissionProfile::Disabled),
..Default::default()
})
.cloud_requirements(CloudRequirementsLoader::new(async move {
Ok(Some(requirements))
}))
.build()
.await?;
let expected_sandbox_policy = SandboxPolicy::new_read_only_policy();
assert_eq!(config.legacy_sandbox_policy(), expected_sandbox_policy);
assert_eq!(
config.permissions.permission_profile(),
PermissionProfile::read_only()
);
Ok(())
}
#[tokio::test]
async fn permission_profile_override_preserves_split_write_roots() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = codex_home.path().join("workspace");
let outside_root = codex_home.path().join("outside-write");
std::fs::create_dir_all(&cwd)?;
std::fs::create_dir_all(&outside_root)?;
let outside_root =
AbsolutePathBuf::from_absolute_path(outside_root).expect("outside root is absolute");
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: outside_root.clone(),
},
access: FileSystemAccessMode::Write,
},
]);
let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement(
SandboxEnforcement::Managed,
&file_system_sandbox_policy,
NetworkSandboxPolicy::Restricted,
);
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(cwd))
.harness_overrides(ConfigOverrides {
permission_profile: Some(permission_profile),
..Default::default()
})
.build()
.await?;
assert!(
config
.permissions
.file_system_sandbox_policy()
.can_write_path_with_cwd(outside_root.as_path(), config.cwd.as_path())
);
assert!(matches!(
&config.legacy_sandbox_policy(),
SandboxPolicy::WorkspaceWrite { .. }
));
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Restricted
);
Ok(())
}
#[tokio::test]
async fn requirements_web_search_mode_overrides_danger_full_access_default() -> std::io::Result<()>
{
@@ -6616,9 +6832,9 @@ async fn requirements_web_search_mode_overrides_danger_full_access_default() ->
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(crate::config_loader::ConfigRequirementsToml {
Ok(Some(codex_config::ConfigRequirementsToml {
allowed_web_search_modes: Some(vec![
crate::config_loader::WebSearchModeRequirement::Cached,
codex_config::WebSearchModeRequirement::Cached,
]),
..Default::default()
}))
@@ -6630,7 +6846,7 @@ async fn requirements_web_search_mode_overrides_danger_full_access_default() ->
assert_eq!(
resolve_web_search_mode_for_turn(
&config.web_search_mode,
config.permissions.sandbox_policy.get(),
&config.permissions.permission_profile(),
),
WebSearchMode::Cached,
);
@@ -6657,7 +6873,7 @@ trust_level = "untrusted"
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(workspace.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(crate::config_loader::ConfigRequirementsToml {
Ok(Some(codex_config::ConfigRequirementsToml {
allowed_approval_policies: Some(vec![AskForApproval::OnRequest]),
..Default::default()
}))
@@ -6686,7 +6902,7 @@ async fn explicit_approval_policy_falls_back_when_disallowed_by_requirements() -
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(crate::config_loader::ConfigRequirementsToml {
Ok(Some(codex_config::ConfigRequirementsToml {
allowed_approval_policies: Some(vec![AskForApproval::OnRequest]),
..Default::default()
}))
@@ -6707,8 +6923,8 @@ async fn feature_requirements_normalize_effective_feature_values() -> std::io::R
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(crate::config_loader::ConfigRequirementsToml {
feature_requirements: Some(crate::config_loader::FeatureRequirementsToml {
Ok(Some(codex_config::ConfigRequirementsToml {
feature_requirements: Some(codex_config::FeatureRequirementsToml {
entries: BTreeMap::from([
("personality".to_string(), true),
("shell_tool".to_string(), false),
@@ -6741,8 +6957,8 @@ async fn feature_requirements_auto_review_disables_guardian_approval() -> std::i
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(crate::config_loader::ConfigRequirementsToml {
feature_requirements: Some(crate::config_loader::FeatureRequirementsToml {
Ok(Some(codex_config::ConfigRequirementsToml {
feature_requirements: Some(codex_config::FeatureRequirementsToml {
entries: BTreeMap::from([("auto_review".to_string(), false)]),
}),
..Default::default()
@@ -6763,8 +6979,8 @@ async fn browser_feature_requirements_are_valid() -> std::io::Result<()> {
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(crate::config_loader::ConfigRequirementsToml {
feature_requirements: Some(crate::config_loader::FeatureRequirementsToml {
Ok(Some(codex_config::ConfigRequirementsToml {
feature_requirements: Some(codex_config::FeatureRequirementsToml {
entries: BTreeMap::from([
("in_app_browser".to_string(), false),
("browser_use".to_string(), false),
@@ -6798,8 +7014,8 @@ shell_tool = true
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(crate::config_loader::ConfigRequirementsToml {
feature_requirements: Some(crate::config_loader::FeatureRequirementsToml {
Ok(Some(codex_config::ConfigRequirementsToml {
feature_requirements: Some(codex_config::FeatureRequirementsToml {
entries: BTreeMap::from([
("personality".to_string(), true),
("shell_tool".to_string(), false),
@@ -6945,7 +7161,7 @@ async fn requirements_disallowing_default_approvals_reviewer_falls_back_to_requi
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(crate::config_loader::ConfigRequirementsToml {
Ok(Some(codex_config::ConfigRequirementsToml {
allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]),
..Default::default()
}))
@@ -6971,7 +7187,7 @@ async fn root_approvals_reviewer_falls_back_when_disallowed_by_requirements() ->
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(crate::config_loader::ConfigRequirementsToml {
Ok(Some(codex_config::ConfigRequirementsToml {
allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]),
..Default::default()
}))
@@ -7008,7 +7224,7 @@ approvals_reviewer = "user"
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(crate::config_loader::ConfigRequirementsToml {
Ok(Some(codex_config::ConfigRequirementsToml {
allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]),
..Default::default()
}))
@@ -7034,7 +7250,7 @@ async fn approvals_reviewer_preserves_valid_user_choice_when_allowed_by_requirem
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(crate::config_loader::ConfigRequirementsToml {
Ok(Some(codex_config::ConfigRequirementsToml {
allowed_approvals_reviewers: Some(vec![
ApprovalsReviewer::User,
ApprovalsReviewer::AutoReview,
@@ -7186,8 +7402,8 @@ async fn feature_requirements_normalize_runtime_feature_mutations() -> std::io::
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(crate::config_loader::ConfigRequirementsToml {
feature_requirements: Some(crate::config_loader::FeatureRequirementsToml {
Ok(Some(codex_config::ConfigRequirementsToml {
feature_requirements: Some(codex_config::FeatureRequirementsToml {
entries: BTreeMap::from([
("personality".to_string(), true),
("shell_tool".to_string(), false),
@@ -7222,8 +7438,8 @@ async fn feature_requirements_warn_on_collab_legacy_alias() -> std::io::Result<(
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(crate::config_loader::ConfigRequirementsToml {
feature_requirements: Some(crate::config_loader::FeatureRequirementsToml {
Ok(Some(codex_config::ConfigRequirementsToml {
feature_requirements: Some(codex_config::FeatureRequirementsToml {
entries: BTreeMap::from([("collab".to_string(), true)]),
}),
..Default::default()
@@ -7252,8 +7468,8 @@ async fn feature_requirements_warn_and_ignore_unknown_feature() -> std::io::Resu
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(crate::config_loader::ConfigRequirementsToml {
feature_requirements: Some(crate::config_loader::FeatureRequirementsToml {
Ok(Some(codex_config::ConfigRequirementsToml {
feature_requirements: Some(codex_config::FeatureRequirementsToml {
entries: BTreeMap::from([("made_up_feature".to_string(), true)]),
}),
..Default::default()
+212 -89
View File
@@ -1,20 +1,6 @@
use crate::agents_md::AgentsMdManager;
use crate::config::edit::ConfigEdit;
use crate::config::edit::ConfigEditsBuilder;
use crate::config_loader::CloudRequirementsLoader;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigLayerStackOrdering;
use crate::config_loader::ConfigRequirements;
use crate::config_loader::ConfigRequirementsToml;
use crate::config_loader::ConstrainedWithSource;
use crate::config_loader::FeatureRequirementsToml;
use crate::config_loader::LoaderOverrides;
use crate::config_loader::McpServerIdentity;
use crate::config_loader::McpServerRequirement;
use crate::config_loader::ResidencyRequirement;
use crate::config_loader::Sourced;
use crate::config_loader::load_config_layers_state;
use crate::config_loader::project_trust_key;
use crate::memories::memory_root;
use crate::path_utils::normalize_for_native_workdir;
use crate::unified_exec::DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS;
@@ -22,6 +8,18 @@ use crate::unified_exec::MIN_EMPTY_YIELD_TIME_MS;
use crate::windows_sandbox::WindowsSandboxLevelExt;
use crate::windows_sandbox::resolve_windows_sandbox_mode;
use crate::windows_sandbox::resolve_windows_sandbox_private_desktop;
use codex_config::CloudRequirementsLoader;
use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_config::ConstrainedWithSource;
use codex_config::FeatureRequirementsToml;
use codex_config::LoaderOverrides;
use codex_config::McpServerIdentity;
use codex_config::McpServerRequirement;
use codex_config::ResidencyRequirement;
use codex_config::Sourced;
use codex_config::ThreadConfigLoader;
use codex_config::config_toml::ConfigToml;
use codex_config::config_toml::ProjectConfig;
@@ -29,6 +27,8 @@ use codex_config::config_toml::RealtimeAudioConfig;
use codex_config::config_toml::RealtimeConfig;
use codex_config::config_toml::ThreadStoreToml;
use codex_config::config_toml::validate_model_providers;
use codex_config::loader::load_config_layers_state;
use codex_config::loader::project_trust_key;
use codex_config::profile_toml::ConfigProfile;
use codex_config::types::ApprovalsReviewer;
use codex_config::types::AuthCredentialsStoreMode;
@@ -44,7 +44,6 @@ use codex_config::types::OAuthCredentialsStoreMode;
use codex_config::types::OtelConfig;
use codex_config::types::OtelConfigToml;
use codex_config::types::OtelExporterKind;
use codex_config::types::ShellEnvironmentPolicy;
use codex_config::types::ToolSuggestConfig;
use codex_config::types::ToolSuggestDiscoverable;
use codex_config::types::TuiNotificationSettings;
@@ -74,6 +73,7 @@ use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::ShellEnvironmentPolicy;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::Verbosity;
use codex_protocol::config_types::WebSearchConfig;
@@ -115,6 +115,7 @@ pub use codex_config::Constrained;
pub use codex_config::ConstraintError;
pub use codex_config::ConstraintResult;
pub use codex_network_proxy::NetworkProxyAuditMetadata;
use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile;
pub use codex_sandboxing::system_bwrap_warning;
pub use managed_features::ManagedFeatures;
pub use network_proxy_spec::NetworkProxySpec;
@@ -191,14 +192,9 @@ pub(crate) async fn test_config() -> Config {
pub struct Permissions {
/// Approval policy for executing commands.
pub approval_policy: Constrained<AskForApproval>,
/// Effective sandbox policy used for shell/unified exec.
pub sandbox_policy: Constrained<SandboxPolicy>,
/// Effective filesystem sandbox policy, including entries that cannot yet
/// be fully represented by the legacy [`SandboxPolicy`] projection.
pub file_system_sandbox_policy: FileSystemSandboxPolicy,
/// Effective network sandbox policy split out from the legacy
/// [`SandboxPolicy`] projection.
pub network_sandbox_policy: NetworkSandboxPolicy,
/// Canonical effective runtime permissions after config requirements and
/// runtime readable-root additions have been applied.
pub permission_profile: Constrained<PermissionProfile>,
/// Effective network configuration applied to all spawned processes.
pub network: Option<NetworkProxySpec>,
/// Whether the model may request a login shell for shell-based tools.
@@ -223,12 +219,99 @@ impl Permissions {
/// Effective runtime permissions after config requirements and runtime
/// readable-root additions have been applied.
pub fn permission_profile(&self) -> PermissionProfile {
PermissionProfile::from_runtime_permissions_with_enforcement(
SandboxEnforcement::from_legacy_sandbox_policy(self.sandbox_policy.get()),
&self.file_system_sandbox_policy,
self.network_sandbox_policy,
self.permission_profile.get().clone()
}
/// Effective filesystem sandbox policy derived from the canonical profile.
pub fn file_system_sandbox_policy(&self) -> FileSystemSandboxPolicy {
self.permission_profile.get().file_system_sandbox_policy()
}
/// Effective network sandbox policy derived from the canonical profile.
pub fn network_sandbox_policy(&self) -> NetworkSandboxPolicy {
self.permission_profile.get().network_sandbox_policy()
}
/// Legacy compatibility projection derived from the canonical profile.
pub fn legacy_sandbox_policy(&self, cwd: &Path) -> SandboxPolicy {
let permission_profile = self.permission_profile.get();
let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy();
compatibility_sandbox_policy_for_permission_profile(
permission_profile,
&file_system_sandbox_policy,
permission_profile.network_sandbox_policy(),
cwd,
)
}
/// Check whether a legacy sandbox policy can be applied to this permission
/// set after projecting it into the canonical permission profile.
pub fn can_set_legacy_sandbox_policy(
&self,
sandbox_policy: &SandboxPolicy,
cwd: &Path,
) -> ConstraintResult<()> {
let file_system_sandbox_policy =
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(sandbox_policy, cwd);
let network_sandbox_policy = NetworkSandboxPolicy::from(sandbox_policy);
let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement(
SandboxEnforcement::from_legacy_sandbox_policy(sandbox_policy),
&file_system_sandbox_policy,
network_sandbox_policy,
);
self.permission_profile.can_set(&permission_profile)
}
/// Replace permissions from a legacy sandbox policy and keep every
/// permission projection in sync.
pub fn set_legacy_sandbox_policy(
&mut self,
sandbox_policy: SandboxPolicy,
cwd: &Path,
) -> ConstraintResult<()> {
self.can_set_legacy_sandbox_policy(&sandbox_policy, cwd)?;
let file_system_sandbox_policy =
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&sandbox_policy, cwd);
let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy);
let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement(
SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy),
&file_system_sandbox_policy,
network_sandbox_policy,
);
self.permission_profile.set(permission_profile)?;
Ok(())
}
/// Replace permissions from the canonical profile.
pub fn set_permission_profile(
&mut self,
permission_profile: PermissionProfile,
) -> ConstraintResult<()> {
self.permission_profile.can_set(&permission_profile)?;
self.permission_profile.set(permission_profile)?;
Ok(())
}
}
fn constrained_permission_profile_from_sandbox_projection(
initial_value: PermissionProfile,
sandbox_constraint: Constrained<SandboxPolicy>,
cwd: AbsolutePathBuf,
) -> std::io::Result<Constrained<PermissionProfile>> {
Constrained::new(initial_value, move |candidate| {
let (file_system_sandbox_policy, network_sandbox_policy) =
candidate.to_runtime_permissions();
let sandbox_policy = compatibility_sandbox_policy_for_permission_profile(
candidate,
&file_system_sandbox_policy,
network_sandbox_policy,
cwd.as_path(),
);
sandbox_constraint.can_set(&sandbox_policy)
})
.map_err(std::io::Error::from)
}
/// Configured thread persistence backend.
@@ -687,7 +770,7 @@ impl AuthManagerConfig for Config {
}
}
#[derive(Clone)]
#[derive(Clone, Default)]
pub struct ConfigBuilder {
codex_home: Option<PathBuf>,
cli_overrides: Option<Vec<(String, TomlValue)>>,
@@ -696,22 +779,6 @@ pub struct ConfigBuilder {
cloud_requirements: CloudRequirementsLoader,
thread_config_loader: Option<Arc<dyn ThreadConfigLoader>>,
fallback_cwd: Option<PathBuf>,
host_name: Option<String>,
}
impl Default for ConfigBuilder {
fn default() -> Self {
Self {
codex_home: None,
cli_overrides: None,
harness_overrides: None,
loader_overrides: None,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: None,
fallback_cwd: None,
host_name: codex_config::host_name(),
}
}
}
impl ConfigBuilder {
@@ -753,11 +820,6 @@ impl ConfigBuilder {
self
}
pub fn host_name(mut self, host_name: Option<String>) -> Self {
self.host_name = host_name;
self
}
pub async fn build(self) -> std::io::Result<Config> {
let Self {
codex_home,
@@ -767,7 +829,6 @@ impl ConfigBuilder {
cloud_requirements,
thread_config_loader,
fallback_cwd,
host_name,
} = self;
let codex_home = match codex_home {
Some(codex_home) => AbsolutePathBuf::from_absolute_path(codex_home)?,
@@ -792,7 +853,6 @@ impl ConfigBuilder {
thread_config_loader
.as_deref()
.unwrap_or(&codex_config::NoopThreadConfigLoader),
host_name.as_deref(),
)
.await?;
let merged_toml = config_layer_stack.effective_config();
@@ -804,10 +864,13 @@ impl ConfigBuilder {
let config_toml: ConfigToml = match merged_toml.try_into() {
Ok(config_toml) => config_toml,
Err(err) => {
if let Some(config_error) =
crate::config_loader::first_layer_config_error(&config_layer_stack).await
if let Some(config_error) = codex_config::first_layer_config_error::<ConfigToml>(
&config_layer_stack,
codex_config::CONFIG_TOML_FILE,
)
.await
{
return Err(crate::config_loader::io_error_from_config_error(
return Err(codex_config::io_error_from_config_error(
std::io::ErrorKind::InvalidData,
config_error,
Some(err),
@@ -833,6 +896,18 @@ impl ConfigBuilder {
}
impl Config {
pub fn legacy_sandbox_policy(&self) -> SandboxPolicy {
self.permissions.legacy_sandbox_policy(self.cwd.as_path())
}
pub fn set_legacy_sandbox_policy(
&mut self,
sandbox_policy: SandboxPolicy,
) -> ConstraintResult<()> {
self.permissions
.set_legacy_sandbox_policy(sandbox_policy, self.cwd.as_path())
}
pub fn to_models_manager_config(&self) -> ModelsManagerConfig {
ModelsManagerConfig {
model_context_window: self.model_context_window,
@@ -907,8 +982,8 @@ impl Config {
format!("failed to serialize default config: {e}"),
)
})?;
let cli_layer = crate::config_loader::build_cli_overrides_layer(&cli_overrides);
crate::config_loader::merge_toml_values(&mut merged, &cli_layer);
let cli_layer = codex_config::build_cli_overrides_layer(&cli_overrides);
codex_config::merge_toml_values(&mut merged, &cli_layer);
let codex_home = AbsolutePathBuf::from_absolute_path_checked(codex_home)?;
let config_toml = deserialize_config_toml_with_base(merged, &codex_home)?;
Self::load_config_with_layer_stack(
@@ -972,7 +1047,6 @@ pub async fn load_config_as_toml_with_cli_and_loader_overrides(
loader_overrides,
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await?;
@@ -1154,7 +1228,6 @@ pub async fn load_global_mcp_servers(
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await?;
let merged_toml = config_layer_stack.effective_config();
@@ -1390,7 +1463,7 @@ fn resolve_permission_config_syntax(
fn apply_managed_filesystem_constraints(
file_system_sandbox_policy: &mut FileSystemSandboxPolicy,
filesystem_constraints: &crate::config_loader::FilesystemConstraints,
filesystem_constraints: &codex_config::FilesystemConstraints,
) {
for deny_read in &filesystem_constraints.deny_read {
let deny_entry = if deny_read.contains_glob() {
@@ -1567,11 +1640,11 @@ fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiA
pub(crate) fn resolve_web_search_mode_for_turn(
web_search_mode: &Constrained<WebSearchMode>,
sandbox_policy: &SandboxPolicy,
permission_profile: &PermissionProfile,
) -> WebSearchMode {
let preferred = web_search_mode.value();
if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess)
if matches!(permission_profile, PermissionProfile::Disabled)
&& preferred != WebSearchMode::Disabled
{
for mode in [
@@ -1807,10 +1880,11 @@ impl Config {
&& has_permission_profiles);
let (
configured_network_proxy_config,
permission_profile,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
) = if let Some(permission_profile) = permission_profile {
) = if let Some(mut permission_profile) = permission_profile {
let (mut file_system_sandbox_policy, network_sandbox_policy) =
permission_profile.to_runtime_permissions();
let configured_network_proxy_config =
@@ -1836,25 +1910,33 @@ impl Config {
} else {
NetworkProxyConfig::default()
};
let mut sandbox_policy = permission_profile
.to_legacy_sandbox_policy(resolved_cwd.as_path())
.map_err(|err| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("invalid permission_profile override: {err}"),
)
})?;
let mut sandbox_policy = compatibility_sandbox_policy_for_permission_profile(
&permission_profile,
&file_system_sandbox_policy,
network_sandbox_policy,
resolved_cwd.as_path(),
);
if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) {
file_system_sandbox_policy = file_system_sandbox_policy
.with_additional_writable_roots(
resolved_cwd.as_path(),
&additional_writable_roots,
);
sandbox_policy = file_system_sandbox_policy
.to_legacy_sandbox_policy(network_sandbox_policy, resolved_cwd.as_path())?;
permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement(
permission_profile.enforcement(),
&file_system_sandbox_policy,
network_sandbox_policy,
);
sandbox_policy = compatibility_sandbox_policy_for_permission_profile(
&permission_profile,
&file_system_sandbox_policy,
network_sandbox_policy,
resolved_cwd.as_path(),
);
}
(
configured_network_proxy_config,
permission_profile,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
@@ -1882,19 +1964,36 @@ impl Config {
resolved_cwd.as_path(),
&mut startup_warnings,
)?;
let mut sandbox_policy = file_system_sandbox_policy
.to_legacy_sandbox_policy(network_sandbox_policy, resolved_cwd.as_path())?;
let mut permission_profile = PermissionProfile::from_runtime_permissions(
&file_system_sandbox_policy,
network_sandbox_policy,
);
let mut sandbox_policy = compatibility_sandbox_policy_for_permission_profile(
&permission_profile,
&file_system_sandbox_policy,
network_sandbox_policy,
resolved_cwd.as_path(),
);
if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) {
file_system_sandbox_policy = file_system_sandbox_policy
.with_additional_writable_roots(
resolved_cwd.as_path(),
&additional_writable_roots,
);
sandbox_policy = file_system_sandbox_policy
.to_legacy_sandbox_policy(network_sandbox_policy, resolved_cwd.as_path())?;
permission_profile = PermissionProfile::from_runtime_permissions(
&file_system_sandbox_policy,
network_sandbox_policy,
);
sandbox_policy = compatibility_sandbox_policy_for_permission_profile(
&permission_profile,
&file_system_sandbox_policy,
network_sandbox_policy,
resolved_cwd.as_path(),
);
}
(
configured_network_proxy_config,
permission_profile,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
@@ -1923,8 +2022,14 @@ impl Config {
resolved_cwd.as_path(),
);
let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy);
let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement(
SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy),
&file_system_sandbox_policy,
network_sandbox_policy,
);
(
configured_network_proxy_config,
permission_profile,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
@@ -2010,13 +2115,6 @@ impl Config {
let history = cfg.history.unwrap_or_default();
let agent_max_threads_from_config = cfg.agents.as_ref().and_then(|agents| agents.max_threads);
if features.enabled(Feature::MultiAgentV2) && agent_max_threads_from_config.is_some() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"agents.max_threads cannot be set when multi_agent_v2 is enabled",
));
}
let agent_max_threads = cfg
.agents
.as_ref()
@@ -2289,10 +2387,17 @@ impl Config {
None => (None, None),
};
let has_network_requirements = network_requirements.is_some();
let network_permission_profile = if *constrained_sandbox_policy.get()
== original_sandbox_policy
{
permission_profile.clone()
} else {
PermissionProfile::from_legacy_sandbox_policy(constrained_sandbox_policy.get())
};
let network = NetworkProxySpec::from_config_and_constraints(
configured_network_proxy_config,
network_requirements,
constrained_sandbox_policy.get(),
&network_permission_profile,
)
.map_err(|err| {
if let Some(source) = network_requirements_source.as_ref() {
@@ -2343,6 +2448,22 @@ impl Config {
} else {
NetworkSandboxPolicy::from(&effective_sandbox_policy)
};
let effective_enforcement = if effective_sandbox_policy == original_sandbox_policy {
permission_profile.enforcement()
} else {
SandboxEnforcement::from_legacy_sandbox_policy(&effective_sandbox_policy)
};
let effective_permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement(
effective_enforcement,
&effective_file_system_sandbox_policy,
effective_network_sandbox_policy,
);
let constrained_permission_profile =
constrained_permission_profile_from_sandbox_projection(
effective_permission_profile,
constrained_sandbox_policy.value.clone(),
resolved_cwd.clone(),
)?;
let config = Self {
model,
service_tier,
@@ -2355,9 +2476,7 @@ impl Config {
startup_warnings,
permissions: Permissions {
approval_policy: constrained_approval_policy.value,
sandbox_policy: constrained_sandbox_policy.value,
file_system_sandbox_policy: effective_file_system_sandbox_policy,
network_sandbox_policy: effective_network_sandbox_policy,
permission_profile: constrained_permission_profile,
network,
allow_login_shell,
shell_environment_policy,
@@ -2610,8 +2729,8 @@ impl Config {
pub fn managed_network_requirements_enabled(&self) -> bool {
!matches!(
self.permissions.sandbox_policy.get(),
SandboxPolicy::DangerFullAccess
self.permissions.permission_profile.get(),
PermissionProfile::Disabled
) && self
.config_layer_stack
.requirements_toml()
@@ -2682,3 +2801,7 @@ pub fn log_dir(cfg: &Config) -> std::io::Result<PathBuf> {
#[cfg(test)]
#[path = "config_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "config_loader_tests.rs"]
mod config_loader_tests;
+20 -25
View File
@@ -1,5 +1,5 @@
use crate::config_loader::NetworkConstraints;
use async_trait::async_trait;
use codex_config::NetworkConstraints;
use codex_execpolicy::Policy;
use codex_network_proxy::BlockedRequestObserver;
use codex_network_proxy::ConfigReloader;
@@ -16,7 +16,7 @@ use codex_network_proxy::build_config_state;
use codex_network_proxy::host_and_port_from_network_addr;
use codex_network_proxy::normalize_host;
use codex_network_proxy::validate_policy_against_constraints;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::models::PermissionProfile;
use std::collections::HashSet;
use std::sync::Arc;
@@ -89,7 +89,7 @@ impl NetworkProxySpec {
pub(crate) fn from_config_and_constraints(
config: NetworkProxyConfig,
requirements: Option<NetworkConstraints>,
sandbox_policy: &SandboxPolicy,
permission_profile: &PermissionProfile,
) -> std::io::Result<Self> {
let base_config = config.clone();
let hard_deny_allowlist_misses = requirements
@@ -99,7 +99,7 @@ impl NetworkProxySpec {
Self::apply_requirements(
config,
requirements,
sandbox_policy,
permission_profile,
hard_deny_allowlist_misses,
)
} else {
@@ -122,7 +122,7 @@ impl NetworkProxySpec {
pub async fn start_proxy(
&self,
sandbox_policy: &SandboxPolicy,
permission_profile: &PermissionProfile,
policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
enable_network_approval_flow: bool,
@@ -133,10 +133,7 @@ impl NetworkProxySpec {
if enable_network_approval_flow && !self.hard_deny_allowlist_misses {
if let Some(policy_decider) = policy_decider {
builder = builder.policy_decider_arc(policy_decider);
} else if matches!(
sandbox_policy,
SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. }
) {
} else if Self::managed_sandbox_active(permission_profile) {
builder = builder
.policy_decider(|_request| async { NetworkDecision::ask("not_allowed") });
}
@@ -154,14 +151,14 @@ impl NetworkProxySpec {
Ok(StartedNetworkProxy::new(proxy, handle))
}
pub(crate) fn recompute_for_sandbox_policy(
pub(crate) fn recompute_for_permission_profile(
&self,
sandbox_policy: &SandboxPolicy,
permission_profile: &PermissionProfile,
) -> std::io::Result<Self> {
Self::from_config_and_constraints(
self.base_config.clone(),
self.requirements.clone(),
sandbox_policy,
permission_profile,
)
}
@@ -216,13 +213,13 @@ impl NetworkProxySpec {
fn apply_requirements(
mut config: NetworkProxyConfig,
requirements: &NetworkConstraints,
sandbox_policy: &SandboxPolicy,
permission_profile: &PermissionProfile,
hard_deny_allowlist_misses: bool,
) -> (NetworkProxyConfig, NetworkProxyConstraints) {
let mut constraints = NetworkProxyConstraints::default();
let allowlist_expansion_enabled =
Self::allowlist_expansion_enabled(sandbox_policy, hard_deny_allowlist_misses);
let denylist_expansion_enabled = Self::denylist_expansion_enabled(sandbox_policy);
Self::allowlist_expansion_enabled(permission_profile, hard_deny_allowlist_misses);
let denylist_expansion_enabled = Self::denylist_expansion_enabled(permission_profile);
if let Some(enabled) = requirements.enabled {
config.network.enabled = enabled;
@@ -322,24 +319,22 @@ impl NetworkProxySpec {
}
fn allowlist_expansion_enabled(
sandbox_policy: &SandboxPolicy,
permission_profile: &PermissionProfile,
hard_deny_allowlist_misses: bool,
) -> bool {
matches!(
sandbox_policy,
SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. }
) && !hard_deny_allowlist_misses
Self::managed_sandbox_active(permission_profile) && !hard_deny_allowlist_misses
}
fn managed_allowed_domains_only(requirements: &NetworkConstraints) -> bool {
requirements.managed_allowed_domains_only.unwrap_or(false)
}
fn denylist_expansion_enabled(sandbox_policy: &SandboxPolicy) -> bool {
matches!(
sandbox_policy,
SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. }
)
fn denylist_expansion_enabled(permission_profile: &PermissionProfile) -> bool {
Self::managed_sandbox_active(permission_profile)
}
fn managed_sandbox_active(permission_profile: &PermissionProfile) -> bool {
matches!(permission_profile, PermissionProfile::Managed { .. })
}
fn merge_domain_lists(mut managed: Vec<String>, user_entries: &[String]) -> Vec<String> {
@@ -1,9 +1,17 @@
use super::*;
use crate::config_loader::NetworkDomainPermissionToml;
use crate::config_loader::NetworkDomainPermissionsToml;
use codex_config::NetworkDomainPermissionToml;
use codex_config::NetworkDomainPermissionsToml;
use codex_network_proxy::NetworkDomainPermission;
use codex_protocol::models::ManagedFileSystemPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::SandboxPolicy;
use pretty_assertions::assert_eq;
fn permission_profile_for_sandbox_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile {
PermissionProfile::from_legacy_sandbox_policy(sandbox_policy)
}
fn domain_permissions(
entries: impl IntoIterator<Item = (&'static str, NetworkDomainPermissionToml)>,
) -> NetworkDomainPermissionsToml {
@@ -54,7 +62,7 @@ fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() {
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::new_read_only_policy(),
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_read_only_policy()),
)
.expect("config should stay within the managed allowlist");
@@ -89,7 +97,7 @@ fn requirements_allowed_domains_do_not_override_user_denies_for_same_pattern() {
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::new_workspace_write_policy(),
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
)
.expect("managed allowlist should not erase a user deny");
@@ -121,7 +129,7 @@ fn requirements_allowlist_expansion_keeps_user_entries_mutable() {
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::new_workspace_write_policy(),
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
)
.expect("managed baseline should still allow user edits");
@@ -144,6 +152,41 @@ fn requirements_allowlist_expansion_keeps_user_entries_mutable() {
.expect("user allowlist entries should not become managed constraints");
}
#[test]
fn managed_unrestricted_profile_allows_domain_expansion() {
let mut config = NetworkProxyConfig::default();
config
.network
.set_allowed_domains(vec!["api.example.com".to_string()]);
let requirements = NetworkConstraints {
domains: Some(domain_permissions([(
"*.example.com",
NetworkDomainPermissionToml::Allow,
)])),
..Default::default()
};
let permission_profile = PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Unrestricted,
network: NetworkSandboxPolicy::Restricted,
};
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&permission_profile,
)
.expect("managed unrestricted filesystem should still use managed network constraints");
assert_eq!(
spec.config.network.allowed_domains(),
Some(vec![
"*.example.com".to_string(),
"api.example.com".to_string()
])
);
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(true));
}
#[test]
fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() {
let mut config = NetworkProxyConfig::default();
@@ -164,7 +207,7 @@ fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() {
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::DangerFullAccess,
&permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess),
)
.expect("yolo mode should pin the effective policy to the managed baseline");
@@ -198,7 +241,7 @@ fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() {
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::new_workspace_write_policy(),
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
)
.expect("managed baseline should still load");
@@ -227,7 +270,7 @@ fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses()
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::new_workspace_write_policy(),
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
)
.expect("managed-only allowlist should still load");
@@ -257,7 +300,7 @@ fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domain
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::new_workspace_write_policy(),
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
)
.expect("managed-only mode should treat missing managed allowlist as empty");
@@ -281,7 +324,7 @@ fn managed_allowed_domains_only_blocks_all_user_domains_in_full_access_without_m
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::DangerFullAccess,
&permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess),
)
.expect("managed-only mode should treat missing managed allowlist as empty");
@@ -308,7 +351,7 @@ fn deny_only_requirements_do_not_create_allow_constraints_in_full_access() {
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::DangerFullAccess,
&permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess),
)
.expect("deny-only requirements should not constrain the allowlist");
@@ -341,7 +384,7 @@ fn allow_only_requirements_do_not_create_deny_constraints_in_full_access() {
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::DangerFullAccess,
&permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess),
)
.expect("allow-only requirements should not constrain the denylist");
@@ -374,7 +417,7 @@ fn requirements_denied_domains_are_a_baseline_for_default_mode() {
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::new_workspace_write_policy(),
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
)
.expect("default mode should merge managed and user deny entries");
@@ -409,7 +452,7 @@ fn requirements_denylist_expansion_keeps_user_entries_mutable() {
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::new_workspace_write_policy(),
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
)
.expect("managed baseline should still allow user edits");
+10
View File
@@ -383,6 +383,16 @@ fn validate_glob_scan_max_depth(max_depth: Option<usize>) -> io::Result<Option<u
}
fn contains_glob_chars(path: &str) -> bool {
contains_glob_chars_for_platform(path, cfg!(windows))
}
fn contains_glob_chars_for_platform(path: &str, is_windows: bool) -> bool {
let normalized_windows_path = if is_windows {
normalize_windows_device_path(path)
} else {
None
};
let path = normalized_windows_path.as_deref().unwrap_or(path);
path.chars().any(|ch| matches!(ch, '*' | '?' | '[' | ']'))
}
+13 -1
View File
@@ -30,6 +30,18 @@ fn normalize_absolute_path_for_platform_simplifies_windows_verbatim_paths() {
assert_eq!(parsed, PathBuf::from(r"D:\c\x\worktrees\2508\swift-base"));
}
#[test]
fn windows_verbatim_path_prefix_does_not_count_as_glob_syntax() {
assert!(!contains_glob_chars_for_platform(
r"\\?\D:\c\x\worktrees\2508\swift-base",
/*is_windows*/ true,
));
assert!(contains_glob_chars_for_platform(
r"\\?\D:\c\x\worktrees\2508\**\*.env",
/*is_windows*/ true,
));
}
#[tokio::test]
async fn restricted_read_implicitly_allows_helper_executables() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
@@ -77,7 +89,7 @@ async fn restricted_read_implicitly_allows_helper_executables() -> std::io::Resu
let expected_zsh = AbsolutePathBuf::try_from(zsh_path)?;
let expected_allowed_arg0_dir = AbsolutePathBuf::try_from(allowed_arg0_dir)?;
let expected_sibling_arg0_dir = AbsolutePathBuf::try_from(sibling_arg0_dir)?;
let policy = &config.permissions.file_system_sandbox_policy;
let policy = config.permissions.file_system_sandbox_policy();
assert!(
policy.can_read_path_with_cwd(expected_zsh.as_path(), &cwd),
+3 -3
View File
@@ -17,7 +17,7 @@ use codex_connectors::DirectoryListResponse;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::EnvironmentManagerArgs;
use codex_exec_server::ExecServerRuntimePaths;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::models::PermissionProfile;
use codex_tools::DiscoverableTool;
use rmcp::model::ToolAnnotations;
use serde::Deserialize;
@@ -25,11 +25,11 @@ use serde::de::DeserializeOwned;
use tracing::warn;
use crate::config::Config;
use crate::config_loader::AppsRequirementsToml;
use crate::mcp::McpManager;
use crate::plugins::PluginsManager;
use crate::plugins::list_tool_suggest_discoverable_plugins;
use crate::session::INITIAL_SUBMIT_ID;
use codex_config::AppsRequirementsToml;
use codex_config::types::AppToolApproval;
use codex_config::types::AppsConfigToml;
use codex_config::types::ToolSuggestDiscoverableType;
@@ -274,7 +274,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
&config.permissions.approval_policy,
INITIAL_SUBMIT_ID.to_owned(),
tx_event,
SandboxPolicy::new_read_only_policy(),
PermissionProfile::default(),
McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()),
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth.as_ref()),
+6 -6
View File
@@ -1,12 +1,12 @@
use super::*;
use crate::config::CONFIG_TOML_FILE;
use crate::config::ConfigBuilder;
use crate::config_loader::AppRequirementToml;
use crate::config_loader::AppsRequirementsToml;
use crate::config_loader::CloudRequirementsLoader;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigRequirements;
use crate::config_loader::ConfigRequirementsToml;
use codex_config::AppRequirementToml;
use codex_config::AppsRequirementsToml;
use codex_config::CloudRequirementsLoader;
use codex_config::ConfigLayerStack;
use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_config::types::AppConfig;
use codex_config::types::AppToolConfig;
use codex_config::types::AppToolsConfig;
-1
View File
@@ -81,7 +81,6 @@ pub trait ContextualUserFragment {
content: vec![ContentItem::InputText {
text: self.render(),
}],
end_turn: None,
phase: None,
}
}
@@ -2,7 +2,9 @@ use super::ContextualUserFragment;
use codex_execpolicy::Policy;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::format_allow_prefixes;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::GranularApprovalConfig;
use codex_protocol::protocol::NetworkAccess;
@@ -57,7 +59,33 @@ pub struct PermissionsInstructions {
}
impl PermissionsInstructions {
/// Builds permissions instructions from the effective sandbox and approval policy.
/// Builds permissions instructions from the effective permission profile and approval policy.
pub fn from_permission_profile(
permission_profile: &PermissionProfile,
approval_policy: AskForApproval,
approvals_reviewer: ApprovalsReviewer,
exec_policy: &Policy,
cwd: &Path,
exec_permission_approvals_enabled: bool,
request_permissions_tool_enabled: bool,
) -> Self {
let (sandbox_mode, writable_roots) = sandbox_prompt_from_profile(permission_profile, cwd);
Self::from_permissions_with_network(
sandbox_mode,
network_access_from_policy(permission_profile.network_sandbox_policy()),
PermissionsPromptConfig {
approval_policy,
approvals_reviewer,
exec_policy,
exec_permission_approvals_enabled,
request_permissions_tool_enabled,
},
writable_roots,
)
}
/// Builds permissions instructions from a legacy sandbox policy.
pub fn from_policy(
sandbox_policy: &SandboxPolicy,
approval_policy: AskForApproval,
@@ -67,33 +95,14 @@ impl PermissionsInstructions {
exec_permission_approvals_enabled: bool,
request_permissions_tool_enabled: bool,
) -> Self {
let network_access = if sandbox_policy.has_full_network_access() {
NetworkAccess::Enabled
} else {
NetworkAccess::Restricted
};
let (sandbox_mode, writable_roots) = match sandbox_policy {
SandboxPolicy::DangerFullAccess => (SandboxMode::DangerFullAccess, None),
SandboxPolicy::ReadOnly { .. } => (SandboxMode::ReadOnly, None),
SandboxPolicy::ExternalSandbox { .. } => (SandboxMode::DangerFullAccess, None),
SandboxPolicy::WorkspaceWrite { .. } => {
let roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
(SandboxMode::WorkspaceWrite, Some(roots))
}
};
Self::from_permissions_with_network(
sandbox_mode,
network_access,
PermissionsPromptConfig {
approval_policy,
approvals_reviewer,
exec_policy,
exec_permission_approvals_enabled,
request_permissions_tool_enabled,
},
writable_roots,
Self::from_permission_profile(
&PermissionProfile::from_legacy_sandbox_policy(sandbox_policy),
approval_policy,
approvals_reviewer,
exec_policy,
cwd,
exec_permission_approvals_enabled,
request_permissions_tool_enabled,
)
}
@@ -125,6 +134,38 @@ impl PermissionsInstructions {
}
}
fn sandbox_prompt_from_profile(
permission_profile: &PermissionProfile,
cwd: &Path,
) -> (SandboxMode, Option<Vec<WritableRoot>>) {
match permission_profile {
PermissionProfile::Disabled | PermissionProfile::External { .. } => {
(SandboxMode::DangerFullAccess, None)
}
PermissionProfile::Managed { .. } => {
let file_system_policy = permission_profile.file_system_sandbox_policy();
if file_system_policy.has_full_disk_write_access() {
return (SandboxMode::DangerFullAccess, None);
}
let writable_roots = file_system_policy.get_writable_roots_with_cwd(cwd);
if writable_roots.is_empty() {
(SandboxMode::ReadOnly, None)
} else {
(SandboxMode::WorkspaceWrite, Some(writable_roots))
}
}
}
}
fn network_access_from_policy(network_policy: NetworkSandboxPolicy) -> NetworkAccess {
if network_policy.is_enabled() {
NetworkAccess::Enabled
} else {
NetworkAccess::Restricted
}
}
impl ContextualUserFragment for PermissionsInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "<permissions instructions>";
@@ -1,5 +1,11 @@
use super::*;
use codex_execpolicy::Decision;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
@@ -70,6 +76,36 @@ fn builds_permissions_from_policy() {
assert!(text.contains("`approval_policy` is `unless-trusted`"));
}
#[test]
fn builds_permissions_from_profile() {
let cwd = PathBuf::from("/tmp");
let writable_root =
AbsolutePathBuf::from_absolute_path(cwd.join("repo")).expect("absolute path");
let permission_profile = PermissionProfile::from_runtime_permissions(
&FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: writable_root.clone(),
},
access: FileSystemAccessMode::Write,
}]),
NetworkSandboxPolicy::Enabled,
);
let instructions = PermissionsInstructions::from_permission_profile(
&permission_profile,
AskForApproval::UnlessTrusted,
ApprovalsReviewer::User,
&Policy::empty(),
&cwd,
/*exec_permission_approvals_enabled*/ false,
/*request_permissions_tool_enabled*/ false,
);
let text = instructions.body();
assert!(text.contains("`sandbox_mode` is `workspace-write`"));
assert!(text.contains("Network access is enabled."));
assert!(text.contains(writable_root.to_string_lossy().as_ref()));
}
#[test]
fn includes_request_rule_instructions_for_on_request() {
let mut exec_policy = Policy::empty();
@@ -41,7 +41,6 @@ fn assistant_msg(text: &str) -> ResponseItem {
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
end_turn: None,
phase: None,
}
}
@@ -60,7 +59,6 @@ fn inter_agent_assistant_msg(text: &str) -> ResponseItem {
content: vec![ContentItem::OutputText {
text: serde_json::to_string(&communication).unwrap(),
}],
end_turn: None,
phase: None,
}
}
@@ -80,7 +78,6 @@ fn user_msg(text: &str) -> ResponseItem {
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
end_turn: None,
phase: None,
}
}
@@ -92,7 +89,6 @@ fn user_input_text_msg(text: &str) -> ResponseItem {
content: vec![ContentItem::InputText {
text: text.to_string(),
}],
end_turn: None,
phase: None,
}
}
@@ -104,7 +100,6 @@ fn developer_msg(text: &str) -> ResponseItem {
content: vec![ContentItem::InputText {
text: text.to_string(),
}],
end_turn: None,
phase: None,
}
}
@@ -119,7 +114,6 @@ fn developer_msg_with_fragments(texts: &[&str]) -> ResponseItem {
text: (*text).to_string(),
})
.collect(),
end_turn: None,
phase: None,
}
}
@@ -200,7 +194,6 @@ fn filters_non_api_messages() {
content: vec![ContentItem::OutputText {
text: "ignored".to_string(),
}],
end_turn: None,
phase: None,
};
let reasoning = reasoning_msg("thinking...");
@@ -231,7 +224,6 @@ fn filters_non_api_messages() {
content: vec![ContentItem::OutputText {
text: "hi".to_string()
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -240,7 +232,6 @@ fn filters_non_api_messages() {
content: vec![ContentItem::OutputText {
text: "hello".to_string()
}],
end_turn: None,
phase: None,
}
]
@@ -390,7 +381,6 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
text: "caption".to_string(),
},
],
end_turn: None,
phase: None,
},
ResponseItem::FunctionCall {
@@ -453,7 +443,6 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
text: "caption".to_string(),
},
],
end_turn: None,
phase: None,
},
ResponseItem::FunctionCall {
@@ -512,7 +501,6 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
detail: Some(DEFAULT_IMAGE_DETAIL),
},
],
end_turn: None,
phase: None,
}]);
let preserved = with_images.for_prompt(&modalities);
@@ -540,7 +528,6 @@ fn for_prompt_preserves_image_generation_calls_when_images_are_supported() {
content: vec![ContentItem::InputText {
text: "hi".to_string(),
}],
end_turn: None,
phase: None,
},
]);
@@ -560,7 +547,6 @@ fn for_prompt_preserves_image_generation_calls_when_images_are_supported() {
content: vec![ContentItem::InputText {
text: "hi".to_string(),
}],
end_turn: None,
phase: None,
}
]
@@ -576,7 +562,6 @@ fn for_prompt_clears_image_generation_result_when_images_are_unsupported() {
content: vec![ContentItem::InputText {
text: "generate a lobster".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::ImageGenerationCall {
@@ -596,7 +581,6 @@ fn for_prompt_clears_image_generation_result_when_images_are_unsupported() {
content: vec![ContentItem::InputText {
text: "generate a lobster".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::ImageGenerationCall {
@@ -758,7 +742,6 @@ fn replace_last_turn_images_does_not_touch_user_images() {
image_url: "data:image/png;base64,AAA".to_string(),
detail: Some(DEFAULT_IMAGE_DETAIL),
}],
end_turn: None,
phase: None,
}];
let mut history = create_history_with_items(items.clone());
@@ -1690,7 +1673,6 @@ fn image_data_url_payload_does_not_dominate_message_estimate() {
detail: Some(DEFAULT_IMAGE_DETAIL),
},
],
end_turn: None,
phase: None,
};
let text_only_item = ResponseItem::Message {
@@ -1699,7 +1681,6 @@ fn image_data_url_payload_does_not_dominate_message_estimate() {
content: vec![ContentItem::InputText {
text: "Here is the screenshot".to_string(),
}],
end_turn: None,
phase: None,
};
@@ -1773,7 +1754,6 @@ fn non_base64_image_urls_are_unchanged() {
image_url: "https://example.com/foo.png".to_string(),
detail: Some(DEFAULT_IMAGE_DETAIL),
}],
end_turn: None,
phase: None,
};
let function_output_item = ResponseItem::FunctionCallOutput {
@@ -1805,7 +1785,6 @@ fn data_url_without_base64_marker_is_unchanged() {
image_url: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'/>".to_string(),
detail: Some(DEFAULT_IMAGE_DETAIL),
}],
end_turn: None,
phase: None,
};
@@ -1846,7 +1825,6 @@ fn mixed_case_data_url_markers_are_adjusted() {
image_url,
detail: Some(DEFAULT_IMAGE_DETAIL),
}],
end_turn: None,
phase: None,
};
@@ -1879,7 +1857,6 @@ fn multiple_inline_images_apply_multiple_fixed_costs() {
detail: Some(DEFAULT_IMAGE_DETAIL),
},
],
end_turn: None,
phase: None,
};
@@ -1962,7 +1939,6 @@ fn text_only_items_unchanged() {
content: vec![ContentItem::OutputText {
text: "Hello world, this is a response.".to_string(),
}],
end_turn: None,
phase: None,
};
+2 -3
View File
@@ -56,8 +56,8 @@ fn build_permissions_update_item(
}
Some(
PermissionsInstructions::from_policy(
next.sandbox_policy.get(),
PermissionsInstructions::from_permission_profile(
&next.permission_profile,
next.approval_policy.value(),
next.config.approvals_reviewer,
exec_policy,
@@ -197,7 +197,6 @@ fn build_text_message(role: &str, text_sections: Vec<String>) -> Option<Response
id: None,
role: role.to_string(),
content,
end_turn: None,
phase: None,
})
}
-12
View File
@@ -34,7 +34,6 @@ fn parses_user_message_with_text_and_two_images() {
detail: Some(DEFAULT_IMAGE_DETAIL),
},
],
end_turn: None,
phase: None,
};
@@ -78,7 +77,6 @@ fn skips_local_image_label_text() {
text: user_text.clone(),
},
],
end_turn: None,
phase: None,
};
@@ -108,7 +106,6 @@ fn parses_assistant_message_input_text_for_backward_compatibility() {
text: "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue"
.to_string(),
}],
end_turn: None,
phase: None,
};
@@ -158,7 +155,6 @@ fn skips_unnamed_image_label_text() {
text: user_text.clone(),
},
],
end_turn: None,
phase: None,
};
@@ -188,7 +184,6 @@ fn skips_user_instructions_and_env() {
content: vec![ContentItem::InputText {
text: "# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -197,7 +192,6 @@ fn skips_user_instructions_and_env() {
content: vec![ContentItem::InputText {
text: "<environment_context>test_text</environment_context>".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -206,7 +200,6 @@ fn skips_user_instructions_and_env() {
content: vec![ContentItem::InputText {
text: "# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -216,7 +209,6 @@ fn skips_user_instructions_and_env() {
text: "<skill>\n<name>demo</name>\n<path>skills/demo/SKILL.md</path>\nbody\n</skill>"
.to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -225,7 +217,6 @@ fn skips_user_instructions_and_env() {
content: vec![ContentItem::InputText {
text: "<user_shell_command>echo 42</user_shell_command>".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -241,7 +232,6 @@ fn skips_user_instructions_and_env() {
.to_string(),
},
],
end_turn: None,
phase: None,
},
];
@@ -292,7 +282,6 @@ fn parses_hook_prompt_and_hides_other_contextual_fragments() {
.to_string(),
},
],
end_turn: None,
phase: None,
};
@@ -321,7 +310,6 @@ fn parses_agent_message() {
content: vec![ContentItem::OutputText {
text: "Hello from Codex".to_string(),
}],
end_turn: None,
phase: None,
};
+9 -13
View File
@@ -30,6 +30,7 @@ use codex_protocol::error::Result;
use codex_protocol::error::SandboxErr;
use codex_protocol::exec_output::ExecToolCallOutput;
use codex_protocol::exec_output::StreamOutput;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemSandboxKind;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
@@ -220,9 +221,7 @@ pub struct StdoutStream {
#[allow(clippy::too_many_arguments)]
pub async fn process_exec_tool_call(
params: ExecParams,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
permission_profile: &PermissionProfile,
sandbox_cwd: &AbsolutePathBuf,
codex_linux_sandbox_exe: &Option<PathBuf>,
use_legacy_landlock: bool,
@@ -230,9 +229,7 @@ pub async fn process_exec_tool_call(
) -> Result<ExecToolCallOutput> {
let exec_req = build_exec_request(
params,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
permission_profile,
sandbox_cwd,
codex_linux_sandbox_exe,
use_legacy_landlock,
@@ -246,9 +243,7 @@ pub async fn process_exec_tool_call(
/// spawned under the requested sandbox policy.
pub fn build_exec_request(
params: ExecParams,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
permission_profile: &PermissionProfile,
sandbox_cwd: &AbsolutePathBuf,
codex_linux_sandbox_exe: &Option<PathBuf>,
use_legacy_landlock: bool,
@@ -271,8 +266,10 @@ pub fn build_exec_request(
} = params;
let enforce_managed_network = network.is_some();
let (file_system_sandbox_policy, network_sandbox_policy) =
permission_profile.to_runtime_permissions();
let sandbox_type = select_process_exec_tool_sandbox_type(
file_system_sandbox_policy,
&file_system_sandbox_policy,
network_sandbox_policy,
windows_sandbox_level,
enforce_managed_network,
@@ -304,9 +301,7 @@ pub fn build_exec_request(
let mut exec_req = manager
.transform(SandboxTransformRequest {
command,
policy: sandbox_policy,
file_system_policy: file_system_sandbox_policy,
network_policy: network_sandbox_policy,
permissions: permission_profile,
sandbox: sandbox_type,
enforce_managed_network,
network: network.as_ref(),
@@ -366,6 +361,7 @@ pub(crate) async fn execute_exec_request(
windows_sandbox_policy_cwd: _,
windows_sandbox_level,
windows_sandbox_private_desktop,
permission_profile: _,
sandbox_policy,
// TODO(mbolin): Use file_system_sandbox_policy instead of sandbox_policy.
file_system_sandbox_policy: _,
+8 -7
View File
@@ -1,10 +1,11 @@
#[cfg(test)]
use codex_config::types::EnvironmentVariablePattern;
use codex_config::types::ShellEnvironmentPolicy;
use codex_protocol::ThreadId;
#[cfg(test)]
use codex_protocol::config_types::EnvironmentVariablePattern;
use codex_protocol::config_types::ShellEnvironmentPolicy;
use codex_protocol::shell_environment;
use std::collections::HashMap;
pub use codex_config::shell_environment::CODEX_THREAD_ID_ENV_VAR;
pub use codex_protocol::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,7 +22,7 @@ pub fn create_env(
thread_id: Option<ThreadId>,
) -> HashMap<String, String> {
let thread_id = thread_id.map(|thread_id| thread_id.to_string());
codex_config::shell_environment::create_env(policy, thread_id.as_deref())
shell_environment::create_env(policy, thread_id.as_deref())
}
#[cfg(all(test, target_os = "windows"))]
@@ -34,7 +35,7 @@ where
I: IntoIterator<Item = (String, String)>,
{
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())
shell_environment::create_env_from_vars(vars, policy, thread_id.as_deref())
}
#[cfg(test)]
@@ -47,7 +48,7 @@ where
I: IntoIterator<Item = (String, String)>,
{
let thread_id = thread_id.map(|thread_id| thread_id.to_string());
codex_config::shell_environment::populate_env(vars, policy, thread_id.as_deref())
shell_environment::populate_env(vars, policy, thread_id.as_deref())
}
#[cfg(test)]
+1 -1
View File
@@ -1,5 +1,5 @@
use super::*;
use codex_config::types::ShellEnvironmentPolicyInherit;
use codex_protocol::config_types::ShellEnvironmentPolicyInherit;
use maplit::hashmap;
use pretty_assertions::assert_eq;
+35 -11
View File
@@ -5,9 +5,9 @@ use std::sync::Arc;
use arc_swap::ArcSwap;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigLayerStackOrdering;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use codex_execpolicy::AmendError;
use codex_execpolicy::Decision;
use codex_execpolicy::Error as ExecPolicyRuleError;
@@ -20,10 +20,10 @@ use codex_execpolicy::RuleMatch;
use codex_execpolicy::blocking_append_allow_prefix_rule;
use codex_execpolicy::blocking_append_network_rule;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemSandboxKind;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use codex_shell_command::is_dangerous_command::command_might_be_dangerous;
use codex_shell_command::is_safe_command::is_known_safe_command;
use thiserror::Error;
@@ -204,8 +204,9 @@ pub(crate) struct ExecPolicyManager {
pub(crate) struct ExecApprovalRequest<'a> {
pub(crate) command: &'a [String],
pub(crate) approval_policy: AskForApproval,
pub(crate) sandbox_policy: &'a SandboxPolicy,
pub(crate) permission_profile: PermissionProfile,
pub(crate) file_system_sandbox_policy: &'a FileSystemSandboxPolicy,
pub(crate) sandbox_cwd: &'a Path,
pub(crate) sandbox_permissions: SandboxPermissions,
pub(crate) prefix_rule: Option<Vec<String>>,
}
@@ -238,8 +239,9 @@ impl ExecPolicyManager {
let ExecApprovalRequest {
command,
approval_policy,
sandbox_policy,
permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
sandbox_permissions,
prefix_rule,
} = req;
@@ -252,8 +254,9 @@ impl ExecPolicyManager {
let exec_policy_fallback = |cmd: &[String]| {
render_decision_for_unmatched_command(
approval_policy,
sandbox_policy,
&permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
cmd,
sandbox_permissions,
used_complex_parsing,
@@ -580,8 +583,9 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result<Policy,
/// If a command is not matched by any execpolicy rule, derive a [`Decision`].
pub fn render_decision_for_unmatched_command(
approval_policy: AskForApproval,
sandbox_policy: &SandboxPolicy,
permission_profile: &PermissionProfile,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
sandbox_cwd: &Path,
command: &[String],
sandbox_permissions: SandboxPermissions,
used_complex_parsing: bool,
@@ -592,8 +596,12 @@ pub fn render_decision_for_unmatched_command(
// On Windows, ReadOnly sandbox is not a real sandbox, so special-case it
// here.
let environment_lacks_sandbox_protections =
cfg!(windows) && matches!(sandbox_policy, SandboxPolicy::ReadOnly { .. });
let environment_lacks_sandbox_protections = cfg!(windows)
&& profile_is_managed_read_only(
permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
);
// If the command is flagged as dangerous or we have no sandbox protection,
// we should never allow it to run without approval.
@@ -605,8 +613,8 @@ pub fn render_decision_for_unmatched_command(
return match approval_policy {
AskForApproval::Never => {
let sandbox_is_explicitly_disabled = matches!(
sandbox_policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
permission_profile,
PermissionProfile::Disabled | PermissionProfile::External { .. }
);
if sandbox_is_explicitly_disabled {
// If the sandbox is explicitly disabled, we should allow the command to run
@@ -670,6 +678,22 @@ pub fn render_decision_for_unmatched_command(
}
}
fn profile_is_managed_read_only(
permission_profile: &PermissionProfile,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
sandbox_cwd: &Path,
) -> bool {
matches!(permission_profile, PermissionProfile::Managed { .. })
&& matches!(
file_system_sandbox_policy.kind,
FileSystemSandboxKind::Restricted
)
&& !file_system_sandbox_policy.has_full_disk_write_access()
&& file_system_sandbox_policy
.get_writable_roots_with_cwd(sandbox_cwd)
.is_empty()
}
fn default_policy_path(codex_home: &Path) -> PathBuf {
codex_home.join(RULES_DIR_NAME).join(DEFAULT_POLICY_FILE)
}
+125 -23
View File
@@ -1,24 +1,26 @@
use super::*;
use crate::config::Config;
use crate::config::ConfigBuilder;
use crate::config_loader::ConfigLayerEntry;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigLayerStackOrdering;
use crate::config_loader::ConfigRequirements;
use crate::config_loader::ConfigRequirementsToml;
use crate::config_loader::LoaderOverrides;
use crate::config_loader::RequirementSource;
use crate::config_loader::Sourced;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_config::LoaderOverrides;
use codex_config::RequirementSource;
use codex_config::RequirementsExecPolicy;
use codex_config::Sourced;
use codex_config::config_toml::ConfigToml;
use codex_config::config_toml::ProjectConfig;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::GranularApprovalConfig;
use codex_protocol::protocol::SandboxPolicy;
@@ -108,6 +110,10 @@ fn read_only_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
}])
}
fn workspace_write_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
FileSystemSandboxPolicy::from_legacy_sandbox_policy(&SandboxPolicy::new_workspace_write_policy())
}
fn unrestricted_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
FileSystemSandboxPolicy::unrestricted()
}
@@ -116,6 +122,10 @@ fn external_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
FileSystemSandboxPolicy::external_sandbox()
}
fn permission_profile_from_sandbox_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile {
PermissionProfile::from_legacy_sandbox_policy(sandbox_policy)
}
async fn test_config() -> (TempDir, Config) {
let home = TempDir::new().expect("create temp dir");
let config = ConfigBuilder::without_managed_config_for_tests()
@@ -954,8 +964,9 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() {
request_permissions: true,
mcp_elicitations: true,
}),
&SandboxPolicy::new_read_only_policy(),
&permission_profile_from_sandbox_policy(&SandboxPolicy::new_read_only_policy()),
&read_only_file_system_sandbox_policy(),
Path::new("/tmp"),
&command,
SandboxPermissions::RequireEscalated,
/*used_complex_parsing*/ false,
@@ -972,8 +983,9 @@ fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() {
Decision::Prompt,
render_decision_for_unmatched_command(
AskForApproval::OnRequest,
&SandboxPolicy::DangerFullAccess,
&PermissionProfile::Disabled,
&restricted_file_system_policy,
Path::new("/tmp"),
&command,
SandboxPermissions::RequireEscalated,
/*used_complex_parsing*/ false,
@@ -981,6 +993,65 @@ fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() {
);
}
#[test]
fn managed_cwd_write_profile_is_not_read_only() {
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
},
access: FileSystemAccessMode::Write,
},
]);
let permission_profile = PermissionProfile::from_runtime_permissions(
&file_system_sandbox_policy,
NetworkSandboxPolicy::Restricted,
);
assert!(!profile_is_managed_read_only(
&permission_profile,
&file_system_sandbox_policy,
Path::new("/tmp/project")
));
}
#[test]
fn managed_unresolvable_write_profile_is_still_read_only() {
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::unknown(
":future_special_path",
/*subpath*/ None,
),
},
access: FileSystemAccessMode::Write,
},
]);
let permission_profile = PermissionProfile::from_runtime_permissions(
&file_system_sandbox_policy,
NetworkSandboxPolicy::Restricted,
);
assert!(profile_is_managed_read_only(
&permission_profile,
&file_system_sandbox_policy,
Path::new("/tmp/project")
));
}
#[tokio::test]
async fn exec_approval_requirement_prompts_for_inline_additional_permissions_under_on_request() {
assert_exec_approval_requirement_for_command(
@@ -1058,8 +1129,11 @@ async fn mixed_rule_and_sandbox_prompt_prioritizes_rule_for_rejection_decision()
request_permissions: true,
mcp_elicitations: true,
}),
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
permission_profile: permission_profile_from_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: None,
})
@@ -1095,8 +1169,11 @@ async fn mixed_rule_and_sandbox_prompt_rejects_when_granular_rules_are_disabled(
request_permissions: true,
mcp_elicitations: true,
}),
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
permission_profile: permission_profile_from_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: None,
})
@@ -1119,8 +1196,11 @@ async fn exec_approval_requirement_falls_back_to_heuristics() {
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
permission_profile: permission_profile_from_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
@@ -1144,8 +1224,11 @@ async fn empty_bash_lc_script_falls_back_to_original_command() {
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
permission_profile: permission_profile_from_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
@@ -1173,8 +1256,11 @@ async fn whitespace_bash_lc_script_falls_back_to_original_command() {
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
permission_profile: permission_profile_from_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
@@ -1202,8 +1288,11 @@ async fn request_rule_uses_prefix_rule() {
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
permission_profile: permission_profile_from_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]),
})
@@ -1234,8 +1323,9 @@ async fn request_rule_falls_back_when_prefix_rule_does_not_approve_all_commands(
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]),
})
@@ -1273,8 +1363,9 @@ async fn heuristics_apply_when_other_commands_match_policy() {
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
@@ -1498,7 +1589,7 @@ prefix_rule(pattern=["cat"], decision="allow")
command: command.clone(),
approval_policy,
sandbox_policy: SandboxPolicy::new_workspace_write_policy(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -1759,8 +1850,11 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &sneaky_command,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
permission_profile: permission_profile_from_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: permissions,
prefix_rule: None,
})
@@ -1783,8 +1877,11 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &dangerous_command,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
permission_profile: permission_profile_from_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: permissions,
prefix_rule: None,
})
@@ -1803,8 +1900,11 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &dangerous_command,
approval_policy: AskForApproval::Never,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
permission_profile: permission_profile_from_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: permissions,
prefix_rule: None,
})
@@ -1897,12 +1997,14 @@ async fn assert_exec_approval_requirement_for_command(
None => Arc::new(Policy::empty()),
};
let permission_profile = permission_profile_from_sandbox_policy(&sandbox_policy);
let requirement = ExecPolicyManager::new(policy)
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy,
sandbox_policy: &sandbox_policy,
permission_profile,
file_system_sandbox_policy: &file_system_sandbox_policy,
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions,
prefix_rule,
})
+5 -6
View File
@@ -1,5 +1,6 @@
use super::*;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::PermissionProfile;
use codex_sandboxing::SandboxType;
use core_test_support::PathBufExt;
use core_test_support::PathExt;
@@ -346,6 +347,7 @@ async fn process_exec_tool_call_preserves_full_buffer_capture_policy() -> Result
let cwd = codex_utils_absolute_path::AbsolutePathBuf::current_dir()?;
let sandbox_policy = SandboxPolicy::DangerFullAccess;
let permission_profile = PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy);
let output = process_exec_tool_call(
ExecParams {
command,
@@ -360,9 +362,7 @@ async fn process_exec_tool_call_preserves_full_buffer_capture_policy() -> Result
justification: None,
arg0: None,
},
&sandbox_policy,
&FileSystemSandboxPolicy::from(&sandbox_policy),
NetworkSandboxPolicy::Enabled,
&permission_profile,
&cwd,
&None,
/*use_legacy_landlock*/ false,
@@ -1021,11 +1021,10 @@ async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> {
tokio::time::sleep(Duration::from_millis(1_000)).await;
cancel_tx.cancel();
});
let permission_profile = PermissionProfile::Disabled;
let result = process_exec_tool_call(
params,
&SandboxPolicy::DangerFullAccess,
&FileSystemSandboxPolicy::from(&SandboxPolicy::DangerFullAccess),
NetworkSandboxPolicy::Enabled,
&permission_profile,
&cwd,
&None,
/*use_legacy_landlock*/ false,
+2
View File
@@ -45,6 +45,8 @@ pub(crate) const GUARDIAN_REVIEW_TIMEOUT: Duration = Duration::from_secs(90);
pub(crate) const GUARDIAN_REVIEWER_NAME: &str = "guardian";
pub(crate) const MAX_CONSECUTIVE_GUARDIAN_DENIALS_PER_TURN: u32 = 3;
pub(crate) const MAX_TOTAL_GUARDIAN_DENIALS_PER_TURN: u32 = 10;
pub(crate) const AUTO_REVIEW_DENIED_ACTION_APPROVAL_DEVELOPER_PREFIX: &str =
"The user has manually approved a specific action that was previously `Rejected`.";
const GUARDIAN_MAX_MESSAGE_TRANSCRIPT_TOKENS: usize = 10_000;
const GUARDIAN_MAX_TOOL_TRANSCRIPT_TOKENS: usize = 10_000;
const GUARDIAN_MAX_MESSAGE_ENTRY_TOKENS: usize = 2_000;
+15
View File
@@ -14,6 +14,7 @@ use codex_utils_output_truncation::approx_bytes_for_tokens;
use codex_utils_output_truncation::approx_token_count;
use codex_utils_output_truncation::approx_tokens_from_byte_count;
use super::AUTO_REVIEW_DENIED_ACTION_APPROVAL_DEVELOPER_PREFIX;
use super::GUARDIAN_MAX_MESSAGE_ENTRY_TOKENS;
use super::GUARDIAN_MAX_MESSAGE_TRANSCRIPT_TOKENS;
use super::GUARDIAN_MAX_TOOL_ENTRY_TOKENS;
@@ -33,6 +34,7 @@ pub(crate) struct GuardianTranscriptEntry {
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum GuardianTranscriptEntryKind {
Developer,
User,
Assistant,
Tool(String),
@@ -41,6 +43,7 @@ pub(crate) enum GuardianTranscriptEntryKind {
impl GuardianTranscriptEntryKind {
fn role(&self) -> &str {
match self {
Self::Developer => "developer",
Self::User => "user",
Self::Assistant => "assistant",
Self::Tool(role) => role.as_str(),
@@ -361,6 +364,18 @@ pub(crate) fn collect_guardian_transcript_entries(
content_entry(GuardianTranscriptEntryKind::User, content)
}
}
ResponseItem::Message { role, content, .. } if role == "developer" => {
content_items_to_text(content).and_then(|text| {
// Preserve only the explicit auto-review approval marker for
// Guardian context; other developer messages are intentionally
// excluded from the review transcript.
text.starts_with(AUTO_REVIEW_DENIED_ACTION_APPROVAL_DEVELOPER_PREFIX)
.then_some(GuardianTranscriptEntry {
kind: GuardianTranscriptEntryKind::Developer,
text,
})
})
}
ResponseItem::Message { role, content, .. } if role == "assistant" => {
content_entry(GuardianTranscriptEntryKind::Assistant, content)
}
+12 -3
View File
@@ -9,6 +9,7 @@ use codex_analytics::GuardianReviewAnalyticsResult;
use codex_analytics::GuardianReviewSessionKind;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::AskForApproval;
@@ -843,8 +844,16 @@ pub(crate) fn build_guardian_review_session_config(
);
guardian_config.developer_instructions = None;
guardian_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never);
guardian_config.permissions.sandbox_policy =
Constrained::allow_only(SandboxPolicy::new_read_only_policy());
let sandbox_policy = SandboxPolicy::new_read_only_policy();
guardian_config.permissions.permission_profile = Constrained::allow_only(
PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy),
);
guardian_config
.permissions
.set_legacy_sandbox_policy(sandbox_policy, guardian_config.cwd.as_path())
.map_err(|err| {
anyhow::anyhow!("guardian review session could not set sandbox policy: {err}")
})?;
guardian_config.include_apps_instructions = false;
guardian_config
.mcp_servers
@@ -864,7 +873,7 @@ pub(crate) fn build_guardian_review_session_config(
guardian_config.permissions.network = Some(NetworkProxySpec::from_config_and_constraints(
live_network_config,
network_constraints,
&SandboxPolicy::new_read_only_policy(),
guardian_config.permissions.permission_profile.get(),
)?);
}
for feature in [
+52 -33
View File
@@ -5,18 +5,18 @@ use crate::config::Constrained;
use crate::config::ManagedFeatures;
use crate::config::NetworkProxySpec;
use crate::config::test_config;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::FeatureRequirementsToml;
use crate::config_loader::NetworkConstraints;
use crate::config_loader::NetworkDomainPermissionToml;
use crate::config_loader::NetworkDomainPermissionsToml;
use crate::config_loader::RequirementSource;
use crate::config_loader::Sourced;
use crate::guardian::approval_request::guardian_request_target_item_id;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::test_support;
use codex_analytics::GuardianApprovalRequestSource;
use codex_config::ConfigLayerStack;
use codex_config::FeatureRequirementsToml;
use codex_config::NetworkConstraints;
use codex_config::NetworkDomainPermissionToml;
use codex_config::NetworkDomainPermissionsToml;
use codex_config::RequirementSource;
use codex_config::Sourced;
use codex_config::config_toml::ConfigToml;
use codex_config::types::McpServerConfig;
use codex_exec_server::LOCAL_FS;
@@ -27,6 +27,7 @@ use codex_protocol::ThreadId;
use codex_protocol::approvals::NetworkApprovalProtocol;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::models::ContentItem;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
@@ -175,7 +176,6 @@ async fn seed_guardian_parent_history(session: &Arc<Session>, turn: &Arc<TurnCon
text: "Please check the repo visibility and push the docs fix if needed."
.to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::FunctionCall {
@@ -198,7 +198,6 @@ async fn seed_guardian_parent_history(session: &Arc<Session>, turn: &Arc<TurnCon
text: "The repo is public; I now need approval to push the docs fix."
.to_string(),
}],
end_turn: None,
phase: None,
},
],
@@ -342,7 +341,6 @@ async fn build_guardian_prompt_delta_mode_preserves_original_numbering() -> anyh
content: vec![ContentItem::InputText {
text: "Please also push the second docs fix.".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -351,7 +349,6 @@ async fn build_guardian_prompt_delta_mode_preserves_original_numbering() -> anyh
content: vec![ContentItem::OutputText {
text: "I need approval for the second push.".to_string(),
}],
end_turn: None,
phase: None,
},
],
@@ -475,7 +472,6 @@ async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() -
content: vec![ContentItem::InputText {
text: "Compacted retained user request.".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -484,7 +480,6 @@ async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() -
content: vec![ContentItem::OutputText {
text: "Compacted summary of earlier guardian context.".to_string(),
}],
end_turn: None,
phase: None,
},
],
@@ -500,7 +495,6 @@ async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() -
content: vec![ContentItem::InputText {
text: "Please push after the compaction.".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -509,7 +503,6 @@ async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() -
content: vec![ContentItem::OutputText {
text: "I need approval for the post-compaction push.".to_string(),
}],
end_turn: None,
phase: None,
},
],
@@ -558,7 +551,6 @@ fn collect_guardian_transcript_entries_skips_contextual_user_messages() {
content: vec![ContentItem::InputText {
text: "<environment_context>\n<cwd>/tmp</cwd>\n</environment_context>".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -567,7 +559,6 @@ fn collect_guardian_transcript_entries_skips_contextual_user_messages() {
content: vec![ContentItem::OutputText {
text: "hello".to_string(),
}],
end_turn: None,
phase: None,
},
];
@@ -584,6 +575,40 @@ fn collect_guardian_transcript_entries_skips_contextual_user_messages() {
);
}
#[test]
fn collect_guardian_transcript_entries_keeps_manual_approval_developer_message() {
let approval_text =
format!("{AUTO_REVIEW_DENIED_ACTION_APPROVAL_DEVELOPER_PREFIX}\n\nApproved action:\n{{}}");
let items = vec![
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "ordinary developer context".to_string(),
}],
phase: None,
},
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: approval_text.clone(),
}],
phase: None,
},
];
let entries = collect_guardian_transcript_entries(&items);
assert_eq!(
entries,
vec![GuardianTranscriptEntry {
kind: GuardianTranscriptEntryKind::Developer,
text: approval_text,
}]
);
}
#[test]
fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() {
let items = vec![
@@ -593,7 +618,6 @@ fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() {
content: vec![ContentItem::InputText {
text: "check the repo".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::FunctionCall {
@@ -615,7 +639,6 @@ fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() {
content: vec![ContentItem::OutputText {
text: "I need to push a fix".to_string(),
}],
end_turn: None,
phase: None,
},
];
@@ -1356,7 +1379,6 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
content: vec![ContentItem::InputText {
text: "Please push the second docs fix too.".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -1365,7 +1387,6 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
content: vec![ContentItem::OutputText {
text: "I need approval for the second docs fix.".to_string(),
}],
end_turn: None,
phase: None,
},
],
@@ -1402,7 +1423,6 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
content: vec![ContentItem::InputText {
text: "Please push the third docs fix too.".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -1411,7 +1431,6 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
content: vec![ContentItem::OutputText {
text: "I need approval for the third docs fix.".to_string(),
}],
end_turn: None,
phase: None,
},
],
@@ -1789,7 +1808,6 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a
content: vec![ContentItem::InputText {
text: "Please inspect pending changes before pushing.".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -1798,7 +1816,6 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a
content: vec![ContentItem::OutputText {
text: "I need approval to run git diff.".to_string(),
}],
end_turn: None,
phase: None,
},
],
@@ -1858,7 +1875,6 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a
content: vec![ContentItem::InputText {
text: "Now inspect whether pushing is safe.".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
@@ -1867,7 +1883,6 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a
content: vec![ContentItem::OutputText {
text: "I need approval to push after the diff check.".to_string(),
}],
end_turn: None,
phase: None,
},
],
@@ -1942,7 +1957,7 @@ async fn guardian_review_session_config_preserves_parent_network_proxy() {
}),
..Default::default()
}),
parent_config.permissions.sandbox_policy.get(),
parent_config.permissions.permission_profile.get(),
)
.expect("network proxy spec");
parent_config.permissions.network = Some(network.clone());
@@ -1969,8 +1984,10 @@ async fn guardian_review_session_config_preserves_parent_network_proxy() {
Constrained::allow_only(AskForApproval::Never)
);
assert_eq!(
guardian_config.permissions.sandbox_policy,
Constrained::allow_only(SandboxPolicy::new_read_only_policy())
guardian_config.permissions.permission_profile,
Constrained::allow_only(PermissionProfile::from_legacy_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
))
);
}
@@ -2007,7 +2024,7 @@ async fn guardian_review_session_config_uses_live_network_proxy_state() {
NetworkProxySpec::from_config_and_constraints(
parent_network,
/*requirements*/ None,
parent_config.permissions.sandbox_policy.get(),
parent_config.permissions.permission_profile.get(),
)
.expect("parent network proxy spec"),
);
@@ -2032,7 +2049,9 @@ async fn guardian_review_session_config_uses_live_network_proxy_state() {
NetworkProxySpec::from_config_and_constraints(
live_network,
/*requirements*/ None,
&SandboxPolicy::new_read_only_policy(),
&PermissionProfile::from_legacy_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
)
.expect("live network proxy spec")
)
@@ -2122,7 +2141,7 @@ async fn guardian_review_session_config_uses_requirements_guardian_policy_config
let config_layer_stack = ConfigLayerStack::new(
Vec::new(),
Default::default(),
crate::config_loader::ConfigRequirementsToml {
codex_config::ConfigRequirementsToml {
guardian_policy_config: Some(
" Use the workspace-managed guardian policy. ".to_string(),
),
+14 -12
View File
@@ -2,9 +2,8 @@ use crate::spawn::SpawnChildRequest;
use crate::spawn::StdioPolicy;
use crate::spawn::spawn_child_async;
use codex_network_proxy::NetworkProxy;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::models::PermissionProfile;
use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile;
use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0;
use codex_sandboxing::landlock::allow_network_for_proxy;
use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_policies;
@@ -18,15 +17,15 @@ use tokio::process::Child;
/// isolation plus seccomp for network restrictions.
///
/// Unlike macOS Seatbelt where we directly embed the policy text, the Linux
/// helper is a separate executable. We pass the legacy [`SandboxPolicy`] plus
/// split filesystem/network policies as JSON so the helper can migrate
/// incrementally without breaking older call sites.
/// helper is a separate executable. We pass both the canonical split
/// filesystem/network policies and a compatibility legacy projection as JSON
/// until the helper protocol no longer needs the legacy field.
#[allow(clippy::too_many_arguments)]
pub async fn spawn_command_under_linux_sandbox<P>(
codex_linux_sandbox_exe: P,
command: Vec<String>,
command_cwd: AbsolutePathBuf,
sandbox_policy: &SandboxPolicy,
permission_profile: &PermissionProfile,
sandbox_policy_cwd: &AbsolutePathBuf,
use_legacy_landlock: bool,
stdio_policy: StdioPolicy,
@@ -36,15 +35,18 @@ pub async fn spawn_command_under_linux_sandbox<P>(
where
P: AsRef<Path>,
{
let file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(
sandbox_policy,
sandbox_policy_cwd,
let (file_system_sandbox_policy, network_sandbox_policy) =
permission_profile.to_runtime_permissions();
let sandbox_policy = compatibility_sandbox_policy_for_permission_profile(
permission_profile,
&file_system_sandbox_policy,
network_sandbox_policy,
sandbox_policy_cwd.as_path(),
);
let network_sandbox_policy = NetworkSandboxPolicy::from(sandbox_policy);
let args = create_linux_sandbox_command_args_for_policies(
command,
command_cwd.as_path(),
sandbox_policy,
&sandbox_policy,
&file_system_sandbox_policy,
network_sandbox_policy,
sandbox_policy_cwd,

Some files were not shown because too many files have changed in this diff Show More