Merge origin/main into exec-server-sandbox-0403

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
starr-openai
2026-04-07 14:40:12 -07:00
co-authored by Codex
462 changed files with 17181 additions and 5304 deletions
+9
View File
@@ -730,6 +730,15 @@ impl AgentControl {
self.state.agent_metadata_for_thread(agent_id)
}
pub(crate) async fn list_live_agent_subtree_thread_ids(
&self,
agent_id: ThreadId,
) -> CodexResult<Vec<ThreadId>> {
let mut thread_ids = vec![agent_id];
thread_ids.extend(self.live_thread_spawn_descendants(agent_id).await?);
Ok(thread_ids)
}
pub(crate) async fn get_agent_config_snapshot(
&self,
agent_id: ThreadId,
+135
View File
@@ -256,6 +256,7 @@ async fn get_status_returns_not_found_without_manager() {
async fn on_event_updates_status_from_task_started() {
let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}));
@@ -267,6 +268,8 @@ async fn on_event_updates_status_from_task_complete() {
let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: Some("done".to_string()),
completed_at: None,
duration_ms: None,
}));
let expected = AgentStatus::Completed(Some("done".to_string()));
assert_eq!(status, Some(expected));
@@ -288,6 +291,8 @@ async fn on_event_updates_status_from_turn_aborted() {
let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
}));
let expected = AgentStatus::Interrupted;
@@ -1200,6 +1205,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() {
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: tester_turn.sub_id.clone(),
last_agent_message: Some("done".to_string()),
completed_at: None,
duration_ms: None,
}),
)
.await;
@@ -1284,6 +1291,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() {
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: tester_turn.sub_id.clone(),
last_agent_message: Some("done".to_string()),
completed_at: None,
duration_ms: None,
}),
)
.await;
@@ -1678,6 +1687,132 @@ async fn resume_agent_from_rollout_reads_archived_rollout_path() {
.expect("resumed child shutdown should succeed");
}
#[tokio::test]
async fn list_agent_subtree_thread_ids_includes_anonymous_and_closed_descendants() {
let harness = AgentControlHarness::new().await;
let (parent_thread_id, _parent_thread) = harness.start_thread().await;
let worker_path = AgentPath::root().join("worker").expect("worker path");
let reviewer_path = AgentPath::root().join("reviewer").expect("reviewer path");
let worker_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello worker"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: Some(worker_path.clone()),
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
)
.await
.expect("worker spawn should succeed");
let worker_child_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello worker child"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: worker_thread_id,
depth: 2,
agent_path: Some(
worker_path
.join("child")
.expect("worker child path should be valid"),
),
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
)
.await
.expect("worker child spawn should succeed");
let no_path_child_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello anonymous child"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: worker_thread_id,
depth: 2,
agent_path: None,
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
)
.await
.expect("no-path child spawn should succeed");
let no_path_grandchild_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello anonymous grandchild"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: no_path_child_thread_id,
depth: 3,
agent_path: None,
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
)
.await
.expect("no-path grandchild spawn should succeed");
let _reviewer_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello reviewer"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: Some(reviewer_path),
agent_nickname: None,
agent_role: Some("reviewer".to_string()),
})),
)
.await
.expect("reviewer spawn should succeed");
let _ = harness
.control
.shutdown_live_agent(no_path_grandchild_thread_id)
.await
.expect("no-path grandchild shutdown should succeed");
let mut worker_subtree_thread_ids = harness
.manager
.list_agent_subtree_thread_ids(worker_thread_id)
.await
.expect("worker subtree thread ids should load");
worker_subtree_thread_ids.sort_by_key(ToString::to_string);
let mut expected_worker_subtree_thread_ids = vec![
worker_thread_id,
worker_child_thread_id,
no_path_child_thread_id,
no_path_grandchild_thread_id,
];
expected_worker_subtree_thread_ids.sort_by_key(ToString::to_string);
assert_eq!(
worker_subtree_thread_ids,
expected_worker_subtree_thread_ids
);
let mut no_path_child_subtree_thread_ids = harness
.manager
.list_agent_subtree_thread_ids(no_path_child_thread_id)
.await
.expect("no-path subtree thread ids should load");
no_path_child_subtree_thread_ids.sort_by_key(ToString::to_string);
let mut expected_no_path_child_subtree_thread_ids =
vec![no_path_child_thread_id, no_path_grandchild_thread_id];
expected_no_path_child_subtree_thread_ids.sort_by_key(ToString::to_string);
assert_eq!(
no_path_child_subtree_thread_ids,
expected_no_path_child_subtree_thread_ids
);
}
#[tokio::test]
async fn shutdown_agent_tree_closes_live_descendants() {
let harness = AgentControlHarness::new().await;
+1 -1
View File
@@ -220,7 +220,7 @@ impl AgentRegistry {
} else {
active_agents.used_agent_nicknames.clear();
active_agents.nickname_reset_count += 1;
if let Some(metrics) = codex_otel::metrics::global() {
if let Some(metrics) = codex_otel::global() {
let _ = metrics.counter(
"codex.multi_agent.nickname_pool_reset",
/*inc*/ 1,
+8 -1
View File
@@ -17,6 +17,7 @@ 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::config_toml::ConfigToml;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::path::Path;
@@ -63,6 +64,12 @@ async fn apply_role_to_config_inner(
return Ok(());
};
let role_layer_toml = load_role_layer_toml(config, config_file, is_built_in, role_name).await?;
if role_layer_toml
.as_table()
.is_some_and(toml::map::Map::is_empty)
{
return Ok(());
}
let (preserve_current_profile, preserve_current_provider) =
preservation_policy(config, &role_layer_toml);
@@ -221,7 +228,7 @@ mod reload {
fn deserialize_effective_config(
config: &Config,
config_layer_stack: &ConfigLayerStack,
) -> anyhow::Result<crate::config::ConfigToml> {
) -> anyhow::Result<ConfigToml> {
Ok(deserialize_config_toml_with_base(
config_layer_stack.effective_config(),
&config.codex_home,
+16
View File
@@ -87,6 +87,22 @@ async fn apply_explorer_role_sets_model_and_adds_session_flags_layer() {
assert_eq!(session_flags_layer_count(&config), before_layers + 1);
}
#[tokio::test]
async fn apply_empty_explorer_role_preserves_current_model_and_reasoning_effort() {
let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
let before_layers = session_flags_layer_count(&config);
config.model = Some("gpt-5.4-mini".to_string());
config.model_reasoning_effort = Some(ReasoningEffort::High);
apply_role_to_config(&mut config, Some("explorer"))
.await
.expect("explorer role should apply");
assert_eq!(config.model.as_deref(), Some("gpt-5.4-mini"));
assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High));
assert_eq!(session_flags_layer_count(&config), before_layers);
}
#[tokio::test]
async fn apply_role_returns_unavailable_for_missing_user_role_file() {
let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
+3 -2
View File
@@ -1,4 +1,5 @@
use super::*;
use core_test_support::PathBufExt;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
@@ -6,14 +7,14 @@ use tempfile::tempdir;
#[test]
fn convert_apply_patch_maps_add_variant() {
let tmp = tempdir().expect("tmp");
let p = tmp.path().join("a.txt");
let p = tmp.path().join("a.txt").abs();
// Create an action with a single Add change
let action = ApplyPatchAction::new_add_for_test(&p, "hello".to_string());
let got = convert_apply_patch_to_protocol(&action);
assert_eq!(
got.get(&p),
got.get(p.as_path()),
Some(&FileChange::Add {
content: "hello".to_string()
})
+1 -1
View File
@@ -1,5 +1,5 @@
use codex_app_server_protocol::AppInfo;
use codex_mcp::mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_protocol::protocol::APPS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG;
+1 -1
View File
@@ -15,6 +15,6 @@ fn main() -> Result<()> {
let out_path = args
.out
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.schema.json"));
codex_core::config::schema::write_config_schema(&out_path)?;
codex_config::schema::write_config_schema(&out_path)?;
Ok(())
}
+33 -10
View File
@@ -31,12 +31,15 @@ use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use codex_api::ApiError;
use codex_api::CompactClient as ApiCompactClient;
use codex_api::CompactionInput as ApiCompactionInput;
use codex_api::Compression;
use codex_api::MemoriesClient as ApiMemoriesClient;
use codex_api::MemorySummarizeInput as ApiMemorySummarizeInput;
use codex_api::MemorySummarizeOutput as ApiMemorySummarizeOutput;
use codex_api::RawMemory as ApiRawMemory;
use codex_api::Reasoning;
use codex_api::RequestTelemetry;
use codex_api::ReqwestTransport;
use codex_api::ResponseCreateWsRequest;
@@ -45,15 +48,12 @@ use codex_api::ResponsesClient as ApiResponsesClient;
use codex_api::ResponsesOptions as ApiResponsesOptions;
use codex_api::ResponsesWebsocketClient as ApiWebSocketResponsesClient;
use codex_api::ResponsesWebsocketConnection as ApiWebSocketConnection;
use codex_api::ResponsesWsRequest;
use codex_api::SseTelemetry;
use codex_api::TransportError;
use codex_api::WebsocketTelemetry;
use codex_api::build_conversation_headers;
use codex_api::common::Reasoning;
use codex_api::common::ResponsesWsRequest;
use codex_api::create_text_param_for_request;
use codex_api::error::ApiError;
use codex_api::requests::responses::Compression;
use codex_api::response_create_client_metadata;
use codex_app_server_protocol::AuthMode;
use codex_login::AuthManager;
@@ -98,8 +98,8 @@ use crate::client_common::ResponseEvent;
use crate::client_common::ResponseStream;
use crate::flags::CODEX_RS_SSE_FIXTURE;
use crate::util::emit_feedback_auth_recovery_tags;
use codex_api::api_bridge::CoreAuthProvider;
use codex_api::api_bridge::map_api_error;
use codex_api::CoreAuthProvider;
use codex_api::map_api_error;
use codex_feedback::FeedbackRequestTags;
use codex_feedback::emit_feedback_request_tags_with_auth_env;
use codex_login::api_bridge::auth_provider_from_auth;
@@ -118,6 +118,7 @@ use codex_response_debug_context::telemetry_api_error_message;
use codex_response_debug_context::telemetry_transport_error_message;
pub const OPENAI_BETA_HEADER: &str = "OpenAI-Beta";
pub const X_CODEX_INSTALLATION_ID_HEADER: &str = "x-codex-installation-id";
pub const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state";
pub const X_CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata";
pub const X_CODEX_PARENT_THREAD_ID_HEADER: &str = "x-codex-parent-thread-id";
@@ -142,6 +143,7 @@ struct ModelClientState {
auth_manager: Option<Arc<AuthManager>>,
conversation_id: ThreadId,
window_generation: AtomicU64,
installation_id: String,
provider: ModelProviderInfo,
auth_env_telemetry: AuthEnvTelemetry,
session_source: SessionSource,
@@ -263,6 +265,7 @@ impl ModelClient {
pub fn new(
auth_manager: Option<Arc<AuthManager>>,
conversation_id: ThreadId,
installation_id: String,
provider: ModelProviderInfo,
session_source: SessionSource,
model_verbosity: Option<VerbosityConfig>,
@@ -280,6 +283,7 @@ impl ModelClient {
auth_manager,
conversation_id,
window_generation: AtomicU64::new(0),
installation_id,
provider,
auth_env_telemetry,
session_source,
@@ -399,7 +403,11 @@ impl ModelClient {
ApiCompactClient::new(transport, client_setup.api_provider, client_setup.api_auth)
.with_telemetry(Some(request_telemetry));
let instructions = prompt.base_instructions.text.clone();
let instructions = prompt
.base_instructions
.as_ref()
.map(|base_instructions| base_instructions.text.clone())
.unwrap_or_default();
let input = prompt.get_formatted_input();
let tools = create_tools_json_for_responses_api(&prompt.tools)?;
let reasoning = Self::build_reasoning(model_info, effort, summary);
@@ -425,7 +433,11 @@ impl ModelClient {
text,
};
let mut extra_headers = self.build_responses_identity_headers();
let mut extra_headers = ApiHeaderMap::new();
if let Ok(header_value) = HeaderValue::from_str(&self.state.installation_id) {
extra_headers.insert(X_CODEX_INSTALLATION_ID_HEADER, header_value);
}
extra_headers.extend(self.build_responses_identity_headers());
extra_headers.extend(build_conversation_headers(Some(
self.state.conversation_id.to_string(),
)));
@@ -511,6 +523,10 @@ impl ModelClient {
turn_metadata_header: Option<&str>,
) -> HashMap<String, String> {
let mut client_metadata = HashMap::new();
client_metadata.insert(
X_CODEX_INSTALLATION_ID_HEADER.to_string(),
self.state.installation_id.clone(),
);
client_metadata.insert(
X_CODEX_WINDOW_ID_HEADER.to_string(),
self.current_window_id(),
@@ -755,7 +771,10 @@ impl ModelClientSession {
summary: ReasoningSummaryConfig,
service_tier: Option<ServiceTier>,
) -> Result<ResponsesApiRequest> {
let instructions = &prompt.base_instructions.text;
let instructions = prompt
.base_instructions
.as_ref()
.map(|base_instructions| base_instructions.text.clone());
let input = prompt.get_formatted_input();
let tools = create_tools_json_for_responses_api(&prompt.tools)?;
let default_reasoning_effort = model_info.default_reasoning_level;
@@ -794,7 +813,7 @@ impl ModelClientSession {
let prompt_cache_key = Some(self.client.state.conversation_id.to_string());
let request = ResponsesApiRequest {
model: model_info.slug.clone(),
instructions: instructions.clone(),
instructions,
input,
tools,
tool_choice: "auto".to_string(),
@@ -810,6 +829,10 @@ impl ModelClientSession {
},
prompt_cache_key,
text,
client_metadata: Some(HashMap::from([(
X_CODEX_INSTALLATION_ID_HEADER.to_string(),
self.client.state.installation_id.clone(),
)])),
};
Ok(request)
}
+16 -3
View File
@@ -1,4 +1,4 @@
pub use codex_api::common::ResponseEvent;
pub use codex_api::ResponseEvent;
use codex_config::types::Personality;
use codex_protocol::error::Result;
use codex_protocol::models::BaseInstructions;
@@ -23,7 +23,7 @@ pub const REVIEW_EXIT_INTERRUPTED_TMPL: &str =
include_str!("../templates/review/exit_interrupted.xml");
/// API request payload for a single model turn
#[derive(Default, Debug, Clone)]
#[derive(Debug, Clone)]
pub struct Prompt {
/// Conversation context input items.
pub input: Vec<ResponseItem>,
@@ -35,7 +35,7 @@ pub struct Prompt {
/// Whether parallel tool calls are permitted for this prompt.
pub(crate) parallel_tool_calls: bool,
pub base_instructions: BaseInstructions,
pub base_instructions: Option<BaseInstructions>,
/// Optionally specify the personality of the model.
pub personality: Option<Personality>,
@@ -44,6 +44,19 @@ pub struct Prompt {
pub output_schema: Option<Value>,
}
impl Default for Prompt {
fn default() -> Self {
Self {
input: Vec::new(),
tools: Vec::new(),
parallel_tool_calls: false,
base_instructions: Some(BaseInstructions::default()),
personality: None,
output_schema: None,
}
}
}
impl Prompt {
pub(crate) fn get_formatted_input(&self) -> Vec<ResponseItem> {
let mut input = self.input.clone();
+10 -6
View File
@@ -1,6 +1,6 @@
use codex_api::OpenAiVerbosity;
use codex_api::ResponsesApiRequest;
use codex_api::common::OpenAiVerbosity;
use codex_api::common::TextControls;
use codex_api::TextControls;
use codex_api::create_text_param_for_request;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::models::FunctionCallOutputPayload;
@@ -14,7 +14,7 @@ fn serializes_text_verbosity_when_set() {
let tools: Vec<serde_json::Value> = vec![];
let req = ResponsesApiRequest {
model: "gpt-5.1".to_string(),
instructions: "i".to_string(),
instructions: Some("i".to_string()),
input,
tools,
tool_choice: "auto".to_string(),
@@ -29,6 +29,7 @@ fn serializes_text_verbosity_when_set() {
verbosity: Some(OpenAiVerbosity::Low),
format: None,
}),
client_metadata: None,
};
let v = serde_json::to_value(&req).expect("json");
@@ -57,7 +58,7 @@ fn serializes_text_schema_with_strict_format() {
let req = ResponsesApiRequest {
model: "gpt-5.1".to_string(),
instructions: "i".to_string(),
instructions: Some("i".to_string()),
input,
tools,
tool_choice: "auto".to_string(),
@@ -69,6 +70,7 @@ fn serializes_text_schema_with_strict_format() {
prompt_cache_key: None,
service_tier: None,
text: Some(text_controls),
client_metadata: None,
};
let v = serde_json::to_value(&req).expect("json");
@@ -94,7 +96,7 @@ fn omits_text_when_not_set() {
let tools: Vec<serde_json::Value> = vec![];
let req = ResponsesApiRequest {
model: "gpt-5.1".to_string(),
instructions: "i".to_string(),
instructions: Some("i".to_string()),
input,
tools,
tool_choice: "auto".to_string(),
@@ -106,6 +108,7 @@ fn omits_text_when_not_set() {
prompt_cache_key: None,
service_tier: None,
text: None,
client_metadata: None,
};
let v = serde_json::to_value(&req).expect("json");
@@ -116,7 +119,7 @@ fn omits_text_when_not_set() {
fn serializes_flex_service_tier_when_set() {
let req = ResponsesApiRequest {
model: "gpt-5.1".to_string(),
instructions: "i".to_string(),
instructions: Some("i".to_string()),
input: vec![],
tools: vec![],
tool_choice: "auto".to_string(),
@@ -128,6 +131,7 @@ fn serializes_flex_service_tier_when_set() {
prompt_cache_key: None,
service_tier: Some(ServiceTier::Flex.to_string()),
text: None,
client_metadata: None,
};
let v = serde_json::to_value(&req).expect("json");
+7 -1
View File
@@ -2,11 +2,12 @@ use super::AuthRequestTelemetryContext;
use super::ModelClient;
use super::PendingUnauthorizedRetry;
use super::UnauthorizedRecoveryExecution;
use super::X_CODEX_INSTALLATION_ID_HEADER;
use super::X_CODEX_PARENT_THREAD_ID_HEADER;
use super::X_CODEX_TURN_METADATA_HEADER;
use super::X_CODEX_WINDOW_ID_HEADER;
use super::X_OPENAI_SUBAGENT_HEADER;
use codex_api::api_bridge::CoreAuthProvider;
use codex_api::CoreAuthProvider;
use codex_app_server_protocol::AuthMode;
use codex_model_provider_info::WireApi;
use codex_model_provider_info::create_oss_provider_with_base_url;
@@ -23,6 +24,7 @@ fn test_model_client(session_source: SessionSource) -> ModelClient {
ModelClient::new(
/*auth_manager*/ None,
ThreadId::new(),
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
provider,
session_source,
/*model_verbosity*/ None,
@@ -107,6 +109,10 @@ fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() {
assert_eq!(
client_metadata,
std::collections::HashMap::from([
(
X_CODEX_INSTALLATION_ID_HEADER.to_string(),
"11111111-1111-4111-8111-111111111111".to_string(),
),
(
X_CODEX_WINDOW_ID_HEADER.to_string(),
format!("{conversation_id}:1"),
+92 -51
View File
@@ -24,6 +24,7 @@ use crate::compact_remote::run_inline_remote_auto_compact_task;
use crate::config::ManagedFeatures;
use crate::connectors;
use crate::exec_policy::ExecPolicyManager;
use crate::installation_id::resolve_installation_id;
use crate::parse_turn_item;
use crate::path_utils::normalize_for_native_workdir;
use crate::realtime_conversation::RealtimeConversationManager;
@@ -54,6 +55,7 @@ use codex_analytics::SubAgentThreadStartedInput;
use codex_analytics::build_track_events_context;
use codex_app_server_protocol::McpServerElicitationRequest;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_exec_server::Environment;
use codex_exec_server::EnvironmentManager;
use codex_features::FEATURES;
@@ -69,11 +71,11 @@ use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::auth_env_telemetry::collect_auth_env_telemetry;
use codex_login::default_client::originator;
use codex_mcp::mcp_connection_manager::McpConnectionManager;
use codex_mcp::mcp_connection_manager::SandboxState;
use codex_mcp::mcp_connection_manager::ToolInfo as McpToolInfo;
use codex_mcp::mcp_connection_manager::codex_apps_tools_cache_key;
use codex_mcp::mcp_connection_manager::filter_non_codex_apps_mcp_tools_only;
use codex_mcp::McpConnectionManager;
use codex_mcp::SandboxState;
use codex_mcp::ToolInfo as McpToolInfo;
use codex_mcp::codex_apps_tools_cache_key;
use codex_mcp::filter_non_codex_apps_mcp_tools_only;
#[cfg(test)]
use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig;
use codex_models_manager::manager::ModelsManager;
@@ -128,7 +130,6 @@ use codex_protocol::request_permissions::RequestPermissionsResponse;
use codex_protocol::request_user_input::RequestUserInputArgs;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_rmcp_client::ElicitationResponse;
use codex_rmcp_client::OAuthCredentialsStoreMode;
use codex_rollout::state_db;
use codex_shell_command::parse_command::parse_command;
use codex_terminal_detection::user_agent;
@@ -149,7 +150,6 @@ use rmcp::model::PaginatedRequestParams;
use rmcp::model::ReadResourceRequestParams;
use rmcp::model::ReadResourceResult;
use rmcp::model::RequestId;
use serde_json;
use serde_json::Value;
use tokio::sync::Mutex;
use tokio::sync::RwLock;
@@ -322,12 +322,12 @@ use crate::util::backoff;
use crate::windows_sandbox::WindowsSandboxLevelExt;
use codex_async_utils::OrCancelExt;
use codex_git_utils::get_git_repo_root;
use codex_mcp::mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::mcp::auth::compute_auth_statuses;
use codex_mcp::mcp::with_codex_apps_mcp;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::compute_auth_statuses;
use codex_mcp::with_codex_apps_mcp;
use codex_otel::SessionTelemetry;
use codex_otel::THREAD_STARTED_METRIC;
use codex_otel::TelemetryAuthMode;
use codex_otel::metrics::names::THREAD_STARTED_METRIC;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
@@ -407,8 +407,6 @@ pub(crate) type SessionLoopTermination = Shared<BoxFuture<'static, ()>>;
pub struct CodexSpawnOk {
pub codex: Codex,
pub thread_id: ThreadId,
#[deprecated(note = "use thread_id")]
pub conversation_id: ThreadId,
}
pub(crate) struct CodexSpawnArgs {
@@ -536,7 +534,11 @@ impl Codex {
config.startup_warnings.push(message);
}
let user_instructions = get_user_instructions(&config).await;
let environment = environment_manager
.current()
.await
.map_err(|err| CodexErr::Fatal(format!("failed to create environment: {err}")))?;
let user_instructions = get_user_instructions(&config, environment.as_deref()).await;
let exec_policy = if crate::guardian::is_guardian_reviewer_source(&session_source) {
// Guardian review should rely on the built-in shell safety checks,
@@ -577,11 +579,15 @@ impl Codex {
let model_info = models_manager
.get_model_info(model.as_str(), &config.to_models_manager_config())
.await;
let base_instructions = config
.base_instructions
.clone()
.or_else(|| conversation_history.get_base_instructions().map(|s| s.text))
.unwrap_or_else(|| model_info.get_model_instructions(config.personality));
let base_instructions = match config.base_instructions.clone() {
Some(base_instructions) => base_instructions,
None => conversation_history
.get_base_instructions()
.map(|base_instructions| {
base_instructions.map(|base_instructions| base_instructions.text)
})
.unwrap_or_else(|| Some(model_info.get_model_instructions(config.personality))),
};
// Respect thread-start tools. When missing (resumed/forked threads), read from the db
// first, then fall back to rollout-file tools.
@@ -610,6 +616,14 @@ impl Codex {
dynamic_tools
};
let developer_instructions_override = config
.developer_instructions_override
.clone()
.or_else(|| conversation_history.get_developer_instructions());
let developer_instructions = developer_instructions_override
.clone()
.unwrap_or_else(|| config.developer_instructions.clone());
// TODO (aibrahim): Consolidate config.model and config.model_reasoning_effort into config.collaboration_mode
// to avoid extracting these fields separately and constructing CollaborationMode here.
let collaboration_mode = CollaborationMode {
@@ -625,7 +639,8 @@ impl Codex {
collaboration_mode,
model_reasoning_summary: config.model_reasoning_summary,
service_tier: config.service_tier,
developer_instructions: config.developer_instructions.clone(),
developer_instructions,
developer_instructions_override,
user_instructions,
personality: config.personality,
base_instructions,
@@ -664,12 +679,12 @@ impl Codex {
agent_status_tx.clone(),
conversation_history,
session_source_clone,
environment_manager,
skills_manager,
plugins_manager,
mcp_manager.clone(),
skills_watcher,
agent_control,
environment,
)
.await
.map_err(|e| {
@@ -693,12 +708,7 @@ impl Codex {
session_loop_termination: session_loop_termination_from_handle(session_loop_handle),
};
#[allow(deprecated)]
Ok(CodexSpawnOk {
codex,
thread_id,
conversation_id: thread_id,
})
Ok(CodexSpawnOk { codex, thread_id })
}
/// Submit the `op` wrapped in a `Submission` with a unique ID.
@@ -862,7 +872,7 @@ pub(crate) struct TurnContext {
pub(crate) reasoning_effort: Option<ReasoningEffortConfig>,
pub(crate) reasoning_summary: ReasoningSummaryConfig,
pub(crate) session_source: SessionSource,
pub(crate) environment: Arc<Environment>,
pub(crate) environment: Option<Arc<Environment>>,
/// The session's absolute working directory. All relative paths provided
/// by the model as well as sandbox policies are resolved against this path
/// instead of `std::env::current_dir()`.
@@ -905,8 +915,13 @@ impl TurnContext {
}
pub(crate) fn apps_enabled(&self) -> bool {
self.features
.apps_enabled_cached(self.auth_manager.as_deref())
let is_chatgpt_auth = self
.auth_manager
.as_deref()
.and_then(AuthManager::auth_cached)
.as_ref()
.is_some_and(CodexAuth::is_chatgpt_auth);
self.features.apps_enabled_for_auth(is_chatgpt_auth)
}
pub(crate) async fn with_model(&self, model: String, models_manager: &ModelsManager) -> Self {
@@ -958,6 +973,7 @@ impl TurnContext {
.with_unified_exec_shell_mode(self.tools_config.unified_exec_shell_mode.clone())
.with_web_search_config(self.tools_config.web_search_config.clone())
.with_allow_login_shell(self.tools_config.allow_login_shell)
.with_has_environment(self.tools_config.has_environment)
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
&config.agent_roles,
));
@@ -977,7 +993,7 @@ impl TurnContext {
reasoning_effort,
reasoning_summary: self.reasoning_summary,
session_source: self.session_source.clone(),
environment: Arc::clone(&self.environment),
environment: self.environment.clone(),
cwd: self.cwd.clone(),
current_date: self.current_date.clone(),
timezone: self.timezone.clone(),
@@ -1089,6 +1105,10 @@ pub(crate) struct SessionConfiguration {
/// Developer instructions that supplement the base instructions.
developer_instructions: Option<String>,
/// Explicit developer instructions override, preserving `null` as distinct
/// from a missing override.
developer_instructions_override: Option<Option<String>>,
/// Model instructions that are appended to the base instructions.
user_instructions: Option<String>,
@@ -1096,7 +1116,7 @@ pub(crate) struct SessionConfiguration {
personality: Option<Personality>,
/// Base instructions for the session.
base_instructions: String,
base_instructions: Option<String>,
/// Compact prompt override.
compact_prompt: Option<String>,
@@ -1406,7 +1426,7 @@ impl Session {
model_info: ModelInfo,
models_manager: &ModelsManager,
network: Option<NetworkProxy>,
environment: Arc<Environment>,
environment: Option<Arc<Environment>>,
sub_id: String,
js_repl: Arc<JsReplHandle>,
skills_outcome: Arc<SkillLoadOutcome>,
@@ -1439,6 +1459,7 @@ impl Session {
)
.with_web_search_config(per_turn_config.web_search_config.clone())
.with_allow_login_shell(per_turn_config.permissions.allow_login_shell)
.with_has_environment(environment.is_some())
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
&per_turn_config.agent_roles,
));
@@ -1511,12 +1532,12 @@ impl Session {
agent_status: watch::Sender<AgentStatus>,
initial_history: InitialHistory,
session_source: SessionSource,
environment_manager: Arc<EnvironmentManager>,
skills_manager: Arc<SkillsManager>,
plugins_manager: Arc<PluginsManager>,
mcp_manager: Arc<McpManager>,
skills_watcher: Arc<SkillsWatcher>,
agent_control: AgentControl,
environment: Option<Arc<Environment>>,
) -> anyhow::Result<Arc<Self>> {
debug!(
"Configuring session: model={}; provider={:?}",
@@ -1534,9 +1555,13 @@ impl Session {
conversation_id,
forked_from_id,
session_source,
BaseInstructions {
text: session_configuration.base_instructions.clone(),
},
session_configuration
.base_instructions
.clone()
.map(|text| BaseInstructions { text }),
session_configuration
.developer_instructions_override
.clone(),
session_configuration.dynamic_tools.clone(),
if session_configuration.persist_extended_history {
EventPersistenceMode::Extended
@@ -1903,6 +1928,7 @@ impl Session {
});
}
let installation_id = resolve_installation_id(&config.codex_home).await?;
let services = SessionServices {
// Initialize the MCP connection manager with an uninitialized
// instance. It will be replaced with one created via
@@ -1946,6 +1972,7 @@ impl Session {
model_client: ModelClient::new(
Some(Arc::clone(&auth_manager)),
conversation_id,
installation_id,
session_configuration.provider.clone(),
session_configuration.session_source.clone(),
config.model_verbosity,
@@ -1956,7 +1983,7 @@ impl Session {
code_mode_service: crate::tools::code_mode::CodeModeService::new(
config.js_repl_node_path.clone(),
),
environment: environment_manager.current().await?,
environment,
};
services
.model_client
@@ -2098,8 +2125,9 @@ impl Session {
));
}
}
sess.schedule_startup_prewarm(session_configuration.base_instructions.clone())
.await;
if let Some(base_instructions) = session_configuration.base_instructions.clone() {
sess.schedule_startup_prewarm(base_instructions).await;
}
let session_start_source = match &initial_history {
InitialHistory::Resumed(_) => codex_hooks::SessionStartSource::Resume,
InitialHistory::New | InitialHistory::Forked(_) => {
@@ -2201,11 +2229,13 @@ impl Session {
state.history.estimate_token_count(turn_context)
}
pub(crate) async fn get_base_instructions(&self) -> BaseInstructions {
pub(crate) async fn get_base_instructions(&self) -> Option<BaseInstructions> {
let state = self.state.lock().await;
BaseInstructions {
text: state.session_configuration.base_instructions.clone(),
}
state
.session_configuration
.base_instructions
.clone()
.map(|text| BaseInstructions { text })
}
// Merges connector IDs into the session-level explicit connector selection.
@@ -2542,7 +2572,7 @@ impl Session {
.network_proxy
.as_ref()
.map(StartedNetworkProxy::proxy),
Arc::clone(&self.services.environment),
self.services.environment.clone(),
sub_id,
Arc::clone(&self.js_repl),
skills_outcome,
@@ -3609,7 +3639,11 @@ impl Session {
state.reference_context_item(),
state.previous_turn_settings(),
state.session_configuration.collaboration_mode.clone(),
state.session_configuration.base_instructions.clone(),
state
.session_configuration
.base_instructions
.clone()
.unwrap_or_default(),
state.session_configuration.session_source.clone(),
)
};
@@ -3850,7 +3884,13 @@ impl Session {
pub(crate) async fn recompute_token_usage(&self, turn_context: &TurnContext) {
let history = self.clone_history().await;
let base_instructions = self.get_base_instructions().await;
let empty_base_instructions = BaseInstructions {
text: String::new(),
};
let base_instructions = self
.get_base_instructions()
.await
.unwrap_or(empty_base_instructions);
let Some(estimated_total_tokens) =
history.estimate_token_count_with_base_instructions(&base_instructions)
else {
@@ -4762,8 +4802,8 @@ mod handlers {
use crate::tasks::UserShellCommandMode;
use crate::tasks::UserShellCommandTask;
use crate::tasks::execute_user_shell_command;
use codex_mcp::mcp::auth::compute_auth_statuses;
use codex_mcp::mcp::collect_mcp_snapshot_from_manager;
use codex_mcp::collect_mcp_snapshot_from_manager;
use codex_mcp::compute_auth_statuses;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::Event;
@@ -5614,6 +5654,7 @@ async fn spawn_review_thread(
)
.with_web_search_config(/*web_search_config*/ None)
.with_allow_login_shell(config.permissions.allow_login_shell)
.with_has_environment(parent_turn_context.environment.is_some())
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
&config.agent_roles,
));
@@ -5672,7 +5713,7 @@ async fn spawn_review_thread(
reasoning_effort,
reasoning_summary,
session_source,
environment: Arc::clone(&parent_turn_context.environment),
environment: parent_turn_context.environment.clone(),
tools_config,
features: parent_turn_context.features.clone(),
ghost_snapshot: parent_turn_context.ghost_snapshot.clone(),
@@ -6543,7 +6584,7 @@ pub(crate) fn build_prompt(
input: Vec<ResponseItem>,
router: &ToolRouter,
turn_context: &TurnContext,
base_instructions: BaseInstructions,
base_instructions: Option<BaseInstructions>,
) -> Prompt {
let deferred_dynamic_tools = turn_context
.dynamic_tools
@@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
codex_protocol::protocol::TurnCompleteEvent {
turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
},
)),
];
@@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: first_turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com
codex_protocol::protocol::TurnCompleteEvent {
turn_id: first_turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
},
)),
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: rolled_back_turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com
codex_protocol::protocol::TurnCompleteEvent {
turn_id: rolled_back_turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
},
)),
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
@@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: first_turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc
codex_protocol::protocol::TurnCompleteEvent {
turn_id: first_turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
},
)),
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: incomplete_turn_id,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: first_turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
codex_protocol::protocol::TurnCompleteEvent {
turn_id: first_turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
},
)),
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: second_turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
codex_protocol::protocol::TurnCompleteEvent {
turn_id: second_turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
},
)),
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: standalone_turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
codex_protocol::protocol::TurnCompleteEvent {
turn_id: standalone_turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
},
)),
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
@@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() {
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: first_turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() {
codex_protocol::protocol::TurnCompleteEvent {
turn_id: first_turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
},
)),
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: assistant_turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() {
codex_protocol::protocol::TurnCompleteEvent {
turn_id: assistant_turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
},
)),
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
@@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: only_turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding
codex_protocol::protocol::TurnCompleteEvent {
turn_id: only_turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
},
)),
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
@@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() {
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: user_turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() {
codex_protocol::protocol::TurnCompleteEvent {
turn_id: user_turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
},
)),
// Standalone task turn (no UserMessage) should not consume rollback skips.
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: standalone_turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() {
codex_protocol::protocol::TurnCompleteEvent {
turn_id: standalone_turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
},
)),
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
@@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: previous_turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp
codex_protocol::protocol::TurnCompleteEvent {
turn_id: previous_turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
},
)),
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: incomplete_turn_id,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: current_turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear
codex_protocol::protocol::TurnCompleteEvent {
turn_id: current_turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
},
)),
];
@@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: previous_turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
codex_protocol::protocol::TurnCompleteEvent {
turn_id: previous_turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
},
)),
];
@@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: previous_turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
codex_protocol::protocol::TurnCompleteEvent {
turn_id: previous_turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
},
)),
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: aborted_turn_id,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
codex_protocol::protocol::TurnAbortedEvent {
turn_id: None,
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
},
)),
RolloutItem::Compacted(CompactedItem {
@@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: previous_turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
codex_protocol::protocol::TurnCompleteEvent {
turn_id: previous_turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
},
)),
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: current_turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
codex_protocol::protocol::TurnAbortedEvent {
turn_id: Some(unmatched_abort_turn_id),
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
},
)),
RolloutItem::TurnContext(current_context_item.clone()),
@@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
codex_protocol::protocol::TurnCompleteEvent {
turn_id: current_turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
},
)),
];
@@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: previous_turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
codex_protocol::protocol::TurnCompleteEvent {
turn_id: previous_turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
},
)),
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: incomplete_turn_id,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: current_turn_id,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: previous_turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
codex_protocol::protocol::TurnCompleteEvent {
turn_id: previous_turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
},
)),
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: compacted_incomplete_turn_id,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: replacing_turn_id,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
+53 -57
View File
@@ -78,8 +78,8 @@ pub(crate) async fn run_codex_thread_interactive(
config,
auth_manager,
models_manager,
environment_manager: Arc::new(EnvironmentManager::new(
parent_ctx.environment.exec_server_url().map(str::to_owned),
environment_manager: Arc::new(EnvironmentManager::from_environment(
parent_ctx.environment.as_deref(),
)),
skills_manager: Arc::clone(&parent_session.services.skills_manager),
plugins_manager: Arc::clone(&parent_session.services.plugins_manager),
@@ -528,64 +528,60 @@ async fn handle_patch_approval(
} = event;
let approval_id = call_id.clone();
let guardian_decision = if routes_approval_to_guardian(parent_ctx) {
let maybe_files = changes
let files = changes
.keys()
.map(|path| parent_ctx.cwd.join(path).ok())
.collect::<Option<Vec<_>>>();
if let Some(files) = maybe_files {
let review_cancel = cancel_token.child_token();
let patch = changes
.iter()
.map(|(path, change)| match change {
codex_protocol::protocol::FileChange::Add { content } => {
format!("*** Add File: {}\n{}", path.display(), content)
.map(|path| parent_ctx.cwd.join(path))
.collect::<Vec<_>>();
let review_cancel = cancel_token.child_token();
let patch = changes
.iter()
.map(|(path, change)| match change {
codex_protocol::protocol::FileChange::Add { content } => {
format!("*** Add File: {}\n{}", path.display(), content)
}
codex_protocol::protocol::FileChange::Delete { content } => {
format!("*** Delete File: {}\n{}", path.display(), content)
}
codex_protocol::protocol::FileChange::Update {
unified_diff,
move_path,
} => {
if let Some(move_path) = move_path {
format!(
"*** Update File: {}\n*** Move to: {}\n{}",
path.display(),
move_path.display(),
unified_diff
)
} else {
format!("*** Update File: {}\n{}", path.display(), unified_diff)
}
codex_protocol::protocol::FileChange::Delete { content } => {
format!("*** Delete File: {}\n{}", path.display(), content)
}
codex_protocol::protocol::FileChange::Update {
unified_diff,
move_path,
} => {
if let Some(move_path) = move_path {
format!(
"*** Update File: {}\n*** Move to: {}\n{}",
path.display(),
move_path.display(),
unified_diff
)
} else {
format!("*** Update File: {}\n{}", path.display(), unified_diff)
}
}
})
.collect::<Vec<_>>()
.join("\n");
let review_rx = spawn_guardian_review(
Arc::clone(parent_session),
Arc::clone(parent_ctx),
GuardianApprovalRequest::ApplyPatch {
id: approval_id.clone(),
cwd: parent_ctx.cwd.to_path_buf(),
files,
patch,
},
reason.clone(),
review_cancel.clone(),
);
Some(
await_approval_with_cancel(
async move { review_rx.await.unwrap_or_default() },
parent_session,
&approval_id,
cancel_token,
Some(&review_cancel),
)
.await,
}
})
.collect::<Vec<_>>()
.join("\n");
let review_rx = spawn_guardian_review(
Arc::clone(parent_session),
Arc::clone(parent_ctx),
GuardianApprovalRequest::ApplyPatch {
id: approval_id.clone(),
cwd: parent_ctx.cwd.to_path_buf(),
files,
patch,
},
reason.clone(),
review_cancel.clone(),
);
Some(
await_approval_with_cancel(
async move { review_rx.await.unwrap_or_default() },
parent_session,
&approval_id,
cancel_token,
Some(&review_cancel),
)
} else {
None
}
.await,
)
} else {
None
};
@@ -53,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() {
msg: EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
}),
})
.await
+84 -40
View File
@@ -15,7 +15,7 @@ use crate::tools::format_exec_output_str;
use codex_features::Features;
use codex_login::CodexAuth;
use codex_mcp::mcp_connection_manager::ToolInfo;
use codex_mcp::ToolInfo;
use codex_model_provider_info::ModelProviderInfo;
use codex_models_manager::bundled_models_response;
use codex_models_manager::model_info;
@@ -36,9 +36,9 @@ use codex_protocol::request_permissions::PermissionGrantScope;
use codex_protocol::request_permissions::RequestPermissionProfile;
use tracing::Span;
use crate::RolloutRecorderParams;
use crate::rollout::policy::EventPersistenceMode;
use crate::rollout::recorder::RolloutRecorder;
use crate::rollout::recorder::RolloutRecorderParams;
use crate::state::TaskKind;
use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
@@ -235,13 +235,19 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted
.await
.expect("expected turn aborted event")
.expect("channel open");
assert!(matches!(
second.msg,
EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some(turn_id),
reason: TurnAbortReason::Interrupted,
}) if turn_id == tc.sub_id
));
let EventMsg::TurnAborted(TurnAbortedEvent {
turn_id,
reason,
completed_at,
duration_ms,
}) = second.msg
else {
panic!("expected turn aborted event");
};
assert_eq!(turn_id, Some(tc.sub_id.clone()));
assert_eq!(reason, TurnAbortReason::Interrupted);
assert!(completed_at.is_some());
assert!(duration_ms.is_some());
}
fn test_model_client_session() -> crate::client::ModelClientSession {
@@ -249,6 +255,7 @@ fn test_model_client_session() -> crate::client::ModelClientSession {
/*auth_manager*/ None,
ThreadId::try_from("00000000-0000-4000-8000-000000000001")
.expect("test thread id should be valid"),
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
ModelProviderInfo::create_openai_provider(/* base_url */ /*base_url*/ None),
codex_protocol::protocol::SessionSource::Exec,
/*model_verbosity*/ None,
@@ -585,11 +592,15 @@ async fn get_base_instructions_no_user_content() {
{
let mut state = session.state.lock().await;
state.session_configuration.base_instructions = model_info.base_instructions.clone();
state.session_configuration.base_instructions =
Some(model_info.base_instructions.clone());
}
let base_instructions = session.get_base_instructions().await;
assert_eq!(base_instructions.text, model_info.base_instructions);
assert_eq!(
base_instructions.expect("base instructions").text,
model_info.base_instructions
);
}
}
@@ -1085,7 +1096,7 @@ async fn recompute_token_usage_uses_session_base_instructions() {
let override_instructions = "SESSION_OVERRIDE_INSTRUCTIONS_ONLY".repeat(120);
{
let mut state = session.state.lock().await;
state.session_configuration.base_instructions = override_instructions.clone();
state.session_configuration.base_instructions = Some(override_instructions.clone());
}
let item = user_message("hello");
@@ -1300,6 +1311,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -1317,6 +1329,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
codex_protocol::protocol::TurnCompleteEvent {
turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
},
)),
];
@@ -1481,6 +1495,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: first_turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -1499,10 +1514,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: first_turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
})),
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: rolled_back_turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -1521,6 +1539,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: rolled_back_turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
})),
])
.await;
@@ -1579,6 +1599,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: first_turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -1595,10 +1616,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: first_turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
})),
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: compact_turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -1610,10 +1634,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: compact_turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
})),
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: rolled_back_turn_id.clone(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -1634,6 +1661,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: rolled_back_turn_id,
last_agent_message: None,
completed_at: None,
duration_ms: None,
})),
])
.await;
@@ -1661,6 +1690,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() {
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -1677,10 +1707,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() {
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: None,
completed_at: None,
duration_ms: None,
})),
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: "turn-2".to_string(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -1697,10 +1730,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() {
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-2".to_string(),
last_agent_message: None,
completed_at: None,
duration_ms: None,
})),
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: "turn-3".to_string(),
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
},
@@ -1717,6 +1753,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() {
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-3".to_string(),
last_agent_message: None,
completed_at: None,
duration_ms: None,
})),
])
.await;
@@ -1816,13 +1854,14 @@ async fn set_rate_limits_retains_previous_credits() {
collaboration_mode,
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
developer_instructions_override: config.developer_instructions_override.clone(),
user_instructions: config.user_instructions.clone(),
service_tier: None,
personality: config.personality,
base_instructions: config
.base_instructions
.clone()
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
.unwrap_or_else(|| Some(model_info.get_model_instructions(config.personality))),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
approvals_reviewer: config.approvals_reviewer,
@@ -1918,13 +1957,14 @@ async fn set_rate_limits_updates_plan_type_when_present() {
collaboration_mode,
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
developer_instructions_override: config.developer_instructions_override.clone(),
user_instructions: config.user_instructions.clone(),
service_tier: None,
personality: config.personality,
base_instructions: config
.base_instructions
.clone()
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
.unwrap_or_else(|| Some(model_info.get_model_instructions(config.personality))),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
approvals_reviewer: config.approvals_reviewer,
@@ -2189,7 +2229,8 @@ async fn attach_rollout_recorder(session: &Arc<Session>) -> PathBuf {
ThreadId::default(),
/*forked_from_id*/ None,
SessionSource::Exec,
BaseInstructions::default(),
Some(BaseInstructions::default()),
/*developer_instructions*/ None,
Vec::new(),
EventPersistenceMode::Limited,
),
@@ -2267,13 +2308,14 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
collaboration_mode,
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
developer_instructions_override: config.developer_instructions_override.clone(),
user_instructions: config.user_instructions.clone(),
service_tier: None,
personality: config.personality,
base_instructions: config
.base_instructions
.clone()
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
.unwrap_or_else(|| Some(model_info.get_model_instructions(config.personality))),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
approvals_reviewer: config.approvals_reviewer,
@@ -2470,10 +2512,7 @@ async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_
#[tokio::test]
async fn session_update_settings_keeps_runtime_cwds_absolute() {
let (session, turn_context) = make_session_and_context().await;
let updated_cwd = turn_context
.cwd
.join("project")
.expect("resolve project dir");
let updated_cwd = turn_context.cwd.join("project");
std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir");
session
@@ -2533,13 +2572,14 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
collaboration_mode,
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
developer_instructions_override: config.developer_instructions_override.clone(),
user_instructions: config.user_instructions.clone(),
service_tier: None,
personality: config.personality,
base_instructions: config
.base_instructions
.clone()
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
.unwrap_or_else(|| Some(model_info.get_model_instructions(config.personality))),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
approvals_reviewer: config.approvals_reviewer,
@@ -2579,14 +2619,16 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
agent_status_tx,
InitialHistory::New,
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::new(
/*exec_server_url*/ None,
)),
skills_manager,
plugins_manager,
mcp_manager,
Arc::new(SkillsWatcher::noop()),
AgentControl::default(),
Some(Arc::new(
codex_exec_server::Environment::create(/*exec_server_url*/ None)
.await
.expect("create environment"),
)),
)
.await;
@@ -2634,13 +2676,14 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
collaboration_mode,
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
developer_instructions_override: config.developer_instructions_override.clone(),
user_instructions: config.user_instructions.clone(),
service_tier: None,
personality: config.personality,
base_instructions: config
.base_instructions
.clone()
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
.unwrap_or_else(|| Some(model_info.get_model_instructions(config.personality))),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
approvals_reviewer: config.approvals_reviewer,
@@ -2727,6 +2770,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
model_client: ModelClient::new(
Some(auth_manager.clone()),
conversation_id,
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
session_configuration.provider.clone(),
session_configuration.session_source.clone(),
config.model_verbosity,
@@ -2737,7 +2781,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
code_mode_service: crate::tools::code_mode::CodeModeService::new(
config.js_repl_node_path.clone(),
),
environment: Arc::clone(&environment),
environment: Some(Arc::clone(&environment)),
};
let js_repl = Arc::new(JsReplHandle::with_node_path(
config.js_repl_node_path.clone(),
@@ -2764,7 +2808,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
model_info,
&models_manager,
/*network*/ None,
environment,
Some(environment),
"turn_id".to_string(),
Arc::clone(&js_repl),
skills_outcome,
@@ -3474,13 +3518,14 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
collaboration_mode,
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
developer_instructions_override: config.developer_instructions_override.clone(),
user_instructions: config.user_instructions.clone(),
service_tier: None,
personality: config.personality,
base_instructions: config
.base_instructions
.clone()
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
.unwrap_or_else(|| Some(model_info.get_model_instructions(config.personality))),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
approvals_reviewer: config.approvals_reviewer,
@@ -3567,6 +3612,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
model_client: ModelClient::new(
Some(Arc::clone(&auth_manager)),
conversation_id,
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
session_configuration.provider.clone(),
session_configuration.session_source.clone(),
config.model_verbosity,
@@ -3577,7 +3623,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
code_mode_service: crate::tools::code_mode::CodeModeService::new(
config.js_repl_node_path.clone(),
),
environment: Arc::clone(&environment),
environment: Some(Arc::clone(&environment)),
};
let js_repl = Arc::new(JsReplHandle::with_node_path(
config.js_repl_node_path.clone(),
@@ -3604,7 +3650,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
model_info,
&models_manager,
/*network*/ None,
environment,
Some(environment),
"turn_id".to_string(),
Arc::clone(&js_repl),
skills_outcome,
@@ -4050,18 +4096,13 @@ async fn handle_output_item_done_records_image_save_history_message() {
let image_output_dir = image_output_path
.parent()
.expect("generated image path should have a parent");
let save_message: ResponseItem = DeveloperInstructions::new(format!(
"Generated images are saved to {} as {} by default.",
let image_message: ResponseItem = DeveloperInstructions::new(format!(
"Generated images are saved to {} as {} by default.\nIf you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.",
image_output_dir.display(),
image_output_path.display(),
))
.into();
let copy_message: ResponseItem = DeveloperInstructions::new(
"If you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it."
.to_string(),
)
.into();
assert_eq!(history.raw_items(), &[save_message, copy_message, item]);
assert_eq!(history.raw_items(), &[image_message, item]);
assert_eq!(
std::fs::read(&expected_saved_path).expect("saved file"),
b"foo"
@@ -4229,7 +4270,8 @@ async fn record_context_updates_and_set_reference_context_item_persists_baseline
ThreadId::default(),
/*forked_from_id*/ None,
SessionSource::Exec,
BaseInstructions::default(),
Some(BaseInstructions::default()),
/*developer_instructions*/ None,
Vec::new(),
EventPersistenceMode::Limited,
),
@@ -4326,7 +4368,8 @@ async fn record_context_updates_and_set_reference_context_item_persists_full_rei
ThreadId::default(),
/*forked_from_id*/ None,
SessionSource::Exec,
BaseInstructions::default(),
Some(BaseInstructions::default()),
/*developer_instructions*/ None,
Vec::new(),
EventPersistenceMode::Limited,
),
@@ -4624,6 +4667,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input()
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id,
last_agent_message: None,
..
}) if turn_id == tc.sub_id
));
}
+21
View File
@@ -22,6 +22,7 @@ use codex_protocol::protocol::Submission;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::W3cTraceContext;
use codex_protocol::user_input::UserInput;
use rmcp::model::ReadResourceRequestParams;
use std::path::PathBuf;
use tokio::sync::Mutex;
use tokio::sync::watch;
@@ -199,6 +200,26 @@ impl CodexThread {
self.codex.thread_config_snapshot().await
}
pub async fn read_mcp_resource(
&self,
server: &str,
uri: &str,
) -> anyhow::Result<serde_json::Value> {
let result = self
.codex
.session
.read_resource(
server,
ReadResourceRequestParams {
meta: None,
uri: uri.to_string(),
},
)
.await?;
Ok(serde_json::to_value(result)?)
}
pub fn enabled(&self, feature: Feature) -> bool {
self.codex.enabled(feature)
}
+1
View File
@@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task(
) -> CodexResult<()> {
let start_event = EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_context.sub_id.clone(),
started_at: turn_context.turn_timing_state.started_at_unix_secs().await,
model_context_window: turn_context.model_context_window(),
collaboration_mode_kind: turn_context.collaboration_mode.mode,
});
+14 -2
View File
@@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task(
) -> CodexResult<()> {
let start_event = EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_context.sub_id.clone(),
started_at: turn_context.turn_timing_state.started_at_unix_secs().await,
model_context_window: turn_context.model_context_window(),
collaboration_mode_kind: turn_context.collaboration_mode.mode,
});
@@ -75,10 +76,16 @@ async fn run_remote_compact_task_inner_impl(
.await;
let mut history = sess.clone_history().await;
let base_instructions = sess.get_base_instructions().await;
let token_count_base_instructions =
base_instructions
.clone()
.unwrap_or_else(|| BaseInstructions {
text: String::new(),
});
let deleted_items = trim_function_call_history_to_fit_context_window(
&mut history,
turn_context.as_ref(),
&base_instructions,
&token_count_base_instructions,
);
if deleted_items > 0 {
info!(
@@ -126,8 +133,13 @@ async fn run_remote_compact_task_inner_impl(
)
.or_else(|err| async {
let total_usage_breakdown = sess.get_total_token_usage_breakdown().await;
let base_instruction_text = prompt
.base_instructions
.as_ref()
.map(|base_instructions| base_instructions.text.as_str())
.unwrap_or("");
let compact_request_log_data =
build_compact_request_log_data(&prompt.input, &prompt.base_instructions.text);
build_compact_request_log_data(&prompt.input, base_instruction_text);
log_remote_compact_failure(
turn_context,
&compact_request_log_data,
+3 -3
View File
@@ -1,9 +1,9 @@
use super::AgentRoleConfig;
use super::AgentRoleToml;
use super::AgentsToml;
use super::ConfigToml;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigLayerStackOrdering;
use codex_config::config_toml::AgentRoleToml;
use codex_config::config_toml::AgentsToml;
use codex_config::config_toml::ConfigToml;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use serde::Deserialize;
+28
View File
@@ -5,6 +5,24 @@ use crate::config_loader::RequirementSource;
use crate::plugins::PluginsManager;
use assert_matches::assert_matches;
use codex_config::CONFIG_TOML_FILE;
use codex_config::config_toml::AgentRoleToml;
use codex_config::config_toml::AgentsToml;
use codex_config::config_toml::ConfigToml;
use codex_config::config_toml::ProjectConfig;
use codex_config::config_toml::RealtimeAudioConfig;
use codex_config::config_toml::RealtimeConfig;
use codex_config::config_toml::RealtimeToml;
use codex_config::config_toml::RealtimeWsMode;
use codex_config::config_toml::RealtimeWsVersion;
use codex_config::config_toml::ToolsToml;
use codex_config::permissions_toml::FilesystemPermissionToml;
use codex_config::permissions_toml::FilesystemPermissionsToml;
use codex_config::permissions_toml::NetworkDomainPermissionToml;
use codex_config::permissions_toml::NetworkDomainPermissionsToml;
use codex_config::permissions_toml::NetworkToml;
use codex_config::permissions_toml::PermissionProfileToml;
use codex_config::permissions_toml::PermissionsToml;
use codex_config::profile_toml::ConfigProfile;
use codex_config::types::AppToolApproval;
use codex_config::types::ApprovalsReviewer;
use codex_config::types::BundledSkillsConfig;
@@ -17,9 +35,14 @@ use codex_config::types::MemoriesToml;
use codex_config::types::ModelAvailabilityNuxConfig;
use codex_config::types::NotificationMethod;
use codex_config::types::Notifications;
use codex_config::types::SandboxWorkspaceWrite;
use codex_config::types::SkillsConfig;
use codex_config::types::ToolSuggestDiscoverableType;
use codex_config::types::Tui;
use codex_features::Feature;
use codex_features::FeaturesToml;
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::permissions::FileSystemAccessMode;
@@ -28,6 +51,7 @@ use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::ReadOnlyAccess;
use serde::Deserialize;
use tempfile::tempdir;
@@ -4482,6 +4506,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
experimental_realtime_ws_startup_context: None,
base_instructions: None,
developer_instructions: None,
developer_instructions_override: None,
guardian_developer_instructions: None,
include_permissions_instructions: true,
include_apps_instructions: true,
@@ -4627,6 +4652,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
experimental_realtime_ws_startup_context: None,
base_instructions: None,
developer_instructions: None,
developer_instructions_override: None,
guardian_developer_instructions: None,
include_permissions_instructions: true,
include_apps_instructions: true,
@@ -4770,6 +4796,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
experimental_realtime_ws_startup_context: None,
base_instructions: None,
developer_instructions: None,
developer_instructions_override: None,
guardian_developer_instructions: None,
include_permissions_instructions: true,
include_apps_instructions: true,
@@ -4899,6 +4926,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
experimental_realtime_ws_startup_context: None,
base_instructions: None,
developer_instructions: None,
developer_instructions_override: None,
guardian_developer_instructions: None,
include_permissions_instructions: true,
include_apps_instructions: true,
+2 -2
View File
@@ -8,8 +8,8 @@ use codex_config::FeatureRequirementsToml;
use codex_config::RequirementSource;
use codex_config::Sourced;
use crate::config::ConfigToml;
use crate::config::profile::ConfigProfile;
use codex_config::config_toml::ConfigToml;
use codex_config::profile_toml::ConfigProfile;
use codex_features::Feature;
use codex_features::FeatureConfigSource;
use codex_features::FeatureOverrides;
+51 -770
View File
@@ -22,49 +22,42 @@ 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_app_server_protocol::Tools;
use codex_app_server_protocol::UserSavedConfig;
use codex_config::config_toml::ConfigToml;
use codex_config::config_toml::ProjectConfig;
use codex_config::config_toml::RealtimeAudioConfig;
use codex_config::config_toml::RealtimeConfig;
use codex_config::config_toml::validate_model_providers;
use codex_config::profile_toml::ConfigProfile;
use codex_config::types::ApprovalsReviewer;
use codex_config::types::AppsConfigToml;
use codex_config::types::AuthCredentialsStoreMode;
use codex_config::types::DEFAULT_OTEL_ENVIRONMENT;
use codex_config::types::History;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerDisabledReason;
use codex_config::types::McpServerTransportConfig;
use codex_config::types::MemoriesConfig;
use codex_config::types::MemoriesToml;
use codex_config::types::ModelAvailabilityNuxConfig;
use codex_config::types::Notice;
use codex_config::types::NotificationMethod;
use codex_config::types::Notifications;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_config::types::OtelConfig;
use codex_config::types::OtelConfigToml;
use codex_config::types::OtelExporterKind;
use codex_config::types::PluginConfig;
use codex_config::types::SandboxWorkspaceWrite;
use codex_config::types::ShellEnvironmentPolicy;
use codex_config::types::ShellEnvironmentPolicyToml;
use codex_config::types::SkillsConfig;
use codex_config::types::ToolSuggestConfig;
use codex_config::types::ToolSuggestDiscoverable;
use codex_config::types::Tui;
use codex_config::types::UriBasedFileOpener;
use codex_config::types::WindowsSandboxModeToml;
use codex_config::types::WindowsToml;
use codex_features::Feature;
use codex_features::FeatureConfigSource;
use codex_features::FeatureOverrides;
use codex_features::Features;
use codex_features::FeaturesToml;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_login::AuthCredentialsStoreMode;
use codex_mcp::mcp::McpConfig;
use codex_login::AuthManagerConfig;
use codex_mcp::McpConfig;
use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID;
use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
use codex_model_provider_info::ModelProviderInfo;
use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR;
use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID;
use codex_model_provider_info::OPENAI_PROVIDER_ID;
use codex_model_provider_info::built_in_model_providers;
use codex_models_manager::ModelsManagerConfig;
use codex_protocol::config_types::AltScreenMode;
@@ -77,22 +70,16 @@ use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::Verbosity;
use codex_protocol::config_types::WebSearchConfig;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::config_types::WebSearchToolConfig;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ReadOnlyAccess;
use codex_protocol::protocol::SandboxPolicy;
use codex_rmcp_client::OAuthCredentialsStoreMode;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::io::ErrorKind;
@@ -102,7 +89,6 @@ use std::path::PathBuf;
use crate::config::permissions::compile_permission_profile;
use crate::config::permissions::get_readable_roots_required_for_codex_runtime;
use crate::config::permissions::network_proxy_config_from_profile_network;
use crate::config::profile::ConfigProfile;
use codex_network_proxy::NetworkProxyConfig;
use toml::Value as TomlValue;
use toml_edit::DocumentMut;
@@ -112,9 +98,9 @@ pub mod edit;
mod managed_features;
mod network_proxy_spec;
mod permissions;
pub mod profile;
pub mod schema;
pub mod service;
#[cfg(test)]
mod schema;
pub(crate) mod service;
pub use codex_config::Constrained;
pub use codex_config::ConstraintError;
pub use codex_config::ConstraintResult;
@@ -123,16 +109,6 @@ pub use codex_sandboxing::system_bwrap_warning;
pub use managed_features::ManagedFeatures;
pub use network_proxy_spec::NetworkProxySpec;
pub use network_proxy_spec::StartedNetworkProxy;
pub use permissions::FilesystemPermissionToml;
pub use permissions::FilesystemPermissionsToml;
pub use permissions::NetworkDomainPermissionToml;
pub use permissions::NetworkDomainPermissionsToml;
pub use permissions::NetworkToml;
pub use permissions::NetworkUnixSocketPermissionToml;
pub use permissions::NetworkUnixSocketPermissionsToml;
pub use permissions::PermissionProfileToml;
pub use permissions::PermissionsToml;
pub(crate) use permissions::overlay_network_domain_permissions;
pub(crate) use permissions::resolve_permission_profile;
pub use service::ConfigService;
pub use service::ConfigServiceError;
@@ -148,11 +124,6 @@ pub(crate) const DEFAULT_AGENT_MAX_DEPTH: i32 = 1;
pub(crate) const DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS: Option<u64> = None;
pub const CONFIG_TOML_FILE: &str = "config.toml";
const RESERVED_MODEL_PROVIDER_IDS: [&str; 3] = [
OPENAI_PROVIDER_ID,
OLLAMA_OSS_PROVIDER_ID,
LMSTUDIO_OSS_PROVIDER_ID,
];
fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option<PathBuf> {
let raw = std::env::var(codex_state::SQLITE_HOME_ENV).ok()?;
@@ -272,11 +243,15 @@ pub struct Config {
pub user_instructions: Option<String>,
/// Base instructions override.
pub base_instructions: Option<String>,
pub base_instructions: Option<Option<String>>,
/// Developer instructions override injected as a separate message.
pub developer_instructions: Option<String>,
/// Explicit developer instructions override, preserving `null` as distinct
/// from a missing override.
pub developer_instructions_override: Option<Option<String>>,
/// Guardian-specific developer instructions override from requirements.toml.
pub guardian_developer_instructions: Option<String>,
@@ -341,7 +316,7 @@ pub struct Config {
/// Controls whether the TUI uses the terminal's alternate screen buffer.
///
/// This is the same `tui.alternate_screen` value from `config.toml` (see [`Tui`]).
/// This is the same `tui.alternate_screen` value from `config.toml`.
/// - `auto` (default): Disable alternate screen in Zellij, enable elsewhere.
/// - `always`: Always use alternate screen (original behavior).
/// - `never`: Never use alternate screen (inline mode, preserves scrollback).
@@ -593,6 +568,20 @@ pub struct Config {
pub otel: codex_config::types::OtelConfig,
}
impl AuthManagerConfig for Config {
fn codex_home(&self) -> PathBuf {
self.codex_home.clone()
}
fn cli_auth_credentials_store_mode(&self) -> AuthCredentialsStoreMode {
self.cli_auth_credentials_store_mode
}
fn forced_chatgpt_workspace_id(&self) -> Option<String> {
self.forced_chatgpt_workspace_id.clone()
}
}
#[derive(Debug, Clone, Default)]
pub struct ConfigBuilder {
codex_home: Option<PathBuf>,
@@ -702,7 +691,7 @@ impl Config {
model_context_window: self.model_context_window,
model_auto_compact_token_limit: self.model_auto_compact_token_limit,
tool_output_token_limit: self.tool_output_token_limit,
base_instructions: self.base_instructions.clone(),
base_instructions: self.base_instructions.clone().flatten(),
personality_enabled: self.features.enabled(Feature::Personality),
model_supports_reasoning_summaries: self.model_supports_reasoning_summaries,
model_catalog: self.model_catalog.clone(),
@@ -1100,26 +1089,7 @@ pub fn set_project_trust_level(
/// Save the default OSS provider preference to config.toml
pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::Result<()> {
// Validate that the provider is one of the known OSS providers
match provider {
LMSTUDIO_OSS_PROVIDER_ID | OLLAMA_OSS_PROVIDER_ID => {
// Valid provider, continue
}
LEGACY_OLLAMA_CHAT_PROVIDER_ID => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
OLLAMA_CHAT_PROVIDER_REMOVED_ERROR,
));
}
_ => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"Invalid OSS provider '{provider}'. Must be one of: {LMSTUDIO_OSS_PROVIDER_ID}, {OLLAMA_OSS_PROVIDER_ID}"
),
));
}
}
codex_config::config_toml::validate_oss_provider(provider)?;
use toml_edit::value;
let edits = [ConfigEdit::SetPath {
@@ -1133,452 +1103,15 @@ pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::R
.map_err(|err| std::io::Error::other(format!("failed to persist config.toml: {err}")))
}
/// Base config deserialized from ~/.codex/config.toml.
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ConfigToml {
/// Optional override of model selection.
pub model: Option<String>,
/// Review model override used by the `/review` feature.
pub review_model: Option<String>,
/// Provider to use from the model_providers map.
pub model_provider: Option<String>,
/// Size of the context window for the model, in tokens.
pub model_context_window: Option<i64>,
/// Token usage threshold triggering auto-compaction of conversation history.
pub model_auto_compact_token_limit: Option<i64>,
/// Default approval policy for executing commands.
pub approval_policy: Option<AskForApproval>,
/// Configures who approval requests are routed to for review once they have
/// been escalated. This does not disable separate safety checks such as
/// ARC.
pub approvals_reviewer: Option<ApprovalsReviewer>,
#[serde(default)]
pub shell_environment_policy: ShellEnvironmentPolicyToml,
/// Whether the model may request a login shell for shell-based tools.
/// Default to `true`
///
/// If `true`, the model may request a login shell (`login = true`), and
/// omitting `login` defaults to using a login shell.
/// If `false`, the model can never use a login shell: `login = true`
/// requests are rejected, and omitting `login` defaults to a non-login
/// shell.
pub allow_login_shell: Option<bool>,
/// Sandbox mode to use.
pub sandbox_mode: Option<SandboxMode>,
/// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`.
pub sandbox_workspace_write: Option<SandboxWorkspaceWrite>,
/// Default named permissions profile to apply from the `[permissions]`
/// table.
pub default_permissions: Option<String>,
/// Named permissions profiles.
#[serde(default)]
pub permissions: Option<PermissionsToml>,
/// Optional external command to spawn for end-user notifications.
#[serde(default)]
pub notify: Option<Vec<String>>,
/// System instructions.
pub instructions: Option<String>,
/// Developer instructions inserted as a `developer` role message.
#[serde(default)]
pub developer_instructions: Option<String>,
/// Whether to inject the `<permissions instructions>` developer block.
pub include_permissions_instructions: Option<bool>,
/// Whether to inject the `<apps_instructions>` developer block.
pub include_apps_instructions: Option<bool>,
/// Whether to inject the `<environment_context>` user block.
pub include_environment_context: Option<bool>,
/// Optional path to a file containing model instructions that will override
/// the built-in instructions for the selected model. Users are STRONGLY
/// DISCOURAGED from using this field, as deviating from the instructions
/// sanctioned by Codex will likely degrade model performance.
pub model_instructions_file: Option<AbsolutePathBuf>,
/// Compact prompt used for history compaction.
pub compact_prompt: Option<String>,
/// Optional commit attribution text for commit message co-author trailers.
///
/// Set to an empty string to disable automatic commit attribution.
pub commit_attribution: Option<String>,
/// When set, restricts ChatGPT login to a specific workspace identifier.
#[serde(default)]
pub forced_chatgpt_workspace_id: Option<String>,
/// When set, restricts the login mechanism users may use.
#[serde(default)]
pub forced_login_method: Option<ForcedLoginMethod>,
/// Preferred backend for storing CLI auth credentials.
/// file (default): Use a file in the Codex home directory.
/// keyring: Use an OS-specific keyring service.
/// auto: Use the keyring if available, otherwise use a file.
#[serde(default)]
pub cli_auth_credentials_store: Option<AuthCredentialsStoreMode>,
/// Definition for MCP servers that Codex can reach out to for tool calls.
#[serde(default)]
// Uses the raw MCP input shape (custom deserialization) rather than `McpServerConfig`.
#[schemars(schema_with = "crate::config::schema::mcp_servers_schema")]
pub mcp_servers: HashMap<String, McpServerConfig>,
/// Preferred backend for storing MCP OAuth credentials.
/// keyring: Use an OS-specific keyring service.
/// https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/oauth.rs#L2
/// file: Use a file in the Codex home directory.
/// auto (default): Use the OS-specific keyring service if available, otherwise use a file.
#[serde(default)]
pub mcp_oauth_credentials_store: Option<OAuthCredentialsStoreMode>,
/// Optional fixed port for the local HTTP callback server used during MCP OAuth login.
/// When unset, Codex will bind to an ephemeral port chosen by the OS.
pub mcp_oauth_callback_port: Option<u16>,
/// Optional redirect URI to use during MCP OAuth login.
/// When set, this URI is used in the OAuth authorization request instead
/// of the local listener address. The local callback listener still binds
/// to 127.0.0.1 (using `mcp_oauth_callback_port` when provided).
pub mcp_oauth_callback_url: Option<String>,
/// User-defined provider entries that extend the built-in list. Built-in
/// IDs cannot be overridden.
#[serde(default, deserialize_with = "deserialize_model_providers")]
pub model_providers: HashMap<String, ModelProviderInfo>,
/// Maximum number of bytes to include from an AGENTS.md project doc file.
pub project_doc_max_bytes: Option<usize>,
/// Ordered list of fallback filenames to look for when AGENTS.md is missing.
pub project_doc_fallback_filenames: Option<Vec<String>>,
/// Token budget applied when storing tool/function outputs in the context manager.
pub tool_output_token_limit: Option<usize>,
/// Maximum poll window for background terminal output (`write_stdin`), in milliseconds.
/// Default: `300000` (5 minutes).
pub background_terminal_max_timeout: Option<u64>,
/// Optional absolute path to the Node runtime used by `js_repl`.
pub js_repl_node_path: Option<AbsolutePathBuf>,
/// Ordered list of directories to search for Node modules in `js_repl`.
pub js_repl_node_module_dirs: Option<Vec<AbsolutePathBuf>>,
/// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution.
pub zsh_path: Option<AbsolutePathBuf>,
/// Profile to use from the `profiles` map.
pub profile: Option<String>,
/// Named profiles to facilitate switching between different configurations.
#[serde(default)]
pub profiles: HashMap<String, ConfigProfile>,
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
#[serde(default)]
pub history: Option<History>,
/// Directory where Codex stores the SQLite state DB.
/// Defaults to `$CODEX_SQLITE_HOME` when set. Otherwise uses `$CODEX_HOME`.
pub sqlite_home: Option<AbsolutePathBuf>,
/// Directory where Codex writes log files, for example `codex-tui.log`.
/// Defaults to `$CODEX_HOME/log`.
pub log_dir: Option<AbsolutePathBuf>,
/// Optional URI-based file opener. If set, citations to files in the model
/// output will be hyperlinked using the specified URI scheme.
pub file_opener: Option<UriBasedFileOpener>,
/// Collection of settings that are specific to the TUI.
pub tui: Option<Tui>,
/// When set to `true`, `AgentReasoning` events will be hidden from the
/// UI/output. Defaults to `false`.
pub hide_agent_reasoning: Option<bool>,
/// When set to `true`, `AgentReasoningRawContentEvent` events will be shown in the UI/output.
/// Defaults to `false`.
pub show_raw_agent_reasoning: Option<bool>,
pub model_reasoning_effort: Option<ReasoningEffort>,
pub plan_mode_reasoning_effort: Option<ReasoningEffort>,
pub model_reasoning_summary: Option<ReasoningSummary>,
/// Optional verbosity control for GPT-5 models (Responses API `text.verbosity`).
pub model_verbosity: Option<Verbosity>,
/// Override to force-enable reasoning summaries for the configured model.
pub model_supports_reasoning_summaries: Option<bool>,
/// Optional path to a JSON model catalog (applied on startup only).
/// Per-thread `config` overrides are accepted but do not reapply this (no-ops).
pub model_catalog_json: Option<AbsolutePathBuf>,
/// Optionally specify a personality for the model
pub personality: Option<Personality>,
/// Optional explicit service tier preference for new turns (`fast` or `flex`).
pub service_tier: Option<ServiceTier>,
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
pub chatgpt_base_url: Option<String>,
/// Base URL override for the built-in `openai` model provider.
pub openai_base_url: Option<String>,
/// Machine-local realtime audio device preferences used by realtime voice.
#[serde(default)]
pub audio: Option<RealtimeAudioToml>,
/// Experimental / do not use. Overrides only the realtime conversation
/// websocket transport base URL (the `Op::RealtimeConversation`
/// `/v1/realtime`
/// connection) without changing normal provider HTTP requests.
pub experimental_realtime_ws_base_url: Option<String>,
/// Experimental / do not use. Selects the realtime websocket model/snapshot
/// used for the `Op::RealtimeConversation` connection.
pub experimental_realtime_ws_model: Option<String>,
/// Experimental / do not use. Realtime websocket session selection.
/// `version` controls v1/v2 and `type` controls conversational/transcription.
#[serde(default)]
pub realtime: Option<RealtimeToml>,
/// Experimental / do not use. Overrides only the realtime conversation
/// websocket transport instructions (the `Op::RealtimeConversation`
/// `/ws` session.update instructions) without changing normal prompts.
pub experimental_realtime_ws_backend_prompt: Option<String>,
/// Experimental / do not use. Replaces the synthesized realtime startup
/// context appended to websocket session instructions. An empty string
/// disables startup context injection entirely.
pub experimental_realtime_ws_startup_context: Option<String>,
/// Experimental / do not use. Replaces the built-in realtime start
/// instructions inserted into developer messages when realtime becomes
/// active.
pub experimental_realtime_start_instructions: Option<String>,
pub projects: Option<HashMap<String, ProjectConfig>>,
/// Controls the web search tool mode: disabled, cached, or live.
pub web_search: Option<WebSearchMode>,
/// Nested tools section for feature toggles
pub tools: Option<ToolsToml>,
/// Additional discoverable tools that can be suggested for installation.
pub tool_suggest: Option<ToolSuggestConfig>,
/// Agent-related settings (thread limits, etc.).
pub agents: Option<AgentsToml>,
/// Memories subsystem settings.
pub memories: Option<MemoriesToml>,
/// User-level skill config entries keyed by SKILL.md path.
pub skills: Option<SkillsConfig>,
/// User-level plugin config entries keyed by plugin name.
#[serde(default)]
pub plugins: HashMap<String, PluginConfig>,
/// Centralized feature flags (new). Prefer this over individual toggles.
#[serde(default)]
// Injects known feature keys into the schema and forbids unknown keys.
#[schemars(schema_with = "crate::config::schema::features_schema")]
pub features: Option<FeaturesToml>,
/// Suppress warnings about unstable (under development) features.
pub suppress_unstable_features_warning: Option<bool>,
/// Settings for ghost snapshots (used for undo).
#[serde(default)]
pub ghost_snapshot: Option<GhostSnapshotToml>,
/// Markers used to detect the project root when searching parent
/// directories for `.codex` folders. Defaults to [".git"] when unset.
#[serde(default)]
pub project_root_markers: Option<Vec<String>>,
/// When `true`, checks for Codex updates on startup and surfaces update prompts.
/// Set to `false` only if your Codex updates are centrally managed.
/// Defaults to `true`.
pub check_for_update_on_startup: Option<bool>,
/// When true, disables burst-paste detection for typed input entirely.
/// All characters are inserted as they are received, and no buffering
/// or placeholder replacement will occur for fast keypress bursts.
pub disable_paste_burst: Option<bool>,
/// When `false`, disables analytics across Codex product surfaces in this machine.
/// Defaults to `true`.
pub analytics: Option<codex_config::types::AnalyticsConfigToml>,
/// When `false`, disables feedback collection across Codex product surfaces.
/// Defaults to `true`.
pub feedback: Option<codex_config::types::FeedbackConfigToml>,
/// Settings for app-specific controls.
#[serde(default)]
pub apps: Option<AppsConfigToml>,
/// OTEL configuration.
pub otel: Option<codex_config::types::OtelConfigToml>,
/// Windows-specific configuration.
#[serde(default)]
pub windows: Option<WindowsToml>,
/// Tracks whether the Windows onboarding screen has been acknowledged.
pub windows_wsl_setup_acknowledged: Option<bool>,
/// Collection of in-product notices (different from notifications)
/// See [`codex_config::types::Notice`] for more details
pub notice: Option<Notice>,
/// Legacy, now use features
/// Deprecated: ignored. Use `model_instructions_file`.
#[schemars(skip)]
pub experimental_instructions_file: Option<AbsolutePathBuf>,
pub experimental_compact_prompt_file: Option<AbsolutePathBuf>,
pub experimental_use_unified_exec_tool: Option<bool>,
pub experimental_use_freeform_apply_patch: Option<bool>,
/// Preferred OSS provider for local models, e.g. "lmstudio" or "ollama".
pub oss_provider: Option<String>,
}
impl From<ConfigToml> for UserSavedConfig {
fn from(config_toml: ConfigToml) -> Self {
let profiles = config_toml
.profiles
.into_iter()
.map(|(k, v)| (k, v.into()))
.collect();
Self {
approval_policy: config_toml.approval_policy,
sandbox_mode: config_toml.sandbox_mode,
sandbox_settings: config_toml.sandbox_workspace_write.map(From::from),
forced_chatgpt_workspace_id: config_toml.forced_chatgpt_workspace_id,
forced_login_method: config_toml.forced_login_method,
model: config_toml.model,
model_reasoning_effort: config_toml.model_reasoning_effort,
model_reasoning_summary: config_toml.model_reasoning_summary,
model_verbosity: config_toml.model_verbosity,
tools: config_toml.tools.map(From::from),
profile: config_toml.profile,
profiles,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ProjectConfig {
pub trust_level: Option<TrustLevel>,
}
impl ProjectConfig {
pub fn is_trusted(&self) -> bool {
matches!(self.trust_level, Some(TrustLevel::Trusted))
}
pub fn is_untrusted(&self) -> bool {
matches!(self.trust_level, Some(TrustLevel::Untrusted))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RealtimeAudioConfig {
pub microphone: Option<String>,
pub speaker: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum RealtimeWsMode {
#[default]
Conversational,
Transcription,
}
pub use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion;
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct RealtimeConfig {
pub version: RealtimeWsVersion,
#[serde(rename = "type")]
pub session_type: RealtimeWsMode,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct RealtimeToml {
pub version: Option<RealtimeWsVersion>,
#[serde(rename = "type")]
pub session_type: Option<RealtimeWsMode>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct RealtimeAudioToml {
pub microphone: Option<String>,
pub speaker: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ToolsToml {
#[serde(
default,
deserialize_with = "deserialize_optional_web_search_tool_config"
)]
pub web_search: Option<WebSearchToolConfig>,
/// Enable the `view_image` tool that lets the agent attach local images.
#[serde(default)]
pub view_image: Option<bool>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum WebSearchToolConfigInput {
Enabled(bool),
Config(WebSearchToolConfig),
}
fn deserialize_optional_web_search_tool_config<'de, D>(
deserializer: D,
) -> Result<Option<WebSearchToolConfig>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<WebSearchToolConfigInput>::deserialize(deserializer)?;
Ok(match value {
None => None,
Some(WebSearchToolConfigInput::Enabled(enabled)) => {
let _ = enabled;
None
}
Some(WebSearchToolConfigInput::Config(config)) => Some(config),
})
pub struct AgentRoleConfig {
/// Human-facing role documentation used in spawn tool guidance.
/// Required for loaded user-defined roles after deprecated/new metadata precedence resolves.
pub description: Option<String>,
/// Path to a role-specific config layer.
pub config_file: Option<PathBuf>,
/// Candidate nicknames for agents spawned with this role.
pub nickname_candidates: Option<Vec<String>>,
}
fn resolve_tool_suggest_config(config_toml: &ConfigToml) -> ToolSuggestConfig {
@@ -1603,218 +1136,6 @@ fn resolve_tool_suggest_config(config_toml: &ConfigToml) -> ToolSuggestConfig {
ToolSuggestConfig { discoverables }
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct AgentsToml {
/// Maximum number of agent threads that can be open concurrently.
/// When unset, no limit is enforced.
#[schemars(range(min = 1))]
pub max_threads: Option<usize>,
/// Maximum nesting depth allowed for spawned agent threads.
/// Root sessions start at depth 0.
#[schemars(range(min = 1))]
pub max_depth: Option<i32>,
/// Default maximum runtime in seconds for agent job workers.
#[schemars(range(min = 1))]
pub job_max_runtime_seconds: Option<u64>,
/// User-defined role declarations keyed by role name.
///
/// Example:
/// ```toml
/// [agents.researcher]
/// description = "Research-focused role."
/// config_file = "./agents/researcher.toml"
/// nickname_candidates = ["Herodotus", "Ibn Battuta"]
/// ```
#[serde(default, flatten)]
pub roles: BTreeMap<String, AgentRoleToml>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AgentRoleConfig {
/// Human-facing role documentation used in spawn tool guidance.
/// Required for loaded user-defined roles after deprecated/new metadata precedence resolves.
pub description: Option<String>,
/// Path to a role-specific config layer.
pub config_file: Option<PathBuf>,
/// Candidate nicknames for agents spawned with this role.
pub nickname_candidates: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct AgentRoleToml {
/// Human-facing role documentation used in spawn tool guidance.
/// Required unless supplied by the referenced agent role file.
pub description: Option<String>,
/// Path to a role-specific config layer.
/// Relative paths are resolved relative to the `config.toml` that defines them.
pub config_file: Option<AbsolutePathBuf>,
/// Candidate nicknames for agents spawned with this role.
pub nickname_candidates: Option<Vec<String>>,
}
impl From<ToolsToml> for Tools {
fn from(tools_toml: ToolsToml) -> Self {
Self {
web_search: tools_toml.web_search.is_some().then_some(true),
view_image: tools_toml.view_image,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct GhostSnapshotToml {
/// Exclude untracked files larger than this many bytes from ghost snapshots.
#[serde(alias = "ignore_untracked_files_over_bytes")]
pub ignore_large_untracked_files: Option<i64>,
/// Ignore untracked directories that contain this many files or more.
/// (Still emits a warning unless warnings are disabled.)
#[serde(alias = "large_untracked_dir_warning_threshold")]
pub ignore_large_untracked_dirs: Option<i64>,
/// Disable all ghost snapshot warning events.
pub disable_warnings: Option<bool>,
}
impl ConfigToml {
/// Derive the effective sandbox policy from the configuration.
fn derive_sandbox_policy(
&self,
sandbox_mode_override: Option<SandboxMode>,
profile_sandbox_mode: Option<SandboxMode>,
windows_sandbox_level: WindowsSandboxLevel,
resolved_cwd: &Path,
sandbox_policy_constraint: Option<&Constrained<SandboxPolicy>>,
) -> SandboxPolicy {
let sandbox_mode_was_explicit = sandbox_mode_override.is_some()
|| profile_sandbox_mode.is_some()
|| self.sandbox_mode.is_some();
let resolved_sandbox_mode = sandbox_mode_override
.or(profile_sandbox_mode)
.or(self.sandbox_mode)
.or_else(|| {
// If no sandbox_mode is set but this directory has a trust decision,
// default to workspace-write except on unsandboxed Windows where we
// default to read-only.
self.get_active_project(resolved_cwd).and_then(|p| {
if p.is_trusted() || p.is_untrusted() {
if cfg!(target_os = "windows")
&& windows_sandbox_level
== codex_protocol::config_types::WindowsSandboxLevel::Disabled
{
Some(SandboxMode::ReadOnly)
} else {
Some(SandboxMode::WorkspaceWrite)
}
} else {
None
}
})
})
.unwrap_or_default();
let mut sandbox_policy = match resolved_sandbox_mode {
SandboxMode::ReadOnly => SandboxPolicy::new_read_only_policy(),
SandboxMode::WorkspaceWrite => match self.sandbox_workspace_write.as_ref() {
Some(SandboxWorkspaceWrite {
writable_roots,
network_access,
exclude_tmpdir_env_var,
exclude_slash_tmp,
}) => SandboxPolicy::WorkspaceWrite {
writable_roots: writable_roots.clone(),
read_only_access: ReadOnlyAccess::FullAccess,
network_access: *network_access,
exclude_tmpdir_env_var: *exclude_tmpdir_env_var,
exclude_slash_tmp: *exclude_slash_tmp,
},
None => SandboxPolicy::new_workspace_write_policy(),
},
SandboxMode::DangerFullAccess => SandboxPolicy::DangerFullAccess,
};
let downgrade_workspace_write_if_unsupported = |policy: &mut SandboxPolicy| {
if cfg!(target_os = "windows")
// If the experimental Windows sandbox is enabled, do not force a downgrade.
&& windows_sandbox_level
== codex_protocol::config_types::WindowsSandboxLevel::Disabled
&& matches!(&*policy, SandboxPolicy::WorkspaceWrite { .. })
{
*policy = SandboxPolicy::new_read_only_policy();
}
};
if matches!(resolved_sandbox_mode, SandboxMode::WorkspaceWrite) {
downgrade_workspace_write_if_unsupported(&mut sandbox_policy);
}
if !sandbox_mode_was_explicit
&& let Some(constraint) = sandbox_policy_constraint
&& let Err(err) = constraint.can_set(&sandbox_policy)
{
tracing::warn!(
error = %err,
"default sandbox policy is disallowed by requirements; falling back to required default"
);
sandbox_policy = constraint.get().clone();
downgrade_workspace_write_if_unsupported(&mut sandbox_policy);
}
sandbox_policy
}
/// Resolves the cwd to an existing project, or returns None if ConfigToml
/// does not contain a project corresponding to cwd or a git repo for cwd
pub fn get_active_project(&self, resolved_cwd: &Path) -> Option<ProjectConfig> {
let projects = self.projects.clone().unwrap_or_default();
let resolved_cwd_key = project_trust_key(resolved_cwd);
let resolved_cwd_raw_key = resolved_cwd.to_string_lossy().to_string();
if let Some(project_config) = projects
.get(&resolved_cwd_key)
.or_else(|| projects.get(&resolved_cwd_raw_key))
{
return Some(project_config.clone());
}
// If cwd lives inside a git repo/worktree, check whether the root git project
// (the primary repository working directory) is trusted. This lets
// worktrees inherit trust from the main project.
if let Some(repo_root) = resolve_root_git_project_for_trust(resolved_cwd) {
let repo_root_key = project_trust_key(repo_root.as_path());
let repo_root_raw_key = repo_root.to_string_lossy().to_string();
if let Some(project_config_for_root) = projects
.get(&repo_root_key)
.or_else(|| projects.get(&repo_root_raw_key))
{
return Some(project_config_for_root.clone());
}
}
None
}
pub fn get_config_profile(
&self,
override_profile: Option<String>,
) -> Result<ConfigProfile, std::io::Error> {
let profile = override_profile.or_else(|| self.profile.clone());
match profile {
Some(key) => {
if let Some(profile) = self.profiles.get(key.as_str()) {
return Ok(profile.clone());
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("config profile `{key}` not found"),
))
}
None => Ok(ConfigProfile::default()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PermissionConfigSyntax {
Legacy,
@@ -1883,8 +1204,8 @@ pub struct ConfigOverrides {
pub js_repl_node_path: Option<PathBuf>,
pub js_repl_node_module_dirs: Option<Vec<PathBuf>>,
pub zsh_path: Option<PathBuf>,
pub base_instructions: Option<String>,
pub developer_instructions: Option<String>,
pub base_instructions: Option<Option<String>>,
pub developer_instructions: Option<Option<String>>,
pub personality: Option<Personality>,
pub compact_prompt: Option<String>,
pub include_apply_patch_tool: Option<bool>,
@@ -1895,49 +1216,6 @@ pub struct ConfigOverrides {
pub additional_writable_roots: Vec<PathBuf>,
}
fn validate_reserved_model_provider_ids(
model_providers: &HashMap<String, ModelProviderInfo>,
) -> Result<(), String> {
let mut conflicts = model_providers
.keys()
.filter(|key| RESERVED_MODEL_PROVIDER_IDS.contains(&key.as_str()))
.map(|key| format!("`{key}`"))
.collect::<Vec<_>>();
conflicts.sort_unstable();
if conflicts.is_empty() {
Ok(())
} else {
Err(format!(
"model_providers contains reserved built-in provider IDs: {}. \
Built-in providers cannot be overridden. Rename your custom provider (for example, `openai-custom`).",
conflicts.join(", ")
))
}
}
fn validate_model_providers(
model_providers: &HashMap<String, ModelProviderInfo>,
) -> Result<(), String> {
validate_reserved_model_provider_ids(model_providers)?;
for (key, provider) in model_providers {
provider
.validate()
.map_err(|message| format!("model_providers.{key}: {message}"))?;
}
Ok(())
}
fn deserialize_model_providers<'de, D>(
deserializer: D,
) -> Result<HashMap<String, ModelProviderInfo>, D::Error>
where
D: serde::Deserializer<'de>,
{
let model_providers = HashMap::<String, ModelProviderInfo>::deserialize(deserializer)?;
validate_model_providers(&model_providers).map_err(serde::de::Error::custom)?;
Ok(model_providers)
}
/// Resolves the OSS provider from CLI override, profile config, or global config.
/// Returns `None` if no provider is configured at any level.
pub fn resolve_oss_provider(
@@ -2171,7 +1449,7 @@ impl Config {
let mut additional_writable_roots: Vec<AbsolutePathBuf> = additional_writable_roots
.into_iter()
.map(|path| AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path()))
.collect::<Result<Vec<_>, _>>()?;
.collect();
let active_project = cfg
.get_active_project(resolved_cwd.as_path())
.unwrap_or(ProjectConfig { trust_level: None });
@@ -2486,8 +1764,10 @@ impl Config {
.or(cfg.model_instructions_file.as_ref());
let file_base_instructions =
Self::try_read_non_empty_file(model_instructions_path, "model instructions file")?;
let base_instructions = base_instructions.or(file_base_instructions);
let developer_instructions = developer_instructions.or(cfg.developer_instructions);
let base_instructions = base_instructions.or_else(|| file_base_instructions.map(Some));
let developer_instructions_override = developer_instructions.clone();
let developer_instructions =
developer_instructions.unwrap_or_else(|| cfg.developer_instructions.clone());
let include_permissions_instructions = config_profile
.include_permissions_instructions
.or(cfg.include_permissions_instructions)
@@ -2670,6 +1950,7 @@ impl Config {
base_instructions,
personality,
developer_instructions,
developer_instructions_override,
compact_prompt,
commit_attribution,
include_permissions_instructions,
+55 -27
View File
@@ -20,6 +20,8 @@ use codex_protocol::protocol::SandboxPolicy;
use std::collections::HashSet;
use std::sync::Arc;
const GLOBAL_ALLOWLIST_PATTERN: &str = "*";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NetworkProxySpec {
config: NetworkProxyConfig,
@@ -195,6 +197,8 @@ impl NetworkProxySpec {
let allowlist_expansion_enabled =
Self::allowlist_expansion_enabled(sandbox_policy, hard_deny_allowlist_misses);
let denylist_expansion_enabled = Self::denylist_expansion_enabled(sandbox_policy);
let danger_full_access_denylist_only =
Self::danger_full_access_denylist_only_enabled(requirements, sandbox_policy);
if let Some(enabled) = requirements.enabled {
config.network.enabled = enabled;
@@ -225,37 +229,43 @@ impl NetworkProxySpec {
constraints.dangerously_allow_all_unix_sockets =
Some(dangerously_allow_all_unix_sockets);
}
let managed_allowed_domains = if hard_deny_allowlist_misses {
Some(
if danger_full_access_denylist_only {
config
.network
.set_allowed_domains(vec![GLOBAL_ALLOWLIST_PATTERN.to_string()]);
} else {
let managed_allowed_domains = if hard_deny_allowlist_misses {
Some(
requirements
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains)
.unwrap_or_default(),
)
} else {
requirements
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains)
.unwrap_or_default(),
)
} else {
requirements
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains)
};
if let Some(managed_allowed_domains) = managed_allowed_domains {
// Managed requirements seed the baseline allowlist. User additions
// can extend that baseline unless managed-only mode pins the
// effective allowlist to the managed set.
let effective_allowed_domains = if allowlist_expansion_enabled {
Self::merge_domain_lists(
managed_allowed_domains.clone(),
config.network.allowed_domains().as_deref().unwrap_or(&[]),
)
} else {
managed_allowed_domains.clone()
};
config
.network
.set_allowed_domains(effective_allowed_domains);
constraints.allowed_domains = Some(managed_allowed_domains);
constraints.allowlist_expansion_enabled = Some(allowlist_expansion_enabled);
if let Some(managed_allowed_domains) = managed_allowed_domains {
// Managed requirements seed the baseline allowlist. User additions
// can extend that baseline unless managed-only mode pins the
// effective allowlist to the managed set.
let effective_allowed_domains = if allowlist_expansion_enabled {
Self::merge_domain_lists(
managed_allowed_domains.clone(),
config.network.allowed_domains().as_deref().unwrap_or(&[]),
)
} else {
managed_allowed_domains.clone()
};
config
.network
.set_allowed_domains(effective_allowed_domains);
constraints.allowed_domains = Some(managed_allowed_domains);
constraints.allowlist_expansion_enabled = Some(allowlist_expansion_enabled);
}
}
let managed_denied_domains = requirements
.domains
@@ -274,7 +284,7 @@ impl NetworkProxySpec {
constraints.denied_domains = Some(managed_denied_domains);
constraints.denylist_expansion_enabled = Some(denylist_expansion_enabled);
}
if requirements.unix_sockets.is_some() {
if requirements.unix_sockets.is_some() && !danger_full_access_denylist_only {
let allow_unix_sockets = requirements
.unix_sockets
.as_ref()
@@ -289,6 +299,14 @@ impl NetworkProxySpec {
config.network.allow_local_binding = allow_local_binding;
constraints.allow_local_binding = Some(allow_local_binding);
}
if danger_full_access_denylist_only {
config.network.allow_upstream_proxy = true;
constraints.allow_upstream_proxy = Some(true);
config.network.dangerously_allow_all_unix_sockets = true;
constraints.dangerously_allow_all_unix_sockets = Some(true);
config.network.allow_local_binding = true;
constraints.allow_local_binding = Some(true);
}
(config, constraints)
}
@@ -307,6 +325,16 @@ impl NetworkProxySpec {
requirements.managed_allowed_domains_only.unwrap_or(false)
}
fn danger_full_access_denylist_only_enabled(
requirements: &NetworkConstraints,
sandbox_policy: &SandboxPolicy,
) -> bool {
matches!(sandbox_policy, SandboxPolicy::DangerFullAccess)
&& requirements
.danger_full_access_denylist_only
.unwrap_or(false)
}
fn denylist_expansion_enabled(sandbox_policy: &SandboxPolicy) -> bool {
matches!(
sandbox_policy,
@@ -1,8 +1,11 @@
use super::*;
use crate::config_loader::NetworkDomainPermissionToml;
use crate::config_loader::NetworkDomainPermissionsToml;
use crate::config_loader::NetworkUnixSocketPermissionToml;
use crate::config_loader::NetworkUnixSocketPermissionsToml;
use codex_network_proxy::NetworkDomainPermission;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
fn domain_permissions(
entries: impl IntoIterator<Item = (&'static str, NetworkDomainPermissionToml)>,
@@ -178,6 +181,147 @@ fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() {
assert_eq!(spec.constraints.denylist_expansion_enabled, Some(false));
}
#[test]
fn danger_full_access_denylist_only_allows_all_domains_and_enforces_managed_denies() {
let mut config = NetworkProxyConfig::default();
config
.network
.set_allowed_domains(vec!["evil.com".to_string()]);
config
.network
.set_denied_domains(vec!["more-blocked.example.com".to_string()]);
let requirements = NetworkConstraints {
allow_upstream_proxy: Some(false),
dangerously_allow_all_unix_sockets: Some(false),
domains: Some(domain_permissions([
("*.example.com", NetworkDomainPermissionToml::Allow),
("blocked.example.com", NetworkDomainPermissionToml::Deny),
])),
danger_full_access_denylist_only: Some(true),
unix_sockets: Some(NetworkUnixSocketPermissionsToml {
entries: BTreeMap::from([(
"/tmp/managed.sock".to_string(),
NetworkUnixSocketPermissionToml::Allow,
)]),
}),
allow_local_binding: Some(false),
..Default::default()
};
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::DangerFullAccess,
)
.expect("denylist-only yolo mode should allow all domains except managed denies");
assert_eq!(
spec.config.network.allowed_domains(),
Some(vec!["*".to_string()])
);
assert_eq!(
spec.config.network.denied_domains(),
Some(vec!["blocked.example.com".to_string()])
);
assert!(spec.config.network.allow_upstream_proxy);
assert!(spec.config.network.dangerously_allow_all_unix_sockets);
assert!(spec.config.network.allow_local_binding);
assert_eq!(spec.constraints.allow_upstream_proxy, Some(true));
assert_eq!(
spec.constraints.dangerously_allow_all_unix_sockets,
Some(true)
);
assert_eq!(spec.constraints.allow_unix_sockets, None);
assert_eq!(spec.constraints.allow_local_binding, Some(true));
assert_eq!(spec.constraints.allowed_domains, None);
assert_eq!(spec.constraints.allowlist_expansion_enabled, None);
assert_eq!(
spec.constraints.denied_domains,
Some(vec!["blocked.example.com".to_string()])
);
assert_eq!(spec.constraints.denylist_expansion_enabled, Some(false));
}
#[test]
fn danger_full_access_denylist_only_does_not_change_workspace_write_behavior() {
let mut config = NetworkProxyConfig::default();
config
.network
.set_allowed_domains(vec!["api.example.com".to_string()]);
config
.network
.set_denied_domains(vec!["blocked.example.com".to_string()]);
let requirements = NetworkConstraints {
allow_upstream_proxy: Some(false),
dangerously_allow_all_unix_sockets: Some(false),
domains: Some(domain_permissions([
("*.example.com", NetworkDomainPermissionToml::Allow),
(
"managed-blocked.example.com",
NetworkDomainPermissionToml::Deny,
),
])),
danger_full_access_denylist_only: Some(true),
unix_sockets: Some(NetworkUnixSocketPermissionsToml {
entries: BTreeMap::from([(
"/tmp/managed.sock".to_string(),
NetworkUnixSocketPermissionToml::Allow,
)]),
}),
allow_local_binding: Some(false),
..Default::default()
};
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::new_workspace_write_policy(),
)
.expect("denylist-only yolo flag should not affect workspace-write mode");
assert_eq!(
spec.config.network.allowed_domains(),
Some(vec![
"*.example.com".to_string(),
"api.example.com".to_string()
])
);
assert_eq!(
spec.config.network.denied_domains(),
Some(vec![
"managed-blocked.example.com".to_string(),
"blocked.example.com".to_string()
])
);
assert!(!spec.config.network.allow_upstream_proxy);
assert!(!spec.config.network.dangerously_allow_all_unix_sockets);
assert_eq!(
spec.config.network.allow_unix_sockets(),
vec!["/tmp/managed.sock".to_string()]
);
assert!(!spec.config.network.allow_local_binding);
assert_eq!(spec.constraints.allow_upstream_proxy, Some(false));
assert_eq!(
spec.constraints.dangerously_allow_all_unix_sockets,
Some(false)
);
assert_eq!(
spec.constraints.allow_unix_sockets,
Some(vec!["/tmp/managed.sock".to_string()])
);
assert_eq!(spec.constraints.allow_local_binding, Some(false));
assert_eq!(
spec.constraints.allowed_domains,
Some(vec!["*.example.com".to_string()])
);
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(true));
assert_eq!(
spec.constraints.denied_domains,
Some(vec!["managed-blocked.example.com".to_string()])
);
assert_eq!(spec.constraints.denylist_expansion_enabled, Some(true));
}
#[test]
fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() {
let mut config = NetworkProxyConfig::default();
+6 -240
View File
@@ -1,256 +1,22 @@
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::io;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
use codex_network_proxy::NetworkDomainPermission as ProxyNetworkDomainPermission;
use codex_network_proxy::NetworkMode;
use codex_config::permissions_toml::FilesystemPermissionToml;
use codex_config::permissions_toml::NetworkToml;
use codex_config::permissions_toml::PermissionProfileToml;
use codex_config::permissions_toml::PermissionsToml;
use codex_network_proxy::NetworkProxyConfig;
#[cfg(test)]
use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission;
use codex_network_proxy::normalize_host;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
pub struct PermissionsToml {
#[serde(flatten)]
pub entries: BTreeMap<String, PermissionProfileToml>,
}
impl PermissionsToml {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PermissionProfileToml {
pub filesystem: Option<FilesystemPermissionsToml>,
pub network: Option<NetworkToml>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
pub struct FilesystemPermissionsToml {
#[serde(flatten)]
pub entries: BTreeMap<String, FilesystemPermissionToml>,
}
impl FilesystemPermissionsToml {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(untagged)]
pub enum FilesystemPermissionToml {
Access(FileSystemAccessMode),
Scoped(BTreeMap<String, FileSystemAccessMode>),
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
pub struct NetworkDomainPermissionsToml {
#[serde(flatten)]
pub entries: BTreeMap<String, NetworkDomainPermissionToml>,
}
impl NetworkDomainPermissionsToml {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[cfg(test)]
pub(crate) fn allowed_domains(&self) -> Option<Vec<String>> {
let allowed_domains: Vec<String> = self
.entries
.iter()
.filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Allow))
.map(|(pattern, _)| pattern.clone())
.collect();
(!allowed_domains.is_empty()).then_some(allowed_domains)
}
#[cfg(test)]
pub(crate) fn denied_domains(&self) -> Option<Vec<String>> {
let denied_domains: Vec<String> = self
.entries
.iter()
.filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Deny))
.map(|(pattern, _)| pattern.clone())
.collect();
(!denied_domains.is_empty()).then_some(denied_domains)
}
}
#[derive(
Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum NetworkDomainPermissionToml {
Allow,
Deny,
}
impl std::fmt::Display for NetworkDomainPermissionToml {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let permission = match self {
Self::Allow => "allow",
Self::Deny => "deny",
};
f.write_str(permission)
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
pub struct NetworkUnixSocketPermissionsToml {
#[serde(flatten)]
pub entries: BTreeMap<String, NetworkUnixSocketPermissionToml>,
}
impl NetworkUnixSocketPermissionsToml {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub(crate) fn allow_unix_sockets(&self) -> Vec<String> {
self.entries
.iter()
.filter(|(_, permission)| matches!(permission, NetworkUnixSocketPermissionToml::Allow))
.map(|(path, _)| path.clone())
.collect()
}
}
#[derive(
Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum NetworkUnixSocketPermissionToml {
Allow,
None,
}
impl std::fmt::Display for NetworkUnixSocketPermissionToml {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let permission = match self {
Self::Allow => "allow",
Self::None => "none",
};
f.write_str(permission)
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NetworkToml {
pub enabled: Option<bool>,
pub proxy_url: Option<String>,
pub enable_socks5: Option<bool>,
pub socks_url: Option<String>,
pub enable_socks5_udp: Option<bool>,
pub allow_upstream_proxy: Option<bool>,
pub dangerously_allow_non_loopback_proxy: Option<bool>,
pub dangerously_allow_all_unix_sockets: Option<bool>,
#[schemars(with = "Option<NetworkModeSchema>")]
pub mode: Option<NetworkMode>,
pub domains: Option<NetworkDomainPermissionsToml>,
pub unix_sockets: Option<NetworkUnixSocketPermissionsToml>,
pub allow_local_binding: Option<bool>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "lowercase")]
enum NetworkModeSchema {
Limited,
Full,
}
impl NetworkToml {
pub(crate) fn apply_to_network_proxy_config(&self, config: &mut NetworkProxyConfig) {
if let Some(enabled) = self.enabled {
config.network.enabled = enabled;
}
if let Some(proxy_url) = self.proxy_url.as_ref() {
config.network.proxy_url = proxy_url.clone();
}
if let Some(enable_socks5) = self.enable_socks5 {
config.network.enable_socks5 = enable_socks5;
}
if let Some(socks_url) = self.socks_url.as_ref() {
config.network.socks_url = socks_url.clone();
}
if let Some(enable_socks5_udp) = self.enable_socks5_udp {
config.network.enable_socks5_udp = enable_socks5_udp;
}
if let Some(allow_upstream_proxy) = self.allow_upstream_proxy {
config.network.allow_upstream_proxy = allow_upstream_proxy;
}
if let Some(dangerously_allow_non_loopback_proxy) =
self.dangerously_allow_non_loopback_proxy
{
config.network.dangerously_allow_non_loopback_proxy =
dangerously_allow_non_loopback_proxy;
}
if let Some(dangerously_allow_all_unix_sockets) = self.dangerously_allow_all_unix_sockets {
config.network.dangerously_allow_all_unix_sockets = dangerously_allow_all_unix_sockets;
}
if let Some(mode) = self.mode {
config.network.mode = mode;
}
if let Some(domains) = self.domains.as_ref() {
overlay_network_domain_permissions(config, domains);
}
if let Some(unix_sockets) = self.unix_sockets.as_ref() {
let mut proxy_unix_sockets = config.network.unix_sockets.take().unwrap_or_default();
for (path, permission) in &unix_sockets.entries {
let permission = match permission {
NetworkUnixSocketPermissionToml::Allow => {
ProxyNetworkUnixSocketPermission::Allow
}
NetworkUnixSocketPermissionToml::None => ProxyNetworkUnixSocketPermission::None,
};
proxy_unix_sockets.entries.insert(path.clone(), permission);
}
config.network.unix_sockets =
(!proxy_unix_sockets.entries.is_empty()).then_some(proxy_unix_sockets);
}
if let Some(allow_local_binding) = self.allow_local_binding {
config.network.allow_local_binding = allow_local_binding;
}
}
pub(crate) fn to_network_proxy_config(&self) -> NetworkProxyConfig {
let mut config = NetworkProxyConfig::default();
self.apply_to_network_proxy_config(&mut config);
config
}
}
pub(crate) fn overlay_network_domain_permissions(
config: &mut NetworkProxyConfig,
domains: &NetworkDomainPermissionsToml,
) {
for (pattern, permission) in &domains.entries {
let permission = match permission {
NetworkDomainPermissionToml::Allow => ProxyNetworkDomainPermission::Allow,
NetworkDomainPermissionToml::Deny => ProxyNetworkDomainPermission::Deny,
};
config
.network
.upsert_domain_permission(pattern.clone(), permission, normalize_host);
}
}
pub(crate) fn network_proxy_config_from_profile_network(
network: Option<&NetworkToml>,
@@ -416,7 +182,7 @@ fn compile_scoped_filesystem_path(
let subpath = parse_relative_subpath(subpath)?;
let base = parse_absolute_path(path)?;
let path = AbsolutePathBuf::resolve_path_against_base(&subpath, base.as_path())?;
let path = AbsolutePathBuf::resolve_path_against_base(&subpath, base.as_path());
Ok(FileSystemPath::Path { path })
}
@@ -1,7 +1,15 @@
use super::*;
use crate::config::Config;
use crate::config::ConfigOverrides;
use crate::config::ConfigToml;
use codex_config::config_toml::ConfigToml;
use codex_config::permissions_toml::FilesystemPermissionsToml;
use codex_config::permissions_toml::NetworkDomainPermissionToml;
use codex_config::permissions_toml::NetworkDomainPermissionsToml;
use codex_config::permissions_toml::NetworkToml;
use codex_config::permissions_toml::NetworkUnixSocketPermissionToml;
use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml;
use codex_config::permissions_toml::PermissionProfileToml;
use codex_config::permissions_toml::PermissionsToml;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
-83
View File
@@ -1,83 +0,0 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use crate::config::ToolsToml;
use codex_config::types::ApprovalsReviewer;
use codex_config::types::Personality;
use codex_config::types::WindowsToml;
use codex_features::FeaturesToml;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::Verbosity;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
/// Collection of common configuration options that a user can define as a unit
/// in `config.toml`.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ConfigProfile {
pub model: Option<String>,
pub service_tier: Option<ServiceTier>,
/// The key in the `model_providers` map identifying the
/// [`ModelProviderInfo`] to use.
pub model_provider: Option<String>,
pub approval_policy: Option<AskForApproval>,
pub approvals_reviewer: Option<ApprovalsReviewer>,
pub sandbox_mode: Option<SandboxMode>,
pub model_reasoning_effort: Option<ReasoningEffort>,
pub plan_mode_reasoning_effort: Option<ReasoningEffort>,
pub model_reasoning_summary: Option<ReasoningSummary>,
pub model_verbosity: Option<Verbosity>,
/// Optional path to a JSON model catalog (applied on startup only).
pub model_catalog_json: Option<AbsolutePathBuf>,
pub personality: Option<Personality>,
pub chatgpt_base_url: Option<String>,
/// Optional path to a file containing model instructions.
pub model_instructions_file: Option<AbsolutePathBuf>,
pub js_repl_node_path: Option<AbsolutePathBuf>,
/// Ordered list of directories to search for Node modules in `js_repl`.
pub js_repl_node_module_dirs: Option<Vec<AbsolutePathBuf>>,
/// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution.
pub zsh_path: Option<AbsolutePathBuf>,
/// Deprecated: ignored. Use `model_instructions_file`.
#[schemars(skip)]
pub experimental_instructions_file: Option<AbsolutePathBuf>,
pub experimental_compact_prompt_file: Option<AbsolutePathBuf>,
pub include_apply_patch_tool: Option<bool>,
pub include_permissions_instructions: Option<bool>,
pub include_apps_instructions: Option<bool>,
pub include_environment_context: Option<bool>,
pub experimental_use_unified_exec_tool: Option<bool>,
pub experimental_use_freeform_apply_patch: Option<bool>,
pub tools_view_image: Option<bool>,
pub tools: Option<ToolsToml>,
pub web_search: Option<WebSearchMode>,
pub analytics: Option<codex_config::types::AnalyticsConfigToml>,
#[serde(default)]
pub windows: Option<WindowsToml>,
/// Optional feature toggles scoped to this profile.
#[serde(default)]
// Injects known feature keys into the schema and forbids unknown keys.
#[schemars(schema_with = "crate::config::schema::features_schema")]
pub features: Option<FeaturesToml>,
pub oss_provider: Option<String>,
}
impl From<ConfigProfile> for codex_app_server_protocol::Profile {
fn from(config_profile: ConfigProfile) -> Self {
Self {
model: config_profile.model,
model_provider: config_profile.model_provider,
approval_policy: config_profile.approval_policy,
model_reasoning_effort: config_profile.model_reasoning_effort,
model_reasoning_summary: config_profile.model_reasoning_summary,
model_verbosity: config_profile.model_verbosity,
chatgpt_base_url: config_profile.chatgpt_base_url,
}
}
}
+3 -100
View File
@@ -1,103 +1,6 @@
use crate::config::ConfigToml;
use codex_config::types::RawMcpServerConfig;
use codex_features::FEATURES;
use codex_features::legacy_feature_keys;
use schemars::r#gen::SchemaGenerator;
use schemars::r#gen::SchemaSettings;
use schemars::schema::InstanceType;
use schemars::schema::ObjectValidation;
use schemars::schema::RootSchema;
use schemars::schema::Schema;
use schemars::schema::SchemaObject;
use serde_json::Map;
use serde_json::Value;
use std::path::Path;
/// Schema for the `[features]` map with known + legacy keys only.
pub(crate) fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema {
let mut object = SchemaObject {
instance_type: Some(InstanceType::Object.into()),
..Default::default()
};
let mut validation = ObjectValidation::default();
for feature in FEATURES {
if feature.id == codex_features::Feature::Artifact {
continue;
}
validation
.properties
.insert(feature.key.to_string(), schema_gen.subschema_for::<bool>());
}
for legacy_key in legacy_feature_keys() {
validation
.properties
.insert(legacy_key.to_string(), schema_gen.subschema_for::<bool>());
}
validation.additional_properties = Some(Box::new(Schema::Bool(false)));
object.object = Some(Box::new(validation));
Schema::Object(object)
}
/// Schema for the `[mcp_servers]` map using the raw input shape.
pub(crate) fn mcp_servers_schema(schema_gen: &mut SchemaGenerator) -> Schema {
let mut object = SchemaObject {
instance_type: Some(InstanceType::Object.into()),
..Default::default()
};
let validation = ObjectValidation {
additional_properties: Some(Box::new(schema_gen.subschema_for::<RawMcpServerConfig>())),
..Default::default()
};
object.object = Some(Box::new(validation));
Schema::Object(object)
}
/// Build the config schema for `config.toml`.
pub fn config_schema() -> RootSchema {
SchemaSettings::draft07()
.with(|settings| {
settings.option_add_null_type = false;
})
.into_generator()
.into_root_schema_for::<ConfigToml>()
}
/// Canonicalize a JSON value by sorting its keys.
fn canonicalize(value: &Value) -> Value {
match value {
Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()),
Value::Object(map) => {
let mut entries: Vec<_> = map.iter().collect();
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
let mut sorted = Map::with_capacity(map.len());
for (key, child) in entries {
sorted.insert(key.clone(), canonicalize(child));
}
Value::Object(sorted)
}
_ => value.clone(),
}
}
/// Render the config schema as pretty-printed JSON.
pub fn config_schema_json() -> anyhow::Result<Vec<u8>> {
let schema = config_schema();
let value = serde_json::to_value(schema)?;
let value = canonicalize(&value);
let json = serde_json::to_vec_pretty(&value)?;
Ok(json)
}
/// Write the config schema fixture to disk.
pub fn write_config_schema(out_path: &Path) -> anyhow::Result<()> {
let json = config_schema_json()?;
std::fs::write(out_path, json)?;
Ok(())
}
use codex_config::schema::canonicalize;
use codex_config::schema::config_schema_json;
use codex_config::schema::write_config_schema;
#[cfg(test)]
#[path = "schema_tests.rs"]
+2 -3
View File
@@ -1,4 +1,3 @@
use super::ConfigToml;
use super::deserialize_config_toml_with_base;
use crate::config::edit::ConfigEdit;
use crate::config::edit::ConfigEditsBuilder;
@@ -29,6 +28,7 @@ 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::config_toml::ConfigToml;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde_json::Value as JsonValue;
use std::borrow::Cow;
@@ -265,8 +265,7 @@ impl ConfigService {
edits: Vec<(String, JsonValue, MergeStrategy)>,
) -> Result<ConfigWriteResponse, ConfigServiceError> {
let allowed_path =
AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, &self.codex_home)
.map_err(|err| ConfigServiceError::io("failed to resolve user config path", err))?;
AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, &self.codex_home);
let provided_path = match file_path {
Some(path) => AbsolutePathBuf::from_absolute_path(PathBuf::from(path))
.map_err(|err| ConfigServiceError::io("failed to resolve user config path", err))?,
+6 -5
View File
@@ -5,11 +5,12 @@ mod macos;
#[cfg(test)]
mod tests;
use crate::config::ConfigToml;
use crate::config_loader::layer_io::LoadedConfigLayers;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigRequirementsWithSources;
use codex_config::config_toml::ConfigToml;
use codex_config::config_toml::ProjectConfig;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::SandboxMode;
@@ -186,7 +187,7 @@ pub async fn load_config_layers_state(
// Add a layer for $CODEX_HOME/config.toml if it exists. Note if the file
// exists, but is malformed, then this error should be propagated to the
// user.
let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home)?;
let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home);
let user_layer = load_config_toml_for_required_layer(&user_file, |config_toml| {
ConfigLayerEntry::new(
ConfigLayerSource::User {
@@ -544,7 +545,7 @@ struct ProjectTrustContext {
#[derive(Deserialize)]
struct ProjectTrustConfigToml {
projects: Option<std::collections::HashMap<String, crate::config::ProjectConfig>>,
projects: Option<std::collections::HashMap<String, ProjectConfig>>,
}
struct ProjectTrustDecision {
@@ -808,7 +809,7 @@ async fn load_project_layers(
if dot_codex_abs == codex_home_abs || dot_codex_normalized == codex_home_normalized {
continue;
}
let config_file = dot_codex_abs.join(CONFIG_TOML_FILE)?;
let config_file = dot_codex_abs.join(CONFIG_TOML_FILE);
match tokio::fs::read_to_string(&config_file).await {
Ok(contents) => {
let config: TomlValue = match toml::from_str(&contents) {
@@ -944,7 +945,7 @@ foo = "xyzzy"
expected_toml_value.insert(
"model_instructions_file".to_string(),
TomlValue::String(
AbsolutePathBuf::resolve_path_against_base("./some_file.md", base_dir)?
AbsolutePathBuf::resolve_path_against_base("./some_file.md", base_dir)
.as_path()
.to_string_lossy()
.to_string(),
+5 -6
View File
@@ -2,9 +2,7 @@ use super::LoaderOverrides;
use super::load_config_layers_state;
use crate::config::ConfigBuilder;
use crate::config::ConfigOverrides;
use crate::config::ConfigToml;
use crate::config::ConstraintError;
use crate::config::ProjectConfig;
use crate::config_loader::CloudRequirementsLoadError;
use crate::config_loader::CloudRequirementsLoader;
use crate::config_loader::ConfigLayerEntry;
@@ -16,6 +14,8 @@ use crate::config_loader::RequirementSource;
use crate::config_loader::load_requirements_toml;
use crate::config_loader::version_for_toml;
use codex_config::CONFIG_TOML_FILE;
use codex_config::config_toml::ConfigToml;
use codex_config::config_toml::ProjectConfig;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::protocol::AskForApproval;
@@ -78,7 +78,7 @@ async fn cli_overrides_resolve_relative_paths_against_cwd() -> std::io::Result<(
.build()
.await?;
let expected = AbsolutePathBuf::resolve_path_against_base("run-logs", cwd_path)?;
let expected = AbsolutePathBuf::resolve_path_against_base("run-logs", cwd_path);
assert_eq!(config.log_dir, expected.to_path_buf());
Ok(())
}
@@ -250,7 +250,6 @@ async fn returns_empty_when_all_layers_missing() {
&ConfigLayerEntry {
name: super::ConfigLayerSource::User {
file: AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, tmp.path())
.expect("resolve user config.toml path")
},
config: TomlValue::Table(toml::map::Map::new()),
raw_toml: None,
@@ -905,7 +904,7 @@ model_instructions_file = "child.txt"
.await?;
assert_eq!(
config.base_instructions.as_deref(),
config.base_instructions.as_ref().and_then(Option::as_deref),
Some("child instructions")
);
@@ -941,7 +940,7 @@ async fn cli_override_model_instructions_file_sets_base_instructions() -> std::i
.await?;
assert_eq!(
config.base_instructions.as_deref(),
config.base_instructions.as_ref().and_then(Option::as_deref),
Some("cli override instructions")
);
+22 -21
View File
@@ -40,14 +40,14 @@ use codex_login::CodexAuth;
use codex_login::default_client::create_client;
use codex_login::default_client::is_first_party_chat_originator;
use codex_login::default_client::originator;
use codex_mcp::mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::mcp::ToolPluginProvenance;
use codex_mcp::mcp::auth::compute_auth_statuses;
use codex_mcp::mcp::with_codex_apps_mcp;
use codex_mcp::mcp_connection_manager::McpConnectionManager;
use codex_mcp::mcp_connection_manager::SandboxState;
use codex_mcp::mcp_connection_manager::ToolInfo;
use codex_mcp::mcp_connection_manager::codex_apps_tools_cache_key;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::McpConnectionManager;
use codex_mcp::SandboxState;
use codex_mcp::ToolInfo;
use codex_mcp::ToolPluginProvenance;
use codex_mcp::codex_apps_tools_cache_key;
use codex_mcp::compute_auth_statuses;
use codex_mcp::with_codex_apps_mcp;
pub use codex_connectors::CONNECTORS_CACHE_TTL;
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
@@ -143,9 +143,13 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
pub async fn list_cached_accessible_connectors_from_mcp_tools(
config: &Config,
) -> Option<Vec<AppInfo>> {
let auth_manager = auth_manager_from_config(config);
let auth_manager =
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false);
let auth = auth_manager.auth().await;
if !config.features.apps_enabled_for_auth(auth.as_ref()) {
if !config
.features
.apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth))
{
return Some(Vec::new());
}
let cache_key = accessible_connectors_cache_key(config, auth.as_ref());
@@ -182,9 +186,13 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
config: &Config,
force_refetch: bool,
) -> anyhow::Result<AccessibleConnectorsStatus> {
let auth_manager = auth_manager_from_config(config);
let auth_manager =
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false);
let auth = auth_manager.auth().await;
if !config.features.apps_enabled_for_auth(auth.as_ref()) {
if !config
.features
.apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth))
{
return Ok(AccessibleConnectorsStatus {
connectors: Vec::new(),
codex_apps_ready: true,
@@ -417,7 +425,8 @@ async fn list_directory_connectors_for_tool_suggest_with_auth(
let token_data = if let Some(auth) = auth {
auth.get_token_data().ok()
} else {
let auth_manager = auth_manager_from_config(config);
let auth_manager =
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false);
auth_manager
.auth()
.await
@@ -492,14 +501,6 @@ async fn chatgpt_get_request_with_token<T: DeserializeOwned>(
}
}
fn auth_manager_from_config(config: &Config) -> std::sync::Arc<AuthManager> {
AuthManager::shared(
config.codex_home.clone(),
/*enable_codex_api_key_env*/ false,
config.cli_auth_credentials_store_mode,
)
}
pub fn connector_display_label(connector: &AppInfo) -> String {
format_connector_label(&connector.name, &connector.id)
}
+2 -2
View File
@@ -12,8 +12,8 @@ use codex_config::types::AppToolConfig;
use codex_config::types::AppToolsConfig;
use codex_config::types::AppsDefaultConfig;
use codex_features::Feature;
use codex_mcp::mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::mcp_connection_manager::ToolInfo;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::ToolInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use rmcp::model::JsonObject;
+1 -1
View File
@@ -416,7 +416,7 @@ fn record_windows_sandbox_spawn_failure(
} else {
"legacy"
};
if let Some(metrics) = codex_otel::metrics::global() {
if let Some(metrics) = codex_otel::global() {
let _ = metrics.counter(
"codex.windows_sandbox.createprocessasuserw_failed",
/*inc*/ 1,
+1 -2
View File
@@ -494,8 +494,7 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result<Policy,
/*include_disabled*/ false,
) {
if let Some(config_folder) = layer.config_folder() {
#[expect(clippy::expect_used)]
let policy_dir = config_folder.join(RULES_DIR_NAME).expect("safe join");
let policy_dir = config_folder.join(RULES_DIR_NAME);
let layer_policy_paths = collect_policy_files(&policy_dir).await?;
policy_paths.extend(layer_policy_paths);
}
+1 -1
View File
@@ -676,7 +676,7 @@ fn emit_migration_metric(
item_type: ExternalAgentConfigMigrationItemType,
skills_count: Option<usize>,
) {
let Some(metrics) = codex_otel::metrics::global() else {
let Some(metrics) = codex_otel::global() else {
return;
};
let tags = migration_metric_tags(item_type, skills_count);
+1 -1
View File
@@ -38,7 +38,7 @@ 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;
const GUARDIAN_MAX_TOOL_ENTRY_TOKENS: usize = 1_000;
const GUARDIAN_MAX_ACTION_STRING_TOKENS: usize = 1_000;
const GUARDIAN_MAX_ACTION_STRING_TOKENS: usize = 16_000;
const GUARDIAN_APPROVAL_RISK_THRESHOLD: u8 = 80;
const GUARDIAN_RECENT_ENTRY_LIMIT: usize = 40;
const TRUNCATION_TAG: &str = "truncated";
+41 -18
View File
@@ -107,16 +107,22 @@ pub(crate) async fn build_guardian_prompt_items(
Ok(items)
}
/// Keeps all user turns plus a bounded amount of recent assistant/tool context.
/// Renders a compact guardian transcript from the retained history entries,
/// which are only user, assistant, and tool call entries.
///
/// The pruning strategy is intentionally simple and reviewable:
/// - always retain user messages because they carry authorization and intent
/// - walk recent non-user entries from newest to oldest
/// - keep them only while the message/tool budgets allow
/// - reserve a separate tool budget so tool evidence cannot crowd out the human
/// conversation
/// Selection is intentionally simple and predictable:
/// - each entry is truncated to its per-entry cap
/// - user and assistant entries share the message budget
/// - tool calls/results use a separate tool budget so tool evidence cannot
/// crowd out the human conversation
/// - if all user turns fit, keep them all
/// - otherwise keep the first and latest user turns as anchors, then fill the
/// remaining message budget with other user turns from newest to oldest
/// - after user turns are selected, keep recent non-user entries from newest to
/// oldest while the budgets and recent-entry limit allow
///
/// User messages are never dropped unless the entire transcript must be omitted.
/// Returns the rendered transcript plus an omission note when some entries were
/// skipped.
pub(crate) fn render_guardian_transcript_entries(
entries: &[GuardianTranscriptEntry],
) -> (Vec<String>, Option<String>) {
@@ -143,20 +149,38 @@ pub(crate) fn render_guardian_transcript_entries(
let mut included = vec![false; entries.len()];
let mut message_tokens = 0usize;
let mut tool_tokens = 0usize;
let user_indices = entries
.iter()
.enumerate()
.filter_map(|(index, entry)| entry.kind.is_user().then_some(index))
.collect::<Vec<_>>();
for (index, entry) in entries.iter().enumerate() {
if !entry.kind.is_user() {
if let Some(&first_user_index) = user_indices.first() {
included[first_user_index] = true;
message_tokens += rendered_entries[first_user_index].1;
}
if let Some(&last_user_index) = user_indices.last()
&& !included[last_user_index]
&& message_tokens + rendered_entries[last_user_index].1
<= GUARDIAN_MAX_MESSAGE_TRANSCRIPT_TOKENS
{
included[last_user_index] = true;
message_tokens += rendered_entries[last_user_index].1;
}
for &index in user_indices.iter().rev() {
if included[index] {
continue;
}
message_tokens += rendered_entries[index].1;
if message_tokens > GUARDIAN_MAX_MESSAGE_TRANSCRIPT_TOKENS {
return (
vec!["<transcript omitted to preserve budget for planned action>".to_string()],
Some("Conversation transcript omitted due to size.".to_string()),
);
let token_count = rendered_entries[index].1;
if message_tokens + token_count > GUARDIAN_MAX_MESSAGE_TRANSCRIPT_TOKENS {
continue;
}
included[index] = true;
message_tokens += token_count;
}
let mut retained_non_user_entries = 0usize;
@@ -192,8 +216,7 @@ pub(crate) fn render_guardian_transcript_entries(
.map(|(index, _)| rendered_entries[index].0.clone())
.collect::<Vec<_>>();
let omitted_any = included.iter().any(|included_entry| !included_entry);
let omission_note =
omitted_any.then(|| "Earlier conversation entries were omitted.".to_string());
let omission_note = omitted_any.then(|| "Some conversation entries were omitted.".to_string());
(transcript, omission_note)
}
+1 -1
View File
@@ -137,7 +137,7 @@ impl GuardianReviewSessionReuseKey {
model_reasoning_summary: spawn_config.model_reasoning_summary,
permissions: spawn_config.permissions.clone(),
developer_instructions: spawn_config.developer_instructions.clone(),
base_instructions: spawn_config.base_instructions.clone(),
base_instructions: spawn_config.base_instructions.clone().flatten(),
user_instructions: spawn_config.user_instructions.clone(),
compact_prompt: spawn_config.compact_prompt.clone(),
cwd: spawn_config.cwd.to_path_buf(),
+51 -2
View File
@@ -3,7 +3,6 @@ use crate::codex::Session;
use crate::codex::TurnContext;
use crate::config::Config;
use crate::config::ConfigOverrides;
use crate::config::ConfigToml;
use crate::config::Constrained;
use crate::config::ManagedFeatures;
use crate::config::NetworkProxySpec;
@@ -16,6 +15,7 @@ use crate::config_loader::NetworkDomainPermissionsToml;
use crate::config_loader::RequirementSource;
use crate::config_loader::Sourced;
use crate::test_support;
use codex_config::config_toml::ConfigToml;
use codex_network_proxy::NetworkProxyConfig;
use codex_protocol::approvals::NetworkApprovalProtocol;
use codex_protocol::config_types::ApprovalsReviewer;
@@ -260,7 +260,7 @@ fn guardian_truncate_text_keeps_prefix_suffix_and_xml_marker() {
#[test]
fn format_guardian_action_pretty_truncates_large_string_fields() -> serde_json::Result<()> {
let patch = "line\n".repeat(10_000);
let patch = "line\n".repeat(100_000);
let action = GuardianApprovalRequest::ApplyPatch {
id: "patch-1".to_string(),
cwd: PathBuf::from("/tmp"),
@@ -271,6 +271,7 @@ fn format_guardian_action_pretty_truncates_large_string_fields() -> serde_json::
let rendered = format_guardian_action_pretty(&action)?;
assert!(rendered.contains("\"tool\": \"apply_patch\""));
assert!(rendered.contains("<truncated omitted_approx_tokens="));
assert!(rendered.len() < patch.len());
Ok(())
}
@@ -470,6 +471,54 @@ fn build_guardian_transcript_reserves_separate_budget_for_tool_evidence() {
assert!(omission.is_some());
}
#[test]
fn build_guardian_transcript_preserves_recent_tool_context_when_user_history_is_large() {
let repeated = "authorization ".repeat(6_000);
let mut entries = (0..8)
.map(|_| GuardianTranscriptEntry {
kind: GuardianTranscriptEntryKind::User,
text: repeated.clone(),
})
.collect::<Vec<_>>();
entries.extend([
GuardianTranscriptEntry {
kind: GuardianTranscriptEntryKind::Tool("tool shell call".to_string()),
text: serde_json::json!({
"command": ["curl", "-X", "POST", "https://example.com/upload"],
"cwd": "/repo",
})
.to_string(),
},
GuardianTranscriptEntry {
kind: GuardianTranscriptEntryKind::Tool("tool shell result".to_string()),
text: "sandbox blocked outbound network access".to_string(),
},
]);
let (transcript, omission) = render_guardian_transcript_entries(&entries);
assert!(
transcript
.iter()
.any(|entry| entry.starts_with("[1] user: "))
);
assert!(transcript.iter().any(|entry| {
entry.contains("tool shell call:")
&& entry.contains("curl")
&& entry.contains("https://example.com/upload")
}));
assert!(
transcript
.iter()
.any(|entry| entry
.contains("tool shell result: sandbox blocked outbound network access"))
);
assert_eq!(
omission,
Some("Some conversation entries were omitted.".to_string())
);
}
#[test]
fn parse_guardian_assessment_extracts_embedded_json() {
let parsed = parse_guardian_assessment(Some(
+145
View File
@@ -0,0 +1,145 @@
use std::fs::OpenOptions;
use std::io::Read;
use std::io::Result;
use std::io::Seek;
use std::io::SeekFrom;
use std::io::Write;
use std::path::Path;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use tokio::fs;
use uuid::Uuid;
pub(crate) const INSTALLATION_ID_FILENAME: &str = "installation_id";
pub(crate) async fn resolve_installation_id(codex_home: &Path) -> Result<String> {
let path = codex_home.join(INSTALLATION_ID_FILENAME);
fs::create_dir_all(codex_home).await?;
tokio::task::spawn_blocking(move || {
let mut options = OpenOptions::new();
options.read(true).write(true).create(true);
#[cfg(unix)]
{
options.mode(0o644);
}
let mut file = options.open(&path)?;
file.lock()?;
#[cfg(unix)]
{
let metadata = file.metadata()?;
let current_mode = metadata.permissions().mode() & 0o777;
if current_mode != 0o644 {
let mut permissions = metadata.permissions();
permissions.set_mode(0o644);
file.set_permissions(permissions)?;
}
}
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let trimmed = contents.trim();
if !trimmed.is_empty()
&& let Ok(existing) = Uuid::parse_str(trimmed)
{
return Ok(existing.to_string());
}
let installation_id = Uuid::new_v4().to_string();
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
file.write_all(installation_id.as_bytes())?;
file.flush()?;
file.sync_all()?;
Ok(installation_id)
})
.await?
}
#[cfg(test)]
mod tests {
use super::INSTALLATION_ID_FILENAME;
use super::resolve_installation_id;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use uuid::Uuid;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[tokio::test]
async fn resolve_installation_id_generates_and_persists_uuid() {
let codex_home = TempDir::new().expect("create temp dir");
let persisted_path = codex_home.path().join(INSTALLATION_ID_FILENAME);
let installation_id = resolve_installation_id(codex_home.path())
.await
.expect("resolve installation id");
assert_eq!(
std::fs::read_to_string(&persisted_path).expect("read persisted installation id"),
installation_id
);
assert!(Uuid::parse_str(&installation_id).is_ok());
#[cfg(unix)]
{
let mode = std::fs::metadata(&persisted_path)
.expect("read installation id metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o644);
}
}
#[tokio::test]
async fn resolve_installation_id_reuses_existing_uuid() {
let codex_home = TempDir::new().expect("create temp dir");
let existing = Uuid::new_v4().to_string().to_uppercase();
std::fs::write(
codex_home.path().join(INSTALLATION_ID_FILENAME),
existing.clone(),
)
.expect("write installation id");
let resolved = resolve_installation_id(codex_home.path())
.await
.expect("resolve installation id");
assert_eq!(
resolved,
Uuid::parse_str(existing.as_str())
.expect("parse existing installation id")
.to_string()
);
}
#[tokio::test]
async fn resolve_installation_id_rewrites_invalid_file_contents() {
let codex_home = TempDir::new().expect("create temp dir");
std::fs::write(
codex_home.path().join(INSTALLATION_ID_FILENAME),
"not-a-uuid",
)
.expect("write invalid installation id");
let resolved = resolve_installation_id(codex_home.path())
.await
.expect("resolve installation id");
assert!(Uuid::parse_str(&resolved).is_ok());
assert_eq!(
std::fs::read_to_string(codex_home.path().join(INSTALLATION_ID_FILENAME))
.expect("read rewritten installation id"),
resolved
);
}
}
-1
View File
@@ -1,2 +1 @@
pub use codex_instructions::USER_INSTRUCTIONS_PREFIX;
pub(crate) use codex_instructions::UserInstructions;
+54 -29
View File
@@ -10,7 +10,7 @@ mod apps;
mod arc_monitor;
mod client;
mod client_common;
pub mod codex;
pub(crate) mod codex;
mod realtime_context;
mod realtime_conversation;
pub use codex::SteerInputError;
@@ -38,28 +38,41 @@ mod flags;
mod git_info_tests;
mod guardian;
mod hook_runtime;
pub mod instructions;
pub mod landlock;
pub mod mcp;
mod installation_id;
pub(crate) mod instructions;
pub(crate) mod landlock;
pub use landlock::spawn_command_under_linux_sandbox;
pub(crate) mod mcp;
mod mcp_skill_dependencies;
mod mcp_tool_approval_templates;
mod network_policy_decision;
pub mod network_proxy_loader;
pub(crate) mod network_proxy_loader;
pub use mcp::McpManager;
pub use network_proxy_loader::MtimeConfigReloader;
pub use network_proxy_loader::build_network_proxy_state;
pub use network_proxy_loader::build_network_proxy_state_and_reloader;
mod original_image_detail;
pub use codex_mcp::mcp_connection_manager;
pub use codex_mcp::mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY;
pub use codex_mcp::mcp_connection_manager::MCP_SANDBOX_STATE_METHOD;
pub use codex_mcp::mcp_connection_manager::SandboxState;
pub use codex_mcp::MCP_SANDBOX_STATE_CAPABILITY;
pub use codex_mcp::MCP_SANDBOX_STATE_METHOD;
pub use codex_mcp::SandboxState;
mod mcp_tool_call;
mod memories;
pub mod mention_syntax;
pub mod message_history;
pub mod utils;
pub(crate) mod mention_syntax;
pub(crate) mod message_history;
pub(crate) mod utils;
pub use mention_syntax::PLUGIN_TEXT_MENTION_SIGIL;
pub use mention_syntax::TOOL_MENTION_SIGIL;
pub use message_history::HistoryEntry as MessageHistoryEntry;
pub use message_history::append_entry as append_message_history_entry;
pub use message_history::history_metadata as message_history_metadata;
pub use message_history::lookup as lookup_message_history_entry;
pub use utils::path_utils;
pub mod personality_migration;
pub mod plugins;
#[doc(hidden)]
pub mod prompt_debug;
pub(crate) mod prompt_debug;
#[doc(hidden)]
pub use prompt_debug::build_prompt_input;
pub(crate) mod mentions {
pub(crate) use crate::plugins::build_connector_slug_counts;
pub(crate) use crate::plugins::build_skill_name_counts;
@@ -102,54 +115,63 @@ mod event_mapping;
pub mod review_format;
pub mod review_prompts;
mod thread_manager;
pub mod web_search;
pub mod windows_sandbox_read_grants;
pub(crate) mod web_search;
pub(crate) mod windows_sandbox_read_grants;
pub use thread_manager::ForkSnapshot;
pub use thread_manager::NewThread;
pub use thread_manager::ThreadManager;
pub use web_search::web_search_action_detail;
pub use web_search::web_search_detail;
pub use windows_sandbox_read_grants::grant_read_root_non_elevated;
#[deprecated(note = "use ThreadManager")]
pub type ConversationManager = ThreadManager;
#[deprecated(note = "use NewThread")]
pub type NewConversation = NewThread;
#[deprecated(note = "use CodexThread")]
pub type CodexConversation = CodexThread;
pub mod project_doc;
pub(crate) mod project_doc;
pub use project_doc::DEFAULT_PROJECT_DOC_FILENAME;
pub use project_doc::LOCAL_PROJECT_DOC_FILENAME;
pub use project_doc::discover_project_doc_paths;
pub use project_doc::read_project_docs;
mod rollout;
pub(crate) mod safety;
pub mod seatbelt;
mod session_rollout_init_error;
pub mod shell;
pub mod shell_snapshot;
pub(crate) mod shell_snapshot;
pub mod spawn;
pub mod state_db_bridge;
pub(crate) mod state_db_bridge;
pub use state_db_bridge::StateDbHandle;
pub use state_db_bridge::get_state_db;
mod thread_rollout_truncation;
mod tools;
pub mod turn_diff_tracker;
pub(crate) mod turn_diff_tracker;
mod turn_metadata;
mod turn_timing;
pub use rollout::ARCHIVED_SESSIONS_SUBDIR;
pub use rollout::Cursor;
pub use rollout::EventPersistenceMode;
pub use rollout::INTERACTIVE_SESSION_SOURCES;
pub use rollout::RolloutRecorder;
pub use rollout::RolloutRecorderParams;
pub use rollout::SESSIONS_SUBDIR;
pub use rollout::SessionMeta;
pub use rollout::ThreadItem;
pub use rollout::ThreadSortKey;
pub use rollout::ThreadsPage;
pub use rollout::append_thread_name;
pub use rollout::find_archived_thread_path_by_id_str;
#[deprecated(note = "use find_thread_path_by_id_str")]
pub use rollout::find_conversation_path_by_id_str;
pub use rollout::find_thread_name_by_id;
pub use rollout::find_thread_names_by_ids;
pub use rollout::find_thread_path_by_id_str;
pub use rollout::find_thread_path_by_name_str;
pub use rollout::list::Cursor;
pub use rollout::list::ThreadItem;
pub use rollout::list::ThreadSortKey;
pub use rollout::list::ThreadsPage;
pub use rollout::list::parse_cursor;
pub use rollout::list::read_head_for_summary;
pub use rollout::list::read_session_meta_line;
pub use rollout::policy::EventPersistenceMode;
pub use rollout::parse_cursor;
pub use rollout::read_head_for_summary;
pub use rollout::read_session_meta_line;
pub use rollout::rollout_date_parts;
pub use rollout::session_index::find_thread_names_by_ids;
mod function_tool;
mod state;
mod tasks;
@@ -158,6 +180,7 @@ pub mod util;
pub use client::ModelClient;
pub use client::ModelClientSession;
pub use client::X_CODEX_INSTALLATION_ID_HEADER;
pub use client::X_CODEX_TURN_METADATA_HEADER;
pub use client_common::Prompt;
pub use client_common::REVIEW_PROMPT;
@@ -172,5 +195,7 @@ pub use exec_policy::load_exec_policy;
pub use file_watcher::FileWatcherEvent;
pub use turn_metadata::build_turn_metadata_header;
pub mod compact;
pub mod memory_trace;
pub(crate) mod memory_trace;
pub use memory_trace::BuiltMemory;
pub use memory_trace::build_memories_from_trace_files;
pub mod otel_init;
+4 -4
View File
@@ -5,10 +5,10 @@ use crate::config::Config;
use crate::plugins::PluginsManager;
use codex_config::McpServerConfig;
use codex_login::CodexAuth;
use codex_mcp::mcp::ToolPluginProvenance;
use codex_mcp::mcp::configured_mcp_servers;
use codex_mcp::mcp::effective_mcp_servers;
use codex_mcp::mcp::tool_plugin_provenance as collect_tool_plugin_provenance;
use codex_mcp::ToolPluginProvenance;
use codex_mcp::configured_mcp_servers;
use codex_mcp::effective_mcp_servers;
use codex_mcp::tool_plugin_provenance as collect_tool_plugin_provenance;
#[derive(Clone)]
pub struct McpManager {
+4 -4
View File
@@ -21,10 +21,10 @@ use crate::SkillMetadata;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::skills::model::SkillToolDependency;
use codex_mcp::mcp::auth::McpOAuthLoginSupport;
use codex_mcp::mcp::auth::oauth_login_support;
use codex_mcp::mcp::auth::resolve_oauth_scopes;
use codex_mcp::mcp::auth::should_retry_without_scopes;
use codex_mcp::McpOAuthLoginSupport;
use codex_mcp::oauth_login_support;
use codex_mcp::resolve_oauth_scopes;
use codex_mcp::should_retry_without_scopes;
const SKILL_MCP_DEPENDENCY_PROMPT_ID: &str = "skill_mcp_dependency_install";
const MCP_DEPENDENCY_OPTION_INSTALL: &str = "Install";
+1 -1
View File
@@ -32,7 +32,7 @@ use codex_analytics::InvocationType;
use codex_analytics::build_track_events_context;
use codex_config::types::AppToolApproval;
use codex_features::Feature;
use codex_mcp::mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_otel::sanitize_metric_tag_value;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::openai_models::InputModality;
+1 -1
View File
@@ -2,9 +2,9 @@ use super::*;
use crate::codex::make_session_and_context;
use crate::codex::make_session_and_context_with_rx;
use crate::config::ConfigBuilder;
use crate::config::ConfigToml;
use crate::state::ActiveTurn;
use codex_config::CONFIG_TOML_FILE;
use codex_config::config_toml::ConfigToml;
use codex_config::types::AppConfig;
use codex_config::types::AppToolConfig;
use codex_config::types::AppToolsConfig;
+2 -2
View File
@@ -336,9 +336,9 @@ mod job {
}],
tools: Vec::new(),
parallel_tool_calls: false,
base_instructions: BaseInstructions {
base_instructions: Some(BaseInstructions {
text: phase_one::PROMPT.to_string(),
},
}),
personality: None,
output_schema: Some(output_schema()),
};
+9 -7
View File
@@ -1,7 +1,4 @@
use crate::config::NetworkToml;
use crate::config::PermissionsToml;
use crate::config::find_codex_home;
use crate::config::overlay_network_domain_permissions;
use crate::config::resolve_permission_profile;
use crate::config_loader::CloudRequirementsLoader;
use crate::config_loader::ConfigLayerStack;
@@ -16,6 +13,9 @@ use anyhow::Result;
use async_trait::async_trait;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::CONFIG_TOML_FILE;
use codex_config::permissions_toml::NetworkToml;
use codex_config::permissions_toml::PermissionsToml;
use codex_config::permissions_toml::overlay_network_domain_permissions;
use codex_network_proxy::ConfigReloader;
use codex_network_proxy::ConfigState;
use codex_network_proxy::NetworkProxyConfig;
@@ -88,10 +88,12 @@ fn collect_layer_mtimes(stack: &ConfigLayerStack) -> Vec<LayerMtime> {
let path = match &layer.name {
ConfigLayerSource::System { file } => Some(file.as_path().to_path_buf()),
ConfigLayerSource::User { file } => Some(file.as_path().to_path_buf()),
ConfigLayerSource::Project { dot_codex_folder } => dot_codex_folder
.join(CONFIG_TOML_FILE)
.ok()
.map(|p| p.as_path().to_path_buf()),
ConfigLayerSource::Project { dot_codex_folder } => Some(
dot_codex_folder
.join(CONFIG_TOML_FILE)
.as_path()
.to_path_buf(),
),
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => {
Some(file.as_path().to_path_buf())
}
+4 -4
View File
@@ -3,11 +3,11 @@ use codex_config::types::OtelExporterKind as Kind;
use codex_config::types::OtelHttpProtocol as Protocol;
use codex_features::Feature;
use codex_login::default_client::originator;
use codex_otel::OtelExporter;
use codex_otel::OtelHttpProtocol;
use codex_otel::OtelProvider;
use codex_otel::config::OtelExporter;
use codex_otel::config::OtelHttpProtocol;
use codex_otel::config::OtelSettings;
use codex_otel::config::OtelTlsConfig as OtelTlsSettings;
use codex_otel::OtelSettings;
use codex_otel::OtelTlsConfig as OtelTlsSettings;
use std::error::Error;
/// Build an OpenTelemetry provider from the app Config.
+1 -1
View File
@@ -1,4 +1,3 @@
use crate::config::ConfigToml;
use crate::config::edit::ConfigEditsBuilder;
use crate::rollout::ARCHIVED_SESSIONS_SUBDIR;
use crate::rollout::SESSIONS_SUBDIR;
@@ -6,6 +5,7 @@ use crate::rollout::list::ThreadListConfig;
use crate::rollout::list::ThreadListLayout;
use crate::rollout::list::ThreadSortKey;
use crate::rollout::list::get_threads_in_root;
use codex_config::config_toml::ConfigToml;
use codex_protocol::config_types::Personality;
use codex_protocol::protocol::SessionSource;
use codex_rollout::state_db;
@@ -43,6 +43,7 @@ async fn write_session_with_user_event(codex_home: &Path) -> io::Result<()> {
agent_role: None,
model_provider: None,
base_instructions: None,
developer_instructions: None,
dynamic_tools: None,
memory_mode: None,
},
+2 -2
View File
@@ -7,8 +7,8 @@ use codex_protocol::models::ResponseItem;
use crate::connectors;
use crate::plugins::PluginCapabilitySummary;
use crate::plugins::render_explicit_plugin_instructions;
use codex_mcp::mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::mcp_connection_manager::ToolInfo;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::ToolInfo;
pub(crate) fn build_plugin_injections(
mentioned_plugins: &[PluginCapabilitySummary],
+274 -38
View File
@@ -25,6 +25,7 @@ use super::startup_sync::start_startup_remote_plugin_sync_once;
use super::store::PluginInstallResult as StorePluginInstallResult;
use super::store::PluginStore;
use super::store::PluginStoreError;
use super::store::plugin_version_for_source;
use super::sync_openai_plugins_repo;
use crate::SkillMetadata;
use crate::config::CONFIG_TOML_FILE;
@@ -99,6 +100,13 @@ struct CachedFeaturedPluginIds {
featured_plugin_ids: Vec<String>,
}
#[derive(Default)]
struct NonCuratedCacheRefreshState {
requested_roots: Option<Vec<AbsolutePathBuf>>,
last_refreshed_roots: Option<Vec<AbsolutePathBuf>>,
in_flight: bool,
}
fn featured_plugin_ids_cache_key(
config: &Config,
auth: Option<&CodexAuth>,
@@ -312,6 +320,7 @@ pub struct PluginsManager {
codex_home: PathBuf,
store: PluginStore,
featured_plugin_ids_cache: RwLock<Option<CachedFeaturedPluginIds>>,
non_curated_cache_refresh_state: RwLock<NonCuratedCacheRefreshState>,
cached_enabled_outcome: RwLock<Option<PluginLoadOutcome>>,
remote_sync_lock: Mutex<()>,
restriction_product: Option<Product>,
@@ -338,6 +347,7 @@ impl PluginsManager {
codex_home: codex_home.clone(),
store: PluginStore::new(codex_home),
featured_plugin_ids_cache: RwLock::new(None),
non_curated_cache_refresh_state: RwLock::new(NonCuratedCacheRefreshState::default()),
cached_enabled_outcome: RwLock::new(None),
remote_sync_lock: Mutex::new(()),
restriction_product,
@@ -719,6 +729,7 @@ impl PluginsManager {
));
}
let mut missing_remote_plugins = Vec::<String>::new();
let mut remote_installed_plugin_names = HashSet::<String>::new();
for plugin in remote_plugins {
if plugin.marketplace_name != marketplace_name {
@@ -727,11 +738,7 @@ impl PluginsManager {
});
}
if !local_plugin_names.contains(&plugin.name) {
warn!(
plugin = plugin.name,
marketplace = %marketplace_name,
"ignoring remote plugin missing from local marketplace during sync"
);
missing_remote_plugins.push(plugin.name);
continue;
}
// For now, sync treats remote `enabled = false` as uninstall rather than a distinct
@@ -753,6 +760,19 @@ impl PluginsManager {
let mut result = RemotePluginSyncResult::default();
let remote_plugin_count = remote_installed_plugin_names.len();
let local_plugin_count = local_plugins.len();
if !missing_remote_plugins.is_empty() {
let sample_missing_plugins = missing_remote_plugins
.iter()
.take(10)
.cloned()
.collect::<Vec<_>>();
warn!(
marketplace = %marketplace_name,
missing_remote_plugin_count = missing_remote_plugins.len(),
missing_remote_plugin_examples = ?sample_missing_plugins,
"ignoring remote plugins missing from local marketplace during sync"
);
}
for (
plugin_name,
@@ -1034,6 +1054,56 @@ impl PluginsManager {
}
}
pub fn maybe_start_non_curated_plugin_cache_refresh_for_roots(
self: &Arc<Self>,
roots: &[AbsolutePathBuf],
) {
let mut roots = roots.to_vec();
roots.sort_unstable_by(|left, right| left.as_path().cmp(right.as_path()));
roots.dedup();
if roots.is_empty() {
return;
}
let should_spawn = {
let mut state = match self.non_curated_cache_refresh_state.write() {
Ok(state) => state,
Err(err) => err.into_inner(),
};
// Collapse repeated plugin/list requests onto one worker and only queue another pass
// when the requested roots set actually changes.
if state.requested_roots.as_ref() == Some(&roots)
|| (!state.in_flight && state.last_refreshed_roots.as_ref() == Some(&roots))
{
return;
}
state.requested_roots = Some(roots);
if state.in_flight {
false
} else {
state.in_flight = true;
true
}
};
if !should_spawn {
return;
}
let manager = Arc::clone(self);
if let Err(err) = std::thread::Builder::new()
.name("plugins-non-curated-cache-refresh".to_string())
.spawn(move || manager.run_non_curated_plugin_cache_refresh_loop())
{
let mut state = match self.non_curated_cache_refresh_state.write() {
Ok(state) => state,
Err(err) => err.into_inner(),
};
state.in_flight = false;
state.requested_roots = None;
warn!("failed to start non-curated plugin cache refresh task: {err}");
}
}
fn start_curated_repo_sync(self: &Arc<Self>) {
if CURATED_REPO_SYNC_STARTED.swap(true, Ordering::SeqCst) {
return;
@@ -1045,8 +1115,13 @@ impl PluginsManager {
.spawn(
move || match sync_openai_plugins_repo(codex_home.as_path()) {
Ok(curated_plugin_version) => {
let configured_curated_plugin_ids =
configured_curated_plugin_ids_from_codex_home(codex_home.as_path());
let configured_curated_plugin_ids = curated_plugin_ids_from_config_keys(
configured_plugins_from_codex_home(
codex_home.as_path(),
"failed to read user config while refreshing curated plugin cache",
"failed to parse user config while refreshing curated plugin cache",
),
);
match refresh_curated_plugin_cache(
codex_home.as_path(),
&curated_plugin_version,
@@ -1076,6 +1151,55 @@ impl PluginsManager {
}
}
fn run_non_curated_plugin_cache_refresh_loop(self: Arc<Self>) {
loop {
let roots = {
let state = match self.non_curated_cache_refresh_state.read() {
Ok(state) => state,
Err(err) => err.into_inner(),
};
state.requested_roots.clone()
};
let Some(roots) = roots else {
let mut state = match self.non_curated_cache_refresh_state.write() {
Ok(state) => state,
Err(err) => err.into_inner(),
};
state.in_flight = false;
return;
};
let refreshed =
match refresh_non_curated_plugin_cache(self.codex_home.as_path(), &roots) {
Ok(cache_refreshed) => {
if cache_refreshed {
self.clear_cache();
}
true
}
Err(err) => {
self.clear_cache();
warn!("failed to refresh non-curated plugin cache: {err}");
false
}
};
let mut state = match self.non_curated_cache_refresh_state.write() {
Ok(state) => state,
Err(err) => err.into_inner(),
};
if refreshed {
state.last_refreshed_roots = Some(roots.clone());
}
if state.requested_roots.as_ref() == Some(&roots) {
state.requested_roots = None;
state.in_flight = false;
return;
}
}
}
fn configured_plugin_states(&self, config: &Config) -> (HashSet<String>, HashSet<String>) {
let configured_plugins = configured_plugins_from_stack(&config.config_layer_stack);
let installed_plugins = configured_plugins
@@ -1308,6 +1432,90 @@ fn refresh_curated_plugin_cache(
Ok(cache_refreshed)
}
fn refresh_non_curated_plugin_cache(
codex_home: &Path,
additional_roots: &[AbsolutePathBuf],
) -> Result<bool, String> {
let configured_non_curated_plugin_ids =
non_curated_plugin_ids_from_config_keys(configured_plugins_from_codex_home(
codex_home,
"failed to read user config while refreshing non-curated plugin cache",
"failed to parse user config while refreshing non-curated plugin cache",
));
if configured_non_curated_plugin_ids.is_empty() {
return Ok(false);
}
let configured_non_curated_plugin_keys = configured_non_curated_plugin_ids
.iter()
.map(PluginId::as_key)
.collect::<HashSet<_>>();
let store = PluginStore::new(codex_home.to_path_buf());
let marketplace_outcome = list_marketplaces(additional_roots)
.map_err(|err| format!("failed to discover marketplaces for cache refresh: {err}"))?;
let mut plugin_sources = HashMap::<String, (AbsolutePathBuf, String)>::new();
for marketplace in marketplace_outcome.marketplaces {
if marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME {
continue;
}
for plugin in marketplace.plugins {
let plugin_id =
PluginId::new(plugin.name.clone(), marketplace.name.clone()).map_err(|err| {
match err {
PluginIdError::Invalid(message) => {
format!("failed to prepare non-curated plugin cache refresh: {message}")
}
}
})?;
let plugin_key = plugin_id.as_key();
if !configured_non_curated_plugin_keys.contains(&plugin_key) {
continue;
}
if plugin_sources.contains_key(&plugin_key) {
warn!(
plugin = plugin.name,
marketplace = marketplace.name,
"ignoring duplicate non-curated plugin entry during cache refresh"
);
continue;
}
let source_path = match plugin.source {
MarketplacePluginSource::Local { path } => path,
};
let plugin_version = plugin_version_for_source(source_path.as_path())
.map_err(|err| format!("failed to read plugin version for {plugin_key}: {err}"))?;
plugin_sources.insert(plugin_key, (source_path, plugin_version));
}
}
let mut cache_refreshed = false;
for plugin_id in configured_non_curated_plugin_ids {
let plugin_key = plugin_id.as_key();
let Some((source_path, plugin_version)) = plugin_sources.get(&plugin_key).cloned() else {
warn!(
plugin = plugin_id.plugin_name,
marketplace = plugin_id.marketplace_name,
"configured non-curated plugin no longer exists in discovered marketplaces during cache refresh"
);
continue;
};
if store.active_plugin_version(&plugin_id).as_deref() == Some(plugin_version.as_str()) {
continue;
}
store
.install_with_version(source_path, plugin_id.clone(), plugin_version)
.map_err(|err| format!("failed to refresh plugin cache for {plugin_key}: {err}"))?;
cache_refreshed = true;
}
Ok(cache_refreshed)
}
fn configured_plugins_from_stack(
config_layer_stack: &ConfigLayerStack,
) -> HashMap<String, PluginConfig> {
@@ -1333,42 +1541,22 @@ fn configured_plugins_from_user_config_value(
}
}
fn configured_curated_plugin_ids(
configured_plugins: HashMap<String, PluginConfig>,
) -> Vec<PluginId> {
let mut configured_curated_plugin_ids = configured_plugins
.into_keys()
.filter_map(|plugin_key| match PluginId::parse(&plugin_key) {
Ok(plugin_id) if plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME => {
Some(plugin_id)
}
Ok(_) => None,
Err(err) => {
warn!(
plugin_key,
error = %err,
"ignoring invalid configured plugin key during curated sync setup"
);
None
}
})
.collect::<Vec<_>>();
configured_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key);
configured_curated_plugin_ids
}
fn configured_curated_plugin_ids_from_codex_home(codex_home: &Path) -> Vec<PluginId> {
fn configured_plugins_from_codex_home(
codex_home: &Path,
read_error_message: &str,
parse_error_message: &str,
) -> HashMap<String, PluginConfig> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
let user_config = match fs::read_to_string(&config_path) {
Ok(user_config) => user_config,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return HashMap::new(),
Err(err) => {
warn!(
path = %config_path.display(),
error = %err,
"failed to read user config while refreshing curated plugin cache"
"{read_error_message}"
);
return Vec::new();
return HashMap::new();
}
};
@@ -1378,13 +1566,61 @@ fn configured_curated_plugin_ids_from_codex_home(codex_home: &Path) -> Vec<Plugi
warn!(
path = %config_path.display(),
error = %err,
"failed to parse user config while refreshing curated plugin cache"
"{parse_error_message}"
);
return Vec::new();
return HashMap::new();
}
};
configured_curated_plugin_ids(configured_plugins_from_user_config_value(&user_config))
configured_plugins_from_user_config_value(&user_config)
}
fn configured_plugin_ids(
configured_plugins: HashMap<String, PluginConfig>,
invalid_plugin_key_message: &str,
) -> Vec<PluginId> {
configured_plugins
.into_keys()
.filter_map(|plugin_key| match PluginId::parse(&plugin_key) {
Ok(plugin_id) => Some(plugin_id),
Err(err) => {
warn!(
plugin_key,
error = %err,
"{invalid_plugin_key_message}"
);
None
}
})
.collect()
}
fn curated_plugin_ids_from_config_keys(
configured_plugins: HashMap<String, PluginConfig>,
) -> Vec<PluginId> {
let mut configured_curated_plugin_ids = configured_plugin_ids(
configured_plugins,
"ignoring invalid configured plugin key during curated sync setup",
)
.into_iter()
.filter(|plugin_id| plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME)
.collect::<Vec<_>>();
configured_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key);
configured_curated_plugin_ids
}
fn non_curated_plugin_ids_from_config_keys(
configured_plugins: HashMap<String, PluginConfig>,
) -> Vec<PluginId> {
let mut configured_non_curated_plugin_ids = configured_plugin_ids(
configured_plugins,
"ignoring invalid plugin key during non-curated cache refresh setup",
)
.into_iter()
.filter(|plugin_id| plugin_id.marketplace_name != OPENAI_CURATED_MARKETPLACE_NAME)
.collect::<Vec<_>>();
configured_non_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key);
configured_non_curated_plugin_ids
}
fn load_plugin(
+293 -8
View File
@@ -30,19 +30,36 @@ use wiremock::matchers::query_param;
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
fn write_plugin_with_version(
root: &Path,
dir_name: &str,
manifest_name: &str,
manifest_version: Option<&str>,
) {
let plugin_root = root.join(dir_name);
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
fs::create_dir_all(plugin_root.join("skills")).unwrap();
let version = manifest_version
.map(|manifest_version| format!(r#","version":"{manifest_version}""#))
.unwrap_or_default();
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
format!(r#"{{"name":"{manifest_name}"}}"#),
format!(r#"{{"name":"{manifest_name}"{version}}}"#),
)
.unwrap();
fs::write(plugin_root.join("skills/SKILL.md"), "skill").unwrap();
fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap();
}
fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
write_plugin_with_version(
root,
dir_name,
manifest_name,
/*manifest_version*/ None,
);
}
fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String {
let mut root = toml::map::Map::new();
@@ -955,6 +972,60 @@ async fn install_plugin_updates_config_with_relative_path_and_plugin_key() {
assert!(config.contains("enabled = true"));
}
#[tokio::test]
async fn install_plugin_uses_manifest_version_for_non_curated_plugins() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_plugin_with_version(
&repo_root,
"sample-plugin",
"sample-plugin",
Some("1.2.3-beta+7"),
);
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample-plugin",
"source": {
"source": "local",
"path": "./sample-plugin"
}
}
]
}"#,
)
.unwrap();
let result = PluginsManager::new(tmp.path().to_path_buf())
.install_plugin(PluginInstallRequest {
plugin_name: "sample-plugin".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
})
.await
.unwrap();
let installed_path = tmp
.path()
.join("plugins/cache/debug/sample-plugin/1.2.3-beta+7");
assert_eq!(
result,
PluginInstallOutcome {
plugin_id: PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(),
plugin_version: "1.2.3-beta+7".to_string(),
installed_path: AbsolutePathBuf::try_from(installed_path).unwrap(),
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
}
);
}
#[tokio::test]
async fn uninstall_plugin_removes_cache_and_config_entry() {
let tmp = tempfile::tempdir().unwrap();
@@ -2208,7 +2279,7 @@ fn refresh_curated_plugin_cache_reinstalls_missing_configured_plugin_with_curren
}
#[test]
fn configured_curated_plugin_ids_from_codex_home_reads_latest_user_config() {
fn curated_plugin_ids_from_config_keys_reads_latest_codex_home_user_config() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
@@ -2224,10 +2295,14 @@ enabled = true
);
assert_eq!(
configured_curated_plugin_ids_from_codex_home(tmp.path())
.into_iter()
.map(|plugin_id| plugin_id.as_key())
.collect::<Vec<_>>(),
curated_plugin_ids_from_config_keys(configured_plugins_from_codex_home(
tmp.path(),
"failed to read user config while refreshing curated plugin cache",
"failed to parse user config while refreshing curated plugin cache",
))
.into_iter()
.map(|plugin_id| plugin_id.as_key())
.collect::<Vec<_>>(),
vec!["slack@openai-curated".to_string()]
);
@@ -2239,7 +2314,11 @@ plugins = true
);
assert_eq!(
configured_curated_plugin_ids_from_codex_home(tmp.path()),
curated_plugin_ids_from_config_keys(configured_plugins_from_codex_home(
tmp.path(),
"failed to read user config while refreshing curated plugin cache",
"failed to parse user config while refreshing curated plugin cache",
)),
Vec::<PluginId>::new()
);
}
@@ -2266,6 +2345,212 @@ fn refresh_curated_plugin_cache_returns_false_when_configured_plugins_are_curren
);
}
#[test]
fn refresh_non_curated_plugin_cache_replaces_existing_local_version_with_manifest_version() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3"));
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample-plugin",
"source": {
"source": "local",
"path": "./sample-plugin"
}
}
]
}"#,
);
write_plugin(
&tmp.path().join("plugins/cache/debug"),
"sample-plugin/local",
"sample-plugin",
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."sample-plugin@debug"]
enabled = true
"#,
);
assert!(
refresh_non_curated_plugin_cache(
tmp.path(),
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
)
.expect("cache refresh should succeed")
);
assert!(
!tmp.path()
.join("plugins/cache/debug/sample-plugin/local")
.exists()
);
assert!(
tmp.path()
.join("plugins/cache/debug/sample-plugin/1.2.3")
.is_dir()
);
}
#[test]
fn refresh_non_curated_plugin_cache_reinstalls_missing_configured_plugin_with_manifest_version() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3"));
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample-plugin",
"source": {
"source": "local",
"path": "./sample-plugin"
}
}
]
}"#,
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."sample-plugin@debug"]
enabled = true
"#,
);
assert!(
refresh_non_curated_plugin_cache(
tmp.path(),
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
)
.expect("cache refresh should reinstall missing configured plugin")
);
assert!(
tmp.path()
.join("plugins/cache/debug/sample-plugin/1.2.3")
.is_dir()
);
}
#[test]
fn refresh_non_curated_plugin_cache_returns_false_when_configured_plugins_are_current() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3"));
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample-plugin",
"source": {
"source": "local",
"path": "./sample-plugin"
}
}
]
}"#,
);
write_plugin_with_version(
&tmp.path().join("plugins/cache/debug"),
"sample-plugin/1.2.3",
"sample-plugin",
Some("1.2.3"),
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."sample-plugin@debug"]
enabled = true
"#,
);
assert!(
!refresh_non_curated_plugin_cache(
tmp.path(),
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
)
.expect("cache refresh should be a no-op when configured plugins are current")
);
}
#[test]
fn refresh_non_curated_plugin_cache_ignores_invalid_unconfigured_plugin_versions() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3"));
write_plugin_with_version(&repo_root, "broken-plugin", "broken-plugin", Some(" "));
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample-plugin",
"source": {
"source": "local",
"path": "./sample-plugin"
}
},
{
"name": "broken-plugin",
"source": {
"source": "local",
"path": "./broken-plugin"
}
}
]
}"#,
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."sample-plugin@debug"]
enabled = true
"#,
);
assert!(
refresh_non_curated_plugin_cache(
tmp.path(),
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
)
.expect("cache refresh should ignore unrelated invalid plugin manifests")
);
assert!(
tmp.path()
.join("plugins/cache/debug/sample-plugin/1.2.3")
.is_dir()
);
}
#[test]
fn load_plugins_ignores_project_config_files() {
let codex_home = TempDir::new().unwrap();
+34 -1
View File
@@ -14,6 +14,8 @@ struct RawPluginManifest {
#[serde(default)]
name: String,
#[serde(default)]
version: Option<String>,
#[serde(default)]
description: Option<String>,
// Keep manifest paths as raw strings so we can validate the required `./...` syntax before
// resolving them under the plugin root.
@@ -30,6 +32,7 @@ struct RawPluginManifest {
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PluginManifest {
pub(crate) name: String,
pub(crate) version: Option<String>,
pub(crate) description: Option<String>,
pub(crate) paths: PluginManifestPaths,
pub(crate) interface: Option<PluginManifestInterface>,
@@ -121,6 +124,7 @@ pub(crate) fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest>
Ok(manifest) => {
let RawPluginManifest {
name: raw_name,
version,
description,
skills,
mcp_servers,
@@ -133,6 +137,10 @@ pub(crate) fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest>
.filter(|_| raw_name.trim().is_empty())
.unwrap_or(&raw_name)
.to_string();
let version = version.and_then(|version| {
let version = version.trim();
(!version.is_empty()).then(|| version.to_string())
});
let interface = interface.and_then(|interface| {
let RawPluginManifestInterface {
display_name,
@@ -204,6 +212,7 @@ pub(crate) fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest>
});
Some(PluginManifest {
name,
version,
description,
paths: PluginManifestPaths {
skills: resolve_manifest_path(plugin_root, "skills", skills.as_deref()),
@@ -381,13 +390,17 @@ mod tests {
use std::path::Path;
use tempfile::tempdir;
fn write_manifest(plugin_root: &Path, interface: &str) {
fn write_manifest(plugin_root: &Path, version: Option<&str>, interface: &str) {
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir");
let version = version
.map(|version| format!(" \"version\": \"{version}\",\n"))
.unwrap_or_default();
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
format!(
r#"{{
"name": "demo-plugin",
{version}
"interface": {interface}
}}"#
),
@@ -405,6 +418,7 @@ mod tests {
let plugin_root = tmp.path().join("demo-plugin");
write_manifest(
&plugin_root,
/*version*/ None,
r#"{
"displayName": "Demo Plugin",
"defaultPrompt": " Summarize my inbox "
@@ -427,6 +441,7 @@ mod tests {
let too_long = "x".repeat(MAX_DEFAULT_PROMPT_LEN + 1);
write_manifest(
&plugin_root,
/*version*/ None,
&format!(
r#"{{
"displayName": "Demo Plugin",
@@ -462,6 +477,7 @@ mod tests {
let plugin_root = tmp.path().join("demo-plugin");
write_manifest(
&plugin_root,
/*version*/ None,
r#"{
"displayName": "Demo Plugin",
"defaultPrompt": { "text": "Summarize my inbox" }
@@ -473,4 +489,21 @@ mod tests {
assert_eq!(interface.default_prompt, None);
}
#[test]
fn plugin_manifest_reads_trimmed_version() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("demo-plugin");
write_manifest(
&plugin_root,
Some(" 1.2.3-beta+7 "),
r#"{
"displayName": "Demo Plugin"
}"#,
);
let manifest = load_manifest(&plugin_root);
assert_eq!(manifest.version, Some("1.2.3-beta+7".to_string()));
}
}
+7 -14
View File
@@ -299,20 +299,18 @@ fn discover_marketplace_paths_from_roots(
for root in additional_roots {
// Curated marketplaces can now come from an HTTP-downloaded directory that is not a git
// checkout, so check the root directly before falling back to repo-root discovery.
if let Ok(path) = root.join(MARKETPLACE_RELATIVE_PATH)
&& path.as_path().is_file()
&& !paths.contains(&path)
{
let path = root.join(MARKETPLACE_RELATIVE_PATH);
if path.as_path().is_file() && !paths.contains(&path) {
paths.push(path);
continue;
}
if let Some(repo_root) = get_git_repo_root(root.as_path())
&& let Ok(repo_root) = AbsolutePathBuf::try_from(repo_root)
&& let Ok(path) = repo_root.join(MARKETPLACE_RELATIVE_PATH)
&& path.as_path().is_file()
&& !paths.contains(&path)
{
paths.push(path);
let path = repo_root.join(MARKETPLACE_RELATIVE_PATH);
if path.as_path().is_file() && !paths.contains(&path) {
paths.push(path);
}
}
}
@@ -370,12 +368,7 @@ fn resolve_plugin_source_path(
// `marketplace.json` lives under `<root>/.agents/plugins/`, but local plugin paths
// are resolved relative to `<root>`, not relative to the `plugins/` directory.
marketplace_root_dir(marketplace_path)?
.join(relative_source_path)
.map_err(|err| MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: format!("plugin source path must resolve to an absolute path: {err}"),
})
Ok(marketplace_root_dir(marketplace_path)?.join(relative_source_path))
}
}
}
@@ -756,18 +756,16 @@ fn resolve_marketplace_plugin_rejects_non_relative_local_paths() {
)
.unwrap();
let err = resolve_marketplace_plugin(
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
"local-plugin",
Some(Product::Codex),
)
.unwrap_err();
let marketplace_path =
AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap();
let err = resolve_marketplace_plugin(&marketplace_path, "local-plugin", Some(Product::Codex))
.unwrap_err();
assert_eq!(
err.to_string(),
format!(
"invalid marketplace file `{}`: local plugin source path must start with `./`",
repo_root.join(".agents/plugins/marketplace.json").display()
marketplace_path.display()
)
);
}
+251 -16
View File
@@ -6,8 +6,8 @@ use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use codex_otel::metrics::names::CURATED_PLUGINS_STARTUP_SYNC_FINAL_METRIC;
use codex_otel::metrics::names::CURATED_PLUGINS_STARTUP_SYNC_METRIC;
use codex_otel::CURATED_PLUGINS_STARTUP_SYNC_FINAL_METRIC;
use codex_otel::CURATED_PLUGINS_STARTUP_SYNC_METRIC;
use reqwest::Client;
use serde::Deserialize;
use tempfile::TempDir;
@@ -24,16 +24,20 @@ use super::PluginsManager;
const GITHUB_API_BASE_URL: &str = "https://api.github.com";
const GITHUB_API_ACCEPT_HEADER: &str = "application/vnd.github+json";
const GITHUB_API_VERSION_HEADER: &str = "2022-11-28";
const CURATED_PLUGINS_BACKUP_ARCHIVE_API_URL: &str =
"https://chatgpt.com/backend-api/plugins/export/curated";
const OPENAI_PLUGINS_OWNER: &str = "openai";
const OPENAI_PLUGINS_REPO: &str = "plugins";
const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins";
const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha";
const CURATED_PLUGINS_BACKUP_ARCHIVE_FALLBACK_VERSION: &str = "export-backup";
const CURATED_PLUGINS_GIT_TIMEOUT: Duration = Duration::from_secs(30);
const CURATED_PLUGINS_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
const CURATED_PLUGINS_BACKUP_ARCHIVE_TIMEOUT: Duration = Duration::from_secs(30);
// Keep this comfortably above a normal sync attempt so we do not race another Codex process.
const CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE: Duration = Duration::from_secs(10 * 60);
const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1";
const STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT: Duration = Duration::from_secs(5);
const STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Deserialize)]
struct GitHubRepositorySummary {
@@ -50,22 +54,37 @@ struct GitHubGitRefObject {
sha: String,
}
#[derive(Debug, Deserialize)]
struct CuratedPluginsBackupArchiveResponse {
download_url: String,
}
pub(crate) fn curated_plugins_repo_path(codex_home: &Path) -> PathBuf {
codex_home.join(CURATED_PLUGINS_RELATIVE_DIR)
}
pub(crate) fn read_curated_plugins_sha(codex_home: &Path) -> Option<String> {
read_sha_file(codex_home.join(CURATED_PLUGINS_SHA_FILE).as_path())
read_sha_file(curated_plugins_sha_path(codex_home).as_path())
}
fn curated_plugins_sha_path(codex_home: &Path) -> PathBuf {
codex_home.join(CURATED_PLUGINS_SHA_FILE)
}
pub(crate) fn sync_openai_plugins_repo(codex_home: &Path) -> Result<String, String> {
sync_openai_plugins_repo_with_transport_overrides(codex_home, "git", GITHUB_API_BASE_URL)
sync_openai_plugins_repo_with_transport_overrides(
codex_home,
"git",
GITHUB_API_BASE_URL,
CURATED_PLUGINS_BACKUP_ARCHIVE_API_URL,
)
}
fn sync_openai_plugins_repo_with_transport_overrides(
codex_home: &Path,
git_binary: &str,
api_base_url: &str,
backup_archive_api_url: &str,
) -> Result<String, String> {
match sync_openai_plugins_repo_via_git(codex_home, git_binary) {
Ok(remote_sha) => {
@@ -80,11 +99,46 @@ fn sync_openai_plugins_repo_with_transport_overrides(
git_binary,
"git sync failed for curated plugin sync; falling back to GitHub HTTP"
);
let result = sync_openai_plugins_repo_via_http(codex_home, api_base_url);
let status = if result.is_ok() { "success" } else { "failure" };
emit_curated_plugins_startup_sync_metric("http", status);
emit_curated_plugins_startup_sync_final_metric("http", status);
result
match sync_openai_plugins_repo_via_http(codex_home, api_base_url) {
Ok(remote_sha) => {
emit_curated_plugins_startup_sync_metric("http", "success");
emit_curated_plugins_startup_sync_final_metric("http", "success");
Ok(remote_sha)
}
Err(http_err) => {
emit_curated_plugins_startup_sync_metric("http", "failure");
if has_local_curated_plugins_snapshot(codex_home) {
emit_curated_plugins_startup_sync_final_metric("http", "failure");
warn!(
error = %http_err,
"GitHub HTTP sync failed for curated plugin sync; skipping export archive fallback because a local curated plugins snapshot already exists"
);
Err(format!(
"git sync failed for curated plugin sync: {err}; GitHub HTTP sync failed for curated plugin sync: {http_err}; export archive fallback skipped because a local curated plugins snapshot already exists"
))
} else {
// The export archive is a lagging backup path. Only use it to bootstrap a
// missing local curated snapshot, never to refresh an existing one.
warn!(
error = %http_err,
backup_archive_api_url,
"GitHub HTTP sync failed for curated plugin sync; falling back to export archive"
);
let result = sync_openai_plugins_repo_via_backup_archive(
codex_home,
backup_archive_api_url,
);
let status = if result.is_ok() { "success" } else { "failure" };
emit_curated_plugins_startup_sync_metric("export_archive", status);
emit_curated_plugins_startup_sync_final_metric("export_archive", status);
result.map_err(|export_err| {
format!(
"git sync failed for curated plugin sync: {err}; GitHub HTTP sync failed for curated plugin sync: {http_err}; export archive sync failed for curated plugin sync: {export_err}"
)
})
}
}
}
}
}
}
@@ -152,6 +206,29 @@ fn sync_openai_plugins_repo_via_http(
Ok(remote_sha)
}
fn sync_openai_plugins_repo_via_backup_archive(
codex_home: &Path,
backup_archive_api_url: &str,
) -> Result<String, String> {
let repo_path = curated_plugins_repo_path(codex_home);
let sha_path = curated_plugins_sha_path(codex_home);
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|err| format!("failed to create curated plugins sync runtime: {err}"))?;
let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?;
let zipball_bytes = runtime.block_on(fetch_curated_repo_backup_archive_zip(
backup_archive_api_url,
))?;
extract_zipball_to_dir(&zipball_bytes, staged_repo_dir.path())?;
ensure_marketplace_manifest_exists(staged_repo_dir.path())?;
let export_version = read_extracted_backup_archive_git_sha(staged_repo_dir.path())?
.unwrap_or_else(|| CURATED_PLUGINS_BACKUP_ARCHIVE_FALLBACK_VERSION.to_string());
activate_curated_repo(&repo_path, staged_repo_dir)?;
write_curated_plugins_sha(&sha_path, &export_version)?;
Ok(export_version)
}
pub(super) fn start_startup_remote_plugin_sync_once(
manager: Arc<PluginsManager>,
codex_home: PathBuf,
@@ -213,17 +290,17 @@ fn startup_remote_plugin_sync_marker_path(codex_home: &Path) -> PathBuf {
codex_home.join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE)
}
fn startup_remote_plugin_sync_prerequisites_ready(codex_home: &Path) -> bool {
codex_home
.join(".tmp/plugins/.agents/plugins/marketplace.json")
fn has_local_curated_plugins_snapshot(codex_home: &Path) -> bool {
curated_plugins_repo_path(codex_home)
.join(".agents/plugins/marketplace.json")
.is_file()
&& codex_home.join(".tmp/plugins.sha").is_file()
&& codex_home.join(CURATED_PLUGINS_SHA_FILE).is_file()
}
async fn wait_for_startup_remote_plugin_sync_prerequisites(codex_home: &Path) -> bool {
let deadline = tokio::time::Instant::now() + STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT;
loop {
if startup_remote_plugin_sync_prerequisites_ready(codex_home) {
if has_local_curated_plugins_snapshot(codex_home) {
return true;
}
if tokio::time::Instant::now() >= deadline {
@@ -374,7 +451,7 @@ fn emit_curated_plugins_startup_sync_counter(
transport: &'static str,
status: &'static str,
) {
let Some(metrics) = codex_otel::metrics::global() else {
let Some(metrics) = codex_otel::global() else {
return;
};
let tags = [("transport", transport), ("status", status)];
@@ -641,6 +718,126 @@ async fn fetch_curated_repo_zipball(
fetch_github_bytes(&client, &zipball_url, "download curated plugins archive").await
}
async fn fetch_curated_repo_backup_archive_zip(
backup_archive_api_url: &str,
) -> Result<Vec<u8>, String> {
let client = build_reqwest_client();
let export_body = fetch_public_text(
&client,
backup_archive_api_url,
"get curated plugins export archive metadata",
)
.await?;
let export_response: CuratedPluginsBackupArchiveResponse = serde_json::from_str(&export_body)
.map_err(|err| {
format!(
"failed to parse curated plugins backup archive response from {backup_archive_api_url}: {err}"
)
})?;
if export_response.download_url.is_empty() {
return Err(format!(
"curated plugins backup archive response from {backup_archive_api_url} did not include a download URL"
));
}
fetch_public_bytes(
&client,
&export_response.download_url,
"download curated plugins export archive",
)
.await
}
fn read_extracted_backup_archive_git_sha(repo_path: &Path) -> Result<Option<String>, String> {
let git_dir = repo_path.join(".git");
if !git_dir.is_dir() {
return Ok(None);
}
let head_path = git_dir.join("HEAD");
let head = std::fs::read_to_string(&head_path).map_err(|err| {
format!(
"failed to read curated plugins backup archive git HEAD {}: {err}",
head_path.display()
)
})?;
let head = head.trim();
if head.is_empty() {
return Err(format!(
"curated plugins backup archive git HEAD is empty at {}",
head_path.display()
));
}
if let Some(reference) = head.strip_prefix("ref: ") {
let reference = validate_backup_archive_git_ref(reference.trim())?;
return read_git_ref_sha(&git_dir, reference).map(Some);
}
Ok(Some(head.to_string()))
}
fn validate_backup_archive_git_ref(reference: &str) -> Result<&str, String> {
if !reference.starts_with("refs/") {
return Err(format!(
"curated plugins backup archive git ref must stay under refs/: {reference}"
));
}
let path = Path::new(reference);
if path.is_absolute() {
return Err(format!(
"curated plugins backup archive git ref must be relative: {reference}"
));
}
for component in path.components() {
match component {
std::path::Component::Normal(_) => {}
_ => {
return Err(format!(
"curated plugins backup archive git ref contains invalid path components: {reference}"
));
}
}
}
Ok(reference)
}
fn read_git_ref_sha(git_dir: &Path, reference: &str) -> Result<String, String> {
let ref_path = git_dir.join(reference);
if let Ok(sha) = std::fs::read_to_string(&ref_path) {
let sha = sha.trim();
if sha.is_empty() {
return Err(format!(
"curated plugins backup archive git ref {reference} is empty at {}",
ref_path.display()
));
}
return Ok(sha.to_string());
}
let packed_refs_path = git_dir.join("packed-refs");
if let Ok(packed_refs) = std::fs::read_to_string(&packed_refs_path)
&& let Some(sha) = packed_refs.lines().find_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('^') {
return None;
}
let (sha, candidate_ref) = trimmed.split_once(' ')?;
(candidate_ref == reference).then_some(sha.to_string())
})
{
return Ok(sha);
}
Err(format!(
"failed to resolve curated plugins backup archive git ref {reference} from {}",
git_dir.display()
))
}
async fn fetch_github_text(client: &Client, url: &str, context: &str) -> Result<String, String> {
let response = github_request(client, url)
.send()
@@ -675,6 +872,44 @@ async fn fetch_github_bytes(client: &Client, url: &str, context: &str) -> Result
Ok(body.to_vec())
}
async fn fetch_public_text(client: &Client, url: &str, context: &str) -> Result<String, String> {
let response = client
.get(url)
.timeout(CURATED_PLUGINS_BACKUP_ARCHIVE_TIMEOUT)
.send()
.await
.map_err(|err| format!("failed to {context} from {url}: {err}"))?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
return Err(format!(
"{context} from {url} failed with status {status}: {body}"
));
}
Ok(body)
}
async fn fetch_public_bytes(client: &Client, url: &str, context: &str) -> Result<Vec<u8>, String> {
let response = client
.get(url)
.timeout(CURATED_PLUGINS_BACKUP_ARCHIVE_TIMEOUT)
.send()
.await
.map_err(|err| format!("failed to {context} from {url}: {err}"))?;
let status = response.status();
let body = response
.bytes()
.await
.map_err(|err| format!("failed to read {context} response from {url}: {err}"))?;
if !status.is_success() {
let body_text = String::from_utf8_lossy(&body);
return Err(format!(
"{context} from {url} failed with status {status}: {body_text}"
));
}
Ok(body.to_vec())
}
fn github_request(client: &Client, url: &str) -> reqwest::RequestBuilder {
client
.get(url)
+429 -167
View File
@@ -8,6 +8,7 @@ use codex_login::CodexAuth;
use pretty_assertions::assert_eq;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use tempfile::tempdir;
use wiremock::Mock;
use wiremock::MockServer;
@@ -33,6 +34,112 @@ fn has_plugins_clone_dirs(codex_home: &Path) -> bool {
})
}
#[cfg(unix)]
fn write_executable_script(path: &Path, contents: &str) {
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
std::fs::write(path, contents).expect("write script");
#[cfg(unix)]
{
let mut permissions = std::fs::metadata(path).expect("metadata").permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(path, permissions).expect("chmod");
}
}
async fn mount_github_repo_and_ref(server: &MockServer, sha: &str) {
Mock::given(method("GET"))
.and(path("/repos/openai/plugins"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#))
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/repos/openai/plugins/git/ref/heads/main"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)),
)
.mount(server)
.await;
}
async fn mount_github_zipball(server: &MockServer, sha: &str, bytes: Vec<u8>) {
Mock::given(method("GET"))
.and(path(format!("/repos/openai/plugins/zipball/{sha}")))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/zip")
.set_body_bytes(bytes),
)
.mount(server)
.await;
}
async fn mount_export_archive(server: &MockServer, bytes: Vec<u8>) -> String {
let export_api_url = format!("{}/backend-api/plugins/export/curated", server.uri());
Mock::given(method("GET"))
.and(path("/backend-api/plugins/export/curated"))
.respond_with(ResponseTemplate::new(200).set_body_string(format!(
r#"{{"download_url":"{}/files/curated-plugins.zip"}}"#,
server.uri()
)))
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/files/curated-plugins.zip"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/zip")
.set_body_bytes(bytes),
)
.mount(server)
.await;
export_api_url
}
async fn run_sync_with_transport_overrides(
codex_home: PathBuf,
git_binary: impl Into<String>,
api_base_url: impl Into<String>,
backup_archive_api_url: impl Into<String>,
) -> Result<String, String> {
let git_binary = git_binary.into();
let api_base_url = api_base_url.into();
let backup_archive_api_url = backup_archive_api_url.into();
tokio::task::spawn_blocking(move || {
sync_openai_plugins_repo_with_transport_overrides(
codex_home.as_path(),
&git_binary,
&api_base_url,
&backup_archive_api_url,
)
})
.await
.expect("sync task should join")
}
async fn run_http_sync(
codex_home: PathBuf,
api_base_url: impl Into<String>,
) -> Result<String, String> {
let api_base_url = api_base_url.into();
tokio::task::spawn_blocking(move || {
sync_openai_plugins_repo_via_http(codex_home.as_path(), &api_base_url)
})
.await
.expect("sync task should join")
}
fn assert_curated_gmail_repo(repo_path: &Path) {
assert!(repo_path.join(".agents/plugins/marketplace.json").is_file());
assert!(
repo_path
.join("plugins/gmail/.codex-plugin/plugin.json")
.is_file()
);
}
#[test]
fn curated_plugins_repo_path_uses_codex_home_tmp_dir() {
let tmp = tempdir().expect("tempdir");
@@ -100,8 +207,6 @@ fn remove_stale_curated_repo_temp_dirs_removes_only_matching_directories() {
#[cfg(unix)]
#[test]
fn sync_openai_plugins_repo_prefers_git_when_available() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempdir().expect("tempdir");
let bin_dir = tempfile::Builder::new()
.prefix("fake-git-")
@@ -110,9 +215,9 @@ fn sync_openai_plugins_repo_prefers_git_when_available() {
let git_path = bin_dir.path().join("git");
let sha = "0123456789abcdef0123456789abcdef01234567";
std::fs::write(
write_executable_script(
&git_path,
format!(
&format!(
r#"#!/bin/sh
if [ "$1" = "ls-remote" ]; then
printf '%s\tHEAD\n' "{sha}"
@@ -135,89 +240,164 @@ echo "unexpected git invocation: $@" >&2
exit 1
"#
),
)
.expect("write fake git");
let mut permissions = std::fs::metadata(&git_path)
.expect("metadata")
.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&git_path, permissions).expect("chmod");
);
let synced_sha = sync_openai_plugins_repo_with_transport_overrides(
tmp.path(),
git_path.to_str().expect("utf8 path"),
"http://127.0.0.1:9",
"http://127.0.0.1:9/backend-api/plugins/export/curated",
)
.expect("git sync should succeed");
assert_eq!(synced_sha, sha);
assert!(curated_plugins_repo_path(tmp.path()).join(".git").is_dir());
assert!(
curated_plugins_repo_path(tmp.path())
.join(".agents/plugins/marketplace.json")
.is_file()
);
let repo_path = curated_plugins_repo_path(tmp.path());
assert!(repo_path.join(".git").is_dir());
assert_curated_gmail_repo(&repo_path);
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
}
#[cfg(unix)]
#[test]
fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() {
let tmp = tempdir().expect("tempdir");
let repo_root = tempfile::Builder::new()
.prefix("curated-repo-success-")
.tempdir()
.expect("tempdir");
let work_repo = repo_root.path().join("work/plugins");
let remote_repo = repo_root.path().join("remotes/openai/plugins.git");
std::fs::create_dir_all(work_repo.join(".agents/plugins")).expect("create marketplace dir");
std::fs::create_dir_all(work_repo.join("plugins/gmail/.codex-plugin"))
.expect("create plugin dir");
std::fs::write(
work_repo.join(".agents/plugins/marketplace.json"),
r#"{"name":"openai-curated","plugins":[{"name":"gmail","source":{"source":"local","path":"./plugins/gmail"}}]}"#,
)
.expect("write marketplace");
std::fs::write(
work_repo.join("plugins/gmail/.codex-plugin/plugin.json"),
r#"{"name":"gmail"}"#,
)
.expect("write plugin manifest");
let init_status = Command::new("git")
.arg("-C")
.arg(&work_repo)
.arg("init")
.status()
.expect("run git init");
assert!(init_status.success());
let add_status = Command::new("git")
.arg("-C")
.arg(&work_repo)
.arg("add")
.arg(".")
.status()
.expect("run git add");
assert!(add_status.success());
let commit_status = Command::new("git")
.arg("-C")
.arg(&work_repo)
.arg("-c")
.arg("user.name=Codex Test")
.arg("-c")
.arg("user.email=codex@example.com")
.arg("commit")
.arg("-m")
.arg("init")
.status()
.expect("run git commit");
assert!(commit_status.success());
std::fs::create_dir_all(remote_repo.parent().expect("remote parent"))
.expect("create remote parent");
let clone_status = Command::new("git")
.arg("clone")
.arg("--bare")
.arg(&work_repo)
.arg(&remote_repo)
.status()
.expect("run git clone --bare");
assert!(clone_status.success());
let sha_output = Command::new("git")
.arg("-C")
.arg(&work_repo)
.arg("rev-parse")
.arg("HEAD")
.output()
.expect("run git rev-parse");
assert!(sha_output.status.success());
let sha = String::from_utf8_lossy(&sha_output.stdout)
.trim()
.to_string();
let git_config_path = repo_root.path().join("git-rewrite.conf");
std::fs::write(
&git_config_path,
format!(
"[url \"file://{}/\"]\n insteadOf = https://github.com/\n",
repo_root.path().join("remotes").display()
),
)
.expect("write git config");
let bin_dir = tempfile::Builder::new()
.prefix("git-rewrite-wrapper-")
.tempdir()
.expect("tempdir");
let git_wrapper = bin_dir.path().join("git");
write_executable_script(
&git_wrapper,
&format!(
"#!/bin/sh\nGIT_CONFIG_GLOBAL='{}' exec git \"$@\"\n",
git_config_path.display()
),
);
let synced_sha =
sync_openai_plugins_repo_via_git(tmp.path(), git_wrapper.to_str().expect("utf8 path"))
.expect("git sync should succeed");
assert_eq!(synced_sha, sha);
assert_curated_gmail_repo(&curated_plugins_repo_path(tmp.path()));
assert_eq!(
read_curated_plugins_sha(tmp.path()).as_deref(),
Some(sha.as_str())
);
assert!(!has_plugins_clone_dirs(tmp.path()));
}
#[tokio::test]
async fn sync_openai_plugins_repo_falls_back_to_http_when_git_is_unavailable() {
let tmp = tempdir().expect("tempdir");
let server = MockServer::start().await;
let sha = "0123456789abcdef0123456789abcdef01234567";
Mock::given(method("GET"))
.and(path("/repos/openai/plugins"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repos/openai/plugins/git/ref/heads/main"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!("/repos/openai/plugins/zipball/{sha}")))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/zip")
.set_body_bytes(curated_repo_zipball_bytes(sha)),
)
.mount(&server)
.await;
mount_github_repo_and_ref(&server, sha).await;
mount_github_zipball(&server, sha, curated_repo_zipball_bytes(sha)).await;
let server_uri = server.uri();
let tmp_path = tmp.path().to_path_buf();
let synced_sha = tokio::task::spawn_blocking(move || {
sync_openai_plugins_repo_with_transport_overrides(
tmp_path.as_path(),
"missing-git-for-test",
&server_uri,
)
})
let synced_sha = run_sync_with_transport_overrides(
tmp.path().to_path_buf(),
"missing-git-for-test",
server.uri(),
"http://127.0.0.1:9/backend-api/plugins/export/curated",
)
.await
.expect("sync task should join")
.expect("fallback sync should succeed");
let repo_path = curated_plugins_repo_path(tmp.path());
assert_eq!(synced_sha, sha);
assert!(repo_path.join(".agents/plugins/marketplace.json").is_file());
assert!(
repo_path
.join("plugins/gmail/.codex-plugin/plugin.json")
.is_file()
);
assert_curated_gmail_repo(&repo_path);
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
}
#[cfg(unix)]
#[tokio::test]
async fn sync_openai_plugins_repo_falls_back_to_http_when_git_sync_fails() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempdir().expect("tempdir");
let bin_dir = tempfile::Builder::new()
.prefix("fake-git-fail-")
@@ -226,73 +406,36 @@ async fn sync_openai_plugins_repo_falls_back_to_http_when_git_sync_fails() {
let git_path = bin_dir.path().join("git");
let sha = "0123456789abcdef0123456789abcdef01234567";
std::fs::write(
write_executable_script(
&git_path,
r#"#!/bin/sh
echo "simulated git failure" >&2
exit 1
"#,
)
.expect("write fake git");
let mut permissions = std::fs::metadata(&git_path)
.expect("metadata")
.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&git_path, permissions).expect("chmod");
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/openai/plugins"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repos/openai/plugins/git/ref/heads/main"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!("/repos/openai/plugins/zipball/{sha}")))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/zip")
.set_body_bytes(curated_repo_zipball_bytes(sha)),
)
.mount(&server)
.await;
mount_github_repo_and_ref(&server, sha).await;
mount_github_zipball(&server, sha, curated_repo_zipball_bytes(sha)).await;
let server_uri = server.uri();
let tmp_path = tmp.path().to_path_buf();
let synced_sha = tokio::task::spawn_blocking(move || {
sync_openai_plugins_repo_with_transport_overrides(
tmp_path.as_path(),
git_path.to_str().expect("utf8 path"),
&server_uri,
)
})
let synced_sha = run_sync_with_transport_overrides(
tmp.path().to_path_buf(),
git_path.to_str().expect("utf8 path"),
server.uri(),
"http://127.0.0.1:9/backend-api/plugins/export/curated",
)
.await
.expect("sync task should join")
.expect("fallback sync should succeed");
let repo_path = curated_plugins_repo_path(tmp.path());
assert_eq!(synced_sha, sha);
assert!(repo_path.join(".agents/plugins/marketplace.json").is_file());
assert!(
repo_path
.join("plugins/gmail/.codex-plugin/plugin.json")
.is_file()
);
assert_curated_gmail_repo(&repo_path);
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
}
#[cfg(unix)]
#[test]
fn sync_openai_plugins_repo_via_git_cleans_up_staged_dir_on_clone_failure() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempdir().expect("tempdir");
let bin_dir = tempfile::Builder::new()
.prefix("fake-git-partial-fail-")
@@ -301,9 +444,9 @@ fn sync_openai_plugins_repo_via_git_cleans_up_staged_dir_on_clone_failure() {
let git_path = bin_dir.path().join("git");
let sha = "0123456789abcdef0123456789abcdef01234567";
std::fs::write(
write_executable_script(
&git_path,
format!(
&format!(
r#"#!/bin/sh
if [ "$1" = "ls-remote" ]; then
printf '%s\tHEAD\n' "{sha}"
@@ -319,13 +462,7 @@ echo "unexpected git invocation: $@" >&2
exit 1
"#
),
)
.expect("write fake git");
let mut permissions = std::fs::metadata(&git_path)
.expect("metadata")
.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&git_path, permissions).expect("chmod");
);
let err = sync_openai_plugins_repo_via_git(tmp.path(), git_path.to_str().expect("utf8 path"))
.expect_err("git sync should fail");
@@ -340,37 +477,12 @@ async fn sync_openai_plugins_repo_via_http_cleans_up_staged_dir_on_extract_failu
let server = MockServer::start().await;
let sha = "0123456789abcdef0123456789abcdef01234567";
Mock::given(method("GET"))
.and(path("/repos/openai/plugins"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repos/openai/plugins/git/ref/heads/main"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!("/repos/openai/plugins/zipball/{sha}")))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/zip")
.set_body_bytes(b"not a zip archive".to_vec()),
)
.mount(&server)
.await;
mount_github_repo_and_ref(&server, sha).await;
mount_github_zipball(&server, sha, b"not a zip archive".to_vec()).await;
let server_uri = server.uri();
let tmp_path = tmp.path().to_path_buf();
let err = tokio::task::spawn_blocking(move || {
sync_openai_plugins_repo_via_http(tmp_path.as_path(), &server_uri)
})
.await
.expect("sync task should join")
.expect_err("http sync should fail");
let err = run_http_sync(tmp.path().to_path_buf(), server.uri())
.await
.expect_err("http sync should fail");
assert!(err.contains("failed to open curated plugins zip archive"));
assert!(!has_plugins_clone_dirs(tmp.path()));
@@ -391,37 +503,141 @@ async fn sync_openai_plugins_repo_skips_archive_download_when_sha_matches() {
std::fs::write(tmp.path().join(".tmp/plugins.sha"), format!("{sha}\n")).expect("write sha");
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/openai/plugins"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repos/openai/plugins/git/ref/heads/main"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)),
)
.mount(&server)
.await;
mount_github_repo_and_ref(&server, sha).await;
let server_uri = server.uri();
let tmp_path = tmp.path().to_path_buf();
tokio::task::spawn_blocking(move || {
sync_openai_plugins_repo_with_transport_overrides(
tmp_path.as_path(),
"missing-git-for-test",
&server_uri,
)
})
run_sync_with_transport_overrides(
tmp.path().to_path_buf(),
"missing-git-for-test",
server.uri(),
"http://127.0.0.1:9/backend-api/plugins/export/curated",
)
.await
.expect("sync task should join")
.expect("sync should succeed");
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
assert!(repo_path.join(".agents/plugins/marketplace.json").is_file());
}
#[tokio::test]
async fn sync_openai_plugins_repo_falls_back_to_export_archive_when_no_snapshot_exists() {
let tmp = tempdir().expect("tempdir");
let server = MockServer::start().await;
let export_sha = "1111111111111111111111111111111111111111";
Mock::given(method("GET"))
.and(path("/repos/openai/plugins"))
.respond_with(ResponseTemplate::new(500).set_body_string("github repo lookup failed"))
.mount(&server)
.await;
let export_api_url =
mount_export_archive(&server, curated_repo_backup_archive_zip_bytes(export_sha)).await;
let synced_sha = run_sync_with_transport_overrides(
tmp.path().to_path_buf(),
"missing-git-for-test",
server.uri(),
export_api_url,
)
.await
.expect("export fallback sync should succeed");
let repo_path = curated_plugins_repo_path(tmp.path());
assert_eq!(synced_sha, export_sha);
assert_curated_gmail_repo(&repo_path);
assert_eq!(
read_curated_plugins_sha(tmp.path()).as_deref(),
Some(export_sha)
);
}
#[tokio::test]
async fn sync_openai_plugins_repo_skips_export_archive_when_snapshot_exists() {
let tmp = tempdir().expect("tempdir");
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &["linear"]);
write_curated_plugin_sha(tmp.path());
let plugin_manifest_path = curated_root.join("plugins/linear/.codex-plugin/plugin.json");
let original_manifest =
std::fs::read_to_string(&plugin_manifest_path).expect("read existing plugin manifest");
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/openai/plugins"))
.respond_with(ResponseTemplate::new(500).set_body_string("github repo lookup failed"))
.mount(&server)
.await;
let export_api_url = mount_export_archive(
&server,
curated_repo_backup_archive_zip_bytes("2222222222222222222222222222222222222222"),
)
.await;
let err = run_sync_with_transport_overrides(
tmp.path().to_path_buf(),
"missing-git-for-test",
server.uri(),
export_api_url,
)
.await
.expect_err("existing snapshot should suppress export fallback");
assert!(err.contains("export archive fallback skipped"));
assert_eq!(
std::fs::read_to_string(&plugin_manifest_path).expect("read plugin manifest after sync"),
original_manifest
);
assert_eq!(
read_curated_plugins_sha(tmp.path()).as_deref(),
Some(TEST_CURATED_PLUGIN_SHA)
);
}
#[test]
fn read_extracted_backup_archive_git_sha_reads_head_ref_from_extracted_repo() {
let tmp = tempdir().expect("tempdir");
let git_dir = tmp.path().join(".git/refs/heads");
std::fs::create_dir_all(&git_dir).expect("create git ref dir");
std::fs::write(tmp.path().join(".git/HEAD"), "ref: refs/heads/main\n").expect("write HEAD");
std::fs::write(
git_dir.join("main"),
"3333333333333333333333333333333333333333\n",
)
.expect("write main ref");
assert_eq!(
read_extracted_backup_archive_git_sha(tmp.path())
.expect("read extracted backup archive git sha"),
Some("3333333333333333333333333333333333333333".to_string())
);
}
#[test]
fn read_extracted_backup_archive_git_sha_rejects_non_refs_head_target() {
let tmp = tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join(".git")).expect("create git dir");
std::fs::write(tmp.path().join(".git/HEAD"), "ref: HEAD\n").expect("write HEAD");
let err = read_extracted_backup_archive_git_sha(tmp.path())
.expect_err("non-refs target should be rejected");
assert!(err.contains("must stay under refs/"));
}
#[test]
fn read_extracted_backup_archive_git_sha_rejects_path_traversal_ref() {
let tmp = tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join(".git")).expect("create git dir");
std::fs::write(tmp.path().join(".git/HEAD"), "ref: refs/heads/../../evil\n")
.expect("write HEAD");
let err = read_extracted_backup_archive_git_sha(tmp.path())
.expect_err("path traversal ref should be rejected");
assert!(err.contains("invalid path components"));
}
#[tokio::test]
async fn startup_remote_plugin_sync_writes_marker_and_reconciles_state() {
let tmp = tempdir().expect("tempdir");
@@ -528,3 +744,49 @@ fn curated_repo_zipball_bytes(sha: &str) -> Vec<u8> {
writer.finish().expect("finish zip writer").into_inner()
}
fn curated_repo_backup_archive_zip_bytes(sha: &str) -> Vec<u8> {
let cursor = std::io::Cursor::new(Vec::new());
let mut writer = ZipWriter::new(cursor);
let options = SimpleFileOptions::default();
writer
.start_file("plugins/.git/HEAD", options)
.expect("start HEAD entry");
writer
.write_all(b"ref: refs/heads/main\n")
.expect("write HEAD");
writer
.start_file("plugins/.git/refs/heads/main", options)
.expect("start main ref entry");
writer
.write_all(format!("{sha}\n").as_bytes())
.expect("write main ref");
writer
.start_file("plugins/.agents/plugins/marketplace.json", options)
.expect("start marketplace entry");
writer
.write_all(
br#"{
"name": "openai-curated",
"plugins": [
{
"name": "gmail",
"source": {
"source": "local",
"path": "./plugins/gmail"
}
}
]
}"#,
)
.expect("write marketplace");
writer
.start_file("plugins/plugins/gmail/.codex-plugin/plugin.json", options)
.expect("start plugin manifest entry");
writer
.write_all(br#"{"name":"gmail"}"#)
.expect("write plugin manifest");
writer.finish().expect("finish zip writer").into_inner()
}
+84 -6
View File
@@ -1,8 +1,11 @@
use super::load_plugin_manifest;
use super::manifest::PluginManifest;
use codex_plugin::PluginId;
use codex_plugin::validate_plugin_segment;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_plugins::PLUGIN_MANIFEST_PATH;
use serde::Deserialize;
use serde_json::Value as JsonValue;
use std::fs;
use std::io;
use std::path::Path;
@@ -62,7 +65,7 @@ impl PluginStore {
entry.file_type().ok().filter(std::fs::FileType::is_dir)?;
entry.file_name().into_string().ok()
})
.filter(|version| validate_plugin_segment(version, "plugin version").is_ok())
.filter(|version| validate_plugin_version_segment(version).is_ok())
.collect::<Vec<_>>();
discovered_versions.sort_unstable();
if discovered_versions.is_empty() {
@@ -91,7 +94,8 @@ impl PluginStore {
source_path: AbsolutePathBuf,
plugin_id: PluginId,
) -> Result<PluginInstallResult, PluginStoreError> {
self.install_with_version(source_path, plugin_id, DEFAULT_PLUGIN_VERSION.to_string())
let plugin_version = plugin_version_for_source(source_path.as_path())?;
self.install_with_version(source_path, plugin_id, plugin_version)
}
pub fn install_with_version(
@@ -114,8 +118,7 @@ impl PluginStore {
plugin_id.plugin_name
)));
}
validate_plugin_segment(&plugin_version, "plugin version")
.map_err(PluginStoreError::Invalid)?;
validate_plugin_version_segment(&plugin_version).map_err(PluginStoreError::Invalid)?;
let installed_path = self.plugin_root(&plugin_id, &plugin_version);
replace_plugin_root_atomically(
source_path.as_path(),
@@ -154,7 +157,33 @@ impl PluginStoreError {
}
}
fn plugin_name_for_source(source_path: &Path) -> Result<String, PluginStoreError> {
pub(crate) fn plugin_version_for_source(source_path: &Path) -> Result<String, PluginStoreError> {
let plugin_version = plugin_manifest_version_for_source(source_path)?
.unwrap_or_else(|| DEFAULT_PLUGIN_VERSION.to_string());
validate_plugin_version_segment(&plugin_version).map_err(PluginStoreError::Invalid)?;
Ok(plugin_version)
}
fn validate_plugin_version_segment(plugin_version: &str) -> Result<(), String> {
if plugin_version.is_empty() {
return Err("invalid plugin version: must not be empty".to_string());
}
if matches!(plugin_version, "." | "..") {
return Err("invalid plugin version: path traversal is not allowed".to_string());
}
if !plugin_version
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '+'))
{
return Err(
"invalid plugin version: only ASCII letters, digits, `.`, `+`, `_`, and `-` are allowed"
.to_string(),
);
}
Ok(())
}
fn plugin_manifest_for_source(source_path: &Path) -> Result<PluginManifest, PluginStoreError> {
let manifest_path = source_path.join(PLUGIN_MANIFEST_PATH);
if !manifest_path.is_file() {
return Err(PluginStoreError::Invalid(format!(
@@ -163,12 +192,61 @@ fn plugin_name_for_source(source_path: &Path) -> Result<String, PluginStoreError
)));
}
let manifest = load_plugin_manifest(source_path).ok_or_else(|| {
load_plugin_manifest(source_path).ok_or_else(|| {
PluginStoreError::Invalid(format!(
"missing or invalid plugin manifest: {}",
manifest_path.display()
))
})
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawPluginManifestVersion {
#[serde(default)]
version: Option<JsonValue>,
}
fn plugin_manifest_version_for_source(
source_path: &Path,
) -> Result<Option<String>, PluginStoreError> {
let manifest_path = source_path.join(PLUGIN_MANIFEST_PATH);
if !manifest_path.is_file() {
return Err(PluginStoreError::Invalid(format!(
"missing plugin manifest: {}",
manifest_path.display()
)));
}
let contents = fs::read_to_string(&manifest_path)
.map_err(|err| PluginStoreError::io("failed to read plugin manifest", err))?;
let manifest: RawPluginManifestVersion = serde_json::from_str(&contents).map_err(|err| {
PluginStoreError::Invalid(format!(
"failed to parse plugin manifest {}: {err}",
manifest_path.display()
))
})?;
let Some(version) = manifest.version else {
return Ok(None);
};
let Some(version) = version.as_str() else {
return Err(PluginStoreError::Invalid(format!(
"invalid plugin version in manifest {}: expected string",
manifest_path.display()
)));
};
let version = version.trim();
if version.is_empty() {
return Err(PluginStoreError::Invalid(format!(
"invalid plugin version in manifest {}: must not be blank",
manifest_path.display()
)));
}
Ok(Some(version.to_string()))
}
fn plugin_name_for_source(source_path: &Path) -> Result<String, PluginStoreError> {
let manifest = plugin_manifest_for_source(source_path)?;
let plugin_name = manifest.name;
validate_plugin_segment(&plugin_name, "plugin name")
+75 -2
View File
@@ -3,19 +3,36 @@ use codex_plugin::PluginId;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
fn write_plugin_with_version(
root: &Path,
dir_name: &str,
manifest_name: &str,
manifest_version: Option<&str>,
) {
let plugin_root = root.join(dir_name);
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
fs::create_dir_all(plugin_root.join("skills")).unwrap();
let version = manifest_version
.map(|manifest_version| format!(r#","version":"{manifest_version}""#))
.unwrap_or_default();
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
format!(r#"{{"name":"{manifest_name}"}}"#),
format!(r#"{{"name":"{manifest_name}"{version}}}"#),
)
.unwrap();
fs::write(plugin_root.join("skills/SKILL.md"), "skill").unwrap();
fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap();
}
fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
write_plugin_with_version(
root,
dir_name,
manifest_name,
/*manifest_version*/ None,
);
}
#[test]
fn install_copies_plugin_into_default_marketplace() {
let tmp = tempdir().unwrap();
@@ -110,6 +127,62 @@ fn install_with_version_uses_requested_cache_version() {
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
}
#[test]
fn install_uses_manifest_version_when_present() {
let tmp = tempdir().unwrap();
write_plugin_with_version(
tmp.path(),
"sample-plugin",
"sample-plugin",
Some("1.2.3-beta+7"),
);
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
let result = PluginStore::new(tmp.path().to_path_buf())
.install(
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
plugin_id.clone(),
)
.unwrap();
let installed_path = tmp
.path()
.join("plugins/cache/debug/sample-plugin/1.2.3-beta+7");
assert_eq!(
result,
PluginInstallResult {
plugin_id,
plugin_version: "1.2.3-beta+7".to_string(),
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
}
);
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
}
#[test]
fn install_rejects_blank_manifest_version() {
let tmp = tempdir().unwrap();
write_plugin_with_version(tmp.path(), "sample-plugin", "sample-plugin", Some(" "));
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
let err = PluginStore::new(tmp.path().to_path_buf())
.install(
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
plugin_id,
)
.expect_err("blank manifest version should be rejected");
let err = err.to_string().replace('\\', "/");
assert!(
err.starts_with("invalid plugin version in manifest "),
"unexpected error: {err}"
);
assert!(
err.ends_with("sample-plugin/.codex-plugin/plugin.json: must not be blank"),
"unexpected error: {err}"
);
}
#[test]
fn active_plugin_version_reads_version_directory_name() {
let tmp = tempdir().unwrap();
+71 -38
View File
@@ -21,10 +21,12 @@ 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_exec_server::Environment;
use codex_exec_server::ExecutorFileSystem;
use codex_features::Feature;
use codex_utils_absolute_path::AbsolutePathBuf;
use dunce::canonicalize as normalize_path;
use std::path::PathBuf;
use tokio::io::AsyncReadExt;
use std::io;
use toml::Value as TomlValue;
use tracing::error;
@@ -76,8 +78,19 @@ fn render_js_repl_instructions(config: &Config) -> Option<String> {
/// Combines `Config::instructions` and `AGENTS.md` (if present) into a single
/// string of instructions.
pub(crate) async fn get_user_instructions(config: &Config) -> Option<String> {
let project_docs = read_project_docs(config).await;
pub(crate) async fn get_user_instructions(
config: &Config,
environment: Option<&Environment>,
) -> Option<String> {
let fs = environment?.get_filesystem();
get_user_instructions_with_fs(config, fs.as_ref()).await
}
pub(crate) async fn get_user_instructions_with_fs(
config: &Config,
fs: &dyn ExecutorFileSystem,
) -> Option<String> {
let project_docs = read_project_docs_with_fs(config, fs).await;
let mut output = String::new();
@@ -125,14 +138,25 @@ pub(crate) async fn get_user_instructions(config: &Config) -> Option<String> {
/// concatenation of all discovered docs. If no documentation file is found the
/// function returns `Ok(None)`. Unexpected I/O failures bubble up as `Err` so
/// callers can decide how to handle them.
pub async fn read_project_docs(config: &Config) -> std::io::Result<Option<String>> {
pub async fn read_project_docs(
config: &Config,
environment: &Environment,
) -> io::Result<Option<String>> {
let fs = environment.get_filesystem();
read_project_docs_with_fs(config, fs.as_ref()).await
}
async fn read_project_docs_with_fs(
config: &Config,
fs: &dyn ExecutorFileSystem,
) -> io::Result<Option<String>> {
let max_total = config.project_doc_max_bytes;
if max_total == 0 {
return Ok(None);
}
let paths = discover_project_doc_paths(config)?;
let paths = discover_project_doc_paths(config, fs).await?;
if paths.is_empty() {
return Ok(None);
}
@@ -145,16 +169,22 @@ pub async fn read_project_docs(config: &Config) -> std::io::Result<Option<String
break;
}
let file = match tokio::fs::File::open(&p).await {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e),
};
match fs.get_metadata(&p).await {
Ok(metadata) if !metadata.is_file => continue,
Ok(_) => {}
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
Err(err) => return Err(err),
}
let size = file.metadata().await?.len();
let mut reader = tokio::io::BufReader::new(file).take(remaining);
let mut data: Vec<u8> = Vec::new();
reader.read_to_end(&mut data).await?;
let mut data = match fs.read_file(&p).await {
Ok(data) => data,
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
Err(err) => return Err(err),
};
let size = data.len() as u64;
if size > remaining {
data.truncate(remaining as usize);
}
if size > remaining {
tracing::warn!(
@@ -183,10 +213,17 @@ pub async fn read_project_docs(config: &Config) -> std::io::Result<Option<String
/// contents. The list is ordered from project root to the current working
/// directory (inclusive). Symlinks are allowed. When `project_doc_max_bytes`
/// is zero, returns an empty list.
pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBuf>> {
let mut dir = config.cwd.to_path_buf();
pub async fn discover_project_doc_paths(
config: &Config,
fs: &dyn ExecutorFileSystem,
) -> io::Result<Vec<AbsolutePathBuf>> {
if config.project_doc_max_bytes == 0 {
return Ok(Vec::new());
}
let mut dir = config.cwd.clone();
if let Ok(canon) = normalize_path(&dir) {
dir = canon;
dir = AbsolutePathBuf::try_from(canon)?;
}
let mut merged = TomlValue::Table(toml::map::Map::new());
@@ -211,14 +248,14 @@ pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBu
if !project_root_markers.is_empty() {
for ancestor in dir.ancestors() {
for marker in &project_root_markers {
let marker_path = ancestor.join(marker);
let marker_exists = match std::fs::metadata(&marker_path) {
let marker_path = AbsolutePathBuf::try_from(ancestor.join(marker))?;
let marker_exists = match fs.get_metadata(&marker_path).await {
Ok(_) => true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
Err(e) => return Err(e),
Err(err) if err.kind() == io::ErrorKind::NotFound => false,
Err(err) => return Err(err),
};
if marker_exists {
project_root = Some(ancestor.to_path_buf());
project_root = Some(AbsolutePathBuf::try_from(ancestor.to_path_buf())?);
break;
}
}
@@ -228,11 +265,11 @@ pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBu
}
}
let search_dirs: Vec<PathBuf> = if let Some(root) = project_root {
let search_dirs: Vec<AbsolutePathBuf> = if let Some(root) = project_root {
let mut dirs = Vec::new();
let mut cursor = dir.as_path();
let mut cursor = dir.clone();
loop {
dirs.push(cursor.to_path_buf());
dirs.push(cursor.clone());
if cursor == root {
break;
}
@@ -247,29 +284,25 @@ pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBu
vec![dir]
};
let mut found: Vec<PathBuf> = Vec::new();
let mut found: Vec<AbsolutePathBuf> = Vec::new();
let candidate_filenames = candidate_filenames(config);
for d in search_dirs {
for name in &candidate_filenames {
let candidate = d.join(name);
match std::fs::symlink_metadata(&candidate) {
Ok(md) => {
let ft = md.file_type();
// Allow regular files and symlinks; opening will later fail for dangling links.
if ft.is_file() || ft.is_symlink() {
found.push(candidate);
break;
}
match fs.get_metadata(&candidate).await {
Ok(md) if md.is_file => {
found.push(candidate);
break;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e),
Ok(_) => {}
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
Err(err) => return Err(err),
}
}
}
Ok(found)
}
fn candidate_filenames<'a>(config: &'a Config) -> Vec<&'a str> {
let mut names: Vec<&'a str> =
Vec::with_capacity(2 + config.project_doc_fallback_filenames.len());
+117 -7
View File
@@ -1,12 +1,23 @@
use super::*;
use crate::config::ConfigBuilder;
use codex_exec_server::LOCAL_FS;
use codex_features::Feature;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::PathBufExt;
use core_test_support::TempDirExt;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
async fn get_user_instructions(config: &Config) -> Option<String> {
super::get_user_instructions_with_fs(config, LOCAL_FS.as_ref()).await
}
async fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<AbsolutePathBuf>> {
super::discover_project_doc_paths(config, LOCAL_FS.as_ref()).await
}
/// Helper that returns a `Config` pointing at `root` and using `limit` as
/// the maximum number of bytes to embed from AGENTS.md. The caller can
/// optionally specify a custom `instructions` string – when `None` the
@@ -85,6 +96,16 @@ async fn no_doc_file_returns_none() {
assert!(res.is_none(), "Expected None when AGENTS.md is absent");
}
#[tokio::test]
async fn no_environment_returns_none() {
let tmp = tempfile::tempdir().expect("tempdir");
let config = make_config(&tmp, /*limit*/ 4096, Some("user instructions")).await;
let res = super::get_user_instructions(&config, /*environment*/ None).await;
assert_eq!(res, None);
}
/// Small file within the byte-limit is returned unmodified.
#[tokio::test]
async fn doc_smaller_than_limit_is_returned() {
@@ -161,6 +182,18 @@ async fn zero_byte_limit_disables_docs() {
);
}
#[tokio::test]
async fn zero_byte_limit_disables_discovery() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "something").unwrap();
let discovery =
discover_project_doc_paths(&make_config(&tmp, /*limit*/ 0, /*instructions*/ None).await)
.await
.expect("discover paths");
assert_eq!(discovery, Vec::<AbsolutePathBuf>::new());
}
#[tokio::test]
async fn js_repl_instructions_are_appended_when_enabled() {
let tmp = tempfile::tempdir().expect("tempdir");
@@ -293,11 +326,17 @@ async fn project_root_markers_are_honored_for_agents_discovery() {
.await;
cfg.cwd = nested.abs();
let discovery = discover_project_doc_paths(&cfg).expect("discover paths");
let expected_parent =
dunce::canonicalize(root.path().join("AGENTS.md")).expect("canonical parent doc path");
let expected_child =
dunce::canonicalize(cfg.cwd.as_path().join("AGENTS.md")).expect("canonical child doc path");
let discovery = discover_project_doc_paths(&cfg)
.await
.expect("discover paths");
let expected_parent = AbsolutePathBuf::try_from(
dunce::canonicalize(root.path().join("AGENTS.md")).expect("canonical parent doc path"),
)
.expect("absolute parent doc path");
let expected_child = AbsolutePathBuf::try_from(
dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical child doc path"),
)
.expect("absolute child doc path");
assert_eq!(discovery.len(), 2);
assert_eq!(discovery[0], expected_parent);
assert_eq!(discovery[1], expected_child);
@@ -321,7 +360,9 @@ async fn agents_local_md_preferred() {
assert_eq!(res, "local");
let discovery = discover_project_doc_paths(&cfg).expect("discover paths");
let discovery = discover_project_doc_paths(&cfg)
.await
.expect("discover paths");
assert_eq!(discovery.len(), 1);
assert_eq!(
discovery[0].file_name().unwrap().to_string_lossy(),
@@ -371,7 +412,9 @@ async fn agents_md_preferred_over_fallbacks() {
assert_eq!(res, "primary");
let discovery = discover_project_doc_paths(&cfg).expect("discover paths");
let discovery = discover_project_doc_paths(&cfg)
.await
.expect("discover paths");
assert_eq!(discovery.len(), 1);
assert!(
discovery[0]
@@ -382,6 +425,73 @@ async fn agents_md_preferred_over_fallbacks() {
);
}
#[tokio::test]
async fn agents_md_directory_is_ignored() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::create_dir(tmp.path().join("AGENTS.md")).unwrap();
let cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
let res = get_user_instructions(&cfg).await;
assert_eq!(res, None);
let discovery = discover_project_doc_paths(&cfg)
.await
.expect("discover paths");
assert_eq!(discovery, Vec::<AbsolutePathBuf>::new());
}
#[cfg(unix)]
#[tokio::test]
async fn agents_md_special_file_is_ignored() {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("AGENTS.md");
let c_path = CString::new(path.as_os_str().as_bytes()).expect("path without nul");
// SAFETY: `c_path` is a valid, nul-terminated path and `mkfifo` does not
// retain the pointer after the call.
let rc = unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) };
assert_eq!(rc, 0);
let cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
let res = get_user_instructions(&cfg).await;
assert_eq!(res, None);
let discovery = discover_project_doc_paths(&cfg)
.await
.expect("discover paths");
assert_eq!(discovery, Vec::<AbsolutePathBuf>::new());
}
#[tokio::test]
async fn override_directory_falls_back_to_agents_md_file() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::create_dir(tmp.path().join(LOCAL_PROJECT_DOC_FILENAME)).unwrap();
fs::write(tmp.path().join(DEFAULT_PROJECT_DOC_FILENAME), "primary").unwrap();
let cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
let res = get_user_instructions(&cfg)
.await
.expect("AGENTS.md should be used when override is a directory");
assert_eq!(res, "primary");
let discovery = discover_project_doc_paths(&cfg)
.await
.expect("discover paths");
assert_eq!(discovery.len(), 1);
assert_eq!(
discovery[0]
.file_name()
.expect("file name")
.to_string_lossy(),
DEFAULT_PROJECT_DOC_FILENAME
);
}
#[tokio::test]
async fn skills_are_not_appended_to_project_doc() {
let tmp = tempfile::tempdir().expect("tempdir");
+2 -6
View File
@@ -26,12 +26,8 @@ pub async fn build_prompt_input(
) -> CodexResult<Vec<ResponseItem>> {
config.ephemeral = true;
let auth_manager = AuthManager::shared(
config.codex_home.clone(),
/*enable_codex_api_key_env*/ false,
config.cli_auth_credentials_store_mode,
);
auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone());
let auth_manager =
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false);
let thread_manager = ThreadManager::new(
&config,
+5 -5
View File
@@ -1,6 +1,4 @@
use crate::codex::Session;
use crate::config::RealtimeWsMode;
use crate::config::RealtimeWsVersion;
use crate::realtime_context::build_realtime_startup_context;
use async_channel::Receiver;
use async_channel::Sender;
@@ -14,10 +12,12 @@ use codex_api::RealtimeEventParser;
use codex_api::RealtimeSessionConfig;
use codex_api::RealtimeSessionMode;
use codex_api::RealtimeWebsocketClient;
use codex_api::api_bridge::map_api_error;
use codex_api::endpoint::realtime_websocket::RealtimeWebsocketEvents;
use codex_api::endpoint::realtime_websocket::RealtimeWebsocketWriter;
use codex_api::RealtimeWebsocketEvents;
use codex_api::RealtimeWebsocketWriter;
use codex_api::map_api_error;
use codex_app_server_protocol::AuthMode;
use codex_config::config_toml::RealtimeWsMode;
use codex_config::config_toml::RealtimeWsVersion;
use codex_login::CodexAuth;
use codex_login::default_client::default_headers;
use codex_login::read_openai_api_key_from_env;
+24 -9
View File
@@ -1,17 +1,26 @@
use crate::config::Config;
pub use codex_rollout::ARCHIVED_SESSIONS_SUBDIR;
pub use codex_rollout::Cursor;
pub use codex_rollout::EventPersistenceMode;
pub use codex_rollout::INTERACTIVE_SESSION_SOURCES;
pub use codex_rollout::RolloutRecorder;
pub use codex_rollout::RolloutRecorderParams;
pub use codex_rollout::SESSIONS_SUBDIR;
pub use codex_rollout::SessionMeta;
pub use codex_rollout::ThreadItem;
pub use codex_rollout::ThreadSortKey;
pub use codex_rollout::ThreadsPage;
pub use codex_rollout::append_thread_name;
pub use codex_rollout::find_archived_thread_path_by_id_str;
#[deprecated(note = "use find_thread_path_by_id_str")]
pub use codex_rollout::find_conversation_path_by_id_str;
pub use codex_rollout::find_thread_name_by_id;
pub use codex_rollout::find_thread_names_by_ids;
pub use codex_rollout::find_thread_path_by_id_str;
pub use codex_rollout::find_thread_path_by_name_str;
pub use codex_rollout::parse_cursor;
pub use codex_rollout::read_head_for_summary;
pub use codex_rollout::read_session_meta_line;
pub use codex_rollout::rollout_date_parts;
impl codex_rollout::RolloutConfigView for Config {
@@ -36,24 +45,30 @@ impl codex_rollout::RolloutConfigView for Config {
}
}
pub mod list {
pub use codex_rollout::list::*;
pub(crate) mod list {
pub use codex_rollout::ThreadListConfig;
pub use codex_rollout::ThreadListLayout;
pub use codex_rollout::ThreadSortKey;
pub use codex_rollout::find_thread_path_by_id_str;
pub use codex_rollout::get_threads_in_root;
}
pub(crate) mod metadata {
pub(crate) use codex_rollout::metadata::builder_from_items;
pub(crate) use codex_rollout::builder_from_items;
}
pub mod policy {
pub use codex_rollout::policy::*;
pub(crate) mod policy {
pub use codex_rollout::EventPersistenceMode;
pub use codex_rollout::should_persist_response_item_for_memories;
}
pub mod recorder {
pub use codex_rollout::recorder::*;
pub(crate) mod recorder {
pub use codex_rollout::RolloutRecorder;
}
pub mod session_index {
pub use codex_rollout::session_index::*;
pub(crate) mod session_index {
pub use codex_rollout::append_thread_name;
pub use codex_rollout::find_thread_name_by_id;
}
pub(crate) use crate::session_rollout_init_error::map_session_init_error;
+16 -5
View File
@@ -12,6 +12,11 @@ use codex_protocol::protocol::SandboxPolicy;
use codex_sandboxing::SandboxType;
use codex_sandboxing::get_platform_sandbox;
const PATCH_REJECTED_OUTSIDE_PROJECT_REASON: &str =
"writing outside of the project; rejected by user approval settings";
const PATCH_REJECTED_READ_ONLY_REASON: &str =
"writing is blocked by read-only sandbox; rejected by user approval settings";
#[derive(Debug, PartialEq)]
pub enum SafetyCheck {
AutoApprove {
@@ -85,9 +90,7 @@ pub fn assess_patch_safety(
None => {
if rejects_sandbox_approval {
SafetyCheck::Reject {
reason:
"writing outside of the project; rejected by user approval settings"
.to_string(),
reason: patch_rejection_reason(sandbox_policy).to_string(),
}
} else {
SafetyCheck::AskUser
@@ -97,14 +100,22 @@ pub fn assess_patch_safety(
}
} else if rejects_sandbox_approval {
SafetyCheck::Reject {
reason: "writing outside of the project; rejected by user approval settings"
.to_string(),
reason: patch_rejection_reason(sandbox_policy).to_string(),
}
} else {
SafetyCheck::AskUser
}
}
fn patch_rejection_reason(sandbox_policy: &SandboxPolicy) -> &'static str {
match sandbox_policy {
SandboxPolicy::ReadOnly { .. } => PATCH_REJECTED_READ_ONLY_REASON,
SandboxPolicy::WorkspaceWrite { .. }
| SandboxPolicy::DangerFullAccess
| SandboxPolicy::ExternalSandbox { .. } => PATCH_REJECTED_OUTSIDE_PROJECT_REASON,
}
}
fn is_write_patch_constrained_to_writable_paths(
action: &ApplyPatchAction,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
+49 -14
View File
@@ -5,6 +5,7 @@ use codex_protocol::protocol::FileSystemSandboxEntry;
use codex_protocol::protocol::FileSystemSpecialPath;
use codex_protocol::protocol::GranularApprovalConfig;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::PathBufExt;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
@@ -17,7 +18,10 @@ fn test_writable_roots_constraint() {
let parent = cwd.parent().unwrap().to_path_buf();
// Helper to build a single‑entry patch that adds a file at `p`.
let make_add_change = |p: PathBuf| ApplyPatchAction::new_add_for_test(&p, "".to_string());
let make_add_change = |p: PathBuf| {
let p = p.abs();
ApplyPatchAction::new_add_for_test(&p, "".to_string())
};
let add_inside = make_add_change(cwd.join("inner.txt"));
let add_outside = make_add_change(parent.join("outside.txt"));
@@ -64,7 +68,8 @@ fn test_writable_roots_constraint() {
fn external_sandbox_auto_approves_in_on_request() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_path_buf();
let add_inside = ApplyPatchAction::new_add_for_test(&cwd.join("inner.txt"), "".to_string());
let add_inside_path = cwd.join("inner.txt").abs();
let add_inside = ApplyPatchAction::new_add_for_test(&add_inside_path, "".to_string());
let policy = SandboxPolicy::ExternalSandbox {
network_access: codex_protocol::protocol::NetworkAccess::Enabled,
@@ -91,8 +96,8 @@ fn granular_with_all_flags_true_matches_on_request_for_out_of_root_patch() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_path_buf();
let parent = cwd.parent().unwrap().to_path_buf();
let add_outside =
ApplyPatchAction::new_add_for_test(&parent.join("outside.txt"), "".to_string());
let outside_path = parent.join("outside.txt").abs();
let add_outside = ApplyPatchAction::new_add_for_test(&outside_path, "".to_string());
let policy_workspace_only = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: Default::default(),
@@ -136,8 +141,8 @@ fn granular_sandbox_approval_false_rejects_out_of_root_patch() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_path_buf();
let parent = cwd.parent().unwrap().to_path_buf();
let add_outside =
ApplyPatchAction::new_add_for_test(&parent.join("outside.txt"), "".to_string());
let outside_path = parent.join("outside.txt").abs();
let add_outside = ApplyPatchAction::new_add_for_test(&outside_path, "".to_string());
let policy_workspace_only = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: Default::default(),
@@ -162,8 +167,37 @@ fn granular_sandbox_approval_false_rejects_out_of_root_patch() {
WindowsSandboxLevel::Disabled,
),
SafetyCheck::Reject {
reason: "writing outside of the project; rejected by user approval settings"
.to_string(),
reason: PATCH_REJECTED_OUTSIDE_PROJECT_REASON.to_string(),
},
);
}
#[test]
fn read_only_policy_rejects_patch_with_read_only_reason() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_path_buf();
let inside_path = cwd.join("inside.txt").abs();
let action = ApplyPatchAction::new_add_for_test(&inside_path, "".to_string());
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let file_system_sandbox_policy =
FileSystemSandboxPolicy::from_legacy_sandbox_policy(&sandbox_policy, &cwd);
assert!(!is_write_patch_constrained_to_writable_paths(
&action,
&file_system_sandbox_policy,
&cwd,
));
assert_eq!(
assess_patch_safety(
&action,
AskForApproval::Never,
&sandbox_policy,
&file_system_sandbox_policy,
&cwd,
WindowsSandboxLevel::Disabled,
),
SafetyCheck::Reject {
reason: PATCH_REJECTED_READ_ONLY_REASON.to_string(),
},
);
}
@@ -172,8 +206,8 @@ fn explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_path_buf();
let blocked_path = cwd.join("blocked.txt");
let blocked_absolute = AbsolutePathBuf::from_absolute_path(blocked_path.clone()).unwrap();
let action = ApplyPatchAction::new_add_for_test(&blocked_path, "".to_string());
let blocked_absolute = blocked_path.abs();
let action = ApplyPatchAction::new_add_for_test(&blocked_absolute, "".to_string());
let sandbox_policy = SandboxPolicy::ExternalSandbox {
network_access: codex_protocol::protocol::NetworkAccess::Restricted,
};
@@ -215,8 +249,9 @@ fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_path_buf();
let blocked_path = cwd.join("docs").join("blocked.txt");
let docs_absolute = AbsolutePathBuf::resolve_path_against_base("docs", &cwd).unwrap();
let action = ApplyPatchAction::new_add_for_test(&blocked_path, "".to_string());
let blocked_absolute = blocked_path.abs();
let docs_absolute = AbsolutePathBuf::resolve_path_against_base("docs", &cwd);
let action = ApplyPatchAction::new_add_for_test(&blocked_absolute, "".to_string());
let sandbox_policy = SandboxPolicy::ExternalSandbox {
network_access: codex_protocol::protocol::NetworkAccess::Restricted,
};
@@ -257,8 +292,8 @@ fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() {
fn missing_project_dot_codex_config_requires_approval() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_path_buf();
let action =
ApplyPatchAction::new_add_for_test(&cwd.join(".codex").join("config.toml"), "".to_string());
let config_path = cwd.join(".codex").join("config.toml").abs();
let action = ApplyPatchAction::new_add_for_test(&config_path, "".to_string());
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: Default::default(),
+4 -4
View File
@@ -13,9 +13,9 @@ use crate::codex::INITIAL_SUBMIT_ID;
use crate::codex::Session;
use crate::codex::build_prompt;
use crate::codex::built_tools;
use codex_otel::STARTUP_PREWARM_AGE_AT_FIRST_TURN_METRIC;
use codex_otel::STARTUP_PREWARM_DURATION_METRIC;
use codex_otel::SessionTelemetry;
use codex_otel::metrics::names::STARTUP_PREWARM_AGE_AT_FIRST_TURN_METRIC;
use codex_otel::metrics::names::STARTUP_PREWARM_DURATION_METRIC;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::models::BaseInstructions;
@@ -217,9 +217,9 @@ async fn schedule_startup_prewarm_inner(
Vec::new(),
startup_router.as_ref(),
startup_turn_context.as_ref(),
BaseInstructions {
Some(BaseInstructions {
text: base_instructions,
},
}),
);
let startup_turn_metadata_header = startup_turn_context
.turn_metadata_state
+2 -2
View File
@@ -17,7 +17,7 @@ use codex_analytics::AnalyticsEventsClient;
use codex_exec_server::Environment;
use codex_hooks::Hooks;
use codex_login::AuthManager;
use codex_mcp::mcp_connection_manager::McpConnectionManager;
use codex_mcp::McpConnectionManager;
use codex_models_manager::manager::ModelsManager;
use codex_otel::SessionTelemetry;
use codex_rollout::state_db::StateDbHandle;
@@ -57,5 +57,5 @@ pub(crate) struct SessionServices {
/// Session-scoped model client shared across turns.
pub(crate) model_client: ModelClient,
pub(crate) code_mode_service: CodeModeService,
pub(crate) environment: Arc<Environment>,
pub(crate) environment: Option<Arc<Environment>>,
}
-13
View File
@@ -1,18 +1,5 @@
use codex_rollout::state_db as rollout_state_db;
pub use codex_rollout::state_db::StateDbHandle;
pub use codex_rollout::state_db::apply_rollout_items;
pub use codex_rollout::state_db::find_rollout_path_by_id;
pub use codex_rollout::state_db::get_dynamic_tools;
pub use codex_rollout::state_db::list_thread_ids_db;
pub use codex_rollout::state_db::list_threads_db;
pub use codex_rollout::state_db::mark_thread_memory_mode_polluted;
pub use codex_rollout::state_db::normalize_cwd_for_state_db;
pub use codex_rollout::state_db::open_if_present;
pub use codex_rollout::state_db::persist_dynamic_tools;
pub use codex_rollout::state_db::read_repair_rollout_path;
pub use codex_rollout::state_db::reconcile_rollout;
pub use codex_rollout::state_db::touch_thread_updated_at;
pub use codex_state::LogEntry;
use crate::config::Config;
+2 -7
View File
@@ -379,17 +379,12 @@ pub(crate) async fn handle_non_tool_response_item(
.parent()
.unwrap_or(turn_context.config.codex_home.as_path());
let message: ResponseItem = DeveloperInstructions::new(format!(
"Generated images are saved to {} as {} by default.",
"Generated images are saved to {} as {} by default.\nIf you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.",
image_output_dir.display(),
image_output_path.display(),
))
.into();
let copy_message: ResponseItem = DeveloperInstructions::new(
"If you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it."
.to_string(),
)
.into();
sess.record_conversation_items(turn_context, &[message, copy_message])
sess.record_conversation_items(turn_context, &[message])
.await;
}
Err(err) => {
+17 -4
View File
@@ -33,10 +33,10 @@ use crate::state::TaskKind;
use codex_login::AuthManager;
use codex_models_manager::manager::ModelsManager;
use codex_otel::SessionTelemetry;
use codex_otel::metrics::names::TURN_E2E_DURATION_METRIC;
use codex_otel::metrics::names::TURN_NETWORK_PROXY_METRIC;
use codex_otel::metrics::names::TURN_TOKEN_USAGE_METRIC;
use codex_otel::metrics::names::TURN_TOOL_CALL_METRIC;
use codex_otel::TURN_E2E_DURATION_METRIC;
use codex_otel::TURN_NETWORK_PROXY_METRIC;
use codex_otel::TURN_TOKEN_USAGE_METRIC;
use codex_otel::TURN_TOOL_CALL_METRIC;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
@@ -511,9 +511,15 @@ impl Session {
&[("token_type", "reasoning_output"), tmp_mem],
);
}
let (completed_at, duration_ms) = turn_context
.turn_timing_state
.completed_at_and_duration_ms()
.await;
let event = EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: turn_context.sub_id.clone(),
last_agent_message,
completed_at,
duration_ms,
});
self.send_event(turn_context.as_ref(), event).await;
@@ -588,9 +594,16 @@ impl Session {
self.flush_rollout().await;
}
let (completed_at, duration_ms) = task
.turn_context
.turn_timing_state
.completed_at_and_duration_ms()
.await;
let event = EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some(task.turn_context.sub_id.clone()),
reason,
completed_at,
duration_ms,
});
self.send_event(task.turn_context.as_ref(), event).await;
}
+3 -3
View File
@@ -1,8 +1,8 @@
use super::emit_turn_network_proxy_metric;
use codex_otel::MetricsClient;
use codex_otel::MetricsConfig;
use codex_otel::SessionTelemetry;
use codex_otel::metrics::MetricsClient;
use codex_otel::metrics::MetricsConfig;
use codex_otel::metrics::names::TURN_NETWORK_PROXY_METRIC;
use codex_otel::TURN_NETWORK_PROXY_METRIC;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SessionSource;
use opentelemetry::KeyValue;
+1
View File
@@ -46,6 +46,7 @@ impl SessionTask for RegularTask {
// not wait on startup prewarm resolution.
let event = EventMsg::TurnStarted(TurnStartedEvent {
turn_id: ctx.sub_id.clone(),
started_at: ctx.turn_timing_state.started_at_unix_secs().await,
model_context_window: ctx.model_context_window(),
collaboration_mode_kind: ctx.collaboration_mode.mode,
});
+1 -1
View File
@@ -112,7 +112,7 @@ async fn start_review_conversation(
let _ = sub_agent_config.features.disable(Feature::Collab);
// Set explicit review rubric for the sub-agent
sub_agent_config.base_instructions = Some(crate::REVIEW_PROMPT.to_string());
sub_agent_config.base_instructions = Some(Some(crate::REVIEW_PROMPT.to_string()));
sub_agent_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never);
let model = config
+7 -4
View File
@@ -111,6 +111,7 @@ pub(crate) async fn execute_user_shell_command(
// freshly reinjected context before the summary/replacement history is applied.
let event = EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_context.sub_id.clone(),
started_at: turn_context.turn_timing_state.started_at_unix_secs().await,
model_context_window: turn_context.model_context_window(),
collaboration_mode_kind: turn_context.collaboration_mode.mode,
});
@@ -123,11 +124,16 @@ pub(crate) async fn execute_user_shell_command(
let use_login_shell = true;
let session_shell = session.user_shell();
let display_command = session_shell.derive_exec_args(&command, use_login_shell);
let exec_env_map = create_env(
&turn_context.shell_environment_policy,
Some(session.conversation_id),
);
let exec_command = maybe_wrap_shell_lc_with_snapshot(
&display_command,
session_shell.as_ref(),
turn_context.cwd.as_path(),
&turn_context.shell_environment_policy.r#set,
&exec_env_map,
);
let call_id = Uuid::new_v4().to_string();
@@ -155,10 +161,7 @@ pub(crate) async fn execute_user_shell_command(
let exec_env = ExecRequest {
command: exec_command.clone(),
cwd: cwd.to_path_buf(),
env: create_env(
&turn_context.shell_environment_policy,
Some(session.conversation_id),
),
env: exec_env_map,
network: turn_context.network.clone(),
// TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we
// should use that instead of an "arbitrarily large" timeout here.
+51
View File
@@ -43,9 +43,11 @@ use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
use codex_protocol::protocol::W3cTraceContext;
use codex_state::DirectionalThreadSpawnEdgeStatus;
use futures::StreamExt;
use futures::stream::FuturesUnordered;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
@@ -401,6 +403,53 @@ impl ThreadManager {
self.state.get_thread(thread_id).await
}
/// List `thread_id` plus all known descendants in its spawn subtree.
pub async fn list_agent_subtree_thread_ids(
&self,
thread_id: ThreadId,
) -> CodexResult<Vec<ThreadId>> {
let thread = self.state.get_thread(thread_id).await?;
let mut subtree_thread_ids = Vec::new();
let mut seen_thread_ids = HashSet::new();
subtree_thread_ids.push(thread_id);
seen_thread_ids.insert(thread_id);
if let Some(state_db_ctx) = thread.state_db() {
for status in [
DirectionalThreadSpawnEdgeStatus::Open,
DirectionalThreadSpawnEdgeStatus::Closed,
] {
for descendant_id in state_db_ctx
.list_thread_spawn_descendants_with_status(thread_id, status)
.await
.map_err(|err| {
CodexErr::Fatal(format!("failed to load thread-spawn descendants: {err}"))
})?
{
if seen_thread_ids.insert(descendant_id) {
subtree_thread_ids.push(descendant_id);
}
}
}
}
for descendant_id in thread
.codex
.session
.services
.agent_control
.list_live_agent_subtree_thread_ids(thread_id)
.await?
{
if seen_thread_ids.insert(descendant_id) {
subtree_thread_ids.push(descendant_id);
}
}
Ok(subtree_thread_ids)
}
pub async fn start_thread(&self, config: Config) -> CodexResult<NewThread> {
// Box delegated thread-spawn futures so these convenience wrappers do
// not inline the full spawn path into every caller's async state.
@@ -1010,6 +1059,8 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option<String>)
let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
turn_id,
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
}));
match history {
+12
View File
@@ -164,6 +164,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() {
RolloutItem::ResponseItem(assistant_msg("a1")),
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-2".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
})),
@@ -320,6 +321,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() {
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: None,
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
})),
])
.expect("serialize expected interrupted fork history"),
@@ -334,6 +337,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() {
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: None,
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
})),
])
.expect("serialize expected interrupted empty history"),
@@ -349,6 +354,8 @@ fn interrupted_snapshot_is_not_mid_turn() {
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
})),
]);
@@ -485,6 +492,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor
EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: expected_turn_id,
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
}),
))
.expect("serialize interrupted abort event");
@@ -536,6 +545,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
InitialHistory::Forked(vec![
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-explicit".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
})),
@@ -594,6 +604,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some(turn_id),
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
})) if turn_id == "turn-explicit"
)
}));
-7
View File
@@ -364,13 +364,6 @@ impl ExecCommandToolOutput {
fn response_text(&self) -> String {
let mut sections = Vec::new();
if let Some(command) = &self.session_command {
sections.push(format!(
"Command: {}",
codex_shell_command::parse_command::shlex_join(command)
));
}
if !self.chunk_id.is_empty() {
sections.push(format!("Chunk ID: {}", self.chunk_id));
}
+2 -7
View File
@@ -249,11 +249,7 @@ fn exec_command_tool_output_formats_truncated_response() {
process_id: None,
exit_code: Some(0),
original_token_count: Some(10),
session_command: Some(vec![
"/bin/zsh".to_string(),
"-lc".to_string(),
"rm -rf /tmp/example.sqlite".to_string(),
]),
session_command: None,
}
.to_response_item("call-42", &payload);
@@ -267,8 +263,7 @@ fn exec_command_tool_output_formats_truncated_response() {
.expect("exec output should serialize as text");
assert_regex_match(
r#"(?sx)
^Command:\ /bin/zsh\ -lc\ 'rm\ -rf\ /tmp/example\.sqlite'
\nChunk\ ID:\ abc123
^Chunk\ ID:\ abc123
\nWall\ time:\ \d+\.\d{4}\ seconds
\nProcess\ exited\ with\ code\ 0
\nOriginal\ token\ count:\ 10
@@ -535,7 +535,7 @@ async fn build_runner_options(
let max_concurrency =
normalize_concurrency(requested_concurrency, turn.config.agent_max_threads);
let base_instructions = session.get_base_instructions().await;
let spawn_config = build_agent_spawn_config(&base_instructions, turn.as_ref())?;
let spawn_config = build_agent_spawn_config(base_instructions.as_ref(), turn.as_ref())?;
Ok(JobRunnerOptions {
max_concurrency,
spawn_config,
@@ -23,6 +23,7 @@ use crate::tools::runtimes::apply_patch::ApplyPatchRuntime;
use crate::tools::sandboxing::ToolCtx;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::ApplyPatchFileChange;
use codex_exec_server::ExecutorFileSystem;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::PermissionProfile;
use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy;
@@ -37,7 +38,7 @@ pub struct ApplyPatchHandler;
fn file_paths_for_action(action: &ApplyPatchAction) -> Vec<AbsolutePathBuf> {
let mut keys = Vec::new();
let cwd = action.cwd.as_path();
let cwd = &action.cwd;
for (path, change) in action.changes() {
if let Some(key) = to_abs_path(cwd, path) {
@@ -55,14 +56,14 @@ fn file_paths_for_action(action: &ApplyPatchAction) -> Vec<AbsolutePathBuf> {
keys
}
fn to_abs_path(cwd: &Path, path: &Path) -> Option<AbsolutePathBuf> {
AbsolutePathBuf::resolve_path_against_base(path, cwd).ok()
fn to_abs_path(cwd: &AbsolutePathBuf, path: &Path) -> Option<AbsolutePathBuf> {
Some(AbsolutePathBuf::resolve_path_against_base(path, cwd))
}
fn write_permissions_for_paths(
file_paths: &[AbsolutePathBuf],
file_system_sandbox_policy: &codex_protocol::permissions::FileSystemSandboxPolicy,
cwd: &Path,
cwd: &AbsolutePathBuf,
) -> Option<PermissionProfile> {
let write_paths = file_paths
.iter()
@@ -71,7 +72,9 @@ fn write_permissions_for_paths(
.unwrap_or_else(|| path.clone())
.into_path_buf()
})
.filter(|path| !file_system_sandbox_policy.can_write_path_with_cwd(path.as_path(), cwd))
.filter(|path| {
!file_system_sandbox_policy.can_write_path_with_cwd(path.as_path(), cwd.as_path())
})
.collect::<BTreeSet<_>>()
.into_iter()
.map(AbsolutePathBuf::from_absolute_path)
@@ -110,7 +113,7 @@ async fn effective_patch_permissions(
let effective_additional_permissions = apply_granted_turn_permissions(
session,
crate::sandboxing::SandboxPermissions::UseDefault,
write_permissions_for_paths(&file_paths, &file_system_sandbox_policy, turn.cwd.as_path()),
write_permissions_for_paths(&file_paths, &file_system_sandbox_policy, &turn.cwd),
)
.await;
@@ -167,7 +170,14 @@ impl ToolHandler for ApplyPatchHandler {
// Avoid building temporary ExecParams/command vectors; derive directly from inputs.
let cwd = turn.cwd.clone();
let command = vec!["apply_patch".to_string(), patch_input.clone()];
match codex_apply_patch::maybe_parse_apply_patch_verified(&command, &cwd) {
let Some(environment) = turn.environment.as_ref() else {
return Err(FunctionCallError::RespondToModel(
"apply_patch is unavailable in this session".to_string(),
));
};
let fs = environment.get_filesystem();
match codex_apply_patch::maybe_parse_apply_patch_verified(&command, &cwd, fs.as_ref()).await
{
codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
let (file_paths, effective_additional_permissions, file_system_sandbox_policy) =
effective_patch_permissions(session.as_ref(), turn.as_ref(), &changes).await;
@@ -254,7 +264,8 @@ impl ToolHandler for ApplyPatchHandler {
#[allow(clippy::too_many_arguments)]
pub(crate) async fn intercept_apply_patch(
command: &[String],
cwd: &Path,
cwd: &AbsolutePathBuf,
fs: &dyn ExecutorFileSystem,
timeout_ms: Option<u64>,
session: Arc<Session>,
turn: Arc<TurnContext>,
@@ -262,7 +273,7 @@ pub(crate) async fn intercept_apply_patch(
call_id: &str,
tool_name: &str,
) -> Result<Option<FunctionToolOutput>, FunctionCallError> {
match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd) {
match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd, fs).await {
codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
session
.record_model_warning(
@@ -1,17 +1,21 @@
use super::*;
use codex_apply_patch::MaybeApplyPatchVerified;
use codex_exec_server::LOCAL_FS;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::protocol::SandboxPolicy;
use core_test_support::PathBufExt;
use core_test_support::PathExt;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
#[test]
fn approval_keys_include_move_destination() {
#[tokio::test]
async fn approval_keys_include_move_destination() {
let tmp = TempDir::new().expect("tmp");
let cwd = tmp.path();
std::fs::create_dir_all(cwd.join("old")).expect("create old dir");
std::fs::create_dir_all(cwd.join("renamed/dir")).expect("create dest dir");
std::fs::write(cwd.join("old/name.txt"), "old content\n").expect("write old file");
let cwd_path = tmp.path();
let cwd = cwd_path.abs();
std::fs::create_dir_all(cwd_path.join("old")).expect("create old dir");
std::fs::create_dir_all(cwd_path.join("renamed/dir")).expect("create dest dir");
std::fs::write(cwd_path.join("old/name.txt"), "old content\n").expect("write old file");
let patch = r#"*** Begin Patch
*** Update File: old/name.txt
*** Move to: renamed/dir/name.txt
@@ -20,10 +24,13 @@ fn approval_keys_include_move_destination() {
+new content
*** End Patch"#;
let argv = vec!["apply_patch".to_string(), patch.to_string()];
let action = match codex_apply_patch::maybe_parse_apply_patch_verified(&argv, cwd) {
MaybeApplyPatchVerified::Body(action) => action,
other => panic!("expected patch body, got: {other:?}"),
};
let action =
match codex_apply_patch::maybe_parse_apply_patch_verified(&argv, &cwd, LOCAL_FS.as_ref())
.await
{
MaybeApplyPatchVerified::Body(action) => action,
other => panic!("expected patch body, got: {other:?}"),
};
let keys = file_paths_for_action(&action);
assert_eq!(keys.len(), 2);
@@ -32,8 +39,9 @@ fn approval_keys_include_move_destination() {
#[test]
fn write_permissions_for_paths_skip_dirs_already_writable_under_workspace_root() {
let tmp = TempDir::new().expect("tmp");
let cwd = tmp.path();
let nested = cwd.join("nested");
let cwd_path = tmp.path();
let cwd = cwd_path.abs();
let nested = cwd_path.join("nested");
std::fs::create_dir_all(&nested).expect("create nested dir");
let file_path = AbsolutePathBuf::try_from(nested.join("file.txt"))
.expect("nested file path should be absolute");
@@ -45,7 +53,7 @@ fn write_permissions_for_paths_skip_dirs_already_writable_under_workspace_root()
exclude_slash_tmp: false,
});
let permissions = write_permissions_for_paths(&[file_path], &sandbox_policy, cwd);
let permissions = write_permissions_for_paths(&[file_path], &sandbox_policy, &cwd);
assert_eq!(permissions, None);
}
@@ -59,6 +67,7 @@ fn write_permissions_for_paths_keep_dirs_outside_workspace_root() {
std::fs::create_dir_all(&outside).expect("create outside dir");
let file_path = AbsolutePathBuf::try_from(outside.join("file.txt"))
.expect("outside file path should be absolute");
let cwd_abs = cwd.abs();
let sandbox_policy = FileSystemSandboxPolicy::from(&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: Default::default(),
@@ -67,11 +76,9 @@ fn write_permissions_for_paths_keep_dirs_outside_workspace_root() {
exclude_slash_tmp: true,
});
let permissions = write_permissions_for_paths(&[file_path], &sandbox_policy, &cwd);
let expected_outside = AbsolutePathBuf::from_absolute_path(dunce::simplified(
&outside.canonicalize().expect("canonicalize outside dir"),
))
.expect("outside dir should be absolute");
let permissions = write_permissions_for_paths(&[file_path], &sandbox_policy, &cwd_abs);
let expected_outside =
dunce::simplified(&outside.canonicalize().expect("canonicalize outside dir")).abs();
assert_eq!(
permissions.and_then(|profile| profile.file_system.and_then(|fs| fs.write)),
+1 -1
View File
@@ -1,5 +1,5 @@
pub(crate) mod agent_jobs;
pub mod apply_patch;
pub(crate) mod apply_patch;
mod dynamic;
mod js_repl;
mod list_dir;
@@ -63,7 +63,7 @@ pub(crate) use send_input::Handler as SendInputHandler;
pub(crate) use spawn::Handler as SpawnAgentHandler;
pub(crate) use wait::Handler as WaitAgentHandler;
pub mod close_agent;
pub(crate) mod close_agent;
mod resume_agent;
mod send_input;
mod spawn;
@@ -59,8 +59,10 @@ impl ToolHandler for Handler {
.into(),
)
.await;
let mut config =
build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?;
let mut config = build_agent_spawn_config(
session.get_base_instructions().await.as_ref(),
turn.as_ref(),
)?;
apply_requested_spawn_agent_model_overrides(
&session,
turn.as_ref(),
@@ -201,11 +201,12 @@ pub(crate) fn parse_collab_input(
/// skipping this helper and cloning stale config state directly can send the child agent out with
/// the wrong provider or runtime policy.
pub(crate) fn build_agent_spawn_config(
base_instructions: &BaseInstructions,
base_instructions: Option<&BaseInstructions>,
turn: &TurnContext,
) -> Result<Config, FunctionCallError> {
let mut config = build_agent_shared_config(turn)?;
config.base_instructions = Some(base_instructions.text.clone());
config.base_instructions =
Some(base_instructions.map(|base_instructions| base_instructions.text.clone()));
Ok(config)
}
@@ -1,4 +1,5 @@
use super::*;
use crate::CodexThread;
use crate::ThreadManager;
use crate::codex::make_session_and_context;
use crate::config::DEFAULT_AGENT_MAX_DEPTH;
@@ -112,6 +113,74 @@ fn history_contains_inter_agent_communication(
})
}
async fn wait_for_turn_aborted(
thread: &Arc<CodexThread>,
expected_turn_id: &str,
expected_reason: TurnAbortReason,
) {
timeout(Duration::from_secs(5), async {
loop {
let event = thread
.next_event()
.await
.expect("child thread should emit events");
if matches!(
event.msg,
EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some(ref turn_id),
ref reason,
..
}) if turn_id == expected_turn_id && *reason == expected_reason
) {
break;
}
}
})
.await
.expect("expected child turn to be interrupted");
}
async fn wait_for_redirected_envelope_in_history(
thread: &Arc<CodexThread>,
expected: &InterAgentCommunication,
) {
timeout(Duration::from_secs(5), async {
loop {
let history_items = thread
.codex
.session
.clone_history()
.await
.raw_items()
.to_vec();
let saw_envelope =
history_contains_inter_agent_communication(&history_items, expected);
let saw_user_message = history_items.iter().any(|item| {
matches!(
item,
ResponseItem::Message { role, content, .. }
if role == "user"
&& content.iter().any(|content_item| matches!(
content_item,
ContentItem::InputText { text }
if text == &expected.content
))
)
});
if saw_envelope {
assert!(
!saw_user_message,
"redirected followup should be stored as an assistant envelope, not a plain user message"
);
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("redirected followup envelope should appear in history");
}
#[derive(Clone, Copy)]
struct NeverEndingTask;
@@ -757,7 +826,7 @@ async fn multi_agent_v2_followup_task_rejects_root_target_from_child() {
agent_role: None,
});
let err = FollowupTaskHandlerV2
let Err(err) = FollowupTaskHandlerV2
.handle(invocation(
Arc::new(session),
Arc::new(turn),
@@ -769,7 +838,9 @@ async fn multi_agent_v2_followup_task_rejects_root_target_from_child() {
})),
))
.await
.expect_err("followup_task should reject the root target");
else {
panic!("followup_task should reject the root target");
};
assert_eq!(
err,
@@ -837,6 +908,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: child_turn.sub_id.clone(),
last_agent_message: Some("done".to_string()),
completed_at: None,
duration_ms: None,
}),
)
.await;
@@ -1199,6 +1272,7 @@ async fn multi_agent_v2_followup_task_interrupts_busy_child_without_losing_messa
.expect("worker thread should exist");
let active_turn = thread.codex.session.new_default_turn().await;
let interrupted_turn_id = active_turn.sub_id.clone();
thread
.codex
.session
@@ -1243,44 +1317,18 @@ async fn multi_agent_v2_followup_task_interrupts_busy_child_without_losing_messa
)
}));
timeout(Duration::from_secs(5), async {
loop {
let history_items = thread
.codex
.session
.clone_history()
.await
.raw_items()
.to_vec();
let saw_envelope = history_contains_inter_agent_communication(
&history_items,
&InterAgentCommunication::new(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
Vec::new(),
"continue".to_string(),
/*trigger_turn*/ true,
),
);
let saw_user_message = history_items.iter().any(|item| {
matches!(
item,
ResponseItem::Message { role, content, .. }
if role == "user"
&& content.iter().any(|content_item| matches!(
content_item,
ContentItem::InputText { text } if text == "continue"
))
)
});
if saw_envelope && !saw_user_message {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("interrupting v2 followup_task should preserve the redirected message");
wait_for_turn_aborted(&thread, &interrupted_turn_id, TurnAbortReason::Interrupted).await;
wait_for_redirected_envelope_in_history(
&thread,
&InterAgentCommunication::new(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
Vec::new(),
"continue".to_string(),
/*trigger_turn*/ true,
),
)
.await;
let _ = thread
.submit(Op::Shutdown {})
@@ -1337,6 +1385,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn()
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: first_turn.sub_id.clone(),
last_agent_message: Some("first done".to_string()),
completed_at: None,
duration_ms: None,
}),
)
.await;
@@ -1363,6 +1413,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn()
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: second_turn.sub_id.clone(),
last_agent_message: Some("second done".to_string()),
completed_at: None,
duration_ms: None,
}),
)
.await;
@@ -1518,6 +1570,8 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() {
EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some(aborted_turn.sub_id.clone()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
}),
)
.await;
@@ -1546,7 +1600,7 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() {
}
#[tokio::test]
async fn multi_agent_v2_spawn_includes_agent_id_key_when_named() {
async fn multi_agent_v2_spawn_omits_agent_id_when_named() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
@@ -1578,7 +1632,7 @@ async fn multi_agent_v2_spawn_includes_agent_id_key_when_named() {
let result: serde_json::Value =
serde_json::from_str(&content).expect("spawn_agent result should be json");
assert_eq!(result["agent_id"], serde_json::Value::Null);
assert!(result.get("agent_id").is_none());
assert_eq!(result["task_name"], "/root/test_process");
assert!(result.get("nickname").is_some());
assert_eq!(success, Some(true));
@@ -3201,9 +3255,9 @@ async fn build_agent_spawn_config_uses_turn_context_values() {
.set(AskForApproval::OnRequest)
.expect("approval policy set");
let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config");
let config = build_agent_spawn_config(Some(&base_instructions), &turn).expect("spawn config");
let mut expected = (*turn.config).clone();
expected.base_instructions = Some(base_instructions.text);
expected.base_instructions = Some(Some(base_instructions.text));
expected.model = Some(turn.model_info.slug.clone());
expected.model_provider = turn.provider.clone();
expected.model_reasoning_effort = turn.reasoning_effort;
@@ -3239,7 +3293,7 @@ async fn build_agent_spawn_config_preserves_base_user_instructions() {
text: "base".to_string(),
};
let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config");
let config = build_agent_spawn_config(Some(&base_instructions), &turn).expect("spawn config");
assert_eq!(config.user_instructions, base_config.user_instructions);
}
@@ -3248,7 +3302,7 @@ async fn build_agent_spawn_config_preserves_base_user_instructions() {
async fn build_agent_resume_config_clears_base_instructions() {
let (_session, mut turn) = make_session_and_context().await;
let mut base_config = (*turn.config).clone();
base_config.base_instructions = Some("caller-base".to_string());
base_config.base_instructions = Some(Some("caller-base".to_string()));
turn.config = Arc::new(base_config);
turn.approval_policy
.set(AskForApproval::OnRequest)
@@ -1,13 +1,13 @@
use super::message_tool::FollowupTaskArgs;
use super::message_tool::MessageDeliveryMode;
use super::message_tool::MessageToolResult;
use super::message_tool::handle_message_string_tool;
use super::*;
use crate::tools::context::FunctionToolOutput;
pub(crate) struct Handler;
impl ToolHandler for Handler {
type Output = MessageToolResult;
type Output = FunctionToolOutput;
fn kind(&self) -> ToolKind {
ToolKind::Function
@@ -4,6 +4,7 @@
//! resulting `InterAgentCommunication` should wake the target immediately.
use super::*;
use crate::tools::context::FunctionToolOutput;
use codex_protocol::protocol::InterAgentCommunication;
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -46,30 +47,6 @@ pub(crate) struct FollowupTaskArgs {
pub(crate) interrupt: bool,
}
#[derive(Debug, Serialize)]
/// Tool result shared by the MultiAgentV2 message-delivery tools.
pub(crate) struct MessageToolResult {
submission_id: String,
}
impl ToolOutput for MessageToolResult {
fn log_preview(&self) -> String {
tool_output_json_text(self, "multi_agent_message")
}
fn success_for_logging(&self) -> bool {
true
}
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
tool_output_response_item(call_id, payload, self, Some(true), "multi_agent_message")
}
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
tool_output_code_mode_result(self, "multi_agent_message")
}
}
fn message_content(message: String) -> Result<String, FunctionCallError> {
if message.trim().is_empty() {
return Err(FunctionCallError::RespondToModel(
@@ -86,7 +63,7 @@ pub(crate) async fn handle_message_string_tool(
target: String,
message: String,
interrupt: bool,
) -> Result<MessageToolResult, FunctionCallError> {
) -> Result<FunctionToolOutput, FunctionCallError> {
handle_message_submission(
invocation,
mode,
@@ -103,15 +80,13 @@ async fn handle_message_submission(
target: String,
prompt: String,
interrupt: bool,
) -> Result<MessageToolResult, FunctionCallError> {
) -> Result<FunctionToolOutput, FunctionCallError> {
let ToolInvocation {
session,
turn,
payload,
call_id,
..
} = invocation;
let _ = payload;
let receiver_thread_id = resolve_agent_target(&session, &turn, &target).await?;
let receiver_agent = session
.services
@@ -186,7 +161,7 @@ async fn handle_message_submission(
.into(),
)
.await;
let submission_id = result?;
result?;
Ok(MessageToolResult { submission_id })
Ok(FunctionToolOutput::from_text(String::new(), Some(true)))
}
@@ -1,13 +1,13 @@
use super::message_tool::MessageDeliveryMode;
use super::message_tool::MessageToolResult;
use super::message_tool::SendMessageArgs;
use super::message_tool::handle_message_string_tool;
use super::*;
use crate::tools::context::FunctionToolOutput;
pub(crate) struct Handler;
impl ToolHandler for Handler {
type Output = MessageToolResult;
type Output = FunctionToolOutput;
fn kind(&self) -> ToolKind {
ToolKind::Function
@@ -5,6 +5,7 @@ use crate::agent::control::render_input_preview;
use crate::agent::next_thread_spawn_depth;
use crate::agent::role::DEFAULT_ROLE_NAME;
use crate::agent::role::apply_role_to_config;
use codex_features::Feature;
use codex_protocol::AgentPath;
use codex_protocol::models::DeveloperInstructions;
use codex_protocol::protocol::InterAgentCommunication;
@@ -68,8 +69,10 @@ impl ToolHandler for Handler {
.into(),
)
.await;
let mut config =
build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?;
let mut config = build_agent_spawn_config(
session.get_base_instructions().await.as_ref(),
turn.as_ref(),
)?;
apply_requested_spawn_agent_model_overrides(
&session,
turn.as_ref(),
@@ -206,11 +209,18 @@ impl ToolHandler for Handler {
)
})?;
Ok(SpawnAgentResult {
agent_id: None,
task_name,
nickname,
})
let hide_agent_metadata = turn
.config
.features
.enabled(Feature::DebugHideSpawnAgentMetadata);
if hide_agent_metadata {
Ok(SpawnAgentResult::HiddenMetadata { task_name })
} else {
Ok(SpawnAgentResult::WithNickname {
task_name,
nickname,
})
}
}
}
@@ -266,10 +276,15 @@ impl SpawnAgentArgs {
}
#[derive(Debug, Serialize)]
pub(crate) struct SpawnAgentResult {
agent_id: Option<String>,
task_name: String,
nickname: Option<String>,
#[serde(untagged)]
pub(crate) enum SpawnAgentResult {
WithNickname {
task_name: String,
nickname: Option<String>,
},
HiddenMetadata {
task_name: String,
},
}
impl ToolOutput for SpawnAgentResult {
@@ -5,6 +5,7 @@ use crate::tools::context::ToolPayload;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_protocol::protocol::SessionSource;
use codex_protocol::request_user_input::RequestUserInputArgs;
use codex_tools::REQUEST_USER_INPUT_TOOL_NAME;
use codex_tools::normalize_request_user_input_args;
@@ -39,6 +40,12 @@ impl ToolHandler for RequestUserInputHandler {
}
};
if matches!(turn.session_source, SessionSource::SubAgent(_)) {
return Err(FunctionCallError::RespondToModel(
"request_user_input can only be used by the root thread".to_string(),
));
}
let mode = session.collaboration_mode().await.mode;
if let Some(message) =
request_user_input_unavailable_message(mode, self.default_mode_request_user_input)
@@ -67,3 +74,7 @@ impl ToolHandler for RequestUserInputHandler {
Ok(FunctionToolOutput::from_text(content, Some(true)))
}
}
#[cfg(test)]
#[path = "request_user_input_tests.rs"]
mod tests;
@@ -0,0 +1,66 @@
use super::*;
use crate::codex::make_session_and_context;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SubAgentSource;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::sync::Arc;
use tokio::sync::Mutex;
#[tokio::test]
async fn multi_agent_v2_request_user_input_rejects_subagent_threads() {
let (session, mut turn) = make_session_and_context().await;
turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: ThreadId::new(),
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
});
let result = RequestUserInputHandler {
default_mode_request_user_input: true,
}
.handle(ToolInvocation {
session: Arc::new(session),
turn: Arc::new(turn),
tracker: Arc::new(Mutex::new(TurnDiffTracker::default())),
call_id: "call-1".to_string(),
tool_name: REQUEST_USER_INPUT_TOOL_NAME.to_string(),
tool_namespace: None,
payload: ToolPayload::Function {
arguments: json!({
"questions": [{
"header": "Hdr",
"question": "Pick one",
"id": "pick_one",
"options": [
{
"label": "A",
"description": "A"
},
{
"label": "B",
"description": "B"
}
]
}]
})
.to_string(),
},
})
.await;
let Err(err) = result else {
panic!("sub-agent request_user_input should fail");
};
assert_eq!(
err,
FunctionCallError::RespondToModel(
"request_user_input can only be used by the root thread".to_string(),
)
);
}
+16 -1
View File
@@ -39,6 +39,7 @@ use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::ExecCommandSource;
use codex_shell_command::is_safe_command::is_known_safe_command;
use codex_tools::ShellCommandBackendConfig;
use codex_utils_absolute_path::AbsolutePathBuf;
pub struct ShellHandler;
@@ -395,6 +396,13 @@ impl ShellHandler {
} = args;
let mut exec_params = exec_params;
let Some(environment) = turn.environment.as_ref() else {
return Err(FunctionCallError::RespondToModel(
"shell is unavailable in this session".to_string(),
));
};
let fs = environment.get_filesystem();
let dependency_env = session.dependency_env().await;
if !dependency_env.is_empty() {
exec_params.env.extend(dependency_env.clone());
@@ -458,9 +466,16 @@ impl ShellHandler {
}
// Intercept apply_patch if present.
let apply_patch_cwd =
AbsolutePathBuf::from_absolute_path(&exec_params.cwd).map_err(|err| {
FunctionCallError::RespondToModel(format!(
"apply_patch verification failed: failed to resolve cwd: {err}"
))
})?;
if let Some(output) = intercept_apply_patch(
&exec_params.command,
&exec_params.cwd,
&apply_patch_cwd,
fs.as_ref(),
exec_params.expiration.timeout_ms(),
session.clone(),
turn.clone(),
@@ -7,7 +7,7 @@ use crate::tools::registry::ToolKind;
use bm25::Document;
use bm25::Language;
use bm25::SearchEngineBuilder;
use codex_mcp::mcp_connection_manager::ToolInfo;
use codex_mcp::ToolInfo;
use codex_tools::TOOL_SEARCH_DEFAULT_LIMIT;
use codex_tools::TOOL_SEARCH_TOOL_NAME;
use codex_tools::ToolSearchResultSource;

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