mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
Merge origin/main into exec-server-sandbox-0403
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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))?,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,2 +1 @@
|
||||
pub use codex_instructions::USER_INSTRUCTIONS_PREFIX;
|
||||
pub(crate) use codex_instructions::UserInstructions;
|
||||
|
||||
+54
-29
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()),
|
||||
};
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
},
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>>,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
}));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,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(),
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user