diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d4eaf1dd2e..39f16431a8 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2886,6 +2886,7 @@ dependencies = [ "codex-app-server-protocol", "codex-client", "codex-file-system", + "codex-network-proxy", "codex-protocol", "codex-sandboxing", "codex-shell-command", diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 091a85e11f..38d9539615 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -177,6 +177,7 @@ fn sample_thread_with_metadata( ) -> Thread { Thread { id: thread_id.to_string(), + extra: None, session_id: format!("session-{thread_id}"), forked_from_id: None, parent_thread_id, @@ -219,7 +220,7 @@ fn sample_thread_start_response( cwd: test_path_buf("/tmp").abs(), runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), - approval_policy: AppServerAskForApproval::OnFailure, + approval_policy: AppServerAskForApproval::OnRequest, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, active_permission_profile: None, @@ -284,7 +285,7 @@ fn sample_thread_resume_response_with_source( cwd: test_path_buf("/tmp").abs(), runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), - approval_policy: AppServerAskForApproval::OnFailure, + approval_policy: AppServerAskForApproval::OnRequest, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, active_permission_profile: None, @@ -3026,6 +3027,7 @@ fn plugin_used_event_serializes_expected_shape() { "event_type": "codex_plugin_used", "event_params": { "plugin_id": "sample@test", + "remote_plugin_id": null, "plugin_name": "sample", "marketplace_name": "test", "has_skills": true, @@ -3056,6 +3058,7 @@ fn plugin_management_event_serializes_expected_shape() { "event_type": "codex_plugin_installed", "event_params": { "plugin_id": "sample@test", + "remote_plugin_id": null, "plugin_name": "sample", "marketplace_name": "test", "has_skills": true, @@ -3085,6 +3088,7 @@ fn plugin_install_failed_event_serializes_expected_shape() { "event_type": "codex_plugin_install_failed", "event_params": { "plugin_id": "sample@test", + "remote_plugin_id": null, "plugin_name": "sample", "marketplace_name": "test", "has_skills": true, @@ -3098,7 +3102,7 @@ fn plugin_install_failed_event_serializes_expected_shape() { } #[test] -fn plugin_management_event_can_use_remote_plugin_id_override() { +fn plugin_management_event_keeps_plugin_id_local_when_remote_id_exists() { let mut plugin = sample_plugin_metadata(); plugin.remote_plugin_id = Some("plugins~Plugin_remote".to_string()); let event = TrackEventRequest::PluginInstalled(CodexPluginEventRequest { @@ -3109,11 +3113,21 @@ fn plugin_management_event_can_use_remote_plugin_id_override() { let payload = serde_json::to_value(&event).expect("serialize plugin installed event"); assert_eq!( - payload["event_params"]["plugin_id"], - "plugins~Plugin_remote" + payload, + json!({ + "event_type": "codex_plugin_installed", + "event_params": { + "plugin_id": "sample@test", + "remote_plugin_id": "plugins~Plugin_remote", + "plugin_name": "sample", + "marketplace_name": "test", + "has_skills": true, + "mcp_server_count": 2, + "connector_ids": ["calendar", "drive"], + "product_client_id": originator().value + } + }) ); - assert_eq!(payload["event_params"]["plugin_name"], "sample"); - assert_eq!(payload["event_params"]["marketplace_name"], "test"); } #[test] @@ -3453,6 +3467,7 @@ async fn reducer_ingests_plugin_state_changed_fact() { "event_type": "codex_plugin_disabled", "event_params": { "plugin_id": "sample@test", + "remote_plugin_id": null, "plugin_name": "sample", "marketplace_name": "test", "has_skills": true, @@ -3488,6 +3503,7 @@ async fn reducer_ingests_plugin_install_failed_fact() { "event_type": "codex_plugin_install_failed", "event_params": { "plugin_id": "sample@test", + "remote_plugin_id": null, "plugin_name": "sample", "marketplace_name": "test", "has_skills": true, @@ -3505,7 +3521,7 @@ async fn reducer_ingests_plugin_install_failed_fact_without_detail() { let mut reducer = AnalyticsReducer::default(); let mut events = Vec::new(); let plugin = PluginTelemetryMetadata { - plugin_id: PluginId::parse("unknown@openai-curated-remote").expect("valid plugin id"), + plugin_id: None, remote_plugin_id: Some("plugins~Plugin_00000000000000000000000000000000".to_string()), capability_summary: None, }; @@ -3528,9 +3544,10 @@ async fn reducer_ingests_plugin_install_failed_fact_without_detail() { json!([{ "event_type": "codex_plugin_install_failed", "event_params": { - "plugin_id": "plugins~Plugin_00000000000000000000000000000000", - "plugin_name": "unknown", - "marketplace_name": "openai-curated-remote", + "plugin_id": null, + "remote_plugin_id": "plugins~Plugin_00000000000000000000000000000000", + "plugin_name": null, + "marketplace_name": null, "has_skills": null, "mcp_server_count": null, "connector_ids": null, @@ -4569,7 +4586,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { - plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), + plugin_id: Some(PluginId::parse("sample@test").expect("valid plugin id")), remote_plugin_id: None, capability_summary: Some(PluginCapabilitySummary { config_name: "sample@test".to_string(), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 24ff523c9c..314f0028ad 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -38,6 +38,7 @@ use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::default_client::create_client; +use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::request_permissions::RequestPermissionsResponse; use std::collections::HashSet; @@ -173,7 +174,15 @@ impl AnalyticsEventsQueue { if emitted.len() >= ANALYTICS_EVENT_DEDUPE_MAX_KEYS { emitted.clear(); } - emitted.insert((tracking.turn_id.clone(), plugin.plugin_id.as_key())) + let Some(plugin_id) = plugin + .plugin_id + .as_ref() + .map(PluginId::as_key) + .or_else(|| plugin.remote_plugin_id.clone()) + else { + return true; + }; + emitted.insert((tracking.turn_id.clone(), plugin_id)) } } diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index a14fe7bf2d..bbc5b5f0b6 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -277,6 +277,7 @@ fn sample_thread_archive_request() -> ClientRequest { fn sample_thread(thread_id: &str) -> Thread { Thread { id: thread_id.to_string(), + extra: None, session_id: format!("session-{thread_id}"), forked_from_id: None, parent_thread_id: None, @@ -309,7 +310,7 @@ fn sample_thread_start_response() -> ClientResponsePayload { cwd: test_path_buf("/tmp").abs(), runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), - approval_policy: AppServerAskForApproval::OnFailure, + approval_policy: AppServerAskForApproval::OnRequest, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, active_permission_profile: None, @@ -327,7 +328,7 @@ fn sample_thread_resume_response() -> ClientResponsePayload { cwd: test_path_buf("/tmp").abs(), runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), - approval_policy: AppServerAskForApproval::OnFailure, + approval_policy: AppServerAskForApproval::OnRequest, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, active_permission_profile: None, @@ -346,7 +347,7 @@ fn sample_thread_fork_response() -> ClientResponsePayload { cwd: test_path_buf("/tmp").abs(), runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), - approval_policy: AppServerAskForApproval::OnFailure, + approval_policy: AppServerAskForApproval::OnRequest, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, active_permission_profile: None, diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 29d896156b..2fe465c9bd 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -26,6 +26,7 @@ use crate::now_unix_millis; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CommandExecutionSource; use codex_login::default_client::originator; +use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::models::AdditionalPermissionProfile; @@ -933,6 +934,7 @@ pub(crate) struct CodexTurnSteerEventRequest { #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, + pub(crate) remote_plugin_id: Option, pub(crate) plugin_name: Option, pub(crate) marketplace_name: Option, pub(crate) has_skills: Option, @@ -1040,11 +1042,13 @@ pub(crate) fn codex_plugin_metadata(plugin: PluginTelemetryMetadata) -> CodexPlu remote_plugin_id, capability_summary, } = plugin; - let event_plugin_id = remote_plugin_id.unwrap_or_else(|| plugin_id.as_key()); CodexPluginMetadata { - plugin_id: Some(event_plugin_id), - plugin_name: Some(plugin_id.plugin_name), - marketplace_name: Some(plugin_id.marketplace_name), + plugin_id: plugin_id.as_ref().map(PluginId::as_key), + remote_plugin_id, + plugin_name: plugin_id + .as_ref() + .map(|plugin_id| plugin_id.plugin_name.clone()), + marketplace_name: plugin_id.map(|plugin_id| plugin_id.marketplace_name), has_skills: capability_summary .as_ref() .map(|summary| summary.has_skills), diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 28597eff62..490161486a 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -125,7 +125,6 @@ { "enum": [ "untrusted", - "on-failure", "on-request", "never" ], diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b79e3cb13f..ea6076fa89 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -450,7 +450,6 @@ { "enum": [ "untrusted", - "on-failure", "on-request", "never" ], @@ -3626,6 +3625,7 @@ "description": "Optional Git metadata captured when the thread was created." }, "id": { + "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, "modelProvider": { @@ -3765,6 +3765,10 @@ ], "type": "object" }, + "ThreadExtra": { + "description": "Extra app-server data for a thread.", + "type": "object" + }, "ThreadGoal": { "properties": { "createdAt": { @@ -4440,7 +4444,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -5111,6 +5115,7 @@ "description": "Only populated when the Turn's status is failed." }, "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", "type": "string" }, "items": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 597fd5a1b8..dc5bc6f24c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -6864,7 +6864,6 @@ { "enum": [ "untrusted", - "on-failure", "on-request", "never" ], @@ -16831,6 +16830,7 @@ "description": "Optional Git metadata captured when the thread was created." }, "id": { + "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, "modelProvider": { @@ -17052,6 +17052,10 @@ "title": "ThreadDeletedNotification", "type": "object" }, + "ThreadExtra": { + "description": "Extra app-server data for a thread.", + "type": "object" + }, "ThreadForkParams": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using a non-empty path, the thread_id param will be ignored. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", @@ -18034,7 +18038,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "$ref": "#/definitions/v2/LegacyAppPathString" }, "type": { "enum": [ @@ -19699,6 +19703,7 @@ "description": "Only populated when the Turn's status is failed." }, "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", "type": "string" }, "items": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 3af48cc6c0..40a5110a73 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -990,7 +990,6 @@ { "enum": [ "untrusted", - "on-failure", "on-request", "never" ], @@ -14610,6 +14609,7 @@ "description": "Optional Git metadata captured when the thread was created." }, "id": { + "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, "modelProvider": { @@ -14831,6 +14831,10 @@ "title": "ThreadDeletedNotification", "type": "object" }, + "ThreadExtra": { + "description": "Extra app-server data for a thread.", + "type": "object" + }, "ThreadForkParams": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using a non-empty path, the thread_id param will be ignored. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", @@ -15813,7 +15817,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -17478,6 +17482,7 @@ "description": "Only populated when the Turn's status is failed." }, "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", "type": "string" }, "items": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index ef2411534d..16f8363416 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -173,7 +173,6 @@ { "enum": [ "untrusted", - "on-failure", "on-request", "never" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index edbb7f0bcd..3fe6a89787 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -15,7 +15,6 @@ { "enum": [ "untrusted", - "on-failure", "on-request", "never" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index d7bfb3827e..8f4b533d8f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -1106,7 +1106,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index 770fc8bd4c..4c930afde1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -1106,7 +1106,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 545ae64d4a..d87dd74401 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1250,7 +1250,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -1438,6 +1438,7 @@ "description": "Only populated when the Turn's status is failed." }, "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", "type": "string" }, "items": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index e72522a26a..ad0174cfe0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -19,7 +19,6 @@ { "enum": [ "untrusted", - "on-failure", "on-request", "never" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index c19251667a..e735aaa89b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -42,7 +42,6 @@ { "enum": [ "untrusted", - "on-failure", "on-request", "never" ], @@ -1059,6 +1058,7 @@ "description": "Optional Git metadata captured when the thread was created." }, "id": { + "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, "modelProvider": { @@ -1165,6 +1165,10 @@ ], "type": "string" }, + "ThreadExtra": { + "description": "Extra app-server data for a thread.", + "type": "object" + }, "ThreadId": { "type": "string" }, @@ -1751,7 +1755,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -2017,6 +2021,7 @@ "description": "Only populated when the Turn's status is failed." }, "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", "type": "string" }, "items": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 9925c5411e..a428f109db 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -865,6 +865,7 @@ "description": "Optional Git metadata captured when the thread was created." }, "id": { + "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, "modelProvider": { @@ -971,6 +972,10 @@ ], "type": "string" }, + "ThreadExtra": { + "description": "Extra app-server data for a thread.", + "type": "object" + }, "ThreadId": { "type": "string" }, @@ -1557,7 +1562,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -1823,6 +1828,7 @@ "description": "Only populated when the Turn's status is failed." }, "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", "type": "string" }, "items": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 1e142947ed..2731ab409a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -865,6 +865,7 @@ "description": "Optional Git metadata captured when the thread was created." }, "id": { + "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, "modelProvider": { @@ -971,6 +972,10 @@ ], "type": "string" }, + "ThreadExtra": { + "description": "Extra app-server data for a thread.", + "type": "object" + }, "ThreadId": { "type": "string" }, @@ -1557,7 +1562,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -1823,6 +1828,7 @@ "description": "Only populated when the Turn's status is failed." }, "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", "type": "string" }, "items": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 532a712807..c591beea3c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -865,6 +865,7 @@ "description": "Optional Git metadata captured when the thread was created." }, "id": { + "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, "modelProvider": { @@ -971,6 +972,10 @@ ], "type": "string" }, + "ThreadExtra": { + "description": "Extra app-server data for a thread.", + "type": "object" + }, "ThreadId": { "type": "string" }, @@ -1557,7 +1562,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -1823,6 +1828,7 @@ "description": "Only populated when the Turn's status is failed." }, "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", "type": "string" }, "items": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index bc9cb524d6..af078af68e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -63,7 +63,6 @@ { "enum": [ "untrusted", - "on-failure", "on-request", "never" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 1a3b22f12c..120f972e51 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -42,7 +42,6 @@ { "enum": [ "untrusted", - "on-failure", "on-request", "never" ], @@ -1059,6 +1058,7 @@ "description": "Optional Git metadata captured when the thread was created." }, "id": { + "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, "modelProvider": { @@ -1165,6 +1165,10 @@ ], "type": "string" }, + "ThreadExtra": { + "description": "Extra app-server data for a thread.", + "type": "object" + }, "ThreadId": { "type": "string" }, @@ -1751,7 +1755,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -2017,6 +2021,7 @@ "description": "Only populated when the Turn's status is failed." }, "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", "type": "string" }, "items": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 9f394b31cf..409a1ca5f5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -865,6 +865,7 @@ "description": "Optional Git metadata captured when the thread was created." }, "id": { + "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, "modelProvider": { @@ -971,6 +972,10 @@ ], "type": "string" }, + "ThreadExtra": { + "description": "Extra app-server data for a thread.", + "type": "object" + }, "ThreadId": { "type": "string" }, @@ -1557,7 +1562,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -1823,6 +1828,7 @@ "description": "Only populated when the Turn's status is failed." }, "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", "type": "string" }, "items": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json index ca2b63634a..8d528246b5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json @@ -39,7 +39,6 @@ { "enum": [ "untrusted", - "on-failure", "on-request", "never" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index 9932456f1b..70a8772751 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -19,7 +19,6 @@ { "enum": [ "untrusted", - "on-failure", "on-request", "never" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index a754c6bc0f..63efc4e3d6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -42,7 +42,6 @@ { "enum": [ "untrusted", - "on-failure", "on-request", "never" ], @@ -1059,6 +1058,7 @@ "description": "Optional Git metadata captured when the thread was created." }, "id": { + "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, "modelProvider": { @@ -1165,6 +1165,10 @@ ], "type": "string" }, + "ThreadExtra": { + "description": "Extra app-server data for a thread.", + "type": "object" + }, "ThreadId": { "type": "string" }, @@ -1751,7 +1755,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -2017,6 +2021,7 @@ "description": "Only populated when the Turn's status is failed." }, "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", "type": "string" }, "items": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 22801809cd..152d83a150 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -865,6 +865,7 @@ "description": "Optional Git metadata captured when the thread was created." }, "id": { + "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, "modelProvider": { @@ -971,6 +972,10 @@ ], "type": "string" }, + "ThreadExtra": { + "description": "Extra app-server data for a thread.", + "type": "object" + }, "ThreadId": { "type": "string" }, @@ -1557,7 +1562,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -1823,6 +1828,7 @@ "description": "Only populated when the Turn's status is failed." }, "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", "type": "string" }, "items": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 3009e86e70..291b88a923 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -865,6 +865,7 @@ "description": "Optional Git metadata captured when the thread was created." }, "id": { + "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, "modelProvider": { @@ -971,6 +972,10 @@ ], "type": "string" }, + "ThreadExtra": { + "description": "Extra app-server data for a thread.", + "type": "object" + }, "ThreadId": { "type": "string" }, @@ -1557,7 +1562,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -1823,6 +1828,7 @@ "description": "Only populated when the Turn's status is failed." }, "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", "type": "string" }, "items": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index b9a940157c..566922e80c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1250,7 +1250,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -1438,6 +1438,7 @@ "description": "Only populated when the Turn's status is failed." }, "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", "type": "string" }, "items": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index bec8b7d89a..36b4d2a15d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -41,7 +41,6 @@ { "enum": [ "untrusted", - "on-failure", "on-request", "never" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index da10713dd1..627aea2126 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1250,7 +1250,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -1438,6 +1438,7 @@ "description": "Only populated when the Turn's status is failed." }, "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", "type": "string" }, "items": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 6b93498221..cacfb01564 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1250,7 +1250,7 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": { "enum": [ @@ -1438,6 +1438,7 @@ "description": "Only populated when the Turn's status is failed." }, "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", "type": "string" }, "items": { diff --git a/codex-rs/app-server-protocol/schema/typescript/ThreadId.ts b/codex-rs/app-server-protocol/schema/typescript/ThreadId.ts index bfb3b4b4d7..801ffb35e4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ThreadId.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ThreadId.ts @@ -2,4 +2,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +/** + * Identifier for a Codex thread. + * + * Codex-generated thread IDs are UUIDv7, and some use cases rely on that. + */ export type ThreadId = string; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AskForApproval.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AskForApproval.ts index 8d41214e01..1d605501b2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AskForApproval.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AskForApproval.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type AskForApproval = "untrusted" | "on-failure" | "on-request" | { "granular": { sandbox_approval: boolean, rules: boolean, skill_approval: boolean, request_permissions: boolean, mcp_elicitations: boolean, } } | "never"; +export type AskForApproval = "untrusted" | "on-request" | { "granular": { sandbox_approval: boolean, rules: boolean, skill_approval: boolean, request_permissions: boolean, mcp_elicitations: boolean, } } | "never"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts index 1c288ccbd4..49e2411f42 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -8,87 +8,70 @@ import type { ThreadSource } from "./ThreadSource"; import type { ThreadStatus } from "./ThreadStatus"; import type { Turn } from "./Turn"; -export type Thread = { id: string, -/** +export type Thread = {/** + * Identifier for this thread. Codex-generated thread IDs are UUIDv7. + */ +id: string, /** * Session id shared by threads that belong to the same session tree. */ -sessionId: string, -/** +sessionId: string, /** * Source thread id when this thread was created by forking another thread. */ -forkedFromId: string | null, -/** +forkedFromId: string | null, /** * The ID of the parent thread. This will only be set if this thread is a subagent. */ -parentThreadId: string | null, -/** +parentThreadId: string | null, /** * Usually the first user message in the thread, if available. */ -preview: string, -/** +preview: string, /** * Whether the thread is ephemeral and should not be materialized on disk. */ -ephemeral: boolean, -/** +ephemeral: boolean, /** * Model provider used for this thread (for example, 'openai'). */ -modelProvider: string, -/** +modelProvider: string, /** * Unix timestamp (in seconds) when the thread was created. */ -createdAt: number, -/** +createdAt: number, /** * Unix timestamp (in seconds) when the thread was last updated. */ -updatedAt: number, -/** +updatedAt: number, /** * Unix timestamp (in seconds) used for thread recency ordering. */ -recencyAt: number | null, -/** +recencyAt: number | null, /** * Current runtime status for the thread. */ -status: ThreadStatus, -/** +status: ThreadStatus, /** * [UNSTABLE] Path to the thread on disk. */ -path: string | null, -/** +path: string | null, /** * Working directory captured for the thread. */ -cwd: AbsolutePathBuf, -/** +cwd: AbsolutePathBuf, /** * Version of the CLI that created the thread. */ -cliVersion: string, -/** +cliVersion: string, /** * Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). */ -source: SessionSource, -/** +source: SessionSource, /** * Optional analytics source classification for this thread. */ -threadSource: ThreadSource | null, -/** +threadSource: ThreadSource | null, /** * Optional random unique nickname assigned to an AgentControl-spawned sub-agent. */ -agentNickname: string | null, -/** +agentNickname: string | null, /** * Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. */ -agentRole: string | null, -/** +agentRole: string | null, /** * Optional Git metadata captured when the thread was created. */ -gitInfo: GitInfo | null, -/** +gitInfo: GitInfo | null, /** * Optional user-facing thread title. */ -name: string | null, -/** +name: string | null, /** * Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` * (when `includeTurns` is true) responses. * For all other responses and notifications returning a Thread, * the turns field will be an empty list. */ -turns: Array, }; +turns: Array}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadExtra.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadExtra.ts new file mode 100644 index 0000000000..aa35e45f07 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadExtra.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Extra app-server data for a thread. + */ +export type ThreadExtra = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index eb8c6486b8..c611ab071c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -105,4 +105,4 @@ reasoningEffort: ReasoningEffort | null, /** * Last known status of the target agents, when available. */ -agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "subAgentActivity", id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch", id: string, query: string, action: WebSearchAction | null, } | { "type": "imageView", id: string, path: AbsolutePathBuf, } | { "type": "sleep", id: string, durationMs: number, } | { "type": "imageGeneration", id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: AbsolutePathBuf, } | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, }; +agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "subAgentActivity", id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch", id: string, query: string, action: WebSearchAction | null, } | { "type": "imageView", id: string, path: LegacyAppPathString, } | { "type": "sleep", id: string, durationMs: number, } | { "type": "imageGeneration", id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: AbsolutePathBuf, } | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 6505ec345f..b8680256d1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -6,7 +6,11 @@ import type { TurnError } from "./TurnError"; import type { TurnItemsView } from "./TurnItemsView"; import type { TurnStatus } from "./TurnStatus"; -export type Turn = { id: string, +export type Turn = { +/** + * Identifier for this turn. Codex-generated turn IDs are UUIDv7. + */ +id: string, /** * Thread items currently included in this turn payload. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 3e769d3fab..57c08391c7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -395,6 +395,7 @@ export type { ThreadCompactStartResponse } from "./ThreadCompactStartResponse"; export type { ThreadDeleteParams } from "./ThreadDeleteParams"; export type { ThreadDeleteResponse } from "./ThreadDeleteResponse"; export type { ThreadDeletedNotification } from "./ThreadDeletedNotification"; +export type { ThreadExtra } from "./ThreadExtra"; export type { ThreadForkParams } from "./ThreadForkParams"; export type { ThreadForkResponse } from "./ThreadForkResponse"; export type { ThreadGoal } from "./ThreadGoal"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index ba63f59e9a..7fe20a84d5 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2573,6 +2573,7 @@ mod tests { response: v2::ThreadStartResponse { thread: v2::Thread { id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), + extra: None, session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(), forked_from_id: None, parent_thread_id: None, @@ -2604,7 +2605,7 @@ mod tests { "/tmp/AGENTS.md", )), ], - approval_policy: v2::AskForApproval::OnFailure, + approval_policy: v2::AskForApproval::OnRequest, approvals_reviewer: v2::ApprovalsReviewer::User, sandbox: v2::SandboxPolicy::DangerFullAccess, active_permission_profile: None, @@ -2622,6 +2623,7 @@ mod tests { "response": { "thread": { "id": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "extra": null, "sessionId": "67e55044-10b1-426f-9247-bb680e5fe0c7", "forkedFromId": null, "parentThreadId": null, @@ -2651,7 +2653,7 @@ mod tests { "cwd": absolute_path_string("tmp"), "runtimeWorkspaceRoots": [], "instructionSources": [absolute_path_string("tmp/AGENTS.md")], - "approvalPolicy": "on-failure", + "approvalPolicy": "on-request", "approvalsReviewer": "user", "sandbox": { "type": "dangerFullAccess" diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 1e087c4c5f..f9ebc93bf2 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -836,7 +836,7 @@ impl ThreadHistoryBuilder { fn handle_view_image_tool_call(&mut self, payload: &ViewImageToolCallEvent) { let item = ThreadItem::ImageView { id: payload.call_id.clone(), - path: payload.path.clone(), + path: payload.path.clone().into(), }; self.upsert_item_in_current_turn(item); } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index e630de634b..e2a0d45129 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -356,7 +356,10 @@ pub enum ThreadItem { }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] - ImageView { id: String, path: AbsolutePathBuf }, + ImageView { + id: String, + path: LegacyAppPathString, + }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] Sleep { @@ -855,7 +858,7 @@ impl From for ThreadItem { }, CoreTurnItem::ImageView(image) => ThreadItem::ImageView { id: image.id, - path: image.path, + path: image.path.into(), }, CoreTurnItem::Sleep(sleep) => ThreadItem::Sleep { id: sleep.id, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/shared.rs b/codex-rs/app-server-protocol/src/protocol/v2/shared.rs index cb881d6267..94e650d923 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/shared.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/shared.rs @@ -163,7 +163,6 @@ pub enum AskForApproval { #[serde(rename = "untrusted")] #[ts(rename = "untrusted")] UnlessTrusted, - OnFailure, OnRequest, #[experimental("askForApproval.granular")] Granular { @@ -182,7 +181,6 @@ impl AskForApproval { pub fn to_core(self) -> CoreAskForApproval { match self { AskForApproval::UnlessTrusted => CoreAskForApproval::UnlessTrusted, - AskForApproval::OnFailure => CoreAskForApproval::OnFailure, AskForApproval::OnRequest => CoreAskForApproval::OnRequest, AskForApproval::Granular { sandbox_approval, @@ -206,7 +204,6 @@ impl From for AskForApproval { fn from(value: CoreAskForApproval) -> Self { match value { CoreAskForApproval::UnlessTrusted => AskForApproval::UnlessTrusted, - CoreAskForApproval::OnFailure => AskForApproval::OnFailure, CoreAskForApproval::OnRequest => AskForApproval::OnRequest, CoreAskForApproval::Granular(granular_config) => AskForApproval::Granular { sandbox_approval: granular_config.sandbox_approval, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index ba69b6d6e2..85a95a2723 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -168,6 +168,7 @@ fn thread_resume_response_round_trips_initial_turns_page() { let response = ThreadResumeResponse { thread: Thread { id: "thr_123".to_string(), + extra: None, session_id: "thr_123".to_string(), forked_from_id: None, parent_thread_id: None, @@ -195,7 +196,7 @@ fn thread_resume_response_round_trips_initial_turns_page() { cwd: absolute_path("tmp"), runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), - approval_policy: AskForApproval::OnFailure, + approval_policy: AskForApproval::OnRequest, approvals_reviewer: ApprovalsReviewer::User, sandbox: SandboxPolicy::DangerFullAccess, active_permission_profile: None, @@ -2573,14 +2574,14 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { let image_view_item = TurnItem::ImageView(ImageViewItem { id: "view-image-1".to_string(), - path: test_path_buf("/tmp/view-image.png").abs(), + path: PathUri::from_abs_path(&test_path_buf("/tmp/view-image.png").abs()), }); assert_eq!( ThreadItem::from(image_view_item), ThreadItem::ImageView { id: "view-image-1".to_string(), - path: test_path_buf("/tmp/view-image.png").abs(), + path: LegacyAppPathString::from_abs_path(&test_path_buf("/tmp/view-image.png").abs()), } ); @@ -3689,7 +3690,7 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { "modelProvider": "openai", "serviceTier": null, "cwd": absolute_path_string("tmp"), - "approvalPolicy": "on-failure", + "approvalPolicy": "on-request", "approvalsReviewer": "user", "sandbox": { "type": "dangerFullAccess" }, "reasoningEffort": null diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs index 52a8d7017a..3338513df7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -2,6 +2,7 @@ use super::CodexErrorInfo; use super::ThreadItem; use super::ThreadStatus; use super::TurnStatus; +use codex_experimental_api_macros::ExperimentalApi; use codex_protocol::protocol::SessionSource as CoreSessionSource; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; use codex_protocol::protocol::ThreadSource as CoreThreadSource; @@ -120,6 +121,12 @@ impl From for CoreThreadSource { } } +/// Extra app-server data for a thread. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub struct ThreadExtra {} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -129,11 +136,15 @@ pub struct GitInfo { pub origin_url: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct Thread { + /// Identifier for this thread. Codex-generated thread IDs are UUIDv7. pub id: String, + /// Optional implementation-specific thread data. + #[experimental("thread.extra")] + pub extra: Option, /// Session id shared by threads that belong to the same session tree. pub session_id: String, /// Source thread id when this thread was created by forking another thread. @@ -186,6 +197,7 @@ pub struct Thread { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct Turn { + /// Identifier for this turn. Codex-generated turn IDs are UUIDv7. pub id: String, /// Thread items currently included in this turn payload. pub items: Vec, diff --git a/codex-rs/app-server-test-client/README.md b/codex-rs/app-server-test-client/README.md index 44687672a0..b1afc77872 100644 --- a/codex-rs/app-server-test-client/README.md +++ b/codex-rs/app-server-test-client/README.md @@ -46,12 +46,13 @@ cargo run -p codex-app-server-test-client -- \ Use `--capture-file /tmp/plugin-analytics.jsonl` to select the output path. The command validates one `codex_plugin_disabled`, `codex_plugin_enabled`, and -`codex_plugin_used` event with the expected local plugin identity and capability -metadata. The enabled and disabled events come from successful writes to the -temporary config; the command does not mutate the remote enabled state. It -prints the events and leaves the JSONL file in place for inspection. It does not -install or uninstall plugins and does not modify the profile's persistent -config. +`codex_plugin_used` event with the expected local and remote plugin identities +and capability metadata. Each event includes the local ID in `plugin_id` and the +backend ID in `remote_plugin_id`. The enabled and disabled events come from +successful writes to the temporary config; the command does not mutate the +remote enabled state. It prints the events and leaves the JSONL file in place +for inspection. It does not install or uninstall plugins and does not modify +the profile's persistent config. ### Testing remote install and uninstall analytics @@ -63,9 +64,11 @@ or CI. Choose a remote plugin that is available to the active account and is not currently installed. The command refuses to run when the plugin is already installed, installs it, validates `codex_plugin_installed`, uninstalls it, and -verifies that the original uninstalled state was restored. The current install -event uses the backend ID as `plugin_id`. Uninstall is part of cleanup but is -not yet an analytics assertion. +validates `codex_plugin_uninstalled`, and verifies that the original +uninstalled state was restored. + +The mutation events include the local Codex ID in `plugin_id` and the backend ID +in `remote_plugin_id`. `--remote-plugin-id` takes the backend ID, such as `plugins~Plugin_...`, not the local `@` ID. @@ -83,7 +86,7 @@ Analytics use the normal queue, reduction, batching, and serialization path, but the debug capture destination suppresses analytics network delivery. The command prints one of these final states: -- `PASS`: the install event validated and the plugin is uninstalled. +- `PASS`: the install and uninstall events validated and the plugin is uninstalled. - `FAIL-CLEAN`: validation failed, but the original uninstalled state was restored. - `FAIL-LOCAL-CACHE`: the backend is uninstalled, but local cleanup reported diff --git a/codex-rs/app-server-test-client/src/plugin_analytics_capture.rs b/codex-rs/app-server-test-client/src/plugin_analytics_capture.rs index 4ffd3fd23d..8a68a5746f 100644 --- a/codex-rs/app-server-test-client/src/plugin_analytics_capture.rs +++ b/codex-rs/app-server-test-client/src/plugin_analytics_capture.rs @@ -35,7 +35,7 @@ pub(super) fn read_events_for_remote_plugin( matching.extend( events .iter() - .filter(|event| event["event_params"]["plugin_id"] == remote_plugin_id) + .filter(|event| event["event_params"]["remote_plugin_id"] == remote_plugin_id) .cloned(), ); } @@ -44,6 +44,7 @@ pub(super) fn read_events_for_remote_plugin( pub(super) struct PluginEventIdentity<'a> { pub(super) plugin_id: &'a str, + pub(super) remote_plugin_id: &'a str, pub(super) plugin_name: &'a str, pub(super) marketplace_name: &'a str, } @@ -52,25 +53,29 @@ pub(super) fn validate_mutation_events( events: Vec, expected: PluginEventIdentity<'_>, ) -> Result> { - let event_type = "codex_plugin_installed"; - let matching = events - .iter() - .filter(|event| event["event_type"] == event_type) - .collect::>(); - let [event] = matching.as_slice() else { - bail!( - "expected exactly one `{event_type}` event for `{}`, found {}", - expected.plugin_id, - matching.len() - ); - }; - validate_event(event, &expected)?; - Ok(vec![(*event).clone()]) + let mut validated = Vec::new(); + for event_type in ["codex_plugin_installed", "codex_plugin_uninstalled"] { + let matching = events + .iter() + .filter(|event| event["event_type"] == event_type) + .collect::>(); + let [event] = matching.as_slice() else { + bail!( + "expected exactly one `{event_type}` event for `{}`, found {}", + expected.remote_plugin_id, + matching.len() + ); + }; + validate_event(event, &expected)?; + validated.push((*event).clone()); + } + Ok(validated) } fn validate_event(event: &Value, expected: &PluginEventIdentity<'_>) -> Result<()> { let params = &event["event_params"]; require_string(params, "plugin_id", expected.plugin_id)?; + require_string(params, "remote_plugin_id", expected.remote_plugin_id)?; require_string(params, "plugin_name", expected.plugin_name)?; require_string(params, "marketplace_name", expected.marketplace_name)?; for field in [ diff --git a/codex-rs/app-server-test-client/src/plugin_analytics_capture_tests.rs b/codex-rs/app-server-test-client/src/plugin_analytics_capture_tests.rs index 45a9c865b8..b85bd48b84 100644 --- a/codex-rs/app-server-test-client/src/plugin_analytics_capture_tests.rs +++ b/codex-rs/app-server-test-client/src/plugin_analytics_capture_tests.rs @@ -14,15 +14,17 @@ const REMOTE_PLUGIN_ID: &str = "plugins~Plugin_test"; fn reads_and_validates_remote_plugin_mutation_events() { let path = unique_capture_path("valid"); let installed = mutation_event("codex_plugin_installed"); + let uninstalled = mutation_event("codex_plugin_uninstalled"); let unrelated = json!({ "event_type": "codex_plugin_installed", "event_params": { - "plugin_id": "plugins~Plugin_other" + "plugin_id": "other@openai-curated-remote", + "remote_plugin_id": "plugins~Plugin_other" } }); let contents = [ json!({"events": [unrelated]}), - json!({"events": [installed]}), + json!({"events": [installed, uninstalled]}), ] .into_iter() .map(|payload| serde_json::to_string(&payload).expect("serialize capture payload")) @@ -35,7 +37,7 @@ fn reads_and_validates_remote_plugin_mutation_events() { let validated = validate_mutation_events(events, expected_identity()).expect("validate mutation events"); - assert_eq!(validated, vec![installed]); + assert_eq!(validated, vec![installed, uninstalled]); fs::remove_file(path).expect("remove capture file"); } @@ -62,7 +64,8 @@ fn mutation_event(event_type: &str) -> Value { json!({ "event_type": event_type, "event_params": { - "plugin_id": REMOTE_PLUGIN_ID, + "plugin_id": "sample@openai-curated-remote", + "remote_plugin_id": REMOTE_PLUGIN_ID, "plugin_name": "sample", "marketplace_name": "openai-curated-remote", "has_skills": true, @@ -75,7 +78,8 @@ fn mutation_event(event_type: &str) -> Value { fn expected_identity() -> PluginEventIdentity<'static> { PluginEventIdentity { - plugin_id: REMOTE_PLUGIN_ID, + plugin_id: "sample@openai-curated-remote", + remote_plugin_id: REMOTE_PLUGIN_ID, plugin_name: "sample", marketplace_name: "openai-curated-remote", } diff --git a/codex-rs/app-server-test-client/src/plugin_analytics_mutation_smoke.rs b/codex-rs/app-server-test-client/src/plugin_analytics_mutation_smoke.rs index 2c6d505412..70aa13ab9c 100644 --- a/codex-rs/app-server-test-client/src/plugin_analytics_mutation_smoke.rs +++ b/codex-rs/app-server-test-client/src/plugin_analytics_mutation_smoke.rs @@ -312,13 +312,19 @@ fn run_mutation_sequence( state_err } })?; + wait_for_remote_plugin_event( + capture_path, + &expected.remote_plugin_id, + "codex_plugin_uninstalled", + )?; let captured_events = read_events_for_remote_plugin(capture_path, &expected.remote_plugin_id)?; let events = validate_mutation_events( captured_events, PluginEventIdentity { - plugin_id: &expected.remote_plugin_id, + plugin_id: &expected.plugin_id, + remote_plugin_id: &expected.remote_plugin_id, plugin_name: &expected.plugin_name, marketplace_name: &expected.marketplace_name, }, diff --git a/codex-rs/app-server-test-client/src/plugin_analytics_smoke.rs b/codex-rs/app-server-test-client/src/plugin_analytics_smoke.rs index 701f6361a7..65704eeb65 100644 --- a/codex-rs/app-server-test-client/src/plugin_analytics_smoke.rs +++ b/codex-rs/app-server-test-client/src/plugin_analytics_smoke.rs @@ -159,6 +159,7 @@ fn wait_for_plugin_usage( #[derive(Debug)] struct ExpectedPlugin { plugin_id: String, + remote_plugin_id: String, plugin_name: String, marketplace_name: String, } @@ -208,13 +209,15 @@ fn expected_plugin(response: &PluginInstalledResponse, plugin_id: &str) -> Resul plugin.availability ); } - plugin + let remote_plugin_id = plugin .remote_plugin_id .as_ref() - .with_context(|| format!("plugin `{plugin_id}` does not have a remote plugin id"))?; + .with_context(|| format!("plugin `{plugin_id}` does not have a remote plugin id"))? + .clone(); Ok(ExpectedPlugin { plugin_id: plugin.id.clone(), + remote_plugin_id, plugin_name: plugin.name.clone(), marketplace_name: marketplace.name.clone(), }) @@ -444,6 +447,7 @@ fn event_count(events: &[Value], event_type: &str) -> usize { fn validate_identity(event: &Value, expected: &ExpectedPlugin) -> Result<()> { let params = &event["event_params"]; require_string(params, "plugin_id", &expected.plugin_id)?; + require_string(params, "remote_plugin_id", &expected.remote_plugin_id)?; require_string(params, "plugin_name", &expected.plugin_name)?; require_string(params, "marketplace_name", &expected.marketplace_name) } diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 76123b6aa2..eb68feb544 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -23,6 +23,7 @@ use codex_mcp::McpOAuthLoginSupport; use codex_mcp::oauth_login_support; use codex_mcp::should_retry_without_scopes; use codex_plugin::PluginId; +use codex_plugin::PluginTelemetryMetadata; use codex_rmcp_client::perform_oauth_login_silent; #[derive(Clone)] @@ -1528,6 +1529,7 @@ impl PluginRequestProcessor { self.track_plugin_install_failed_for_remote_plugin( &remote_plugin_id, &remote_marketplace_name, + /*plugin_id*/ None, error_type, err.to_string(), ); @@ -1538,6 +1540,12 @@ impl PluginRequestProcessor { })?; let actual_remote_marketplace_name = remote_detail.marketplace_name.clone(); let remote_plugin_name = remote_detail.summary.name.clone(); + let resolved_plugin_id = PluginId::parse(&remote_detail.summary.id).map_err(|err| { + internal_error(format!( + "invalid resolved plugin id `{}`: {err}", + remote_detail.summary.id + )) + })?; if remote_detail.summary.availability == PluginAvailability::DisabledByAdmin { return Err(invalid_request(format!( "remote plugin {remote_plugin_id} is disabled by admin" @@ -1569,6 +1577,7 @@ impl PluginRequestProcessor { self.track_plugin_install_failed_for_remote_plugin( &remote_plugin_id, &actual_remote_marketplace_name, + Some(&resolved_plugin_id), error_type, err.to_string(), ); @@ -1585,6 +1594,7 @@ impl PluginRequestProcessor { self.track_plugin_install_failed_for_remote_plugin( &remote_plugin_id, &actual_remote_marketplace_name, + Some(&resolved_plugin_id), error_type, err.to_string(), ); @@ -1606,6 +1616,7 @@ impl PluginRequestProcessor { self.track_plugin_install_failed_for_remote_plugin( &remote_plugin_id, &actual_remote_marketplace_name, + Some(&result.plugin_id), error_type, err.to_string(), ); @@ -1702,6 +1713,7 @@ impl PluginRequestProcessor { &self, remote_plugin_id: &str, marketplace_name: &str, + plugin_id: Option<&PluginId>, error_type: &'static str, error_message: String, ) { @@ -1712,16 +1724,17 @@ impl PluginRequestProcessor { error = %error_message, "remote plugin install failed" ); - // The remote id is reported separately; this local name only satisfies - // PluginId validation before remote details are available. - let Ok(plugin_id) = PluginId::new("unknown".to_string(), marketplace_name.to_string()) - else { - return; + let plugin = if let Some(plugin_id) = plugin_id { + self.thread_manager + .plugins_manager() + .telemetry_metadata_for_plugin_id_with_remote_id(plugin_id, remote_plugin_id) + } else { + PluginTelemetryMetadata { + plugin_id: None, + remote_plugin_id: Some(remote_plugin_id.to_string()), + capability_summary: None, + } }; - let plugin = self - .thread_manager - .plugins_manager() - .telemetry_metadata_for_plugin_id_with_remote_id(&plugin_id, remote_plugin_id); self.analytics_events_client .track_plugin_install_failed(plugin, error_type.to_string()); } @@ -1980,11 +1993,31 @@ impl PluginRequestProcessor { let remote_plugin_service_config = RemotePluginServiceConfig { chatgpt_base_url: config.chatgpt_base_url.clone(), }; + let uninstall_target = codex_core_plugins::remote::resolve_remote_plugin_uninstall_target( + &remote_plugin_service_config, + auth.as_ref(), + &plugin_id, + ) + .await + .map_err(|err| { + remote_plugin_catalog_error_to_jsonrpc(err, "resolve remote plugin before uninstall") + })?; + let plugins_manager = self.thread_manager.plugins_manager(); + let mut plugin_telemetry = plugins_manager + .telemetry_metadata_for_installed_plugin_with_remote_id( + &uninstall_target.plugin_id, + &uninstall_target.remote_plugin_id, + ) + .await; + if plugin_telemetry.capability_summary.is_none() { + plugin_telemetry.capability_summary = + Some(uninstall_target.fallback_capability_summary.clone()); + } let uninstall_result = codex_core_plugins::remote::uninstall_remote_plugin( &remote_plugin_service_config, auth.as_ref(), config.codex_home.to_path_buf(), - &plugin_id, + uninstall_target, ) .await; @@ -1992,7 +2025,8 @@ impl PluginRequestProcessor { &uninstall_result, Ok(()) | Err(RemotePluginCatalogError::CacheRemove(_)) ) { - let plugins_manager = self.thread_manager.plugins_manager(); + self.analytics_events_client + .track_plugin_uninstalled(plugin_telemetry); if plugins_manager.clear_remote_installed_plugins_cache() { self.on_effective_plugins_changed(); } diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index a59bb530d1..89ef93f63d 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -4156,6 +4156,7 @@ pub(crate) fn thread_from_stored_thread( let thread_id = thread.thread_id.to_string(); let thread = Thread { id: thread_id.clone(), + extra: None, session_id: thread_id, forked_from_id: thread.forked_from_id.map(|id| id.to_string()), parent_thread_id: thread.parent_thread_id.map(|id| id.to_string()), @@ -4366,6 +4367,7 @@ fn build_thread_from_snapshot( let now = time::OffsetDateTime::now_utc().unix_timestamp(); Thread { id: thread_id.to_string(), + extra: None, session_id, forked_from_id: None, parent_thread_id: config_snapshot.parent_thread_id.map(|id| id.to_string()), diff --git a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs index b7d8640695..586828e8dd 100644 --- a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs +++ b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs @@ -183,6 +183,7 @@ mod tests { fn test_thread(items: Vec) -> Thread { Thread { id: "thread-1".to_string(), + extra: None, session_id: "session-1".to_string(), forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index bb2c6708cd..3b668e0a1e 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -314,6 +314,7 @@ pub(crate) fn summary_to_thread( let thread_id = conversation_id.to_string(); Thread { id: thread_id.clone(), + extra: None, session_id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index a5997a58f5..32b79e1f69 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -889,6 +889,7 @@ mod tests { fn test_thread(thread_id: &str, source: codex_app_server_protocol::SessionSource) -> Thread { Thread { id: thread_id.to_string(), + extra: None, session_id: thread_id.to_string(), forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index aebc30dfea..cc76ef1eb0 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -538,12 +538,10 @@ async fn plugin_install_tracks_analytics_when_remote_detail_fetch_fails() -> Res payload["events"][0]["event_type"], "codex_plugin_install_failed" ); - assert_eq!(event_params["plugin_id"], REMOTE_PLUGIN_ID); - assert_eq!(event_params["plugin_name"], "unknown"); - assert_eq!( - event_params["marketplace_name"], - "caller-marketplace-is-ignored" - ); + assert_eq!(event_params["plugin_id"], json!(null)); + assert_eq!(event_params["remote_plugin_id"], REMOTE_PLUGIN_ID); + assert_eq!(event_params["plugin_name"], json!(null)); + assert_eq!(event_params["marketplace_name"], json!(null)); assert_eq!( event_params["error_type"], "remote_catalog_unexpected_status" @@ -883,6 +881,7 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> { "event_type": "codex_plugin_installed", "event_params": { "plugin_id": "sample-plugin@debug", + "remote_plugin_id": null, "plugin_name": "sample-plugin", "marketplace_name": "debug", "has_skills": false, @@ -946,6 +945,7 @@ async fn plugin_install_failure_tracks_analytics_event() -> Result<()> { "codex_plugin_install_failed" ); assert_eq!(event_params["plugin_id"], "sample-plugin@debug"); + assert_eq!(event_params["remote_plugin_id"], json!(null)); assert_eq!(event_params["plugin_name"], "sample-plugin"); assert_eq!(event_params["marketplace_name"], "debug"); assert_eq!(event_params["has_skills"], json!(null)); @@ -995,7 +995,8 @@ async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> { "events": [{ "event_type": "codex_plugin_installed", "event_params": { - "plugin_id": REMOTE_PLUGIN_ID, + "plugin_id": "linear@openai-curated-remote", + "remote_plugin_id": REMOTE_PLUGIN_ID, "plugin_name": "linear", "marketplace_name": "openai-curated-remote", "has_skills": true, @@ -1072,7 +1073,8 @@ async fn plugin_install_preserves_status_when_remote_bundle_error_body_is_too_la payload["events"][0]["event_type"], "codex_plugin_install_failed" ); - assert_eq!(event_params["plugin_id"], REMOTE_PLUGIN_ID); + assert_eq!(event_params["plugin_id"], "linear@openai-curated-remote"); + assert_eq!(event_params["remote_plugin_id"], REMOTE_PLUGIN_ID); assert_eq!(event_params["marketplace_name"], "openai-curated-remote"); assert_eq!(event_params["error_type"], "remote_bundle_download_status"); assert!( diff --git a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs index 5d31d8390f..6f9041a74e 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -139,6 +139,7 @@ async fn plugin_uninstall_tracks_analytics_event() -> Result<()> { "event_type": "codex_plugin_uninstalled", "event_params": { "plugin_id": "sample-plugin@debug", + "remote_plugin_id": null, "plugin_name": "sample-plugin", "marketplace_name": "debug", "has_skills": false, @@ -216,6 +217,11 @@ async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabl ) .mount(&server) .await; + Mock::given(method("POST")) + .and(path("/backend-api/codex/analytics-events/events")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status":"ok"}"#)) + .mount(&server) + .await; let remote_plugin_cache_root = codex_home .path() @@ -225,6 +231,11 @@ async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabl remote_plugin_cache_root.join("1.0.0/.codex-plugin/plugin.json"), r#"{"name":"linear","version":"1.0.0"}"#, )?; + std::fs::create_dir_all(remote_plugin_cache_root.join("1.0.0/skills/plan-work"))?; + std::fs::write( + remote_plugin_cache_root.join("1.0.0/skills/plan-work/SKILL.md"), + "---\nname: plan-work\ndescription: Plan work\n---\n", + )?; let legacy_remote_plugin_cache_root = codex_home.path().join(format!( "plugins/cache/openai-curated-remote/{REMOTE_PLUGIN_ID}" )); @@ -233,6 +244,10 @@ async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabl let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + // Simulate a background remote-cache refresh removing the local bundle + // before the uninstall request captures its telemetry metadata. + std::fs::remove_dir_all(remote_plugin_cache_root.join("1.0.0"))?; + let request_id = mcp .send_plugin_uninstall_request(PluginUninstallParams { plugin_id: REMOTE_PLUGIN_ID.to_string(), @@ -255,6 +270,25 @@ async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabl .await?; assert!(!remote_plugin_cache_root.exists()); assert!(!legacy_remote_plugin_cache_root.exists()); + let payload = wait_for_plugin_analytics_payload(&server).await?; + assert_eq!( + payload, + json!({ + "events": [{ + "event_type": "codex_plugin_uninstalled", + "event_params": { + "plugin_id": "linear@openai-curated-remote", + "remote_plugin_id": REMOTE_PLUGIN_ID, + "plugin_name": "linear", + "marketplace_name": "openai-curated-remote", + "has_skills": true, + "mcp_server_count": 0, + "connector_ids": [], + "product_client_id": DEFAULT_CLIENT_NAME, + } + }] + }) + ); Ok(()) } @@ -638,7 +672,11 @@ async fn mount_remote_plugin_detail_with_name( "interface": {{ "short_description": "Plan and track work" }}, - "skills": [] + "skills": [{{ + "name": "plan-work", + "description": "Plan work", + "interface": null + }}] }} }}"# ); @@ -652,6 +690,29 @@ async fn mount_remote_plugin_detail_with_name( .await; } +async fn wait_for_plugin_analytics_payload(server: &MockServer) -> Result { + timeout(DEFAULT_TIMEOUT, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + if let Some(request) = requests.iter().find(|request| { + request.method == "POST" + && request + .url + .path() + .ends_with("/codex/analytics-events/events") + }) { + return serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + async fn wait_for_remote_plugin_request_count( server: &MockServer, method_name: &str, diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index dd2d646aa6..4f77a4b822 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -383,6 +383,7 @@ async fn run_command_under_sandbox( network_sandbox_policy, sandbox_policy_cwd: sandbox_policy_cwd.as_path(), enforce_managed_network, + managed_network: None, environment_id: None, network: network.as_ref(), extra_allow_unix_sockets: allow_unix_sockets, @@ -472,6 +473,7 @@ async fn run_command_under_windows_session( ) -> ! { use codex_core::windows_sandbox::WindowsSandboxLevelExt; use codex_protocol::config_types::WindowsSandboxLevel; + use codex_windows_sandbox::WindowsSandboxProxySettingsMode; use codex_windows_sandbox::WindowsSandboxSessionRequest; use codex_windows_sandbox::spawn_windows_sandbox_session_for_level; @@ -484,6 +486,7 @@ async fn run_command_under_windows_session( cwd: cwd.as_path(), env_map: env, windows_sandbox_level: WindowsSandboxLevel::from_config(config), + proxy_settings_mode: WindowsSandboxProxySettingsMode::Reconcile, proxy_enforced: false, timeout_ms: None, read_roots_override: None, diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 53e9efc6d7..33631393cc 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -345,8 +345,15 @@ impl McpConnectionManager { /// Stop all MCP clients owned by this manager and terminate stdio server processes. pub async fn shutdown(&self) { self.startup_cancellation_token.cancel(); - for client in self.clients.values() { - client.shutdown().await; + let clients = self.clients.values().cloned().collect::>(); + // Keep cleanup alive if an interrupt cancels the refresh that requested it. + let shutdown_task = tokio::spawn(async move { + for client in clients { + client.shutdown().await; + } + }); + if let Err(error) = shutdown_task.await { + warn!("MCP client shutdown task failed: {error}"); } } diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 887a9b3d46..f4dfc1fd6d 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -205,9 +205,6 @@ fn tool_with_model_visible_input_schema_leaves_tools_without_file_params_unchang #[test] fn elicitation_granular_policy_defaults_to_prompting() { - assert!(!elicitation_is_rejected_by_policy( - AskForApproval::OnFailure - )); assert!(!elicitation_is_rejected_by_policy( AskForApproval::OnRequest )); @@ -789,7 +786,7 @@ async fn list_all_tools_uses_cached_tool_info_snapshot_while_client_is_pending() let pending_client = futures::future::pending::>() .boxed() .shared(); - let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let approval_policy = Constrained::allow_any(AskForApproval::OnRequest); let permission_profile = Constrained::allow_any(PermissionProfile::default()); let mut manager = McpConnectionManager::new_uninitialized( &approval_policy, @@ -826,7 +823,7 @@ async fn list_available_server_infos_uses_cache_while_client_is_pending() { let pending_client = futures::future::pending::>() .boxed() .shared(); - let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let approval_policy = Constrained::allow_any(AskForApproval::OnRequest); let permission_profile = Constrained::allow_any(PermissionProfile::default()); let mut manager = McpConnectionManager::new_uninitialized( &approval_policy, @@ -865,7 +862,7 @@ async fn list_all_tools_accepts_canonical_namespaced_tool_names() { let pending_client = futures::future::pending::>() .boxed() .shared(); - let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let approval_policy = Constrained::allow_any(AskForApproval::OnRequest); let permission_profile = Constrained::allow_any(PermissionProfile::default()); let mut manager = McpConnectionManager::new_uninitialized( &approval_policy, @@ -909,7 +906,7 @@ async fn list_all_tools_applies_legacy_mcp_prefix_by_default() { let pending_client = futures::future::pending::>() .boxed() .shared(); - let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let approval_policy = Constrained::allow_any(AskForApproval::OnRequest); let permission_profile = Constrained::allow_any(PermissionProfile::default()); let mut manager = McpConnectionManager::new_uninitialized( &approval_policy, @@ -952,7 +949,7 @@ async fn list_all_tools_blocks_while_client_is_pending_without_cached_tool_info_ let pending_client = futures::future::pending::>() .boxed() .shared(); - let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let approval_policy = Constrained::allow_any(AskForApproval::OnRequest); let permission_profile = Constrained::allow_any(PermissionProfile::default()); let mut manager = McpConnectionManager::new_uninitialized( &approval_policy, @@ -989,7 +986,7 @@ async fn shutdown_cancels_pending_tool_listing() { } .boxed() .shared(); - let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let approval_policy = Constrained::allow_any(AskForApproval::OnRequest); let permission_profile = Constrained::allow_any(PermissionProfile::default()); let mut manager = McpConnectionManager::new_uninitialized( &approval_policy, @@ -1020,12 +1017,65 @@ async fn shutdown_cancels_pending_tool_listing() { assert!(tools.is_empty()); } +#[tokio::test] +async fn shutdown_continues_after_caller_is_aborted() { + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (completed_tx, completed_rx) = tokio::sync::oneshot::channel(); + let release = Arc::new(tokio::sync::Notify::new()); + let release_for_client = Arc::clone(&release); + let blocking_client = async move { + let _ = started_tx.send(()); + release_for_client.notified().await; + let _ = completed_tx.send(()); + Err(StartupOutcomeError::Cancelled) + } + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + let permission_profile = Constrained::allow_any(PermissionProfile::default()); + let mut manager = McpConnectionManager::new_uninitialized( + &approval_policy, + &permission_profile, + /*prefix_mcp_tool_names*/ true, + ); + manager.clients.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + AsyncManagedClient { + client: blocking_client, + is_codex_apps_mcp_server: true, + cached_tool_info_snapshot: None, + cached_server_info: None, + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), + cancel_token: CancellationToken::new(), + }, + ); + let manager = Arc::new(manager); + let shutdown_task = tokio::spawn({ + let manager = Arc::clone(&manager); + async move { manager.shutdown().await } + }); + + started_rx.await.expect("client shutdown should start"); + shutdown_task.abort(); + let shutdown_error = shutdown_task + .await + .expect_err("caller shutdown task should be aborted"); + assert!(shutdown_error.is_cancelled()); + release.notify_one(); + + tokio::time::timeout(Duration::from_secs(1), completed_rx) + .await + .expect("client shutdown should survive caller cancellation") + .expect("client shutdown completion sender should stay alive"); +} + #[tokio::test] async fn list_all_tools_does_not_block_when_cached_tool_info_snapshot_is_empty() { let pending_client = futures::future::pending::>() .boxed() .shared(); - let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let approval_policy = Constrained::allow_any(AskForApproval::OnRequest); let permission_profile = Constrained::allow_any(PermissionProfile::default()); let mut manager = McpConnectionManager::new_uninitialized( &approval_policy, @@ -1065,7 +1115,7 @@ async fn list_all_tools_uses_cached_tool_info_snapshot_when_client_startup_fails )) .boxed() .shared(); - let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let approval_policy = Constrained::allow_any(AskForApproval::OnRequest); let permission_profile = Constrained::allow_any(PermissionProfile::default()); let mut manager = McpConnectionManager::new_uninitialized( &approval_policy, @@ -1112,7 +1162,7 @@ async fn list_all_tools_adds_server_metadata_to_cached_tools() { let pending_client = futures::future::pending::>() .boxed() .shared(); - let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let approval_policy = Constrained::allow_any(AskForApproval::OnRequest); let permission_profile = Constrained::allow_any(PermissionProfile::default()); let mut manager = McpConnectionManager::new_uninitialized( &approval_policy, @@ -1179,7 +1229,7 @@ fn server_metadata_preserves_tool_approval_policy() { #[test] fn host_owned_codex_apps_requires_server_metadata() { - let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let approval_policy = Constrained::allow_any(AskForApproval::OnRequest); let permission_profile = Constrained::allow_any(PermissionProfile::default()); let manager = McpConnectionManager::new_uninitialized( &approval_policy, @@ -1192,7 +1242,7 @@ fn host_owned_codex_apps_requires_server_metadata() { #[test] fn host_owned_codex_apps_matches_reserved_name_with_server_metadata() { - let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let approval_policy = Constrained::allow_any(AskForApproval::OnRequest); let permission_profile = Constrained::allow_any(PermissionProfile::default()); let mut manager = McpConnectionManager::new_uninitialized( &approval_policy, @@ -1214,7 +1264,7 @@ fn host_owned_codex_apps_matches_reserved_name_with_server_metadata() { #[tokio::test] async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { - let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let approval_policy = Constrained::allow_any(AskForApproval::OnRequest); let (tx_event, rx_event) = async_channel::unbounded(); drop(rx_event); let codex_home = tempdir().expect("tempdir"); diff --git a/codex-rs/codex-mcp/src/elicitation.rs b/codex-rs/codex-mcp/src/elicitation.rs index abd5a46899..d6a685b502 100644 --- a/codex-rs/codex-mcp/src/elicitation.rs +++ b/codex-rs/codex-mcp/src/elicitation.rs @@ -247,7 +247,6 @@ impl ElicitationRequestManager { pub(crate) fn elicitation_is_rejected_by_policy(approval_policy: AskForApproval) -> bool { match approval_policy { AskForApproval::Never => true, - AskForApproval::OnFailure => false, AskForApproval::OnRequest => false, AskForApproval::UnlessTrusted => false, AskForApproval::Granular(granular_config) => !granular_config.allows_mcp_elicitations(), diff --git a/codex-rs/codex-mcp/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs index 31c31960c7..9617dca089 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -27,7 +27,7 @@ fn test_mcp_config(codex_home: PathBuf) -> McpConfig { mcp_oauth_callback_port: None, mcp_oauth_callback_url: None, skill_mcp_dependency_install_enabled: true, - approval_policy: Constrained::allow_any(AskForApproval::OnFailure), + approval_policy: Constrained::allow_any(AskForApproval::OnRequest), codex_linux_sandbox_exe: None, use_legacy_landlock: false, apps_enabled: false, @@ -83,7 +83,6 @@ fn mcp_prompt_auto_approval_honors_unrestricted_managed_profiles() { fn mcp_prompt_auto_approval_honors_approved_tools_in_all_permission_modes() { for approval_policy in [ AskForApproval::UnlessTrusted, - AskForApproval::OnFailure, AskForApproval::OnRequest, AskForApproval::Granular(GranularApprovalConfig { sandbox_approval: true, diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index ac51734536..4ccf4523aa 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -2513,17 +2513,6 @@ allowed_approvals_reviewers = ["user"] .can_set(&AskForApproval::UnlessTrusted) .is_ok() ); - assert_eq!( - requirements - .approval_policy - .can_set(&AskForApproval::OnFailure), - Err(ConstraintError::InvalidValue { - field_name: "approval_policy", - candidate: "OnFailure".into(), - allowed: "[UnlessTrusted, OnRequest]".into(), - requirement_source: RequirementSource::Unknown, - }) - ); assert!( requirements .approval_policy diff --git a/codex-rs/config/src/state_tests.rs b/codex-rs/config/src/state_tests.rs index fb6e26968b..7ad4ed297d 100644 --- a/codex-rs/config/src/state_tests.rs +++ b/codex-rs/config/src/state_tests.rs @@ -52,7 +52,7 @@ fn active_user_layer_is_highest_precedence_user_layer() { toml::from_str( r#" model = "base" -approval_policy = "on-failure" +approval_policy = "on-request" "#, ) .expect("base config"), @@ -86,7 +86,7 @@ approval_policy = "on-failure" .expect("merged user config") .get("approval_policy") .and_then(toml::Value::as_str), - Some("on-failure") + Some("on-request") ); } @@ -107,7 +107,7 @@ fn with_user_config_updates_matching_user_layer_without_replacing_active_profile file: profile_file.clone(), profile: Some("work".to_string()), }, - toml::from_str(r#"approval_policy = "on-failure""#).expect("profile config"), + toml::from_str(r#"approval_policy = "on-request""#).expect("profile config"), ); let stack = ConfigLayerStack::new( vec![base_layer, profile_layer], @@ -136,6 +136,6 @@ fn with_user_config_updates_matching_user_layer_without_replacing_active_profile .expect("merged user config") .get("approval_policy") .and_then(toml::Value::as_str), - Some("on-failure") + Some("on-request") ); } diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 19b48b2c01..e60ce50344 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -708,6 +708,37 @@ impl PluginsManager { remote_installed_plugins_to_config(plugins, &self.store) } + fn remote_plugin_id_for(&self, plugin_id: &PluginId) -> Option { + let cached_remote_plugin_id = { + let cache = match self.remote_installed_plugins_cache.read() { + Ok(cache) => cache, + Err(err) => err.into_inner(), + }; + cache.as_ref().and_then(|plugins| { + plugins.iter().find_map(|plugin| { + (plugin.name == plugin_id.plugin_name + && plugin.marketplace_name == plugin_id.marketplace_name) + .then(|| plugin.id.clone()) + }) + }) + }; + if cached_remote_plugin_id.is_some() { + return cached_remote_plugin_id; + } + + match self.store.remote_plugin_id(plugin_id) { + Ok(remote_plugin_id) => remote_plugin_id, + Err(err) => { + tracing::warn!( + plugin_id = %plugin_id.as_key(), + error = %err, + "failed to read persisted remote plugin identity" + ); + None + } + } + } + pub async fn telemetry_metadata_for_installed_plugin( &self, plugin_id: &PluginId, @@ -739,8 +770,8 @@ impl PluginsManager { plugin_id: &PluginId, ) -> PluginTelemetryMetadata { PluginTelemetryMetadata { - plugin_id: plugin_id.clone(), - remote_plugin_id: None, + plugin_id: Some(plugin_id.clone()), + remote_plugin_id: self.remote_plugin_id_for(plugin_id), capability_summary: None, } } @@ -762,8 +793,8 @@ impl PluginsManager { ) -> Option { let plugin_id = PluginId::parse(&summary.config_name).ok()?; Some(PluginTelemetryMetadata { - plugin_id, - remote_plugin_id: None, + remote_plugin_id: self.remote_plugin_id_for(&plugin_id), + plugin_id: Some(plugin_id), capability_summary: Some(summary.clone()), }) } diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index adf721fc6e..3dfcc2aa8b 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -859,7 +859,7 @@ async fn installed_plugin_telemetry_metadata_collects_capabilities() { assert_eq!( metadata, PluginTelemetryMetadata { - plugin_id, + plugin_id: Some(plugin_id), remote_plugin_id: None, capability_summary: Some(PluginCapabilitySummary { config_name: "sample@test".to_string(), @@ -873,6 +873,71 @@ async fn installed_plugin_telemetry_metadata_collects_capabilities() { ); } +#[tokio::test] +async fn installed_plugin_telemetry_metadata_resolves_persisted_remote_identity() { + let codex_home = TempDir::new().unwrap(); + write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear"); + let plugin_id = + PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse"); + PluginStore::new(codex_home.path().to_path_buf()) + .write_remote_plugin_id(&plugin_id, "plugins~Plugin_linear") + .expect("persist remote plugin id"); + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + + let metadata = manager + .telemetry_metadata_for_installed_plugin(&plugin_id) + .await; + + assert_eq!( + metadata, + PluginTelemetryMetadata { + plugin_id: Some(plugin_id), + remote_plugin_id: Some("plugins~Plugin_linear".to_string()), + capability_summary: Some(PluginCapabilitySummary { + config_name: "linear@openai-curated-remote".to_string(), + display_name: "linear".to_string(), + description: None, + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }), + } + ); +} + +#[tokio::test] +async fn installed_plugin_telemetry_metadata_prefers_remote_snapshot_identity() { + let codex_home = TempDir::new().unwrap(); + write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear"); + let plugin_id = + PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse"); + PluginStore::new(codex_home.path().to_path_buf()) + .write_remote_plugin_id(&plugin_id, "plugins~Plugin_stale") + .expect("persist remote plugin id"); + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + manager.write_remote_installed_plugins_cache(vec![remote_installed_linear_plugin()]); + + let metadata = manager + .telemetry_metadata_for_installed_plugin(&plugin_id) + .await; + + assert_eq!( + metadata, + PluginTelemetryMetadata { + plugin_id: Some(plugin_id), + remote_plugin_id: Some("plugins~Plugin_linear".to_string()), + capability_summary: Some(PluginCapabilitySummary { + config_name: "linear@openai-curated-remote".to_string(), + display_name: "linear".to_string(), + description: None, + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }), + } + ); +} + #[tokio::test] async fn installed_plugin_telemetry_metadata_accepts_authoritative_remote_identity() { let codex_home = TempDir::new().unwrap(); @@ -887,7 +952,7 @@ async fn installed_plugin_telemetry_metadata_accepts_authoritative_remote_identi assert_eq!( metadata, PluginTelemetryMetadata { - plugin_id, + plugin_id: Some(plugin_id), remote_plugin_id: Some("plugins~Plugin_linear".to_string()), capability_summary: None, } @@ -912,14 +977,46 @@ fn capability_summary_telemetry_metadata_uses_local_identity() { assert_eq!( metadata, Some(PluginTelemetryMetadata { - plugin_id: PluginId::parse("linear@openai-curated-remote") - .expect("plugin id should parse"), + plugin_id: Some( + PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse"), + ), remote_plugin_id: None, capability_summary: Some(summary), }) ); } +#[test] +fn capability_summary_telemetry_metadata_resolves_persisted_remote_identity() { + let codex_home = TempDir::new().unwrap(); + write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear"); + let plugin_id = + PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse"); + PluginStore::new(codex_home.path().to_path_buf()) + .write_remote_plugin_id(&plugin_id, "plugins~Plugin_linear") + .expect("persist remote plugin id"); + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + let summary = PluginCapabilitySummary { + config_name: "linear@openai-curated-remote".to_string(), + display_name: "Linear".to_string(), + description: Some("Track work".to_string()), + has_skills: true, + mcp_server_names: vec!["linear".to_string()], + app_connector_ids: vec![AppConnectorId("linear-app".to_string())], + }; + + let metadata = manager.telemetry_metadata_for_capability_summary(&summary); + + assert_eq!( + metadata, + Some(PluginTelemetryMetadata { + plugin_id: Some(plugin_id), + remote_plugin_id: Some("plugins~Plugin_linear".to_string()), + capability_summary: Some(summary), + }) + ); +} + #[tokio::test] async fn remote_installed_cache_prefers_local_curated_conflicts_when_remote_plugin_disabled() { let codex_home = TempDir::new().unwrap(); diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 05ce110953..506b943b1e 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -12,8 +12,10 @@ use codex_login::CodexAuth; use codex_login::default_client::build_reqwest_client; use codex_plugin::AppConnectorId; use codex_plugin::AppDeclaration; +use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::app_connector_ids_from_declarations; +use codex_plugin::prompt_safe_plugin_description; use codex_utils_absolute_path::AbsolutePathBuf; use reqwest::RequestBuilder; use serde::Deserialize; @@ -120,6 +122,13 @@ pub struct RemotePluginServiceConfig { pub chatgpt_base_url: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemotePluginUninstallTarget { + pub plugin_id: PluginId, + pub remote_plugin_id: String, + pub fallback_capability_summary: PluginCapabilitySummary, +} + #[derive(Debug, Clone, PartialEq)] pub struct RemoteMarketplace { pub name: String, @@ -1304,40 +1313,90 @@ pub async fn install_remote_plugin( }) } +pub async fn resolve_remote_plugin_uninstall_target( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + remote_plugin_id: &str, +) -> Result { + let auth = ensure_chatgpt_auth(auth)?; + let plugin = fetch_plugin_detail( + config, + auth, + remote_plugin_id, + /*include_download_urls*/ false, + ) + .await?; + let marketplace_name = remote_plugin_canonical_marketplace_name(&plugin)?.to_string(); + let plugin_id = PluginId::new(plugin.name.clone(), marketplace_name).map_err(|err| { + RemotePluginCatalogError::UnexpectedResponse(format!( + "invalid local plugin id for remote plugin `{}`: {err}", + plugin.id + )) + })?; + let app_declarations = plugin + .release + .app_manifest + .as_ref() + .map(plugin_app_declarations_from_value) + .unwrap_or_else(|| app_declarations_from_remote_app_ids(&plugin.release.app_ids)); + let mut mcp_server_names = plugin + .release + .mcp_servers + .iter() + .map(|server| server.key.clone()) + .collect::>(); + mcp_server_names.sort_unstable(); + mcp_server_names.dedup(); + let fallback_capability_summary = PluginCapabilitySummary { + config_name: plugin_id.as_key(), + display_name: plugin.release.display_name, + description: prompt_safe_plugin_description(Some(&plugin.release.description)), + has_skills: !plugin.release.skills.is_empty(), + mcp_server_names, + app_connector_ids: app_connector_ids_from_declarations(&app_declarations), + }; + Ok(RemotePluginUninstallTarget { + plugin_id, + remote_plugin_id: plugin.id, + fallback_capability_summary, + }) +} + pub async fn uninstall_remote_plugin( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, codex_home: PathBuf, - plugin_id: &str, + target: RemotePluginUninstallTarget, ) -> Result<(), RemotePluginCatalogError> { let auth = ensure_chatgpt_auth(auth)?; - let plugin = fetch_plugin_detail( - config, auth, plugin_id, /*include_download_urls*/ false, - ) - .await?; - let marketplace_name = remote_plugin_canonical_marketplace_name(&plugin)?.to_string(); - let plugin_name = plugin.name; + let RemotePluginUninstallTarget { + plugin_id, + remote_plugin_id, + fallback_capability_summary: _, + } = target; + let marketplace_name = plugin_id.marketplace_name.clone(); + let plugin_name = plugin_id.plugin_name.clone(); let base_url = config.chatgpt_base_url.trim_end_matches('/'); - let url = format!("{base_url}/ps/plugins/{plugin_id}/uninstall"); + let url = format!("{base_url}/ps/plugins/{remote_plugin_id}/uninstall"); let client = build_reqwest_client(); let request = authenticated_request(client.post(&url), auth)?; let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?; - if response.id != plugin_id { + if response.id != remote_plugin_id { return Err(RemotePluginCatalogError::UnexpectedPluginId { - expected: plugin_id.to_string(), + expected: remote_plugin_id, actual: response.id, }); } if response.enabled { return Err(RemotePluginCatalogError::UnexpectedEnabledState { - plugin_id: plugin_id.to_string(), + plugin_id: response.id, expected_enabled: false, actual_enabled: response.enabled, }); } - let legacy_plugin_id = plugin_id.to_string(); + let legacy_plugin_id = response.id; tokio::task::spawn_blocking(move || { remove_remote_plugin_cache(codex_home, marketplace_name, plugin_name, legacy_plugin_id) }) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index ffcbd75634..5458f42671 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -252,13 +252,6 @@ ], "type": "string" }, - { - "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", - "enum": [ - "on-failure" - ], - "type": "string" - }, { "description": "The model decides when to ask the user for approval.", "enum": [ diff --git a/codex-rs/core/gpt_5_1_prompt.md b/codex-rs/core/gpt_5_1_prompt.md index 440422ae6a..da2ec674f7 100644 --- a/codex-rs/core/gpt_5_1_prompt.md +++ b/codex-rs/core/gpt_5_1_prompt.md @@ -171,7 +171,7 @@ For all of testing, running, building, and formatting, do not attempt to fix unr Be mindful of whether to run validation commands proactively. In the absence of behavioral guidance: -- When running in non-interactive approval modes like **never** or **on-failure**, you can proactively run tests, lint and do whatever you need to ensure you've completed the task. If you are unable to run tests, you must still do your utmost best to complete the task. +- When running in the non-interactive approval mode **never**, you can proactively run tests, lint and do whatever you need to ensure you've completed the task. If you are unable to run tests, you must still do your utmost best to complete the task. - When working in interactive approval modes like **untrusted**, or **on-request**, hold off on running tests or lint commands until the user is ready for you to finalize your output, because these commands take time to run and slow down iteration. Instead suggest what you want to do next, and let the user confirm first. - When working on test-related tasks, such as adding tests, fixing tests, or reproducing a bug to verify behavior, you may proactively run tests regardless of approval mode. Use your judgement to decide whether this is a test-related task. diff --git a/codex-rs/core/gpt_5_2_prompt.md b/codex-rs/core/gpt_5_2_prompt.md index 7dd684bf06..8aa188f5e1 100644 --- a/codex-rs/core/gpt_5_2_prompt.md +++ b/codex-rs/core/gpt_5_2_prompt.md @@ -145,7 +145,7 @@ For all of testing, running, building, and formatting, do not attempt to fix unr Be mindful of whether to run validation commands proactively. In the absence of behavioral guidance: -- When running in non-interactive approval modes like **never** or **on-failure**, you can proactively run tests, lint and do whatever you need to ensure you've completed the task. If you are unable to run tests, you must still do your utmost best to complete the task. +- When running in the non-interactive approval mode **never**, you can proactively run tests, lint and do whatever you need to ensure you've completed the task. If you are unable to run tests, you must still do your utmost best to complete the task. - When working in interactive approval modes like **untrusted**, or **on-request**, hold off on running tests or lint commands until the user is ready for you to finalize your output, because these commands take time to run and slow down iteration. Instead suggest what you want to do next, and let the user confirm first. - When working on test-related tasks, such as adding tests, fixing tests, or reproducing a bug to verify behavior, you may proactively run tests regardless of approval mode. Use your judgement to decide whether this is a test-related task. diff --git a/codex-rs/core/prompt_with_apply_patch_instructions.md b/codex-rs/core/prompt_with_apply_patch_instructions.md index f9c308fbd1..2650c6709c 100644 --- a/codex-rs/core/prompt_with_apply_patch_instructions.md +++ b/codex-rs/core/prompt_with_apply_patch_instructions.md @@ -158,7 +158,7 @@ For all of testing, running, building, and formatting, do not attempt to fix unr Be mindful of whether to run validation commands proactively. In the absence of behavioral guidance: -- When running in non-interactive approval modes like **never** or **on-failure**, proactively run tests, lint and do whatever you need to ensure you've completed the task. +- When running in the non-interactive approval mode **never**, proactively run tests, lint and do whatever you need to ensure you've completed the task. - When working in interactive approval modes like **untrusted**, or **on-request**, hold off on running tests or lint commands until the user is ready for you to finalize your output, because these commands take time to run and slow down iteration. Instead suggest what you want to do next, and let the user confirm first. - When working on test-related tasks, such as adding tests, fixing tests, or reproducing a bug to verify behavior, you may proactively run tests regardless of approval mode. Use your judgement to decide whether this is a test-related task. diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index c60bd155e8..d049fd49f8 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -4,6 +4,7 @@ use std::time::Instant; use crate::Prompt; use crate::client::ModelClientSession; use crate::client_common::ResponseEvent; +use crate::context::world_state::WorldState; use crate::hook_runtime::PostCompactHookOutcome; use crate::hook_runtime::PreCompactHookOutcome; use crate::hook_runtime::run_post_compact_hooks; @@ -60,12 +61,29 @@ const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000; /// Mid-turn compaction must use `BeforeLastUserMessage` because the model is trained to see the /// compaction summary as the last item in history after mid-turn compaction; we therefore inject /// initial context into the replacement history just above the last real user message. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Debug)] pub(crate) enum InitialContextInjection { - BeforeLastUserMessage, + BeforeLastUserMessage(Arc), DoNotInject, } +pub(crate) async fn build_compaction_initial_context( + sess: &Session, + turn_context: &TurnContext, + initial_context_injection: &InitialContextInjection, +) -> (Vec, Option>) { + // Return the rendered state with its items so history and its baseline stay identical. + match initial_context_injection { + InitialContextInjection::BeforeLastUserMessage(world_state) => { + let items = sess + .build_initial_context_with_world_state(turn_context, world_state.as_ref()) + .await; + (items, Some(Arc::clone(world_state))) + } + InitialContextInjection::DoNotInject => (Vec::new(), None), + } +} + pub(crate) fn should_use_remote_compact_task(provider: &ModelProviderInfo) -> bool { provider.supports_remote_compaction() } @@ -309,17 +327,21 @@ async fn run_compact_task_inner_impl( } let (window_number, window_ids) = sess.advance_auto_compact_window().await; - if matches!( - initial_context_injection, - InitialContextInjection::BeforeLastUserMessage - ) { - let initial_context = sess.build_initial_context(turn_context.as_ref()).await; + let (initial_context, world_state_baseline) = build_compaction_initial_context( + sess.as_ref(), + turn_context.as_ref(), + &initial_context_injection, + ) + .await; + if !initial_context.is_empty() { new_history = insert_initial_context_before_last_real_user_or_summary(new_history, initial_context); } let reference_context_item = match initial_context_injection { InitialContextInjection::DoNotInject => None, - InitialContextInjection::BeforeLastUserMessage => Some(turn_context.to_turn_context_item()), + InitialContextInjection::BeforeLastUserMessage(_) => { + Some(turn_context.to_turn_context_item()) + } }; let compacted_item = CompactedItem { message: summary_text.clone(), @@ -333,6 +355,7 @@ async fn run_compact_task_inner_impl( turn_context.as_ref(), new_history, reference_context_item, + world_state_baseline, compacted_item, ) .await; diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 48784141a8..f9c5e656b1 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -6,8 +6,10 @@ use crate::client::CompactConversationRequestSettings; use crate::compact::CompactionAnalyticsAttempt; use crate::compact::CompactionAnalyticsDetails; use crate::compact::InitialContextInjection; +use crate::compact::build_compaction_initial_context; use crate::compact::compaction_status_from_result; use crate::compact::insert_initial_context_before_last_real_user_or_summary; +use crate::context::world_state::WorldState; use crate::context_manager::ContextManager; use crate::hook_runtime::PostCompactHookOutcome; use crate::hook_runtime::PreCompactHookOutcome; @@ -237,7 +239,7 @@ async fn run_remote_compact_task_inner_impl( window_id, CodexResponsesRequestKind::Compaction(compaction_metadata), ); - let mut new_history = sess + let new_history = sess .services .model_client .compact_conversation_history( @@ -259,17 +261,19 @@ async fn run_remote_compact_task_inner_impl( ) .await?; let (new_window_number, new_window_ids) = sess.advance_auto_compact_window().await; - new_history = process_compacted_history( + let (new_history, world_state_baseline) = process_compacted_history( sess.as_ref(), turn_context.as_ref(), new_history, - initial_context_injection, + &initial_context_injection, ) .await; let reference_context_item = match initial_context_injection { InitialContextInjection::DoNotInject => None, - InitialContextInjection::BeforeLastUserMessage => Some(turn_context.to_turn_context_item()), + InitialContextInjection::BeforeLastUserMessage(_) => { + Some(turn_context.to_turn_context_item()) + } }; let compacted_item = CompactedItem { message: String::new(), @@ -290,6 +294,7 @@ async fn run_remote_compact_task_inner_impl( turn_context.as_ref(), new_history, reference_context_item, + world_state_baseline, compacted_item, ) .await; @@ -304,22 +309,19 @@ pub(crate) async fn process_compacted_history( sess: &Session, turn_context: &TurnContext, mut compacted_history: Vec, - initial_context_injection: InitialContextInjection, -) -> Vec { + initial_context_injection: &InitialContextInjection, +) -> (Vec, Option>) { // Mid-turn compaction is the only path that must inject initial context above the last user // message in the replacement history. Pre-turn compaction instead injects context after the // compaction item, but mid-turn compaction keeps the compaction item last for model training. - let initial_context = if matches!( - initial_context_injection, - InitialContextInjection::BeforeLastUserMessage - ) { - sess.build_initial_context(turn_context).await - } else { - Vec::new() - }; + let (initial_context, world_state_baseline) = + build_compaction_initial_context(sess, turn_context, initial_context_injection).await; compacted_history.retain(should_keep_compacted_history_item); - insert_initial_context_before_last_real_user_or_summary(compacted_history, initial_context) + ( + insert_initial_context_before_last_real_user_or_summary(compacted_history, initial_context), + world_state_baseline, + ) } /// Returns whether an item from remote compaction output should be preserved. diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index d188f9dfc1..192e5527d5 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -293,17 +293,19 @@ async fn run_remote_compact_task_inner_impl( build_v2_compacted_history(&prompt_input, compaction_output); analytics_details.retained_image_count = Some(retained_images); let (new_window_number, new_window_ids) = sess.advance_auto_compact_window().await; - let new_history = process_compacted_history( + let (new_history, world_state_baseline) = process_compacted_history( sess.as_ref(), turn_context.as_ref(), compacted_history, - initial_context_injection, + &initial_context_injection, ) .await; let reference_context_item = match initial_context_injection { InitialContextInjection::DoNotInject => None, - InitialContextInjection::BeforeLastUserMessage => Some(turn_context.to_turn_context_item()), + InitialContextInjection::BeforeLastUserMessage(_) => { + Some(turn_context.to_turn_context_item()) + } }; let compacted_item = CompactedItem { message: String::new(), @@ -321,6 +323,7 @@ async fn run_remote_compact_task_inner_impl( turn_context.as_ref(), new_history, reference_context_item, + world_state_baseline, compacted_item, ) .await; diff --git a/codex-rs/core/src/compact_tests.rs b/codex-rs/core/src/compact_tests.rs index bb6adf643d..19ca75b180 100644 --- a/codex-rs/core/src/compact_tests.rs +++ b/codex-rs/core/src/compact_tests.rs @@ -1,9 +1,11 @@ use super::*; +use crate::session::tests::build_world_state_from_turn_context; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::WireApi; use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::InternalChatMessageMetadataPassthrough; use pretty_assertions::assert_eq; +use std::sync::Arc; async fn process_compacted_history_with_test_session( compacted_history: Vec, @@ -13,12 +15,16 @@ async fn process_compacted_history_with_test_session( session .set_previous_turn_settings(previous_turn_settings.cloned()) .await; - let initial_context = session.build_initial_context(&turn_context).await; - let refreshed = crate::compact_remote::process_compacted_history( + let world_state = Arc::new(build_world_state_from_turn_context(&session, &turn_context).await); + let initial_context = session + .build_initial_context_with_world_state(&turn_context, world_state.as_ref()) + .await; + let initial_context_injection = InitialContextInjection::BeforeLastUserMessage(world_state); + let (refreshed, _) = crate::compact_remote::process_compacted_history( &session, &turn_context, compacted_history, - InitialContextInjection::BeforeLastUserMessage, + &initial_context_injection, ) .await; (refreshed, initial_context) diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 1a9245b1aa..9142ac51de 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -640,7 +640,7 @@ async fn selected_user_config_file_layers_over_base_user_config() { tmp.path().join(CONFIG_TOML_FILE), r#" model = "gpt-main" -approval_policy = "on-failure" +approval_policy = "on-request" "#, ) .expect("write default user config"); @@ -697,7 +697,7 @@ approval_policy = "on-failure" .effective_config() .get("approval_policy") .and_then(TomlValue::as_str), - Some("on-failure") + Some("on-request") ); } @@ -1033,12 +1033,6 @@ personality = true config_requirements .approval_policy .can_set(&AskForApproval::Never)?; - assert!( - config_requirements - .approval_policy - .can_set(&AskForApproval::OnFailure) - .is_err() - ); assert_eq!( config_requirements.web_search_mode.value(), WebSearchMode::Cached diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 6b281c4084..f098084609 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -8272,7 +8272,7 @@ model_provider = "openai-custom" [profiles.zdr] model = "o3" model_provider = "openai" -approval_policy = "on-failure" +approval_policy = "on-request" [profiles.zdr.analytics] enabled = false @@ -8280,7 +8280,7 @@ enabled = false [profiles.gpt5] model = "gpt-5.4" model_provider = "openai" -approval_policy = "on-failure" +approval_policy = "on-request" model_reasoning_effort = "high" model_reasoning_summary = "detailed" model_verbosity = "high" diff --git a/codex-rs/core/src/context/world_state/environment.rs b/codex-rs/core/src/context/world_state/environment.rs index 589e8dbadf..d6e3284667 100644 --- a/codex-rs/core/src/context/world_state/environment.rs +++ b/codex-rs/core/src/context/world_state/environment.rs @@ -24,10 +24,6 @@ pub(crate) struct EnvironmentsState { } impl EnvironmentsState { - pub(crate) fn from_turn_context(turn_context: &TurnContext) -> Self { - Self::from_turn_context_with_environments(turn_context, &turn_context.environments) - } - pub(crate) fn from_turn_context_with_environments( turn_context: &TurnContext, environments: &TurnEnvironmentSnapshot, diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index b474ed1a9a..daa9e3cd40 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -86,18 +86,18 @@ impl ContextManager { pub(crate) fn update_world_state( &mut self, - world_state: WorldState, + world_state: Arc, ) -> Vec> { let fragments = self.world_state_baseline.as_deref().map_or_else( || world_state.render_full(), |previous| world_state.render_diff(previous), ); - self.world_state_baseline = Some(Arc::new(world_state)); + self.world_state_baseline = Some(world_state); fragments } - pub(crate) fn set_world_state_baseline(&mut self, world_state: WorldState) { - self.world_state_baseline = Some(Arc::new(world_state)); + pub(crate) fn set_world_state_baseline(&mut self, world_state: Arc) { + self.world_state_baseline = Some(world_state); } pub(crate) fn set_token_usage_full(&mut self, context_window: i64) { diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index da2f14fd4f..7e7f7c3773 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -32,6 +32,7 @@ use image::Luma; use image::Rgba; use pretty_assertions::assert_eq; use regex_lite::Regex; +use std::sync::Arc; const EXEC_FORMAT_MAX_BYTES: usize = 10_000; const EXEC_FORMAT_MAX_TOKENS: usize = 2_500; @@ -82,7 +83,7 @@ fn world_state_baseline_deduplicates_until_history_is_replaced() { state.add_section(EnvironmentsState::from_turn_context_item( &reference_context_item(), )); - state + Arc::new(state) }; let mut history = ContextManager::new(); diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 647203db2e..015b20715a 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -377,6 +377,7 @@ pub fn build_exec_request( args: args.to_vec(), cwd, env, + managed_network: None, additional_permissions: None, }; let options = ExecOptions { @@ -459,6 +460,7 @@ pub(crate) async fn execute_exec_request( arg0, exec_server_sandbox: _, exec_server_enforce_managed_network: _, + exec_server_managed_network: _, } = exec_request; // TODO(anp): Keep PathUri through the local process launch boundary. diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 7b33a196b0..d79faba1ac 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -177,7 +177,6 @@ pub(crate) fn prompt_is_rejected_by_policy( ) -> Option<&'static str> { match approval_policy { AskForApproval::Never => Some(PROMPT_CONFLICT_REASON), - AskForApproval::OnFailure => None, AskForApproval::OnRequest => None, AskForApproval::UnlessTrusted => None, AskForApproval::Granular(granular_config) => { @@ -689,15 +688,14 @@ pub(crate) fn render_decision_for_unmatched_command( Decision::Forbidden } } - AskForApproval::OnFailure - | AskForApproval::OnRequest + AskForApproval::OnRequest | AskForApproval::UnlessTrusted | AskForApproval::Granular(_) => Decision::Prompt, }; } match approval_policy { - AskForApproval::Never | AskForApproval::OnFailure => { + AskForApproval::Never => { // We allow the command to run, relying on the sandbox for // protection. Decision::Allow diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 96ae6c9908..12627e2d52 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -738,10 +738,9 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str); fn hook_permission_mode(turn_context: &TurnContext) -> String { match turn_context.approval_policy.value() { AskForApproval::Never => "bypassPermissions", - AskForApproval::UnlessTrusted - | AskForApproval::OnFailure - | AskForApproval::OnRequest - | AskForApproval::Granular(_) => "default", + AskForApproval::UnlessTrusted | AskForApproval::OnRequest | AskForApproval::Granular(_) => { + "default" + } } .to_string() } diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 0cb0c11a2a..71b3e60b7e 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -653,10 +653,8 @@ async fn maybe_request_codex_apps_auth_elicitation( AskForApproval::Granular(granular_config) if !granular_config.allows_mcp_elicitations() => { return result; } - AskForApproval::OnFailure - | AskForApproval::OnRequest - | AskForApproval::UnlessTrusted - | AskForApproval::Granular(_) => {} + AskForApproval::OnRequest | AskForApproval::UnlessTrusted | AskForApproval::Granular(_) => { + } } let connector_id = metadata.and_then(|metadata| metadata.connector_id.as_deref()); diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index f8b94de5aa..89b4f9de6c 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -2915,7 +2915,6 @@ async fn approve_mode_skips_guardian_in_every_permission_mode() { for approval_policy in [ AskForApproval::UnlessTrusted, - AskForApproval::OnFailure, AskForApproval::OnRequest, AskForApproval::Granular(GranularApprovalConfig { sandbox_approval: true, diff --git a/codex-rs/core/src/safety.rs b/codex-rs/core/src/safety.rs index 916379705b..1fe9b0257b 100644 --- a/codex-rs/core/src/safety.rs +++ b/codex-rs/core/src/safety.rs @@ -44,10 +44,7 @@ pub fn assess_patch_safety( } match policy { - AskForApproval::OnFailure - | AskForApproval::Never - | AskForApproval::OnRequest - | AskForApproval::Granular(_) => { + AskForApproval::Never | AskForApproval::OnRequest | AskForApproval::Granular(_) => { // Continue to see if this can be auto-approved. } // TODO(ragona): I'm not sure this is actually correct? I believe in this case @@ -66,9 +63,7 @@ pub fn assess_patch_safety( // Even though the patch appears to be constrained to writable paths, it is // possible that paths in the patch are hard links to files outside the // writable roots, so we should still run `apply_patch` in a sandbox in that case. - if is_write_patch_constrained_to_writable_paths(action, file_system_sandbox_policy, cwd) - || matches!(policy, AskForApproval::OnFailure) - { + if is_write_patch_constrained_to_writable_paths(action, file_system_sandbox_policy, cwd) { if matches!( permission_profile, PermissionProfile::Disabled | PermissionProfile::External { .. } diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index d0006f8657..1c570e1bbb 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -15,6 +15,7 @@ use crate::exec::execute_exec_request; use crate::spawn::CODEX_SANDBOX_ENV_VAR; use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_file_system::FileSystemSandboxContext; +use codex_network_proxy::ManagedNetworkSandboxContext; use codex_network_proxy::NetworkProxy; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::exec_output::ExecToolCallOutput; @@ -63,6 +64,7 @@ pub struct ExecRequest { pub arg0: Option, pub(crate) exec_server_sandbox: Option, pub(crate) exec_server_enforce_managed_network: bool, + pub(crate) exec_server_managed_network: Option, } impl ExecRequest { @@ -107,6 +109,7 @@ impl ExecRequest { arg0, exec_server_sandbox: None, exec_server_enforce_managed_network: false, + exec_server_managed_network: None, } } @@ -165,6 +168,7 @@ impl ExecRequest { arg0, exec_server_sandbox: None, exec_server_enforce_managed_network: false, + exec_server_managed_network: None, } } } diff --git a/codex-rs/core/src/session/context_window.rs b/codex-rs/core/src/session/context_window.rs new file mode 100644 index 0000000000..cd4e22ca00 --- /dev/null +++ b/codex-rs/core/src/session/context_window.rs @@ -0,0 +1,108 @@ +use super::session::Session; +use super::turn_context::TurnContext; +use codex_protocol::config_types::AutoCompactTokenLimitScope; + +#[derive(Debug)] +pub(crate) struct ContextWindowTokenStatus { + // Full active context usage, independent of the configured auto-compact scope. + pub(crate) active_context_tokens: i64, + // Usage counted against `model_auto_compact_token_limit` for the current scope. + pub(crate) auto_compact_scope_tokens: i64, + pub(crate) auto_compact_scope_limit: i64, + pub(crate) full_context_window_limit: Option, + pub(crate) tokens_until_compaction: i64, + pub(crate) auto_compact_window_prefill_tokens: Option, + pub(crate) full_context_window_limit_reached: bool, + pub(crate) token_limit_reached: bool, + pub(crate) context_remaining_tokens: Option, +} + +struct BodyAfterPrefixWindowStatus { + full_context_window_limit: Option, + auto_compact_window_prefill_tokens: Option, + has_context_remaining_limit: bool, +} + +pub(crate) async fn context_window_token_status( + sess: &Session, + turn_context: &TurnContext, +) -> ContextWindowTokenStatus { + let active_context_tokens = sess.get_total_token_usage().await; + + let (auto_compact_scope_tokens, auto_compact_scope_limit, body_window) = + match turn_context.config.model_auto_compact_token_limit_scope { + AutoCompactTokenLimitScope::Total => ( + active_context_tokens, + turn_context + .model_info + .auto_compact_token_limit() + .unwrap_or(i64::MAX), + None, + ), + AutoCompactTokenLimitScope::BodyAfterPrefix => { + let window = sess.auto_compact_window_snapshot().await; + let baseline = window.prefill_input_tokens.unwrap_or(active_context_tokens); + + let scope_limit = turn_context + .config + .model_auto_compact_token_limit + .or_else(|| turn_context.model_info.auto_compact_token_limit()); + let full_context_window_limit = turn_context.model_context_window(); + + ( + active_context_tokens.saturating_sub(baseline), + scope_limit.unwrap_or(i64::MAX), + Some(BodyAfterPrefixWindowStatus { + full_context_window_limit, + auto_compact_window_prefill_tokens: window.prefill_input_tokens, + has_context_remaining_limit: scope_limit.is_some() + || full_context_window_limit.is_some(), + }), + ) + } + }; + + let full_context_window_limit = body_window + .as_ref() + .and_then(|window| window.full_context_window_limit); + let auto_compact_window_prefill_tokens = body_window + .as_ref() + .and_then(|window| window.auto_compact_window_prefill_tokens); + + let full_context_window_limit_reached = + full_context_window_limit.is_some_and(|full_context_window_limit| { + active_context_tokens >= full_context_window_limit + }); + let token_limit_reached = + auto_compact_scope_tokens >= auto_compact_scope_limit || full_context_window_limit_reached; + + let full_context_remaining = full_context_window_limit.map_or(i64::MAX, |limit| { + limit.saturating_sub(active_context_tokens) + }); + let tokens_until_compaction = auto_compact_scope_limit + .saturating_sub(auto_compact_scope_tokens) + .min(full_context_remaining) + .max(0); + + let context_remaining_tokens = if let Some(body_window) = body_window { + body_window + .has_context_remaining_limit + .then_some(tokens_until_compaction) + } else { + turn_context + .model_context_window() + .map(|limit| limit.saturating_sub(active_context_tokens).max(0)) + }; + + ContextWindowTokenStatus { + active_context_tokens, + auto_compact_scope_tokens, + auto_compact_scope_limit, + full_context_window_limit, + tokens_until_compaction, + auto_compact_window_prefill_tokens, + full_context_window_limit_reached, + token_limit_reached, + context_remaining_tokens, + } +} diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 42c274fdb4..737335c598 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -371,9 +371,11 @@ impl Session { let current_manager = self.services.mcp_connection_manager.load_full(); refreshed_manager.set_elicitations_auto_deny(current_manager.elicitations_auto_deny()); } - self.services + let superseded_manager = self + .services .mcp_connection_manager - .store(Arc::new(refreshed_manager)); + .swap(Arc::new(refreshed_manager)); + superseded_manager.shutdown().await; } pub(crate) async fn refresh_mcp_servers_if_requested( diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index ce09422e7b..b6ee621b31 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -210,6 +210,7 @@ use codex_protocol::exec_output::StreamOutput; mod code_mode_warning; mod config_lock; +pub(crate) mod context_window; mod handlers; mod inject; mod input_queue; @@ -249,7 +250,6 @@ use self::turn::realtime_text_for_event; use self::turn_context::TurnContext; use self::turn_context::TurnSkillsContext; use self::world_state::build_world_state_from_environment_snapshot; -use self::world_state::build_world_state_from_turn_context; use self::world_state::build_world_state_from_turn_context_item; #[cfg(test)] mod rollout_reconstruction_tests; @@ -726,7 +726,7 @@ impl Codex { op: Op, trace: Option, ) -> CodexResult { - let id = Uuid::now_v7().to_string(); + let id = new_submission_id(); let sub = Submission { id: id.clone(), op, @@ -744,7 +744,7 @@ impl Codex { client_user_message_id: Option, ) -> CodexResult { debug_assert!(matches!(op, Op::UserInput { .. })); - let id = Uuid::now_v7().to_string(); + let id = new_submission_id(); let sub = Submission { id: id.clone(), op, @@ -871,6 +871,15 @@ impl Codex { } } +/// Generate a core submission ID. App-server exposes submission IDs that +/// create turns as a public-facing turn ID. +/// +/// Some use cases take advantage of the fact that these are UUID7 which +/// encodes a timestamp, so think carefully before changing this. +fn new_submission_id() -> String { + Uuid::now_v7().to_string() +} + fn get_service_tier( configured_service_tier: Option, fast_mode_enabled: bool, @@ -1404,7 +1413,9 @@ impl Session { let mut state = self.state.lock().await; state.replace_history(history, reference_context_item); if let Some(world_state) = world_state_baseline { - state.history.set_world_state_baseline(world_state); + state + .history + .set_world_state_baseline(Arc::new(world_state)); } let fallback_ids = state.auto_compact_window_ids(); let window_id = window_id.unwrap_or(fallback_ids.window_id); @@ -2250,8 +2261,7 @@ impl Session { strict_auto_review: false, }); } - AskForApproval::OnFailure - | AskForApproval::OnRequest + AskForApproval::OnRequest | AskForApproval::UnlessTrusted | AskForApproval::Granular(_) => {} } @@ -2780,25 +2790,27 @@ impl Session { pub(crate) async fn record_step_environment_context_if_changed( &self, turn_context: &TurnContext, + previous_world_state: &Arc, step_context: &step_context::StepContext, - ) { - if !turn_context.config.include_environment_context { - return; + ) -> Arc { + let world_state = Arc::new( + self.build_world_state_for_environments(turn_context, &step_context.environments) + .await, + ); + let items = crate::context_manager::updates::merge_contextual_fragments( + world_state.render_diff(previous_world_state.as_ref()), + ); + if !items.is_empty() { + self.record_conversation_items(turn_context, &items).await; } - let world_state = - build_world_state_from_environment_snapshot(turn_context, &step_context.environments); - let items = { - let mut state = self.state.lock().await; - crate::context_manager::updates::merge_contextual_fragments( - state.history.update_world_state(world_state), - ) - }; - if items.is_empty() { - return; - } - - self.record_conversation_items(turn_context, &items).await; + // ContextManager remembers this for later turns; run_turn owns the live value. + self.state + .lock() + .await + .history + .set_world_state_baseline(Arc::clone(&world_state)); + world_state } pub(crate) async fn record_inter_agent_communication( @@ -2900,6 +2912,7 @@ impl Session { turn_context: &TurnContext, items: Vec, reference_context_item: Option, + world_state_baseline: Option>, compacted_item: CompactedItem, ) { let items = if turn_context.config.features.enabled(Feature::ItemIds) { @@ -2911,11 +2924,6 @@ impl Session { replacement_history: Some(items.clone()), ..compacted_item }; - let world_state_baseline = if reference_context_item.is_some() { - Some(self.build_world_state(turn_context).await) - } else { - None - }; { let mut state = self.state.lock().await; state.replace_history(items, reference_context_item.clone()); @@ -3047,16 +3055,11 @@ impl Session { items } - pub(crate) async fn build_initial_context( + pub(crate) async fn build_world_state_for_environments( &self, turn_context: &TurnContext, - ) -> Vec { - let world_state = self.build_world_state(turn_context).await; - self.build_initial_context_with_world_state(turn_context, &world_state) - .await - } - - async fn build_world_state(&self, turn_context: &TurnContext) -> WorldState { + environments: &TurnEnvironmentSnapshot, + ) -> WorldState { let environment_subagents = if turn_context.config.include_environment_context { self.services .agent_control @@ -3065,10 +3068,14 @@ impl Session { } else { String::new() }; - build_world_state_from_turn_context(turn_context, &environment_subagents) + build_world_state_from_environment_snapshot( + turn_context, + environments, + &environment_subagents, + ) } - async fn build_initial_context_with_world_state( + pub(crate) async fn build_initial_context_with_world_state( &self, turn_context: &TurnContext, world_state: &WorldState, @@ -3417,15 +3424,15 @@ impl Session { pub(crate) async fn maybe_start_new_context_window( &self, turn_context: &TurnContext, + world_state: Arc, ) -> Option { let window = { let mut state = self.state.lock().await; state.start_new_context_window_if_requested() }; let (window_number, window_ids) = window?; - let world_state = self.build_world_state(turn_context).await; let context_items = self - .build_initial_context_with_world_state(turn_context, &world_state) + .build_initial_context_with_world_state(turn_context, world_state.as_ref()) .await; let turn_context_item = turn_context.to_turn_context_item(); let replacement_history = context_items; @@ -3476,7 +3483,7 @@ impl Session { pub(crate) async fn record_context_updates_and_set_reference_context_item( &self, turn_context: &TurnContext, - ) { + ) -> Arc { let reference_context_item = { let state = self.state.lock().await; state.reference_context_item() @@ -3484,16 +3491,19 @@ impl Session { let turn_context_item = turn_context.to_turn_context_item(); let turn_context_changed = reference_context_item.as_ref() != Some(&turn_context_item); let should_inject_full_context = reference_context_item.is_none(); - let world_state = self.build_world_state(turn_context).await; + let world_state = Arc::new( + self.build_world_state_for_environments(turn_context, &turn_context.environments) + .await, + ); let mut context_items = if should_inject_full_context { let context_items = self - .build_initial_context_with_world_state(turn_context, &world_state) + .build_initial_context_with_world_state(turn_context, world_state.as_ref()) .await; self.state .lock() .await .history - .set_world_state_baseline(world_state); + .set_world_state_baseline(Arc::clone(&world_state)); context_items } else { // Steady-state path: append only built-in context diffs here; turn-scoped extension @@ -3504,7 +3514,7 @@ impl Session { let world_state_items = { let mut state = self.state.lock().await; crate::context_manager::updates::merge_contextual_fragments( - state.history.update_world_state(world_state), + state.history.update_world_state(Arc::clone(&world_state)), ) }; context_items.extend(world_state_items); @@ -3517,7 +3527,7 @@ impl Session { ); } if !turn_context_changed && context_items.is_empty() { - return; + return world_state; } if !context_items.is_empty() { self.record_conversation_items(turn_context, &context_items) @@ -3532,6 +3542,7 @@ impl Session { // context items. let mut state = self.state.lock().await; state.set_reference_context_item(Some(turn_context_item)); + world_state } pub(crate) async fn update_token_usage_info( diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 4680437d09..ee62155a87 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -752,15 +752,6 @@ impl Session { ) { post_session_configured_events.push(event); } - if config.permissions.approval_policy.value() == AskForApproval::OnFailure { - post_session_configured_events.push(Event { - id: "".to_owned(), - msg: EventMsg::Warning(WarningEvent { - message: "`on-failure` approval policy is deprecated and will be removed in a future release. Use `on-request` for interactive approvals or `never` for non-interactive runs.".to_string(), - }), - }); - } - let auth = auth.as_ref(); let auth_mode = auth.map(CodexAuth::auth_mode).map(TelemetryAuthMode::from); let account_id = auth.and_then(CodexAuth::get_account_id); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index fc24b5824e..094461e3e9 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1289,7 +1289,7 @@ async fn reload_user_config_layer_updates_base_and_selected_profile_layers() { let profile_config_path = codex_home.join("work.config.toml"); std::fs::write( &base_config_path, - "model = \"base\"\napproval_policy = \"on-failure\"\n", + "model = \"base\"\napproval_policy = \"on-request\"\n", ) .expect("write base user config"); std::fs::write(&profile_config_path, "model = \"profile-old\"\n") @@ -2003,7 +2003,7 @@ async fn resumed_history_injects_initial_context_on_first_context_update_only() session .record_context_updates_and_set_reference_context_item(&turn_context) .await; - let initial_context = session.build_initial_context(&turn_context).await; + let initial_context = build_initial_context(&session, &turn_context).await; expected.extend(initial_context); let history_after_seed = session.clone_history().await; assert_eq!(expected, history_after_seed.raw_items()); @@ -2915,7 +2915,7 @@ async fn thread_rollback_drops_last_turn_from_history() { ) .await; - let initial_context = sess.build_initial_context(tc.as_ref()).await; + let initial_context = build_initial_context(&sess, tc.as_ref()).await; let turn_1 = vec![ user_message("turn 1 user"), assistant_message("turn 1 assistant"), @@ -2983,7 +2983,7 @@ async fn thread_rollback_clears_history_when_num_turns_exceeds_existing_turns() ) .await; - let initial_context = sess.build_initial_context(tc.as_ref()).await; + let initial_context = build_initial_context(&sess, tc.as_ref()).await; let turn_1 = vec![user_message("turn 1 user")]; let mut full_history = Vec::new(); full_history.extend(initial_context.clone()); @@ -3009,7 +3009,7 @@ async fn thread_rollback_clears_history_when_num_turns_exceeds_existing_turns() async fn thread_rollback_fails_without_persisted_thread_history() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let initial_context = sess.build_initial_context(tc.as_ref()).await; + let initial_context = build_initial_context(&sess, tc.as_ref()).await; sess.record_conversation_items(tc.as_ref(), &initial_context) .await; @@ -3419,7 +3419,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { async fn thread_rollback_fails_when_turn_in_progress() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let initial_context = sess.build_initial_context(tc.as_ref()).await; + let initial_context = build_initial_context(&sess, tc.as_ref()).await; sess.record_conversation_items(tc.as_ref(), &initial_context) .await; @@ -3440,7 +3440,7 @@ async fn thread_rollback_fails_when_turn_in_progress() { async fn thread_rollback_fails_when_num_turns_is_zero() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let initial_context = sess.build_initial_context(tc.as_ref()).await; + let initial_context = build_initial_context(&sess, tc.as_ref()).await; sess.record_conversation_items(tc.as_ref(), &initial_context) .await; @@ -5062,6 +5062,22 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { assert!(msg.contains("zsh fork feature enabled, but no packaged zsh fork is available")); } +async fn build_initial_context(session: &Session, turn_context: &TurnContext) -> Vec { + let world_state = build_world_state_from_turn_context(session, turn_context).await; + session + .build_initial_context_with_world_state(turn_context, &world_state) + .await +} + +pub(crate) async fn build_world_state_from_turn_context( + session: &Session, + turn_context: &TurnContext, +) -> WorldState { + session + .build_world_state_for_environments(turn_context, &turn_context.environments) + .await +} + // todo: use online model info pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let (tx_event, _rx_event) = async_channel::unbounded(); @@ -7723,7 +7739,7 @@ async fn build_initial_context_uses_previous_realtime_state() { let (session, mut turn_context) = make_session_and_context().await; turn_context.realtime_active = true; - let initial_context = session.build_initial_context(&turn_context).await; + let initial_context = build_initial_context(&session, &turn_context).await; let developer_texts = developer_input_texts(&initial_context); assert!( developer_texts @@ -7737,7 +7753,7 @@ async fn build_initial_context_uses_previous_realtime_state() { let mut state = session.state.lock().await; state.set_reference_context_item(Some(previous_context_item)); } - let resumed_context = session.build_initial_context(&turn_context).await; + let resumed_context = build_initial_context(&session, &turn_context).await; let resumed_developer_texts = developer_input_texts(&resumed_context); assert!( !resumed_developer_texts @@ -7836,7 +7852,7 @@ async fn build_initial_context_includes_prompt_fragments_from_extensions() { .thread_extension_data .insert(PromptExtensionTestState); - let initial_context = session.build_initial_context(&turn_context).await; + let initial_context = build_initial_context(&session, &turn_context).await; let developer_messages = developer_message_texts(&initial_context); assert!( @@ -7862,7 +7878,7 @@ async fn build_initial_context_includes_turn_context_fragments_from_extensions() expected_model_context_window: Some(50), }); - let initial_context = session.build_initial_context(&turn_context).await; + let initial_context = build_initial_context(&session, &turn_context).await; let developer_messages = developer_message_texts(&initial_context); assert!( @@ -7889,7 +7905,7 @@ async fn record_context_updates_includes_turn_context_fragments_on_steady_state_ }); let mut previous_context_item = turn_context.to_turn_context_item(); previous_context_item.turn_id = Some("previous-turn-id".to_string()); - let world_state = session.build_world_state(&turn_context).await; + let world_state = Arc::new(build_world_state_from_turn_context(&session, &turn_context).await); { let mut state = session.state.lock().await; state.set_reference_context_item(Some(previous_context_item)); @@ -7916,7 +7932,7 @@ async fn build_initial_context_omits_prompt_fragments_without_extension_state() let (mut session, turn_context) = make_session_and_context().await; session.services.extensions = prompt_extension_test_registry(); - let initial_context = session.build_initial_context(&turn_context).await; + let initial_context = build_initial_context(&session, &turn_context).await; let developer_messages = developer_message_texts(&initial_context); assert!( @@ -7933,7 +7949,7 @@ async fn build_initial_context_adds_multi_agent_v2_root_usage_hint_as_developer_ let (session, turn_context) = make_multi_agent_v2_usage_hint_test_session(/*enable_multi_agent_v2*/ true).await; - let initial_context = session.build_initial_context(turn_context.as_ref()).await; + let initial_context = build_initial_context(&session, turn_context.as_ref()).await; let developer_messages = developer_message_texts(&initial_context); assert!( @@ -7971,7 +7987,7 @@ async fn build_initial_context_adds_multi_agent_v2_subagent_usage_hint_as_develo .expect("thread settings should not be shared") .session_source = session_source; - let initial_context = session.build_initial_context(turn_context.as_ref()).await; + let initial_context = build_initial_context(&session, turn_context.as_ref()).await; let developer_messages = developer_message_texts(&initial_context); assert!( @@ -7993,7 +8009,7 @@ async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_feature_dis let (session, turn_context) = make_multi_agent_v2_usage_hint_test_session(/*enable_multi_agent_v2*/ false).await; - let initial_context = session.build_initial_context(turn_context.as_ref()).await; + let initial_context = build_initial_context(&session, turn_context.as_ref()).await; let developer_messages = developer_message_texts(&initial_context); assert!( @@ -8020,7 +8036,7 @@ async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_hint_is_emp ) .await; - let initial_context = session.build_initial_context(turn_context.as_ref()).await; + let initial_context = build_initial_context(&session, turn_context.as_ref()).await; let developer_messages = developer_message_texts(&initial_context); assert!( @@ -8050,7 +8066,7 @@ async fn build_initial_context_omits_default_image_save_location_with_image_hist ) .await; - let initial_context = session.build_initial_context(&turn_context).await; + let initial_context = build_initial_context(&session, &turn_context).await; let developer_texts = developer_input_texts(&initial_context); assert!( !developer_texts @@ -8064,7 +8080,7 @@ async fn build_initial_context_omits_default_image_save_location_with_image_hist async fn build_initial_context_omits_default_image_save_location_without_image_history() { let (session, turn_context) = make_session_and_context().await; - let initial_context = session.build_initial_context(&turn_context).await; + let initial_context = build_initial_context(&session, &turn_context).await; let developer_texts = developer_input_texts(&initial_context); assert!( @@ -8106,7 +8122,7 @@ async fn build_initial_context_trims_skill_metadata_from_context_window_budget() turn_context.model_info.context_window = Some(100); turn_context.turn_skills = TurnSkillsContext::new(HostSkillsSnapshot::new(Arc::new(outcome))); - let initial_context = session.build_initial_context(&turn_context).await; + let initial_context = build_initial_context(&session, &turn_context).await; let developer_texts = developer_input_texts(&initial_context); assert!( @@ -8257,7 +8273,7 @@ async fn build_initial_context_emits_thread_start_skill_warning_on_repeated_buil turn_context.model_info.context_window = Some(100); turn_context.turn_skills = TurnSkillsContext::new(HostSkillsSnapshot::new(Arc::new(outcome))); - let _ = session.build_initial_context(&turn_context).await; + let _ = build_initial_context(&session, &turn_context).await; let warning_event = timeout(Duration::from_secs(1), rx.recv()) .await .expect("warning event should arrive") @@ -8268,7 +8284,7 @@ async fn build_initial_context_emits_thread_start_skill_warning_on_repeated_buil if message == "Exceeded skills context budget of 2%. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list." )); - let _ = session.build_initial_context(&turn_context).await; + let _ = build_initial_context(&session, &turn_context).await; let warning_event = timeout(Duration::from_secs(1), rx.recv()) .await .expect("warning event should arrive on repeated build") @@ -8388,7 +8404,7 @@ async fn build_initial_context_uses_previous_turn_settings_for_realtime_end() { session .set_previous_turn_settings(Some(previous_turn_settings)) .await; - let initial_context = session.build_initial_context(&turn_context).await; + let initial_context = build_initial_context(&session, &turn_context).await; let developer_texts = developer_input_texts(&initial_context); assert!( developer_texts @@ -8411,7 +8427,7 @@ async fn build_initial_context_restates_realtime_start_when_reference_context_is session .set_previous_turn_settings(Some(previous_turn_settings)) .await; - let initial_context = session.build_initial_context(&turn_context).await; + let initial_context = build_initial_context(&session, &turn_context).await; let developer_texts = developer_input_texts(&initial_context); assert!( developer_texts @@ -8498,7 +8514,7 @@ async fn record_context_updates_and_set_reference_context_item_injects_full_cont .record_context_updates_and_set_reference_context_item(&turn_context) .await; let history = session.clone_history().await; - let initial_context = session.build_initial_context(&turn_context).await; + let initial_context = build_initial_context(&session, &turn_context).await; assert_eq!(history.raw_items().to_vec(), initial_context); let current_context = session.reference_context_item().await; @@ -8545,7 +8561,7 @@ async fn record_context_updates_and_set_reference_context_item_reinjects_full_co let history = session.clone_history().await; let mut expected_history = vec![compacted_summary]; - let initial_context = session.build_initial_context(&turn_context).await; + let initial_context = build_initial_context(&session, &turn_context).await; expected_history.extend(initial_context); assert_eq!(history.raw_items().to_vec(), expected_history); } @@ -8563,7 +8579,8 @@ async fn record_context_updates_and_set_reference_context_item_persists_baseline .with_model(next_model.to_string(), &session.services.models_manager) .await; let previous_context_item = previous_context.to_turn_context_item(); - let world_state = session.build_world_state(&previous_context).await; + let world_state = + Arc::new(build_world_state_from_turn_context(&session, &previous_context).await); { let mut state = session.state.lock().await; state.set_reference_context_item(Some(previous_context_item.clone())); @@ -8657,7 +8674,7 @@ async fn build_initial_context_prepends_model_switch_message() { session .set_previous_turn_settings(Some(previous_turn_settings)) .await; - let initial_context = session.build_initial_context(&turn_context).await; + let initial_context = build_initial_context(&session, &turn_context).await; let ResponseItem::Message { role, content, .. } = &initial_context[0] else { panic!("expected developer message"); @@ -9982,9 +9999,7 @@ async fn sample_rollout( // Use the same turn_context source as record_initial_history so model_info (and thus // personality_spec) matches reconstruction. let reconstruction_turn = session.new_default_turn().await; - let mut initial_context = session - .build_initial_context(reconstruction_turn.as_ref()) - .await; + let mut initial_context = build_initial_context(session, reconstruction_turn.as_ref()).await; // Ensure personality_spec is present when Personality is enabled, so expected matches // what reconstruction produces (build_initial_context may omit it when baked into model). if !initial_context.iter().any(|m| { @@ -10162,7 +10177,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { // Ensure policy is NOT OnRequest so the early rejection path triggers turn_context_raw .approval_policy - .set(AskForApproval::OnFailure) + .set(AskForApproval::Never) .expect("test setup should allow updating approval policy"); let session = Arc::new(session); let mut turn_context = Arc::new(turn_context_raw); @@ -10325,7 +10340,7 @@ async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() let (session, mut turn_context_raw) = make_session_and_context().await; turn_context_raw .approval_policy - .set(AskForApproval::OnFailure) + .set(AskForApproval::Never) .expect("test setup should allow updating approval policy"); let session = Arc::new(session); let turn_context = Arc::new(turn_context_raw); diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index e44b12ef72..aef75ccee9 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -407,7 +407,7 @@ async fn strict_auto_review_turn_grant_forces_guardian_for_shell_command_policy_ turn_context_raw .approval_policy - .set(AskForApproval::OnFailure) + .set(AskForApproval::Never) .expect("test setup should allow updating approval policy"); turn_context_raw.permission_profile = codex_protocol::models::PermissionProfile::Disabled; let mut config = (*turn_context_raw.config).clone(); @@ -523,8 +523,10 @@ async fn process_compacted_history_preserves_separate_guardian_developer_message } turn_context.session_source = guardian_source; turn_context.developer_instructions = Some(guardian_policy.clone()); + let world_state = Arc::new(build_world_state_from_turn_context(&session, &turn_context).await); + let initial_context_injection = InitialContextInjection::BeforeLastUserMessage(world_state); - let refreshed = crate::compact_remote::process_compacted_history( + let (refreshed, _) = crate::compact_remote::process_compacted_history( &session, &turn_context, vec![ @@ -547,7 +549,7 @@ async fn process_compacted_history_preserves_separate_guardian_developer_message internal_chat_message_metadata_passthrough: None, }, ], - InitialContextInjection::BeforeLastUserMessage, + &initial_context_injection, ) .await; diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 84442a6caf..d8ab0b281b 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -164,7 +164,9 @@ pub(crate) async fn run_turn( return Ok(None); } - sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref()) + // Keep the exact model-visible state used by this turn and its inline compactions. + let mut world_state = sess + .record_context_updates_and_set_reference_context_item(turn_context.as_ref()) .await; let Some((injection_items, explicitly_enabled_connectors)) = @@ -250,11 +252,13 @@ pub(crate) async fn run_turn( let step_context = StepContext { environments: sess.services.turn_environments.snapshot().await, }; - sess.record_step_environment_context_if_changed( - turn_context.as_ref(), - &step_context, - ) - .await; + world_state = sess + .record_step_environment_context_if_changed( + turn_context.as_ref(), + &world_state, + &step_context, + ) + .await; } // Construct the input that we will send to the model. @@ -294,8 +298,11 @@ pub(crate) async fn run_turn( let (has_pending_input, token_status, estimated_token_count) = async { let has_pending_input = sess.input_queue.has_pending_input(&sess.active_turn).await; - let token_status = - auto_compact_token_status(sess.as_ref(), turn_context.as_ref()).await; + let token_status = super::context_window::context_window_token_status( + sess.as_ref(), + turn_context.as_ref(), + ) + .await; let estimated_token_count = sess.get_estimated_token_count(turn_context.as_ref()).await; (has_pending_input, token_status, estimated_token_count) @@ -322,26 +329,15 @@ pub(crate) async fn run_turn( "post sampling token usage" ); - let tokens_after_sampling = token_status.active_context_tokens; - let full_context_remaining = token_status - .full_context_window_limit - .map_or(i64::MAX, |limit| { - limit.saturating_sub(tokens_after_sampling) - }); - let tokens_until_compaction = token_status - .auto_compact_scope_limit - .saturating_sub(token_status.auto_compact_scope_tokens) - .min(full_context_remaining) - .max(0); super::token_budget::maybe_record( sess.as_ref(), turn_context.as_ref(), - tokens_until_compaction, + token_status.tokens_until_compaction, ) .await; let started_new_context_window = sess - .maybe_start_new_context_window(turn_context.as_ref()) + .maybe_start_new_context_window(turn_context.as_ref(), Arc::clone(&world_state)) .await .is_some(); if started_new_context_window && needs_follow_up { @@ -361,7 +357,7 @@ pub(crate) async fn run_turn( &sess, &turn_context, &mut client_session, - InitialContextInjection::BeforeLastUserMessage, + InitialContextInjection::BeforeLastUserMessage(Arc::clone(&world_state)), CompactionReason::ContextLimit, CompactionPhase::MidTurn, ) @@ -802,68 +798,6 @@ async fn track_turn_resolved_config_analytics( }); } -#[derive(Debug)] -struct AutoCompactTokenStatus { - // Full active context usage, independent of the configured auto-compact scope. - active_context_tokens: i64, - // Usage counted against `model_auto_compact_token_limit` for the current scope. - auto_compact_scope_tokens: i64, - auto_compact_scope_limit: i64, - full_context_window_limit: Option, - auto_compact_window_prefill_tokens: Option, - full_context_window_limit_reached: bool, - token_limit_reached: bool, -} - -async fn auto_compact_token_status( - sess: &Session, - turn_context: &TurnContext, -) -> AutoCompactTokenStatus { - let active_context_tokens = sess.get_total_token_usage().await; - let mut auto_compact_window_prefill_tokens = None; - let (auto_compact_scope_tokens, auto_compact_scope_limit, full_context_window_limit) = - match turn_context.config.model_auto_compact_token_limit_scope { - AutoCompactTokenLimitScope::Total => ( - active_context_tokens, - turn_context - .model_info - .auto_compact_token_limit() - .unwrap_or(i64::MAX), - None, - ), - AutoCompactTokenLimitScope::BodyAfterPrefix => { - let window = sess.auto_compact_window_snapshot().await; - auto_compact_window_prefill_tokens = window.prefill_input_tokens; - let baseline = window.prefill_input_tokens.unwrap_or(active_context_tokens); - ( - active_context_tokens.saturating_sub(baseline), - turn_context - .config - .model_auto_compact_token_limit - .or_else(|| turn_context.model_info.auto_compact_token_limit()) - .unwrap_or(i64::MAX), - turn_context.model_context_window(), - ) - } - }; - let full_context_window_limit_reached = - full_context_window_limit.is_some_and(|full_context_window_limit| { - active_context_tokens >= full_context_window_limit - }); - let token_limit_reached = - auto_compact_scope_tokens >= auto_compact_scope_limit || full_context_window_limit_reached; - - AutoCompactTokenStatus { - active_context_tokens, - auto_compact_scope_tokens, - auto_compact_scope_limit, - full_context_window_limit, - auto_compact_window_prefill_tokens, - full_context_window_limit_reached, - token_limit_reached, - } -} - #[instrument(level = "trace", skip_all)] async fn run_pre_sampling_compact( sess: &Arc, @@ -879,7 +813,9 @@ async fn run_pre_sampling_compact( } maybe_run_previous_model_inline_compact(sess, turn_context, client_session).await?; - let token_status = auto_compact_token_status(sess.as_ref(), turn_context.as_ref()).await; + let token_status = + super::context_window::context_window_token_status(sess.as_ref(), turn_context.as_ref()) + .await; // Compact if the configured auto-compaction budget or usable context window is exhausted. if token_status.token_limit_reached { run_auto_compact( diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index 5d12857748..3d46522b8e 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -4,34 +4,21 @@ use crate::context::world_state::WorldState; use crate::environment_selection::TurnEnvironmentSnapshot; use codex_protocol::protocol::TurnContextItem; -pub(super) fn build_world_state_from_turn_context( +pub(super) fn build_world_state_from_environment_snapshot( turn_context: &TurnContext, + environments: &TurnEnvironmentSnapshot, environment_subagents: &str, ) -> WorldState { let mut world_state = WorldState::default(); if turn_context.config.include_environment_context { world_state.add_section( - EnvironmentsState::from_turn_context(turn_context) + EnvironmentsState::from_turn_context_with_environments(turn_context, environments) .with_subagents(environment_subagents.to_string()), ); } world_state } -pub(super) fn build_world_state_from_environment_snapshot( - turn_context: &TurnContext, - environments: &TurnEnvironmentSnapshot, -) -> WorldState { - let mut world_state = WorldState::default(); - if turn_context.config.include_environment_context { - world_state.add_section(EnvironmentsState::from_turn_context_with_environments( - turn_context, - environments, - )); - } - world_state -} - pub(super) fn build_world_state_from_turn_context_item( turn_context_item: &TurnContextItem, ) -> WorldState { diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 844e327bdf..d588e5a356 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -226,6 +226,7 @@ pub(crate) async fn execute_user_shell_command( arg0: None, exec_server_sandbox: None, exec_server_enforce_managed_network: false, + exec_server_managed_network: None, }; let stdout_stream = Some(StdoutStream { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 6375bd914c..ea9879bbd7 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -4,6 +4,7 @@ use crate::init_state_db; use crate::installation_id::INSTALLATION_ID_FILENAME; use crate::rollout::RolloutRecorder; use crate::session::session::SessionSettingsUpdate; +use crate::session::tests::build_world_state_from_turn_context; use crate::session::tests::make_session_and_context; use crate::tasks::InterruptedTurnHistoryMarker; use crate::tasks::interrupted_turn_history_marker; @@ -225,7 +226,10 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { #[tokio::test] async fn ignores_session_prefix_messages_when_truncating() { let (session, turn_context) = make_session_and_context().await; - let mut items = session.build_initial_context(&turn_context).await; + let world_state = build_world_state_from_turn_context(&session, &turn_context).await; + let mut items = session + .build_initial_context_with_world_state(&turn_context, &world_state) + .await; items.push(user_msg("feature request")); items.push(assistant_msg("ack")); items.push(user_msg("second question")); diff --git a/codex-rs/core/src/thread_rollout_truncation_tests.rs b/codex-rs/core/src/thread_rollout_truncation_tests.rs index 2df3d1c98e..2e6d8fde7c 100644 --- a/codex-rs/core/src/thread_rollout_truncation_tests.rs +++ b/codex-rs/core/src/thread_rollout_truncation_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::session::tests::build_world_state_from_turn_context; use crate::session::tests::make_session_and_context; use codex_protocol::AgentPath; use codex_protocol::models::ContentItem; @@ -166,7 +167,10 @@ fn truncates_rollout_from_start_applies_thread_rollback_markers() { #[tokio::test] async fn ignores_session_prefix_messages_when_truncating_rollout_from_start() { let (session, turn_context) = make_session_and_context().await; - let mut items = session.build_initial_context(&turn_context).await; + let world_state = build_world_state_from_turn_context(&session, &turn_context).await; + let mut items = session + .build_initial_context_with_world_state(&turn_context, &world_state) + .await; items.push(user_msg("feature request")); items.push(assistant_msg("ack")); items.push(user_msg("second question")); diff --git a/codex-rs/core/src/tools/handlers/get_context_remaining.rs b/codex-rs/core/src/tools/handlers/get_context_remaining.rs index b9a3d73ca8..aad773198a 100644 --- a/codex-rs/core/src/tools/handlers/get_context_remaining.rs +++ b/codex-rs/core/src/tools/handlers/get_context_remaining.rs @@ -75,19 +75,15 @@ impl ToolExecutor for GetContextRemainingHandler { )); } - let Some(model_context_window) = invocation.turn.model_context_window() else { - return Ok(boxed_tool_output(GetContextRemainingOutput::new( - /*tokens_left*/ None, - ))); - }; - let active_context_tokens = invocation.session.get_total_token_usage().await.max(0); - let tokens_left = model_context_window - .saturating_sub(active_context_tokens) - .max(0); + let token_status = crate::session::context_window::context_window_token_status( + invocation.session.as_ref(), + invocation.turn.as_ref(), + ) + .await; - Ok(boxed_tool_output(GetContextRemainingOutput::new(Some( - tokens_left, - )))) + Ok(boxed_tool_output(GetContextRemainingOutput::new( + token_status.context_remaining_tokens, + ))) }) } } diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 09a6844f19..ca04ef470e 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -24,7 +24,6 @@ use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; use codex_tools::ToolName; use codex_tools::ToolSpec; -use codex_utils_path_uri::PathUri; pub struct ViewImageHandler { options: ViewImageToolOptions, @@ -140,36 +139,31 @@ impl ViewImageHandler { "view_image is unavailable in this session".to_string(), )); }; - // TODO(anp): Resolve tool paths using the selected environment's native path convention - // so view_image can support relative paths in foreign environments. - let cwd = turn_environment.cwd().to_abs_path().map_err(|err| { + let path_uri = turn_environment.cwd().join(&path).map_err(|err| { FunctionCallError::RespondToModel(format!( - "environment cwd `{}` is not native to the Codex host: {err}", - turn_environment.cwd() + "unable to resolve image path `{path}` against environment cwd `{}`: {err}", + turn_environment.cwd(), )) })?; - let abs_path = cwd.join(path); + let model_visible_path = path_uri.inferred_native_path_string(); let sandbox = turn.file_system_sandbox_context( /*additional_permissions*/ None, turn_environment.cwd(), ); let fs = turn_environment.environment.get_filesystem(); - let path_uri = PathUri::from_abs_path(&abs_path); let metadata = fs .get_metadata(&path_uri, Some(&sandbox)) .await .map_err(|error| { FunctionCallError::RespondToModel(format!( - "unable to locate image at `{}`: {error}", - abs_path.display() + "unable to locate image at `{model_visible_path}`: {error}" )) })?; if !metadata.is_file { return Err(FunctionCallError::RespondToModel(format!( - "image path `{}` is not a file", - abs_path.display() + "image path `{model_visible_path}` is not a file" ))); } let file_bytes = fs @@ -177,11 +171,9 @@ impl ViewImageHandler { .await .map_err(|error| { FunctionCallError::RespondToModel(format!( - "unable to read image at `{}`: {error}", - abs_path.display() + "unable to read image at `{model_visible_path}`: {error}" )) })?; - let event_path = abs_path.clone(); let can_request_original_detail = can_request_original_image_detail(&turn.model_info); let use_original_detail = @@ -197,7 +189,7 @@ impl ViewImageHandler { let item = TurnItem::ImageView(ImageViewItem { id: call_id, - path: event_path, + path: path_uri, }); session.emit_turn_item_started(turn.as_ref(), &item).await; session.emit_turn_item_completed(turn.as_ref(), item).await; diff --git a/codex-rs/core/src/tools/network_approval_tests.rs b/codex-rs/core/src/tools/network_approval_tests.rs index 60c7215b1b..7a9ba86cef 100644 --- a/codex-rs/core/src/tools/network_approval_tests.rs +++ b/codex-rs/core/src/tools/network_approval_tests.rs @@ -250,7 +250,6 @@ fn allow_once_and_allow_for_session_both_allow_network() { fn only_never_policy_disables_network_approval_flow() { assert!(!allows_network_approval_flow(AskForApproval::Never)); assert!(allows_network_approval_flow(AskForApproval::OnRequest)); - assert!(allows_network_approval_flow(AskForApproval::OnFailure)); assert!(allows_network_approval_flow(AskForApproval::UnlessTrusted)); } diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 7ef7687842..840a0dd01e 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -205,7 +205,6 @@ impl Approvable for ApplyPatchRuntime { match policy { AskForApproval::Never => false, AskForApproval::Granular(granular_config) => granular_config.allows_sandbox_approval(), - AskForApproval::OnFailure => true, AskForApproval::OnRequest => true, AskForApproval::UnlessTrusted => true, } diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 27f4ee5946..db55cc2bbc 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -50,6 +50,7 @@ pub(crate) fn build_sandbox_command( args: args.to_vec(), cwd, env: env.clone(), + managed_network: None, additional_permissions, }) } @@ -67,26 +68,26 @@ pub(crate) fn exec_env_for_sandbox_permissions( env } -pub(crate) fn strip_managed_proxy_env(env: &mut HashMap) { - for key in PROXY_ENV_KEYS { - env.remove(*key); +pub(crate) fn is_managed_proxy_env_var(key: &str, value: &str) -> bool { + if PROXY_ENV_KEYS.contains(&key) { + return true; } - for key in CUSTOM_CA_ENV_KEYS { - if env - .get(key) - .is_some_and(|value| is_managed_mitm_ca_trust_bundle_path(value)) - { - env.remove(key); - } + if CUSTOM_CA_ENV_KEYS.contains(&key) { + return is_managed_mitm_ca_trust_bundle_path(value); } - // Only macOS injects a Codex-owned SSH wrapper for the managed SOCKS proxy. #[cfg(target_os = "macos")] - if env - .get(PROXY_GIT_SSH_COMMAND_ENV_KEY) - .is_some_and(|command| command.starts_with(CODEX_PROXY_GIT_SSH_COMMAND_MARKER)) { - env.remove(PROXY_GIT_SSH_COMMAND_ENV_KEY); + key == PROXY_GIT_SSH_COMMAND_ENV_KEY + && value.starts_with(CODEX_PROXY_GIT_SSH_COMMAND_MARKER) } + #[cfg(not(target_os = "macos"))] + { + false + } +} + +pub(crate) fn strip_managed_proxy_env(env: &mut HashMap) { + env.retain(|key, value| !is_managed_proxy_env_var(key, value)); } /// Prepends `path_entry` to `PATH`, removing duplicate and empty existing diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index a77645f941..10cb460205 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -168,6 +168,7 @@ pub(super) async fn try_run_zsh_fork( arg0, exec_server_sandbox: _, exec_server_enforce_managed_network: _, + exec_server_managed_network: _, } = sandbox_exec_request; let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?; let effective_timeout = Duration::from_millis( @@ -902,6 +903,7 @@ impl CoreShellCommandExecutor { arg0: self.arg0.clone(), exec_server_sandbox: None, exec_server_enforce_managed_network: false, + exec_server_managed_network: None, }, /*stdout_stream*/ None, after_spawn, @@ -1010,6 +1012,7 @@ impl CoreShellCommandExecutor { args: args.to_vec(), cwd, env, + managed_network: None, additional_permissions, }; let options = ExecOptions { diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index c1d5191204..5df42cc979 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -41,6 +41,7 @@ use crate::unified_exec::NoopSpawnLifecycle; use crate::unified_exec::UnifiedExecError; use crate::unified_exec::UnifiedExecProcess; use crate::unified_exec::UnifiedExecProcessManager; +use codex_network_proxy::ManagedNetworkSandboxContext; use codex_network_proxy::NetworkProxy; use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; @@ -117,6 +118,7 @@ fn build_unified_exec_sandbox_command( command: &[String], cwd: &PathUri, env: &HashMap, + managed_network: Option, additional_permissions: Option, ) -> Result { let (program, args) = command @@ -127,6 +129,7 @@ fn build_unified_exec_sandbox_command( args: args.to_vec(), cwd: cwd.clone(), env: env.clone(), + managed_network, additional_permissions, }) } @@ -321,22 +324,28 @@ impl<'a> ToolRuntime for UnifiedExecRunt req.network.as_ref(), launch_sandbox_permissions, ); - let mut env = exec_env_for_sandbox_permissions(&req.env, launch_sandbox_permissions); - if let Some(network) = managed_network { - network - .apply_to_env_for_optional_environment( - &mut env, - Some(&req.turn_environment.environment_id), - ) - .map_err(|err| { - ToolError::Codex(CodexErr::Io(io::Error::other(format!( - "failed to prepare network proxy for environment `{}`: {err}", - req.turn_environment.environment_id - )))) - })?; - } + let env = exec_env_for_sandbox_permissions(&req.env, launch_sandbox_permissions); + let (env, managed_network_context) = match managed_network { + Some(network) => { + let prepared = network + .prepare_for_optional_environment( + env, + Some(&req.turn_environment.environment_id), + ) + .map_err(|err| { + ToolError::Codex(CodexErr::Io(io::Error::other(format!( + "failed to prepare network proxy for environment `{}`: {err}", + req.turn_environment.environment_id + )))) + })?; + (prepared.env, Some(prepared.sandbox_context)) + } + None => (env, None), + }; let explicit_env_overrides = req.explicit_env_overrides.clone(); #[cfg(unix)] + let mut env = env; + #[cfg(unix)] let runtime_path_prepends = { let mut runtime_path_prepends = RuntimePathPrepends::default(); if !environment_is_remote { @@ -385,6 +394,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt &command, &req.cwd, &env, + managed_network_context.clone(), req.additional_permissions.clone(), ) .map_err(|error| match error { @@ -450,6 +460,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt &command, &req.cwd, &env, + managed_network_context, req.additional_permissions.clone(), ) .map_err(|error| match error { diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 77f68d96b5..8e9e274f24 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -196,7 +196,7 @@ impl ExecApprovalRequirement { } } -/// - Never, OnFailure: do not ask +/// - Never: do not ask /// - OnRequest: ask unless filesystem access is unrestricted /// - Granular: ask unless filesystem access is unrestricted, but auto-reject /// when granular sandbox approval is disabled. @@ -206,7 +206,7 @@ pub(crate) fn default_exec_approval_requirement( file_system_sandbox_policy: &FileSystemSandboxPolicy, ) -> ExecApprovalRequirement { let needs_approval = match policy { - AskForApproval::Never | AskForApproval::OnFailure => false, + AskForApproval::Never => false, AskForApproval::OnRequest | AskForApproval::Granular(_) => { matches!( file_system_sandbox_policy.kind, @@ -356,7 +356,6 @@ pub(crate) trait Approvable { /// Decide we can request an approval for no-sandbox execution. fn wants_no_sandbox_approval(&self, policy: AskForApproval) -> bool { match policy { - AskForApproval::OnFailure => true, AskForApproval::UnlessTrusted => true, AskForApproval::Never => false, AskForApproval::OnRequest => false, @@ -466,6 +465,7 @@ impl<'a> SandboxAttempt<'a> { network: Option<&NetworkProxy>, environment_id: Option<&str>, ) -> Result { + let managed_network = command.managed_network.clone(); let exec_server_permissions = effective_permission_profile( self.exec_server_permissions, command.additional_permissions.as_ref(), @@ -492,6 +492,7 @@ impl<'a> SandboxAttempt<'a> { options, self.workspace_roots.to_vec(), ); + exec_request.exec_server_managed_network = managed_network; if self.sandbox_requested { exec_request.exec_server_sandbox = Some(FileSystemSandboxContext { permissions: exec_server_permissions.into(), diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index c647e1c401..28b9de9ce1 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -1,6 +1,7 @@ use super::*; use crate::sandboxing::SandboxPermissions; use crate::tools::hook_names::HookToolName; +use codex_network_proxy::ManagedNetworkSandboxContext; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -212,7 +213,7 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { .clone() .materialize_project_roots_with_workspace_roots(std::slice::from_ref(&cwd)); let manager = SandboxManager::new(); - let attempt = SandboxAttempt { + let mut attempt = SandboxAttempt { sandbox: SandboxType::None, sandbox_requested: true, permissions: &permissions, @@ -227,20 +228,24 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { windows_sandbox_private_desktop: false, network_denial_cancellation_token: None, }; - let command = SandboxCommand { + let managed_network = ManagedNetworkSandboxContext { + loopback_ports: vec![43123], + allow_local_binding: false, + }; + let command = || SandboxCommand { program: "/bin/bash".into(), args: vec!["-lc".to_string(), "pwd".to_string()], cwd: cwd_uri.clone(), env: HashMap::new(), + managed_network: Some(managed_network.clone()), additional_permissions: None, }; - let options = crate::sandboxing::ExecOptions { + let options = || crate::sandboxing::ExecOptions { expiration: crate::exec::ExecExpiration::DefaultTimeout, capture_policy: crate::exec::ExecCapturePolicy::ShellTool, }; - let request = attempt - .env_for_exec_server(command, options, /*network*/ None, Some("remote")) + .env_for_exec_server(command(), options(), /*network*/ None, Some("remote")) .expect("prepare remote exec request"); assert_eq!( @@ -256,8 +261,8 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { assert_eq!( request.exec_server_sandbox, Some(codex_exec_server::FileSystemSandboxContext { - permissions: exec_server_permissions.into(), - cwd: Some(cwd_uri), + permissions: exec_server_permissions.clone().into(), + cwd: Some(cwd_uri.clone()), workspace_roots: Vec::new(), windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -265,4 +270,17 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { }) ); assert!(request.exec_server_enforce_managed_network); + assert_eq!( + request.exec_server_managed_network, + Some(managed_network.clone()) + ); + + attempt.sandbox_requested = false; + let request = attempt + .env_for_exec_server(command(), options(), /*network*/ None, Some("remote")) + .expect("prepare unsandboxed remote exec request"); + + assert_eq!(request.exec_server_sandbox, None); + assert!(!request.exec_server_enforce_managed_network); + assert_eq!(request.exec_server_managed_network, Some(managed_network)); } diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 51cd436503..d161d2c3b9 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -96,6 +96,7 @@ use tracing::warn; const MULTI_AGENT_V2_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents."; const IMAGE_GEN_NAMESPACE: &str = "image_gen"; const IMAGEGEN_TOOL_NAME: &str = "imagegen"; +const ACTOR_AUTHORIZATION_HEADER: &str = "x-openai-actor-authorization"; type PlannedRuntime = Arc; @@ -383,10 +384,12 @@ fn image_generation_tool_enabled(turn_context: &TurnContext) -> bool { } fn image_generation_runtime_enabled(turn_context: &TurnContext) -> bool { - turn_context - .auth_manager - .as_deref() - .is_some_and(AuthManager::current_auth_uses_codex_backend) + (provider_uses_actor_authorization(turn_context) + || (turn_context.provider.info().requires_openai_auth + && turn_context + .auth_manager + .as_deref() + .is_some_and(AuthManager::current_auth_uses_codex_backend))) && turn_context.provider.capabilities().image_generation && turn_context .model_info @@ -394,6 +397,16 @@ fn image_generation_runtime_enabled(turn_context: &TurnContext) -> bool { .contains(&InputModality::Image) } +fn provider_uses_actor_authorization(turn_context: &TurnContext) -> bool { + let provider_info = turn_context.provider.info(); + !provider_info.requires_openai_auth + && provider_info.http_headers.as_ref().is_some_and(|headers| { + headers.iter().any(|(name, value)| { + name.eq_ignore_ascii_case(ACTOR_AUTHORIZATION_HEADER) && !value.trim().is_empty() + }) + }) +} + fn standalone_image_generation_model_visible(turn_context: &TurnContext) -> bool { if !image_generation_runtime_enabled(turn_context) || !namespace_tools_enabled(turn_context) { return false; diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 3fe33b141b..689948ad29 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::collections::HashMap; use std::sync::Arc; use codex_features::Feature; @@ -270,6 +271,29 @@ fn use_bedrock_provider(turn: &mut TurnContext) { turn.provider = create_model_provider(provider_info, turn.auth_manager.clone()); } +fn use_provider_auth( + turn: &mut TurnContext, + requires_openai_auth: bool, + actor_header: Option<(&str, &str)>, +) { + let mut provider_info = turn.config.model_provider.clone(); + provider_info.requires_openai_auth = requires_openai_auth; + provider_info.http_headers = actor_header.map(|(name, value)| { + HashMap::from([ + (name.to_string(), value.to_string()), + ( + "ChatGPT-Account-ID".to_string(), + "test-account-id".to_string(), + ), + ]) + }); + turn.auth_manager = None; + update_config(turn, |config| { + config.model_provider = provider_info.clone(); + }); + turn.provider = create_model_provider(provider_info, /*auth_manager*/ None); +} + struct WebRunExtensionTool; impl ToolExecutor for WebRunExtensionTool { @@ -1455,6 +1479,110 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() { .await; api_key_auth.assert_visible_lacks(&["image_generation"]); + let unrelated_chatgpt_auth = probe(|turn| { + use_chatgpt_auth(turn); + set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); + let mut provider_info = turn.provider.info().clone(); + provider_info.requires_openai_auth = false; + provider_info.http_headers = None; + update_config(turn, |config| { + config.model_provider = provider_info.clone(); + }); + turn.provider = create_model_provider(provider_info, turn.auth_manager.clone()); + turn.model_info.input_modalities = vec![InputModality::Image]; + }) + .await; + unrelated_chatgpt_auth.assert_visible_lacks(&["image_generation"]); + + let provider_without_actor_auth = probe(|turn| { + set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); + use_provider_auth( + turn, /*requires_openai_auth*/ false, /*actor_header*/ None, + ); + turn.model_info.input_modalities = vec![InputModality::Image]; + }) + .await; + provider_without_actor_auth.assert_visible_lacks(&["image_generation"]); + + let provider_authenticated = probe(|turn| { + set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); + use_provider_auth( + turn, + /*requires_openai_auth*/ false, + Some(("X-OpenAI-Actor-Authorization", "test-actor-authorization")), + ); + turn.model_info.input_modalities = vec![InputModality::Image]; + }) + .await; + provider_authenticated.assert_visible_contains(&["image_generation"]); + + let empty_actor_auth = probe(|turn| { + set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); + use_provider_auth( + turn, + /*requires_openai_auth*/ false, + Some(("x-openai-actor-authorization", " ")), + ); + turn.model_info.input_modalities = vec![InputModality::Image]; + }) + .await; + empty_actor_auth.assert_visible_lacks(&["image_generation"]); + + let feature_disabled = probe(|turn| { + set_feature(turn, Feature::ImageGeneration, /*enabled*/ false); + use_provider_auth( + turn, + /*requires_openai_auth*/ false, + Some(("x-openai-actor-authorization", "test-actor-authorization")), + ); + turn.model_info.input_modalities = vec![InputModality::Image]; + }) + .await; + feature_disabled.assert_visible_lacks(&["image_generation"]); + + let text_only_model = probe(|turn| { + set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); + use_provider_auth( + turn, + /*requires_openai_auth*/ false, + Some(("x-openai-actor-authorization", "test-actor-authorization")), + ); + turn.model_info.input_modalities = vec![]; + }) + .await; + text_only_model.assert_visible_lacks(&["image_generation"]); + + let unsupported_image_generation_provider = probe(|turn| { + set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); + use_bedrock_provider(turn); + let mut provider_info = turn.provider.info().clone(); + provider_info.requires_openai_auth = false; + provider_info.http_headers = Some(HashMap::from([( + "x-openai-actor-authorization".to_string(), + "test-actor-authorization".to_string(), + )])); + turn.auth_manager = None; + update_config(turn, |config| { + config.model_provider = provider_info.clone(); + }); + turn.provider = create_model_provider(provider_info, /*auth_manager*/ None); + turn.model_info.input_modalities = vec![InputModality::Image]; + }) + .await; + unsupported_image_generation_provider.assert_visible_lacks(&["image_generation"]); + + let codex_managed_auth_provider = probe(|turn| { + set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); + use_provider_auth( + turn, + /*requires_openai_auth*/ true, + Some(("x-openai-actor-authorization", "test-actor-authorization")), + ); + turn.model_info.input_modalities = vec![InputModality::Image]; + }) + .await; + codex_managed_auth_provider.assert_visible_lacks(&["image_generation"]); + let image_generation = probe(|turn| { use_chatgpt_auth(turn); set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 2cb62a6d4b..5e89445765 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -26,6 +26,7 @@ use crate::tools::events::ToolEventStage; use crate::tools::network_approval::DeferredNetworkApproval; use crate::tools::network_approval::finish_deferred_network_approval; use crate::tools::orchestrator::ToolOrchestrator; +use crate::tools::runtimes::is_managed_proxy_env_var; use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest; use crate::tools::runtimes::unified_exec::UnifiedExecRuntime; use crate::tools::sandboxing::SandboxAttempt; @@ -143,10 +144,16 @@ fn exec_server_env_for_request( HashMap, ) { if let Some(exec_server_env_config) = &request.exec_server_env_config { - ( - Some(exec_server_env_config.policy.clone()), - env_overlay_for_exec_server(&request.env, &exec_server_env_config.local_policy_env), - ) + let mut env = + env_overlay_for_exec_server(&request.env, &exec_server_env_config.local_policy_env); + if request.exec_server_managed_network.is_some() { + for (key, value) in &request.env { + if is_managed_proxy_env_var(key, value) { + env.insert(key.clone(), value.clone()); + } + } + } + (Some(exec_server_env_config.policy.clone()), env) } else { (None, request.env.clone()) } @@ -175,6 +182,7 @@ fn exec_server_params_for_request( arg0: request.arg0.clone(), sandbox: request.exec_server_sandbox.clone(), enforce_managed_network: request.exec_server_enforce_managed_network, + managed_network: request.exec_server_managed_network.clone(), } } diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index cd48d01c7c..93ffd0cedb 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::unified_exec::clamp_yield_time; +use codex_network_proxy::ManagedNetworkSandboxContext; use pretty_assertions::assert_eq; use tokio::time::Duration; use tokio::time::Instant; @@ -76,6 +77,10 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { codex_protocol::permissions::FileSystemSandboxPolicy::unrestricted(); let network_sandbox_policy = codex_protocol::permissions::NetworkSandboxPolicy::Restricted; let permission_profile = codex_protocol::models::PermissionProfile::Disabled; + let managed_network = ManagedNetworkSandboxContext { + loopback_ports: vec![43123], + allow_local_binding: false, + }; let mut request = ExecRequest { command: vec!["bash".to_string(), "-lc".to_string(), "true".to_string()], cwd: cwd.clone().into(), @@ -83,6 +88,15 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { ("HOME".to_string(), "/client-home".to_string()), ("PATH".to_string(), "/sandbox-path".to_string()), ("CODEX_THREAD_ID".to_string(), "thread-1".to_string()), + ( + "HTTP_PROXY".to_string(), + "http://127.0.0.1:43123".to_string(), + ), + ("CODEX_NETWORK_PROXY_ACTIVE".to_string(), "1".to_string()), + ( + "SSL_CERT_FILE".to_string(), + "/client/custom-ca.pem".to_string(), + ), ]), exec_server_env_config: Some(ExecServerEnvConfig { policy: codex_exec_server::ExecEnvPolicy { @@ -95,6 +109,15 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { local_policy_env: HashMap::from([ ("HOME".to_string(), "/client-home".to_string()), ("PATH".to_string(), "/client-path".to_string()), + ( + "HTTP_PROXY".to_string(), + "http://127.0.0.1:43123".to_string(), + ), + ("CODEX_NETWORK_PROXY_ACTIVE".to_string(), "1".to_string()), + ( + "SSL_CERT_FILE".to_string(), + "/client/custom-ca.pem".to_string(), + ), ]), }), network: None, @@ -112,7 +135,8 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { windows_sandbox_filesystem_overrides: None, arg0: None, exec_server_sandbox: None, - exec_server_enforce_managed_network: false, + exec_server_enforce_managed_network: true, + exec_server_managed_network: Some(managed_network.clone()), }; let params = @@ -120,12 +144,19 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { assert_eq!(params.process_id.as_str(), "123"); assert_eq!(params.cwd, request.cwd); + assert!(params.enforce_managed_network); + assert_eq!(params.managed_network, Some(managed_network)); assert!(params.env_policy.is_some()); assert_eq!( params.env, HashMap::from([ ("PATH".to_string(), "/sandbox-path".to_string()), ("CODEX_THREAD_ID".to_string(), "thread-1".to_string()), + ( + "HTTP_PROXY".to_string(), + "http://127.0.0.1:43123".to_string(), + ), + ("CODEX_NETWORK_PROXY_ACTIVE".to_string(), "1".to_string(),), ]) ); request.exec_server_sandbox = Some( diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f26b8e4b85..f0ab48c5e1 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -1085,40 +1085,6 @@ fn scenarios() -> Vec { output_contains: "rejected by user", }, }, - ScenarioSpec { - name: "danger_full_access_on_failure_allows_outside_write", - approval_policy: OnFailure, - sandbox_policy: SandboxPolicy::DangerFullAccess, - action: ActionKind::WriteFile { - target: TargetPath::OutsideWorkspace("dfa_on_failure.txt"), - content: "danger-on-failure", - }, - sandbox_permissions: SandboxPermissions::UseDefault, - features: vec![], - model_override: Some("gpt-5.2"), - outcome: Outcome::Auto, - expectation: Expectation::FileCreated { - target: TargetPath::OutsideWorkspace("dfa_on_failure.txt"), - content: "danger-on-failure", - }, - }, - ScenarioSpec { - name: "danger_full_access_on_failure_allows_outside_write_gpt_5_1_no_exit", - approval_policy: OnFailure, - sandbox_policy: SandboxPolicy::DangerFullAccess, - action: ActionKind::WriteFile { - target: TargetPath::OutsideWorkspace("dfa_on_failure_5_1.txt"), - content: "danger-on-failure", - }, - sandbox_permissions: SandboxPermissions::UseDefault, - features: vec![], - model_override: Some("gpt-5.4"), - outcome: Outcome::Auto, - expectation: Expectation::FileCreatedNoExitCode { - target: TargetPath::OutsideWorkspace("dfa_on_failure_5_1.txt"), - content: "danger-on-failure", - }, - }, ScenarioSpec { name: "danger_full_access_unless_trusted_requests_approval", approval_policy: UnlessTrusted, @@ -1297,48 +1263,6 @@ fn scenarios() -> Vec { message_contains: &["exec command rejected by user"], }, }, - #[cfg(not(target_os = "linux"))] // TODO (pakrym): figure out why linux behaves differently - ScenarioSpec { - name: "read_only_on_failure_escalates_after_sandbox_error", - approval_policy: OnFailure, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - action: ActionKind::WriteFile { - target: TargetPath::Workspace("ro_on_failure.txt"), - content: "read-only-on-failure", - }, - sandbox_permissions: SandboxPermissions::UseDefault, - features: vec![], - model_override: Some("gpt-5.2"), - outcome: Outcome::ExecApproval { - decision: ReviewDecision::Approved, - expected_reason: Some("command failed; retry without sandbox?"), - }, - expectation: Expectation::FileCreated { - target: TargetPath::Workspace("ro_on_failure.txt"), - content: "read-only-on-failure", - }, - }, - #[cfg(not(target_os = "linux"))] - ScenarioSpec { - name: "read_only_on_failure_escalates_after_sandbox_error_gpt_5_1_no_exit", - approval_policy: OnFailure, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - action: ActionKind::WriteFile { - target: TargetPath::Workspace("ro_on_failure_5_1.txt"), - content: "read-only-on-failure", - }, - sandbox_permissions: SandboxPermissions::UseDefault, - features: vec![], - model_override: Some("gpt-5.4"), - outcome: Outcome::ExecApproval { - decision: ReviewDecision::Approved, - expected_reason: Some("command failed; retry without sandbox?"), - }, - expectation: Expectation::FileCreatedNoExitCode { - target: TargetPath::Workspace("ro_on_failure_5_1.txt"), - content: "read-only-on-failure", - }, - }, ScenarioSpec { name: "read_only_on_request_network_escalates_when_approved", approval_policy: OnRequest, @@ -1676,27 +1600,6 @@ fn scenarios() -> Vec { body_contains: "workspace-network-ok", }, }, - #[cfg(not(target_os = "linux"))] // TODO (pakrym): figure out why linux behaves differently - ScenarioSpec { - name: "workspace_write_on_failure_escalates_outside_workspace", - approval_policy: OnFailure, - sandbox_policy: workspace_write(false), - action: ActionKind::WriteFile { - target: TargetPath::OutsideWorkspace("ww_on_failure.txt"), - content: "workspace-on-failure", - }, - sandbox_permissions: SandboxPermissions::UseDefault, - features: vec![], - model_override: Some("gpt-5.2"), - outcome: Outcome::ExecApproval { - decision: ReviewDecision::Approved, - expected_reason: Some("command failed; retry without sandbox?"), - }, - expectation: Expectation::FileCreated { - target: TargetPath::OutsideWorkspace("ww_on_failure.txt"), - content: "workspace-on-failure", - }, - }, ScenarioSpec { name: "workspace_write_unless_trusted_requires_approval_outside_workspace", approval_policy: UnlessTrusted, @@ -2982,7 +2885,7 @@ mode = "limited" allow_local_binding = true "#, )?; - let approval_policy = AskForApproval::OnFailure; + let approval_policy = AskForApproval::OnRequest; let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], network_access: true, @@ -3467,7 +3370,7 @@ mode = "limited" allow_local_binding = true "#, )?; - let approval_policy = AskForApproval::OnFailure; + let approval_policy = AskForApproval::OnRequest; let turn_sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], network_access: true, diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index f0ad29fd68..71c0acfe84 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -2200,7 +2200,7 @@ allow_local_binding = true ) .await; - let approval_policy = AskForApproval::OnFailure; + let approval_policy = AskForApproval::OnRequest; let permission_profile = network_workspace_write_profile(); let permission_profile_for_config = permission_profile.clone(); let test = test_codex() @@ -2283,71 +2283,6 @@ allow_local_binding = true Ok(()) } -#[cfg(not(target_os = "linux"))] -#[tokio::test] -async fn permission_request_hook_sees_retry_context_after_sandbox_denial() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let call_id = "permissionrequest-retry-shell-command"; - let marker = "permissionrequest_retry_marker.txt"; - let command = format!("printf retry > {marker}"); - let args = serde_json::json!({ "command": command }); - let responses = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-1"), - core_test_support::responses::ev_function_call( - call_id, - "shell_command", - &serde_json::to_string(&args)?, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_assistant_message("msg-1", "permission request hook allowed retry"), - ev_completed("resp-2"), - ]), - ], - ) - .await; - - let mut builder = test_codex() - .with_pre_build_hook(|home| { - install_allow_permission_request_hook(home) - .expect("failed to write permission request hook test fixture"); - }) - .with_config(trust_discovered_hooks); - let test = builder.build(&server).await?; - let marker_path = test.workspace_path(marker); - let _ = fs::remove_file(&marker_path); - - test.submit_turn_with_approval_and_permission_profile( - "retry the shell command after sandbox denial", - AskForApproval::OnFailure, - PermissionProfile::read_only(), - ) - .await?; - - let requests = responses.requests(); - assert_eq!(requests.len(), 2); - requests[1].function_call_output(call_id); - assert_eq!( - fs::read_to_string(&marker_path).context("read retry marker")?, - "retry" - ); - - assert_single_permission_request_hook_input( - test.codex_home_path(), - &command, - /*description*/ None, - )?; - - Ok(()) -} - #[tokio::test] async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs b/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs new file mode 100644 index 0000000000..a8e06bb35b --- /dev/null +++ b/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs @@ -0,0 +1,127 @@ +use std::collections::HashMap; +use std::fs; +use std::sync::Arc; +use std::time::Duration; + +use codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID; +use codex_config::types::McpServerConfig; +use codex_config::types::McpServerTransportConfig; +use core_test_support::process::process_is_alive; +use core_test_support::process::wait_for_pid_file; +use core_test_support::process::wait_for_process_exit; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use core_test_support::stdio_server_bin; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_mcp_server; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn refresh_shuts_down_superseded_mcp_stdio_server() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let temp_dir = tempfile::tempdir()?; + let pid_file = temp_dir.path().join("mcp.pid"); + let pid_file_for_config = pid_file.clone(); + let command = stdio_server_bin()?; + let fixture = test_codex() + .with_config(move |config| { + let mut servers = config.mcp_servers.get().clone(); + servers.insert( + "refresh_cleanup".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command, + args: Vec::new(), + env: Some(HashMap::from([( + "MCP_TEST_PID_FILE".to_string(), + pid_file_for_config.to_string_lossy().into_owned(), + )])), + env_vars: Vec::new(), + cwd: None, + }, + environment_id: DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: Some(Duration::from_secs(10)), + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth: None, + oauth_resource: None, + tools: HashMap::new(), + }, + ); + config + .mcp_servers + .set(servers) + .expect("test MCP servers should accept any configuration"); + }) + .build(&server) + .await?; + wait_for_mcp_server(&fixture.codex, "refresh_cleanup").await?; + + let superseded_pid = wait_for_pid_file(&pid_file).await?; + assert!(process_is_alive(&superseded_pid)?); + + let barrier = serde_json::json!({ + "id": "mcp-refresh-cleanup", + "participants": 2, + "timeout_ms": 1_000 + }); + let long_call = tokio::spawn({ + let codex = Arc::clone(&fixture.codex); + let barrier = barrier.clone(); + async move { + codex + .call_mcp_tool( + "refresh_cleanup", + "sync", + Some(serde_json::json!({ + "barrier": barrier, + "sleep_after_ms": 30_000 + })), + /*meta*/ None, + ) + .await + } + }); + fixture + .codex + .call_mcp_tool( + "refresh_cleanup", + "sync", + Some(serde_json::json!({ "barrier": barrier })), + /*meta*/ None, + ) + .await?; + fs::remove_file(&pid_file)?; + + responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "done"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + fixture + .codex + .set_openai_form_elicitation_support(/*supported*/ true) + .await?; + fixture.submit_turn("refresh MCP servers").await?; + + let replacement_pid = wait_for_pid_file(&pid_file).await?; + assert_ne!(replacement_pid, superseded_pid); + wait_for_process_exit(&superseded_pid).await?; + assert!(process_is_alive(&replacement_pid)?); + assert!(long_call.await?.is_err()); + + fixture.codex.shutdown_and_wait().await?; + wait_for_process_exit(&replacement_pid).await +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index a9298ec6d9..3d4a191f12 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -65,6 +65,8 @@ mod image_rollout; mod items; mod json_result; mod live_cli; +#[cfg(unix)] +mod mcp_refresh_cleanup; mod mcp_tool_exposure; mod mcp_turn_metadata; mod model_overrides; diff --git a/codex-rs/core/tests/suite/network_approval.rs b/codex-rs/core/tests/suite/network_approval.rs index f476fb7287..e644fcf6b8 100644 --- a/codex-rs/core/tests/suite/network_approval.rs +++ b/codex-rs/core/tests/suite/network_approval.rs @@ -165,7 +165,7 @@ mode = "limited" allow_local_binding = true "#, )?; - let approval_policy = AskForApproval::OnFailure; + let approval_policy = AskForApproval::OnRequest; let permission_profile = PermissionProfile::workspace_write_with( &[], NetworkSandboxPolicy::Enabled, @@ -263,7 +263,7 @@ async fn submit_managed_network_turn( additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { environments: Some(turn_environment_selections), - approval_policy: Some(AskForApproval::OnFailure), + approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 2df03f92a8..fab1f01a47 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -1,6 +1,7 @@ use anyhow::Context; use anyhow::Result; use codex_config::types::ApprovalsReviewer; +use codex_core::compact::SUMMARIZATION_PROMPT; use codex_core::config::Constrained; use codex_exec_server::CopyOptions; use codex_exec_server::CreateDirectoryOptions; @@ -38,6 +39,7 @@ use core_test_support::get_remote_test_env; use core_test_support::responses::ev_apply_patch_custom_tool_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_completed_with_tokens; use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; @@ -313,151 +315,6 @@ async fn explicit_remote_shell_runs_in_remote_cwd() -> Result<()> { Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn remote_sandbox_denial_requests_approval_and_retries() -> Result<()> { - skip_if_no_network!(Ok(())); - skip_if_wine_exec!(Ok(()), "requires the Docker-backed POSIX executor"); - let Some(_remote_env) = get_remote_test_env() else { - return Ok(()); - }; - - const CALL_ID: &str = "remote-sandbox-denial"; - const CONTENTS: &str = "remote sandbox retry succeeded"; - - let server = start_mock_server().await; - let test = unified_exec_test(&server).await?; - let nonce = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis(); - let remote_cwd = PathBuf::from(format!("/tmp/codex-remote-denial-cwd-{nonce}")).abs(); - let target_path = PathBuf::from(format!("/tmp/codex-remote-denial-target-{nonce}")).abs(); - let remote_cwd_uri = PathUri::from_host_native_path(&remote_cwd)?; - let target_uri = PathUri::from_host_native_path(&target_path)?; - test.fs() - .create_directory( - &remote_cwd_uri, - CreateDirectoryOptions { recursive: true }, - /*sandbox*/ None, - ) - .await?; - test.fs() - .remove( - &target_uri, - RemoveOptions { - recursive: false, - force: true, - }, - /*sandbox*/ None, - ) - .await?; - - let command = format!("printf {CONTENTS:?} > {target_path:?} && cat {target_path:?}"); - let response_mock = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-remote-denial-1"), - ev_function_call( - CALL_ID, - "exec_command", - &json!({ - "shell": "/bin/sh", - "cmd": command, - "login": false, - "yield_time_ms": 5_000, - "environment_id": REMOTE_ENVIRONMENT_ID, - }) - .to_string(), - ), - ev_completed("resp-remote-denial-1"), - ]), - sse(vec![ - ev_response_created("resp-remote-denial-2"), - ev_assistant_message("msg-remote-denial", "done"), - ev_completed("resp-remote-denial-2"), - ]), - ], - ) - .await; - - submit_turn_with_approval_and_environments( - &test, - "retry a sandbox-denied command in the remote environment", - vec![TurnEnvironmentSelection { - environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd: PathUri::from_abs_path(&remote_cwd), - }], - AskForApproval::OnFailure, - ) - .await?; - - let event = wait_for_event(&test.codex, |event| { - matches!( - event, - EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) - ) - }) - .await; - let EventMsg::ExecApprovalRequest(approval) = event else { - panic!("expected remote sandbox approval before completion: {event:?}"); - }; - assert_eq!(approval.call_id, CALL_ID); - assert_eq!( - approval.environment_id.as_deref(), - Some(REMOTE_ENVIRONMENT_ID) - ); - assert_eq!( - approval.reason.as_deref(), - Some("command failed; retry without sandbox?") - ); - - test.codex - .submit(Op::ExecApproval { - id: approval.effective_approval_id(), - turn_id: None, - decision: ReviewDecision::Approved, - }) - .await?; - wait_for_event(&test.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) - .await; - - assert!( - response_mock - .function_call_output_text(CALL_ID) - .is_some_and(|output| output.contains(CONTENTS)), - "approved retry should return the remote command output" - ); - assert_eq!( - test.fs() - .read_file_text(&target_uri, /*sandbox*/ None) - .await?, - CONTENTS - ); - - test.fs() - .remove( - &target_uri, - RemoveOptions { - recursive: false, - force: true, - }, - /*sandbox*/ None, - ) - .await?; - test.fs() - .remove( - &remote_cwd_uri, - RemoveOptions { - recursive: true, - force: true, - }, - /*sandbox*/ None, - ) - .await?; - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn deferred_executor_does_not_duplicate_initial_environment_context() -> Result<()> { let server = start_mock_server().await; @@ -683,6 +540,141 @@ async fn deferred_executor_updates_model_context_after_startup() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn deferred_executor_compaction_preserves_then_updates_environment_once() -> Result<()> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let server = start_mock_server().await; + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + "wait-for-startup", + "request_user_input", + &json!({ + "questions": [{ + "id": "continue", + "header": "Continue", + "question": "Continue after startup?", + "options": [{ + "label": "Yes (Recommended)", + "description": "Continue the test." + }, { + "label": "No", + "description": "Stop the test." + }] + }] + }) + .to_string(), + ), + ev_completed_with_tokens("resp-1", /*total_tokens*/ 96), + ]), + sse(vec![ + ev_assistant_message("msg-compact", "AUTO_COMPACT_SUMMARY"), + ev_completed_with_tokens("resp-compact", /*total_tokens*/ 10), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-2", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + let mut builder = test_codex() + .with_exec_server_url(format!("ws://{}", listener.local_addr()?)) + .with_config(|config| { + assert!(config.features.enable(Feature::DeferredExecutor).is_ok()); + assert!( + config + .features + .enable(Feature::DefaultModeRequestUserInput) + .is_ok() + ); + config.model_provider.name = "OpenAI (test)".to_string(); + config.compact_prompt = Some(SUMMARIZATION_PROMPT.to_string()); + config.model_context_window = Some(100); + config.model_auto_compact_token_limit = Some(90); + }); + let test = timeout(Duration::from_secs(5), builder.build(&server)) + .await + .context("thread startup should not wait for the remote environment")??; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "wait for the environment".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + let request = wait_for_event_match(&test.codex, |event| match event { + EventMsg::RequestUserInput(request) => Some(request.clone()), + _ => None, + }) + .await; + + serve_environment_info(listener).await; + test.codex + .submit(Op::UserInputAnswer { + id: request.turn_id, + response: RequestUserInputResponse { + answers: HashMap::from([( + "continue".to_string(), + RequestUserInputAnswer { + answers: vec!["Yes (Recommended)".to_string()], + }, + )]), + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 3); + let initial_context = requests[0].message_input_texts("user"); + assert!( + initial_context + .iter() + .any(|text| text.contains("starting")) + ); + + let post_compaction_context = requests[2].message_input_texts("user"); + assert_eq!( + post_compaction_context + .iter() + .filter(|text| text.contains("starting")) + .count(), + 1 + ); + assert_eq!( + post_compaction_context + .iter() + .filter(|text| text.contains("zsh")) + .count(), + 1 + ); + let starting_index = post_compaction_context + .iter() + .position(|text| text.contains("starting")) + .expect("compaction should preserve the prior environment state"); + let ready_index = post_compaction_context + .iter() + .position(|text| text.contains("zsh")) + .expect("the next sampling step should report that the environment is ready"); + assert!(starting_index < ready_index); + + Ok(()) +} + fn absolute_path(path: PathBuf) -> AbsolutePathBuf { AbsolutePathBuf::try_from(path).expect("path should be absolute") } diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index 09399e511e..fb35c422fd 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -4,6 +4,7 @@ use codex_config::types::McpServerTransportConfig; use codex_core::config::TokenBudgetConfig; use codex_features::Feature; use codex_model_provider_info::built_in_model_providers; +use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::protocol::CONTEXT_WINDOW_CLOSE_TAG; use codex_protocol::protocol::CONTEXT_WINDOW_OPEN_TAG; use codex_protocol::protocol::EventMsg; @@ -79,6 +80,22 @@ fn tool_names(request: &ResponsesRequest) -> Vec { .collect() } +fn ev_completed_with_usage(id: &str, input_tokens: i64, output_tokens: i64) -> Value { + json!({ + "type": "response.completed", + "response": { + "id": id, + "usage": { + "input_tokens": input_tokens, + "input_tokens_details": null, + "output_tokens": output_tokens, + "output_tokens_details": null, + "total_tokens": input_tokens + output_tokens + } + } + }) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn token_budget_context_is_only_emitted_with_full_context() -> Result<()> { skip_if_no_network!(Ok(())); @@ -264,6 +281,70 @@ async fn token_budget_reminder_emits_after_crossing_compaction_threshold() -> Re Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn token_budget_reminder_uses_body_after_prefix_window() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_completed_with_tokens("resp-1", /*total_tokens*/ 8_000), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_completed_with_tokens("resp-2", /*total_tokens*/ 8_600), + ]), + sse(vec![ev_response_created("resp-3"), ev_completed("resp-3")]), + ], + ) + .await; + let test = test_codex() + .with_config(|config| { + config.model_context_window = Some(10_000); + config.model_auto_compact_token_limit = Some(1_000); + config.model_auto_compact_token_limit_scope = + AutoCompactTokenLimitScope::BodyAfterPrefix; + config.token_budget = Some(TokenBudgetConfig { + reminder_threshold_tokens: Some(600), + ..TokenBudgetConfig::default() + }); + config + .features + .enable(Feature::TokenBudget) + .expect("test config should allow token budget"); + }) + .build(&server) + .await?; + + test.submit_turn("establish prefix").await?; + test.submit_turn("grow body").await?; + test.submit_turn("observe reminder").await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 3); + let reminder = "Your context window is nearly exhausted (only 400 tokens remaining) and will be automatically reset for you soon. Once reset, message items in current context window will be cleared in the new window, but notes and history items will be persistent across windows."; + assert!( + requests[1] + .message_input_texts("developer") + .into_iter() + .all(|text| text != reminder), + "first-window prefix should not count against the body-after-prefix reminder threshold" + ); + assert_eq!( + requests[2] + .message_input_texts("developer") + .into_iter() + .filter(|text| text == reminder) + .count(), + 1 + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_context_remaining_returns_token_budget_remaining_fragment() -> Result<()> { skip_if_no_network!(Ok(())); @@ -327,6 +408,70 @@ async fn get_context_remaining_returns_token_budget_remaining_fragment() -> Resu Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_context_remaining_uses_body_after_prefix_window() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "remaining-call"; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "noted"), + ev_completed_with_usage( + "resp-1", /*input_tokens*/ 2_000, /*output_tokens*/ 500, + ), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_function_call(call_id, "get_context_remaining", "{}"), + ev_completed_with_tokens("resp-2", /*total_tokens*/ 2_500), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-3", "done"), + ev_completed("resp-3"), + ]), + ], + ) + .await; + let test = test_codex() + .with_config(|config| { + config.model_context_window = Some(10_000); + config.model_auto_compact_token_limit = Some(7_000); + config.model_auto_compact_token_limit_scope = + AutoCompactTokenLimitScope::BodyAfterPrefix; + config + .features + .enable(Feature::TokenBudget) + .expect("test config should allow token budget"); + }) + .build(&server) + .await?; + + test.submit_turn("spend some tokens").await?; + test.submit_turn("check remaining context").await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 3); + assert!( + tool_names(&requests[1]) + .iter() + .any(|name| name == "get_context_remaining"), + "get_context_remaining should be exposed when token budget is enabled" + ); + + let remaining_context = "You have 6500 tokens left in this context window.".to_string(); + assert_eq!( + requests[2].function_call_output_content_and_success(call_id), + Some((Some(remaining_context), None)) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_context_remaining_returns_unknown_when_window_is_unavailable() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index a660ec4da6..e0e52172fb 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -29,7 +29,6 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::user_input::UserInput; use codex_utils_path_uri::PathUri; -use core_test_support::PathBufExt; use core_test_support::PathExt; use core_test_support::get_remote_test_env; use core_test_support::responses; @@ -42,7 +41,6 @@ use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; -use core_test_support::skip_if_wine_exec; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::local; use core_test_support::test_codex::test_codex; @@ -59,8 +57,6 @@ use serde_json::json; use std::fs; use std::io::Cursor; use std::path::PathBuf; -use std::time::SystemTime; -use std::time::UNIX_EPOCH; use tempfile::TempDir; use tokio::time::Duration; use wiremock::BodyPrintLimit; @@ -295,6 +291,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { let rel_path = "assets/example.png"; let abs_path = cwd.join(rel_path); + let path_uri = PathUri::from_abs_path(&abs_path); let original_width = 2304; let original_height = 864; write_workspace_png( @@ -369,20 +366,20 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { match item_started.expect("view image item started event emitted") { codex_protocol::items::TurnItem::ImageView(item) => { assert_eq!(item.id, call_id); - assert_eq!(item.path, abs_path); + assert_eq!(item.path, path_uri); } other => panic!("expected ImageView item, got {other:?}"), } match item_completed.expect("view image item completed event emitted") { codex_protocol::items::TurnItem::ImageView(item) => { assert_eq!(item.id, call_id); - assert_eq!(item.path, abs_path); + assert_eq!(item.path, path_uri); } other => panic!("expected ImageView item, got {other:?}"), } let legacy_event = legacy_event.expect("legacy view image event emitted"); assert_eq!(legacy_event.call_id, call_id); - assert_eq!(legacy_event.path, abs_path); + assert_eq!(legacy_event.path, path_uri); let req = mock.single_request(); let body = req.body_json(); @@ -557,8 +554,10 @@ async fn view_image_tool_applies_local_sandbox_read_denies() -> anyhow::Result<( .function_call_output_content_and_success(call_id) .and_then(|(content, _)| content) .context("sandboxed view_image error text present")?; - let expected_locate_prefix = format!("unable to locate image at `{}`:", denied_path.display()); - let expected_read_prefix = format!("unable to read image at `{}`:", denied_path.display()); + let denied_path_display = + PathUri::from_host_native_path(&denied_path)?.inferred_native_path_string(); + let expected_locate_prefix = format!("unable to locate image at `{denied_path_display}`:"); + let expected_read_prefix = format!("unable to read image at `{denied_path_display}`:"); assert!( output_text.starts_with(&expected_locate_prefix) || output_text.starts_with(&expected_read_prefix), @@ -570,8 +569,6 @@ async fn view_image_tool_applies_local_sandbox_read_denies() -> anyhow::Result<( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn view_image_routes_to_selected_remote_environment() -> anyhow::Result<()> { - // TODO(anp): Remove after remote-cwd fixtures use target-native paths. - skip_if_wine_exec!(Ok(()), "hardcodes a POSIX remote cwd"); skip_if_no_network!(Ok(())); let Some(_remote_env) = get_remote_test_env() else { return Ok(()); @@ -583,37 +580,26 @@ async fn view_image_routes_to_selected_remote_environment() -> anyhow::Result<() let local_cwd = TempDir::new()?; fs::write(local_cwd.path().join("remote.png"), b"not a remote image")?; let local_selection = local(local_cwd.path().abs()); - let remote_cwd = PathBuf::from(format!( - "/tmp/codex-view-image-routing-{}", - SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() - )) - .abs(); - let image_path = remote_cwd.join("remote.png"); - let remote_cwd_uri = PathUri::from_host_native_path(&remote_cwd)?; - test.fs() - .create_directory( - &remote_cwd_uri, - CreateDirectoryOptions { recursive: true }, - /*sandbox*/ None, - ) - .await?; + let remote_cwd_uri = PathUri::from_abs_path(test.executor_environment().cwd()); + let image_path_uri = remote_cwd_uri.join("remote.png")?; let png = png_bytes(/*width*/ 1, /*height*/ 1, [0, 255, 0, 255])?; - let image_path_uri = PathUri::from_host_native_path(&image_path)?; test.fs() .write_file(&image_path_uri, png, /*sandbox*/ None) .await?; + let absolute_image_path = image_path_uri.inferred_native_path_string(); let remote_selection = TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd: PathUri::from_abs_path(&remote_cwd), + cwd: remote_cwd_uri, }; - let call_id = "call-view-image-multi-env"; + let relative_call_id = "call-view-image-relative-multi-env"; + let absolute_call_id = "call-view-image-absolute-multi-env"; let response_mock = mount_sse_sequence( &server, vec![ sse(vec![ ev_response_created("resp-1"), ev_function_call( - call_id, + relative_call_id, "view_image", &json!({ "path": "remote.png", @@ -625,9 +611,22 @@ async fn view_image_routes_to_selected_remote_environment() -> anyhow::Result<() ]), sse(vec![ ev_response_created("resp-2"), - ev_assistant_message("msg-1", "done"), + ev_function_call( + absolute_call_id, + "view_image", + &json!({ + "path": absolute_image_path, + "environment_id": REMOTE_ENVIRONMENT_ID, + }) + .to_string(), + ), ev_completed("resp-2"), ]), + sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-3"), + ]), ], ) .await; @@ -638,30 +637,32 @@ async fn view_image_routes_to_selected_remote_environment() -> anyhow::Result<() ) .await?; - let output = response_mock + let request = response_mock .last_request() .context("missing request containing view_image output")? - .function_call_output(call_id) .clone(); - let output_items = output - .get("output") - .and_then(Value::as_array) - .context("view_image output should be content items")?; - assert_eq!(output_items.len(), 1); - let image_url = output_items[0] - .get("image_url") - .and_then(Value::as_str) - .context("view_image output should include image_url")?; - assert!( - image_url.starts_with("data:image/png;base64,"), - "unexpected image_url: {image_url}", - ); + for call_id in [relative_call_id, absolute_call_id] { + let output = request.function_call_output(call_id); + let output_items = output + .get("output") + .and_then(Value::as_array) + .context("view_image output should be content items")?; + assert_eq!(output_items.len(), 1); + let image_url = output_items[0] + .get("image_url") + .and_then(Value::as_str) + .context("view_image output should include image_url")?; + assert!( + image_url.starts_with("data:image/png;base64,"), + "unexpected image_url: {image_url}", + ); + } test.fs() .remove( - &remote_cwd_uri, + &image_path_uri, RemoveOptions { - recursive: true, + recursive: false, force: true, }, /*sandbox*/ None, @@ -1174,7 +1175,8 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { .function_call_output_content_and_success(call_id) .and_then(|(content, _)| content) .expect("output text present"); - let expected_message = format!("image path `{}` is not a file", abs_path.display()); + let expected_path = PathUri::from_host_native_path(&abs_path)?.inferred_native_path_string(); + let expected_message = format!("image path `{expected_path}` is not a file"); assert_eq!(output_text, expected_message); assert!( @@ -1253,19 +1255,26 @@ async fn view_image_tool_turns_invalid_image_into_placeholder() -> anyhow::Resul async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); + let remote_test_env = get_remote_test_env(); + println!("view_image missing-file test exec-server environment: {remote_test_env:?}"); + let server = start_mock_server().await; let mut builder = test_codex(); let test = builder.build_with_remote_env(&server).await?; let TestCodex { codex, - config, session_configured, .. } = &test; let rel_path = "missing/example.png"; - let abs_path = config.cwd.join(rel_path); + // Under wine-exec, the executor cwd is stored as a host-compatible `/C:/...` + // projection. Reconstruct its `PathUri` so the expected error uses the selected + // environment's native Windows spelling, matching the handler. + let expected_path = PathUri::from_abs_path(test.executor_environment().cwd()) + .join(rel_path)? + .inferred_native_path_string(); let call_id = "view-image-missing"; let arguments = serde_json::json!({ "path": rel_path }).to_string(); @@ -1309,7 +1318,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { .function_call_output_content_and_success(call_id) .and_then(|(content, _)| content) .expect("output text present"); - let expected_prefix = format!("unable to locate image at `{}`:", abs_path.display()); + let expected_prefix = format!("unable to locate image at `{expected_path}`:"); assert!( output_text.starts_with(&expected_prefix), "expected error to start with `{expected_prefix}` but got `{output_text}`" diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 31fc4ea0aa..02f23dfc49 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -20,6 +20,7 @@ codex-app-server-protocol = { workspace = true } codex-api = { workspace = true } codex-client = { workspace = true } codex-file-system = { workspace = true } +codex-network-proxy = { workspace = true } codex-protocol = { workspace = true } codex-sandboxing = { workspace = true } codex-shell-command = { workspace = true } diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 40d73cade5..ee0e303caf 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -1162,6 +1162,7 @@ mod tests { arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await .expect("start process"); @@ -1201,6 +1202,7 @@ mod tests { arg0: None, sandbox: Some(sandbox), enforce_managed_network: false, + managed_network: None, }) .await; let Err(err) = result else { diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index 3dbcf73c14..41a3e12215 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -115,6 +115,7 @@ impl FileSystemSandboxRunner { args: vec![CODEX_FS_HELPER_ARG1.to_string()], cwd: cwd.uri.clone(), env: self.helper_env.clone(), + managed_network: None, additional_permissions: None, }; let native_workspace_roots = sandbox_context @@ -130,6 +131,8 @@ impl FileSystemSandboxRunner { sandbox_manager .transform_for_direct_spawn(SandboxDirectSpawnTransformRequest { workspace_roots, + windows_sandbox_proxy_settings_mode: + codex_sandboxing::WindowsSandboxProxySettingsMode::Preserve, transform: SandboxTransformRequest { command, permissions: permission_profile, diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index 1f0dc5fec9..d77209e7cf 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -965,6 +965,7 @@ mod tests { arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, } } diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs index d157f4c6dc..ef97d0ce5f 100644 --- a/codex-rs/exec-server/src/process_sandbox.rs +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -1,6 +1,8 @@ use std::collections::HashMap; use codex_app_server_protocol::JSONRPCErrorError; +use codex_network_proxy::CUSTOM_CA_ENV_KEYS; +use codex_network_proxy::is_managed_mitm_ca_trust_bundle_path; use codex_protocol::models::PermissionProfile; use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxDirectSpawnTransformRequest; @@ -8,6 +10,7 @@ use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; +use codex_sandboxing::with_managed_mitm_ca_readable_root; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; @@ -59,6 +62,20 @@ pub(crate) fn prepare_exec_request( native_workspace_roots.as_slice() }; let permissions = permissions.materialize_project_roots_with_workspace_roots(workspace_roots); + let managed_mitm_ca_trust_bundle_path = params.managed_network.as_ref().and_then(|_| { + CUSTOM_CA_ENV_KEYS.iter().find_map(|key| { + let path = env.get(*key)?; + if !is_managed_mitm_ca_trust_bundle_path(path) { + return None; + } + AbsolutePathBuf::from_absolute_path(path).ok() + }) + }); + let permissions = with_managed_mitm_ca_readable_root( + permissions, + managed_mitm_ca_trust_bundle_path.as_ref(), + native_sandbox_policy_cwd.as_path(), + ); let (file_system_policy, network_policy) = permissions.to_runtime_permissions(); let sandbox_manager = SandboxManager::new(); let sandbox = sandbox_manager.select_initial( @@ -90,6 +107,8 @@ pub(crate) fn prepare_exec_request( let request = sandbox_manager .transform_for_direct_spawn(SandboxDirectSpawnTransformRequest { workspace_roots, + windows_sandbox_proxy_settings_mode: + codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile, transform: SandboxTransformRequest { // TODO(jif): Preserve params.arg0 for the inner command across the sandbox // wrapper, or reject sandboxed requests with a custom arg0. @@ -98,6 +117,7 @@ pub(crate) fn prepare_exec_request( args: args.to_vec(), cwd: params.cwd.clone(), env, + managed_network: params.managed_network.clone(), additional_permissions: None, }, permissions: &permissions, diff --git a/codex-rs/exec-server/src/process_sandbox_tests.rs b/codex-rs/exec-server/src/process_sandbox_tests.rs index 1b0408afd8..bc71b57320 100644 --- a/codex-rs/exec-server/src/process_sandbox_tests.rs +++ b/codex-rs/exec-server/src/process_sandbox_tests.rs @@ -1,5 +1,7 @@ use std::collections::HashMap; +#[cfg(target_os = "macos")] +use codex_network_proxy::ManagedNetworkSandboxContext; #[cfg(unix)] use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; @@ -44,6 +46,7 @@ fn sandbox_request_wraps_native_argv_on_executor() { arg0: None, sandbox: Some(sandbox), enforce_managed_network: false, + managed_network: None, }; let prepared = prepare_exec_request(¶ms, HashMap::new(), Some(&runtime_paths)) @@ -78,6 +81,49 @@ fn sandbox_request_wraps_native_argv_on_executor() { ); } +#[cfg(target_os = "macos")] +#[test] +fn sandbox_request_allows_prepared_managed_proxy_port() { + let cwd: AbsolutePathBuf = std::env::current_dir() + .expect("current directory") + .try_into() + .expect("absolute cwd"); + let cwd_uri = PathUri::from_abs_path(&cwd); + let self_exe = std::env::current_exe().expect("current executable"); + let runtime_paths = + ExecServerRuntimePaths::new(self_exe.clone(), Some(self_exe)).expect("runtime paths"); + let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + PermissionProfile::workspace_write(), + cwd_uri.clone(), + ); + let params = ExecParams { + process_id: ProcessId::from("process-managed-network"), + argv: vec!["/usr/bin/true".to_string()], + cwd: cwd_uri, + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: None, + sandbox: Some(sandbox), + enforce_managed_network: true, + managed_network: Some(ManagedNetworkSandboxContext { + loopback_ports: vec![43123], + allow_local_binding: false, + }), + }; + + let prepared = prepare_exec_request(¶ms, HashMap::new(), Some(&runtime_paths)) + .expect("prepare managed-network sandbox request"); + let policy = prepared + .command + .windows(2) + .find_map(|args| (args[0] == "-p").then_some(args[1].as_str())) + .expect("Seatbelt policy argument"); + + assert!(policy.contains("(allow network-outbound (remote ip \"localhost:43123\"))")); +} + #[test] fn native_request_preserves_native_launch_fields() { let cwd: AbsolutePathBuf = std::env::current_dir() @@ -97,6 +143,7 @@ fn native_request_preserves_native_launch_fields() { arg0: Some("custom-arg0".to_string()), sandbox: None, enforce_managed_network: false, + managed_network: None, }; let prepared = prepare_exec_request(¶ms, env.clone(), /*runtime_paths*/ None) diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index 8771e869df..921223e312 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_file_system::FileSystemSandboxContext; +use codex_network_proxy::ManagedNetworkSandboxContext; use codex_protocol::config_types::ShellEnvironmentPolicyInherit; use codex_utils_path_uri::PathUri; use serde::Deserialize; @@ -109,6 +110,12 @@ pub struct ExecParams { /// Whether the eventual executor-side sandbox must enforce managed networking. #[serde(default)] pub enforce_managed_network: bool, + /// Optional details for enforcing managed networking without a live proxy object. + /// + /// When `enforce_managed_network` is true and these details are absent, the executor must + /// continue to fail closed. This preserves compatibility with older clients. + #[serde(default)] + pub managed_network: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -504,12 +511,60 @@ mod base64_bytes { #[cfg(test)] mod tests { + use super::ExecParams; use super::FsReadFileParams; use super::HttpRequestParams; + use super::ProcessId; use codex_file_system::FileSystemSandboxContext; + use codex_network_proxy::ManagedNetworkSandboxContext; use codex_protocol::models::PermissionProfile; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; + use std::collections::HashMap; + + #[test] + fn exec_params_managed_network_context_round_trips_and_defaults_for_legacy_peers() { + let cwd = + PathUri::from_host_native_path(std::env::current_dir().expect("current directory")) + .expect("cwd URI"); + let params = ExecParams { + process_id: ProcessId::from("managed-network"), + argv: vec!["true".to_string()], + cwd, + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: None, + sandbox: None, + enforce_managed_network: true, + managed_network: Some(ManagedNetworkSandboxContext { + loopback_ports: vec![43123, 48081], + allow_local_binding: false, + }), + }; + + let mut serialized = serde_json::to_value(¶ms).expect("serialize exec params"); + assert_eq!( + serialized["managedNetwork"], + serde_json::json!({ + "loopbackPorts": [43123, 48081], + "allowLocalBinding": false, + }) + ); + let round_trip: ExecParams = + serde_json::from_value(serialized.clone()).expect("deserialize exec params"); + assert_eq!(round_trip, params); + + serialized + .as_object_mut() + .expect("exec params object") + .remove("managedNetwork"); + let legacy: ExecParams = + serde_json::from_value(serialized).expect("deserialize legacy exec params"); + assert!(legacy.enforce_managed_network); + assert_eq!(legacy.managed_network, None); + } #[test] fn filesystem_protocol_accepts_legacy_absolute_paths_and_serializes_path_uris() { diff --git a/codex-rs/exec-server/src/server/handler/tests.rs b/codex-rs/exec-server/src/server/handler/tests.rs index ebb2b6e6ea..2cea99b214 100644 --- a/codex-rs/exec-server/src/server/handler/tests.rs +++ b/codex-rs/exec-server/src/server/handler/tests.rs @@ -36,6 +36,7 @@ fn exec_params_with_argv(process_id: &str, argv: Vec) -> ExecParams { arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, } } diff --git a/codex-rs/exec-server/src/server/processor.rs b/codex-rs/exec-server/src/server/processor.rs index e9316e0d54..d960e53ca0 100644 --- a/codex-rs/exec-server/src/server/processor.rs +++ b/codex-rs/exec-server/src/server/processor.rs @@ -441,6 +441,7 @@ mod tests { arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, } } diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index 2f9b9c1dbe..8fe5fe0be9 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -83,6 +83,7 @@ async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> { arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; assert_eq!(session.process.process_id().as_str(), "proc-1"); @@ -226,6 +227,7 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> { arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -259,6 +261,7 @@ async fn assert_exec_process_pushes_events(use_remote: bool) -> Result<()> { arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -308,6 +311,7 @@ async fn assert_exec_process_replays_events_after_close(use_remote: bool) -> Res arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -358,6 +362,7 @@ async fn assert_exec_process_retains_output_after_exit_until_streams_close( arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -433,6 +438,7 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> { arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -472,6 +478,7 @@ async fn assert_exec_process_write_then_read_without_tty(use_remote: bool) -> Re arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -507,6 +514,7 @@ async fn assert_exec_process_rejects_write_without_pipe_stdin(use_remote: bool) arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -543,6 +551,7 @@ async fn assert_exec_process_signal_interrupts_process(use_remote: bool) -> Resu arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -598,6 +607,7 @@ async fn assert_exec_process_signal_reports_unsupported_on_windows(use_remote: b arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; @@ -640,6 +650,7 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe( arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; @@ -700,6 +711,7 @@ async fn remote_exec_process_recovers_after_transport_disconnect() -> Result<()> arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; diff --git a/codex-rs/exec-server/tests/relay.rs b/codex-rs/exec-server/tests/relay.rs index 30ce1459b7..918cba7393 100644 --- a/codex-rs/exec-server/tests/relay.rs +++ b/codex-rs/exec-server/tests/relay.rs @@ -152,6 +152,7 @@ async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> { arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; assert_eq!( diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 4e341d773b..9f345eb334 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -334,6 +334,7 @@ async fn resume_lookup_model_providers_filters_only_last_lookup() { fn turn_items_for_thread_returns_matching_turn_items() { let thread = AppServerThread { id: "thread-1".to_string(), + extra: None, session_id: "thread-1".to_string(), forked_from_id: None, parent_thread_id: None, @@ -753,6 +754,7 @@ fn sample_thread_start_response() -> ThreadStartResponse { ThreadStartResponse { thread: codex_app_server_protocol::Thread { id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), + extra: None, session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(), forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index f90686dbea..531c8f8038 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -36,7 +36,7 @@ pub struct CodexToolCallParam { pub cwd: Option, /// Approval policy for shell commands generated by the model: - /// `untrusted`, `on-failure`, `on-request`, `never`. + /// `untrusted`, `on-request`, `never`. #[serde(default, skip_serializing_if = "Option::is_none")] pub approval_policy: Option, @@ -68,7 +68,6 @@ pub struct CodexToolCallParam { #[serde(rename_all = "kebab-case")] pub enum CodexToolCallApprovalPolicy { Untrusted, - OnFailure, OnRequest, Never, } @@ -77,7 +76,6 @@ impl From for AskForApproval { fn from(value: CodexToolCallApprovalPolicy) -> Self { match value { CodexToolCallApprovalPolicy::Untrusted => AskForApproval::UnlessTrusted, - CodexToolCallApprovalPolicy::OnFailure => AskForApproval::OnFailure, CodexToolCallApprovalPolicy::OnRequest => AskForApproval::OnRequest, CodexToolCallApprovalPolicy::Never => AskForApproval::Never, } @@ -301,10 +299,9 @@ mod tests { "additionalProperties": false, "properties": { "approval-policy": { - "description": "Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `on-request`, `never`.", + "description": "Approval policy for shell commands generated by the model: `untrusted`, `on-request`, `never`.", "enum": [ "untrusted", - "on-failure", "on-request", "never" ], diff --git a/codex-rs/models-manager/models.json b/codex-rs/models-manager/models.json index 213fdcf6c4..d17785476f 100644 --- a/codex-rs/models-manager/models.json +++ b/codex-rs/models-manager/models.json @@ -404,7 +404,7 @@ "migration_markdown": "Introducing GPT-5.4\n\nCodex just got an upgrade with GPT-5.4, our most capable model for professional work. It outperforms prior models while being more token efficient, with notable improvements on long-running tasks, tool calling, computer use, and frontend development.\n\nLearn more: https://openai.com/index/introducing-gpt-5-4\n\nYou can always keep using GPT-5.3-Codex if you prefer.\n" }, "priority": 10, - "base_instructions": "You are GPT-5.2 running in the Codex CLI, a terminal-based coding assistant. Codex CLI is an open source project led by OpenAI. You are expected to be precise, safe, and helpful.\n\nYour capabilities:\n\n- Receive user prompts and other context provided by the harness, such as files in the workspace.\n- Communicate with the user by streaming thinking & responses, and by making & updating plans.\n- Emit function calls to run terminal commands and apply patches. Depending on how this specific run is configured, you can request that these function calls be escalated to the user for approval before running. More on this in the \"Sandbox and approvals\" section.\n\nWithin this context, Codex refers to the open-source agentic coding interface (not the old Codex language model built by OpenAI).\n\n# How you work\n\n## Personality\n\nYour default personality and tone is concise, direct, and friendly. You communicate efficiently, always keeping the user clearly informed about ongoing actions without unnecessary detail. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work.\n\n## AGENTS.md spec\n- Repos often contain AGENTS.md files. These files can appear anywhere within the repository.\n- These files are a way for humans to give you (the agent) instructions or tips for working within the container.\n- Some examples might be: coding conventions, info about how code is organized, or instructions for how to run or test code.\n- Instructions in AGENTS.md files:\n - The scope of an AGENTS.md file is the entire directory tree rooted at the folder that contains it.\n - For every file you touch in the final patch, you must obey instructions in any AGENTS.md file whose scope includes that file.\n - Instructions about code style, structure, naming, etc. apply only to code within the AGENTS.md file's scope, unless the file states otherwise.\n - More-deeply-nested AGENTS.md files take precedence in the case of conflicting instructions.\n - Direct system/developer/user instructions (as part of a prompt) take precedence over AGENTS.md instructions.\n- The contents of the AGENTS.md file at the root of the repo and any directories from the CWD up to the root are included with the developer message and don't need to be re-read. When working in a subdirectory of CWD, or a directory outside the CWD, check for any AGENTS.md files that may be applicable.\n\n## Autonomy and Persistence\nPersist until the task is fully handled end-to-end within the current turn whenever feasible: do not stop at analysis or partial fixes; carry changes through implementation, verification, and a clear explanation of outcomes unless the user explicitly pauses or redirects you.\n\nUnless the user explicitly asks for a plan, asks a question about the code, is brainstorming potential solutions, or some other intent that makes it clear that code should not be written, assume the user wants you to make code changes or run tools to solve the user's problem. In these cases, it's bad to output your proposed solution in a message, you should go ahead and actually implement the change. If you encounter challenges or blockers, you should attempt to resolve them yourself.\n\n## Responsiveness\n\n## Planning\n\nYou have access to an `update_plan` tool which tracks steps and progress and renders them to the user. Using the tool helps demonstrate that you've understood the task and convey how you're approaching it. Plans can help to make complex, ambiguous, or multi-phase work clearer and more collaborative for the user. A good plan should break the task into meaningful, logically ordered steps that are easy to verify as you go.\n\nNote that plans are not for padding out simple work with filler steps or stating the obvious. The content of your plan should not involve doing anything that you aren't capable of doing (i.e. don't try to test things that you can't test). Do not use plans for simple or single-step queries that you can just do or answer immediately.\n\nDo not repeat the full contents of the plan after an `update_plan` call — the harness already displays it. Instead, summarize the change made and highlight any important context or next step.\n\nBefore running a command, consider whether or not you have completed the previous step, and make sure to mark it as completed before moving on to the next step. It may be the case that you complete all steps in your plan after a single pass of implementation. If this is the case, you can simply mark all the planned steps as completed. Sometimes, you may need to change plans in the middle of a task: call `update_plan` with the updated plan and make sure to provide an `explanation` of the rationale when doing so.\n\nMaintain statuses in the tool: exactly one item in_progress at a time; mark items complete when done; post timely status transitions. Do not jump an item from pending to completed: always set it to in_progress first. Do not batch-complete multiple items after the fact. Finish with all items completed or explicitly canceled/deferred before ending the turn. Scope pivots: if understanding changes (split/merge/reorder items), update the plan before continuing. Do not let the plan go stale while coding.\n\nUse a plan when:\n\n- The task is non-trivial and will require multiple actions over a long time horizon.\n- There are logical phases or dependencies where sequencing matters.\n- The work has ambiguity that benefits from outlining high-level goals.\n- You want intermediate checkpoints for feedback and validation.\n- When the user asked you to do more than one thing in a single prompt\n- The user has asked you to use the plan tool (aka \"TODOs\")\n- You generate additional steps while working, and plan to do them before yielding to the user\n\n### Examples\n\n**High-quality plans**\n\nExample 1:\n\n1. Add CLI entry with file args\n2. Parse Markdown via CommonMark library\n3. Apply semantic HTML template\n4. Handle code blocks, images, links\n5. Add error handling for invalid files\n\nExample 2:\n\n1. Define CSS variables for colors\n2. Add toggle with localStorage state\n3. Refactor components to use variables\n4. Verify all views for readability\n5. Add smooth theme-change transition\n\nExample 3:\n\n1. Set up Node.js + WebSocket server\n2. Add join/leave broadcast events\n3. Implement messaging with timestamps\n4. Add usernames + mention highlighting\n5. Persist messages in lightweight DB\n6. Add typing indicators + unread count\n\n**Low-quality plans**\n\nExample 1:\n\n1. Create CLI tool\n2. Add Markdown parser\n3. Convert to HTML\n\nExample 2:\n\n1. Add dark mode toggle\n2. Save preference\n3. Make styles look good\n\nExample 3:\n\n1. Create single-file HTML game\n2. Run quick sanity check\n3. Summarize usage instructions\n\nIf you need to write a plan, only write high quality plans, not low quality ones.\n\n## Task execution\n\nYou are a coding agent. You must keep going until the query or task is completely resolved, before ending your turn and yielding back to the user. Persist until the task is fully handled end-to-end within the current turn whenever feasible and persevere even when function calls fail. Only terminate your turn when you are sure that the problem is solved. Autonomously resolve the query to the best of your ability, using the tools available to you, before coming back to the user. Do NOT guess or make up an answer.\n\nYou MUST adhere to the following criteria when solving queries:\n\n- Working on the repo(s) in the current environment is allowed, even if they are proprietary.\n- Analyzing code for vulnerabilities is allowed.\n- Showing user code and tool call details is allowed.\n- Use the `apply_patch` tool to edit files (NEVER try `applypatch` or `apply-patch`, only `apply_patch`). This is a FREEFORM tool, so do not wrap the patch in JSON.\n\nIf completing the user's task requires writing or modifying files, your code and final answer should follow these coding guidelines, though user instructions (i.e. AGENTS.md) may override these guidelines:\n\n- Fix the problem at the root cause rather than applying surface-level patches, when possible.\n- Avoid unneeded complexity in your solution.\n- Do not attempt to fix unrelated bugs or broken tests. It is not your responsibility to fix them. (You may mention them to the user in your final message though.)\n- Update documentation as necessary.\n- Keep changes consistent with the style of the existing codebase. Changes should be minimal and focused on the task.\n- If you're building a web app from scratch, give it a beautiful and modern UI, imbued with best UX practices.\n- Use `git log` and `git blame` to search the history of the codebase if additional context is required.\n- NEVER add copyright or license headers unless specifically requested.\n- Do not waste tokens by re-reading files after calling `apply_patch` on them. The tool call will fail if it didn't work. The same goes for making folders, deleting folders, etc.\n- Do not `git commit` your changes or create new git branches unless explicitly requested.\n- Do not add inline comments within code unless explicitly requested.\n- Do not use one-letter variable names unless explicitly requested.\n- NEVER output inline citations like \"【F:README.md†L5-L14】\" in your outputs. The CLI is not able to render these so they will just be broken in the UI. Instead, if you output valid filepaths, users will be able to click on them to open the files in their editor.\n\n## Validating your work\n\nIf the codebase has tests, or the ability to build or run tests, consider using them to verify changes once your work is complete.\n\nWhen testing, your philosophy should be to start as specific as possible to the code you changed so that you can catch issues efficiently, then make your way to broader tests as you build confidence. If there's no test for the code you changed, and if the adjacent patterns in the codebases show that there's a logical place for you to add a test, you may do so. However, do not add tests to codebases with no tests.\n\nSimilarly, once you're confident in correctness, you can suggest or use formatting commands to ensure that your code is well formatted. If there are issues you can iterate up to 3 times to get formatting right, but if you still can't manage it's better to save the user time and present them a correct solution where you call out the formatting in your final message. If the codebase does not have a formatter configured, do not add one.\n\nFor all of testing, running, building, and formatting, do not attempt to fix unrelated bugs. It is not your responsibility to fix them. (You may mention them to the user in your final message though.)\n\nBe mindful of whether to run validation commands proactively. In the absence of behavioral guidance:\n\n- When running in non-interactive approval modes like **never** or **on-failure**, you can proactively run tests, lint and do whatever you need to ensure you've completed the task. If you are unable to run tests, you must still do your utmost best to complete the task.\n- When working in interactive approval modes like **untrusted**, or **on-request**, hold off on running tests or lint commands until the user is ready for you to finalize your output, because these commands take time to run and slow down iteration. Instead suggest what you want to do next, and let the user confirm first.\n- When working on test-related tasks, such as adding tests, fixing tests, or reproducing a bug to verify behavior, you may proactively run tests regardless of approval mode. Use your judgement to decide whether this is a test-related task.\n\n## Ambition vs. precision\n\nFor tasks that have no prior context (i.e. the user is starting something brand new), you should feel free to be ambitious and demonstrate creativity with your implementation.\n\nIf you're operating in an existing codebase, you should make sure you do exactly what the user asks with surgical precision. Treat the surrounding codebase with respect, and don't overstep (i.e. changing filenames or variables unnecessarily). You should balance being sufficiently ambitious and proactive when completing tasks of this nature.\n\nYou should use judicious initiative to decide on the right level of detail and complexity to deliver based on the user's needs. This means showing good judgment that you're capable of doing the right extras without gold-plating. This might be demonstrated by high-value, creative touches when scope of the task is vague; while being surgical and targeted when scope is tightly specified.\n\n## Presenting your work \n\nYour final message should read naturally, like an update from a concise teammate. For casual conversation, brainstorming tasks, or quick questions from the user, respond in a friendly, conversational tone. You should ask questions, suggest ideas, and adapt to the user’s style. If you've finished a large amount of work, when describing what you've done to the user, you should follow the final answer formatting guidelines to communicate substantive changes. You don't need to add structured formatting for one-word answers, greetings, or purely conversational exchanges.\n\nYou can skip heavy formatting for single, simple actions or confirmations. In these cases, respond in plain sentences with any relevant next step or quick option. Reserve multi-section structured responses for results that need grouping or explanation.\n\nThe user is working on the same computer as you, and has access to your work. As such there's no need to show the contents of files you have already written unless the user explicitly asks for them. Similarly, if you've created or modified files using `apply_patch`, there's no need to tell users to \"save the file\" or \"copy the code into a file\"—just reference the file path.\n\nIf there's something that you think you could help with as a logical next step, concisely ask the user if they want you to do so. Good examples of this are running tests, committing changes, or building out the next logical component. If there’s something that you couldn't do (even with approval) but that the user might want to do (such as verifying changes by running the app), include those instructions succinctly.\n\nBrevity is very important as a default. You should be very concise (i.e. no more than 10 lines), but can relax this requirement for tasks where additional detail and comprehensiveness is important for the user's understanding.\n\n### Final answer structure and style guidelines\n\nYou are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.\n\n**Section Headers**\n\n- Use only when they improve clarity — they are not mandatory for every answer.\n- Choose descriptive names that fit the content\n- Keep headers short (1–3 words) and in `**Title Case**`. Always start headers with `**` and end with `**`\n- Leave no blank line before the first bullet under a header.\n- Section headers should only be used where they genuinely improve scanability; avoid fragmenting the answer.\n\n**Bullets**\n\n- Use `-` followed by a space for every bullet.\n- Merge related points when possible; avoid a bullet for every trivial detail.\n- Keep bullets to one line unless breaking for clarity is unavoidable.\n- Group into short lists (4–6 bullets) ordered by importance.\n- Use consistent keyword phrasing and formatting across sections.\n\n**Monospace**\n\n- Wrap all commands, file paths, env vars, code identifiers, and code samples in backticks (`` `...` ``).\n- Apply to inline examples and to bullet keywords if the keyword itself is a literal file/command.\n- Never mix monospace and bold markers; choose one based on whether it’s a keyword (`**`) or inline code/path (`` ` ``).\n\n**File References**\nWhen referencing files in your response, make sure to include the relevant start line and always follow the below rules:\n * Use inline code to make file paths clickable.\n * Each reference should have a stand alone path. Even if it's the same file.\n * Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix.\n * Line/column (1‑based, optional): :line[:column] or #Lline[Ccolumn] (column defaults to 1).\n * Do not use URIs like file://, vscode://, or https://.\n * Do not provide range of lines\n * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\\repo\\project\\main.rs:12:5\n\n**Structure**\n\n- Place related bullets together; don’t mix unrelated concepts in the same section.\n- Order sections from general → specific → supporting info.\n- For subsections (e.g., “Binaries” under “Rust Workspace”), introduce with a bolded keyword bullet, then list items under it.\n- Match structure to complexity:\n - Multi-part or detailed results → use clear headers and grouped bullets.\n - Simple results → minimal headers, possibly just a short list or paragraph.\n\n**Tone**\n\n- Keep the voice collaborative and natural, like a coding partner handing off work.\n- Be concise and factual — no filler or conversational commentary and avoid unnecessary repetition\n- Use present tense and active voice (e.g., “Runs tests” not “This will run tests”).\n- Keep descriptions self-contained; don’t refer to “above” or “below”.\n- Use parallel structure in lists for consistency.\n\n**Verbosity**\n- Final answer compactness rules (enforced):\n - Tiny/small single-file change (≤ ~10 lines): 2–5 sentences or ≤3 bullets. No headings. 0–1 short snippet (≤3 lines) only if essential.\n - Medium change (single area or a few files): ≤6 bullets or 6–10 sentences. At most 1–2 short snippets total (≤8 lines each).\n - Large/multi-file change: Summarize per file with 1–2 bullets; avoid inlining code unless critical (still ≤2 short snippets total).\n - Never include \"before/after\" pairs, full method bodies, or large/scrolling code blocks in the final message. Prefer referencing file/symbol names instead.\n\n**Don’t**\n\n- Don’t use literal words “bold” or “monospace” in the content.\n- Don’t nest bullets or create deep hierarchies.\n- Don’t output ANSI escape codes directly — the CLI renderer applies them.\n- Don’t cram unrelated keywords into a single bullet; split for clarity.\n- Don’t let keyword lists run long — wrap or reformat for scanability.\n\nGenerally, ensure your final answers adapt their shape and depth to the request. For example, answers to code explanations should have a precise, structured explanation with code references that answer the question directly. For tasks with a simple implementation, lead with the outcome and supplement only with what’s needed for clarity. Larger changes can be presented as a logical walkthrough of your approach, grouping related steps, explaining rationale where it adds value, and highlighting next actions to accelerate the user. Your answers should provide the right level of detail while being easily scannable.\n\nFor casual greetings, acknowledgements, or other one-off conversational messages that are not delivering substantive information or structured results, respond naturally without section headers or bullet formatting.\n\n# Tool Guidelines\n\n## Shell commands\n\nWhen using the shell, you must adhere to the following guidelines:\n\n- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)\n- Do not use python scripts to attempt to output larger chunks of a file.\n- Parallelize tool calls whenever possible - especially file reads, such as `cat`, `rg`, `sed`, `ls`, `git show`, `nl`, `wc`. Use `multi_tool_use.parallel` to parallelize tool calls and only this.\n\n## apply_patch\n\nUse the `apply_patch` tool to edit files. Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope:\n\n*** Begin Patch\n[ one or more file sections ]\n*** End Patch\n\nWithin that envelope, you get a sequence of file operations.\nYou MUST include a header to specify the action you are taking.\nEach operation starts with one of three headers:\n\n*** Add File: - create a new file. Every following line is a + line (the initial contents).\n*** Delete File: - remove an existing file. Nothing follows.\n*** Update File: - patch an existing file in place (optionally with a rename).\n\nExample patch:\n\n```\n*** Begin Patch\n*** Add File: hello.txt\n+Hello world\n*** Update File: src/app.py\n*** Move to: src/main.py\n@@ def greet():\n-print(\"Hi\")\n+print(\"Hello, world!\")\n*** Delete File: obsolete.txt\n*** End Patch\n```\n\nIt is important to remember:\n\n- You must include a header with your intended action (Add/Delete/Update)\n- You must prefix new lines with `+` even when creating a new file\n\n## `update_plan`\n\nA tool named `update_plan` is available to you. You can use it to keep an up‑to‑date, step‑by‑step plan for the task.\n\nTo create a new plan, call `update_plan` with a short list of 1‑sentence steps (no more than 5-7 words each) with a `status` for each step (`pending`, `in_progress`, or `completed`).\n\nWhen steps have been completed, use `update_plan` to mark each finished step as `completed` and the next step you are working on as `in_progress`. There should always be exactly one `in_progress` step until everything is done. You can mark multiple items as complete in a single `update_plan` call.\n\nIf all steps are complete, ensure you call `update_plan` to mark all steps as `completed`.\n", + "base_instructions": "You are GPT-5.2 running in the Codex CLI, a terminal-based coding assistant. Codex CLI is an open source project led by OpenAI. You are expected to be precise, safe, and helpful.\n\nYour capabilities:\n\n- Receive user prompts and other context provided by the harness, such as files in the workspace.\n- Communicate with the user by streaming thinking & responses, and by making & updating plans.\n- Emit function calls to run terminal commands and apply patches. Depending on how this specific run is configured, you can request that these function calls be escalated to the user for approval before running. More on this in the \"Sandbox and approvals\" section.\n\nWithin this context, Codex refers to the open-source agentic coding interface (not the old Codex language model built by OpenAI).\n\n# How you work\n\n## Personality\n\nYour default personality and tone is concise, direct, and friendly. You communicate efficiently, always keeping the user clearly informed about ongoing actions without unnecessary detail. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work.\n\n## AGENTS.md spec\n- Repos often contain AGENTS.md files. These files can appear anywhere within the repository.\n- These files are a way for humans to give you (the agent) instructions or tips for working within the container.\n- Some examples might be: coding conventions, info about how code is organized, or instructions for how to run or test code.\n- Instructions in AGENTS.md files:\n - The scope of an AGENTS.md file is the entire directory tree rooted at the folder that contains it.\n - For every file you touch in the final patch, you must obey instructions in any AGENTS.md file whose scope includes that file.\n - Instructions about code style, structure, naming, etc. apply only to code within the AGENTS.md file's scope, unless the file states otherwise.\n - More-deeply-nested AGENTS.md files take precedence in the case of conflicting instructions.\n - Direct system/developer/user instructions (as part of a prompt) take precedence over AGENTS.md instructions.\n- The contents of the AGENTS.md file at the root of the repo and any directories from the CWD up to the root are included with the developer message and don't need to be re-read. When working in a subdirectory of CWD, or a directory outside the CWD, check for any AGENTS.md files that may be applicable.\n\n## Autonomy and Persistence\nPersist until the task is fully handled end-to-end within the current turn whenever feasible: do not stop at analysis or partial fixes; carry changes through implementation, verification, and a clear explanation of outcomes unless the user explicitly pauses or redirects you.\n\nUnless the user explicitly asks for a plan, asks a question about the code, is brainstorming potential solutions, or some other intent that makes it clear that code should not be written, assume the user wants you to make code changes or run tools to solve the user's problem. In these cases, it's bad to output your proposed solution in a message, you should go ahead and actually implement the change. If you encounter challenges or blockers, you should attempt to resolve them yourself.\n\n## Responsiveness\n\n## Planning\n\nYou have access to an `update_plan` tool which tracks steps and progress and renders them to the user. Using the tool helps demonstrate that you've understood the task and convey how you're approaching it. Plans can help to make complex, ambiguous, or multi-phase work clearer and more collaborative for the user. A good plan should break the task into meaningful, logically ordered steps that are easy to verify as you go.\n\nNote that plans are not for padding out simple work with filler steps or stating the obvious. The content of your plan should not involve doing anything that you aren't capable of doing (i.e. don't try to test things that you can't test). Do not use plans for simple or single-step queries that you can just do or answer immediately.\n\nDo not repeat the full contents of the plan after an `update_plan` call — the harness already displays it. Instead, summarize the change made and highlight any important context or next step.\n\nBefore running a command, consider whether or not you have completed the previous step, and make sure to mark it as completed before moving on to the next step. It may be the case that you complete all steps in your plan after a single pass of implementation. If this is the case, you can simply mark all the planned steps as completed. Sometimes, you may need to change plans in the middle of a task: call `update_plan` with the updated plan and make sure to provide an `explanation` of the rationale when doing so.\n\nMaintain statuses in the tool: exactly one item in_progress at a time; mark items complete when done; post timely status transitions. Do not jump an item from pending to completed: always set it to in_progress first. Do not batch-complete multiple items after the fact. Finish with all items completed or explicitly canceled/deferred before ending the turn. Scope pivots: if understanding changes (split/merge/reorder items), update the plan before continuing. Do not let the plan go stale while coding.\n\nUse a plan when:\n\n- The task is non-trivial and will require multiple actions over a long time horizon.\n- There are logical phases or dependencies where sequencing matters.\n- The work has ambiguity that benefits from outlining high-level goals.\n- You want intermediate checkpoints for feedback and validation.\n- When the user asked you to do more than one thing in a single prompt\n- The user has asked you to use the plan tool (aka \"TODOs\")\n- You generate additional steps while working, and plan to do them before yielding to the user\n\n### Examples\n\n**High-quality plans**\n\nExample 1:\n\n1. Add CLI entry with file args\n2. Parse Markdown via CommonMark library\n3. Apply semantic HTML template\n4. Handle code blocks, images, links\n5. Add error handling for invalid files\n\nExample 2:\n\n1. Define CSS variables for colors\n2. Add toggle with localStorage state\n3. Refactor components to use variables\n4. Verify all views for readability\n5. Add smooth theme-change transition\n\nExample 3:\n\n1. Set up Node.js + WebSocket server\n2. Add join/leave broadcast events\n3. Implement messaging with timestamps\n4. Add usernames + mention highlighting\n5. Persist messages in lightweight DB\n6. Add typing indicators + unread count\n\n**Low-quality plans**\n\nExample 1:\n\n1. Create CLI tool\n2. Add Markdown parser\n3. Convert to HTML\n\nExample 2:\n\n1. Add dark mode toggle\n2. Save preference\n3. Make styles look good\n\nExample 3:\n\n1. Create single-file HTML game\n2. Run quick sanity check\n3. Summarize usage instructions\n\nIf you need to write a plan, only write high quality plans, not low quality ones.\n\n## Task execution\n\nYou are a coding agent. You must keep going until the query or task is completely resolved, before ending your turn and yielding back to the user. Persist until the task is fully handled end-to-end within the current turn whenever feasible and persevere even when function calls fail. Only terminate your turn when you are sure that the problem is solved. Autonomously resolve the query to the best of your ability, using the tools available to you, before coming back to the user. Do NOT guess or make up an answer.\n\nYou MUST adhere to the following criteria when solving queries:\n\n- Working on the repo(s) in the current environment is allowed, even if they are proprietary.\n- Analyzing code for vulnerabilities is allowed.\n- Showing user code and tool call details is allowed.\n- Use the `apply_patch` tool to edit files (NEVER try `applypatch` or `apply-patch`, only `apply_patch`). This is a FREEFORM tool, so do not wrap the patch in JSON.\n\nIf completing the user's task requires writing or modifying files, your code and final answer should follow these coding guidelines, though user instructions (i.e. AGENTS.md) may override these guidelines:\n\n- Fix the problem at the root cause rather than applying surface-level patches, when possible.\n- Avoid unneeded complexity in your solution.\n- Do not attempt to fix unrelated bugs or broken tests. It is not your responsibility to fix them. (You may mention them to the user in your final message though.)\n- Update documentation as necessary.\n- Keep changes consistent with the style of the existing codebase. Changes should be minimal and focused on the task.\n- If you're building a web app from scratch, give it a beautiful and modern UI, imbued with best UX practices.\n- Use `git log` and `git blame` to search the history of the codebase if additional context is required.\n- NEVER add copyright or license headers unless specifically requested.\n- Do not waste tokens by re-reading files after calling `apply_patch` on them. The tool call will fail if it didn't work. The same goes for making folders, deleting folders, etc.\n- Do not `git commit` your changes or create new git branches unless explicitly requested.\n- Do not add inline comments within code unless explicitly requested.\n- Do not use one-letter variable names unless explicitly requested.\n- NEVER output inline citations like \"【F:README.md†L5-L14】\" in your outputs. The CLI is not able to render these so they will just be broken in the UI. Instead, if you output valid filepaths, users will be able to click on them to open the files in their editor.\n\n## Validating your work\n\nIf the codebase has tests, or the ability to build or run tests, consider using them to verify changes once your work is complete.\n\nWhen testing, your philosophy should be to start as specific as possible to the code you changed so that you can catch issues efficiently, then make your way to broader tests as you build confidence. If there's no test for the code you changed, and if the adjacent patterns in the codebases show that there's a logical place for you to add a test, you may do so. However, do not add tests to codebases with no tests.\n\nSimilarly, once you're confident in correctness, you can suggest or use formatting commands to ensure that your code is well formatted. If there are issues you can iterate up to 3 times to get formatting right, but if you still can't manage it's better to save the user time and present them a correct solution where you call out the formatting in your final message. If the codebase does not have a formatter configured, do not add one.\n\nFor all of testing, running, building, and formatting, do not attempt to fix unrelated bugs. It is not your responsibility to fix them. (You may mention them to the user in your final message though.)\n\nBe mindful of whether to run validation commands proactively. In the absence of behavioral guidance:\n\n- When running in the non-interactive approval mode **never**, you can proactively run tests, lint and do whatever you need to ensure you've completed the task. If you are unable to run tests, you must still do your utmost best to complete the task.\n- When working in interactive approval modes like **untrusted**, or **on-request**, hold off on running tests or lint commands until the user is ready for you to finalize your output, because these commands take time to run and slow down iteration. Instead suggest what you want to do next, and let the user confirm first.\n- When working on test-related tasks, such as adding tests, fixing tests, or reproducing a bug to verify behavior, you may proactively run tests regardless of approval mode. Use your judgement to decide whether this is a test-related task.\n\n## Ambition vs. precision\n\nFor tasks that have no prior context (i.e. the user is starting something brand new), you should feel free to be ambitious and demonstrate creativity with your implementation.\n\nIf you're operating in an existing codebase, you should make sure you do exactly what the user asks with surgical precision. Treat the surrounding codebase with respect, and don't overstep (i.e. changing filenames or variables unnecessarily). You should balance being sufficiently ambitious and proactive when completing tasks of this nature.\n\nYou should use judicious initiative to decide on the right level of detail and complexity to deliver based on the user's needs. This means showing good judgment that you're capable of doing the right extras without gold-plating. This might be demonstrated by high-value, creative touches when scope of the task is vague; while being surgical and targeted when scope is tightly specified.\n\n## Presenting your work \n\nYour final message should read naturally, like an update from a concise teammate. For casual conversation, brainstorming tasks, or quick questions from the user, respond in a friendly, conversational tone. You should ask questions, suggest ideas, and adapt to the user’s style. If you've finished a large amount of work, when describing what you've done to the user, you should follow the final answer formatting guidelines to communicate substantive changes. You don't need to add structured formatting for one-word answers, greetings, or purely conversational exchanges.\n\nYou can skip heavy formatting for single, simple actions or confirmations. In these cases, respond in plain sentences with any relevant next step or quick option. Reserve multi-section structured responses for results that need grouping or explanation.\n\nThe user is working on the same computer as you, and has access to your work. As such there's no need to show the contents of files you have already written unless the user explicitly asks for them. Similarly, if you've created or modified files using `apply_patch`, there's no need to tell users to \"save the file\" or \"copy the code into a file\"—just reference the file path.\n\nIf there's something that you think you could help with as a logical next step, concisely ask the user if they want you to do so. Good examples of this are running tests, committing changes, or building out the next logical component. If there’s something that you couldn't do (even with approval) but that the user might want to do (such as verifying changes by running the app), include those instructions succinctly.\n\nBrevity is very important as a default. You should be very concise (i.e. no more than 10 lines), but can relax this requirement for tasks where additional detail and comprehensiveness is important for the user's understanding.\n\n### Final answer structure and style guidelines\n\nYou are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.\n\n**Section Headers**\n\n- Use only when they improve clarity — they are not mandatory for every answer.\n- Choose descriptive names that fit the content\n- Keep headers short (1–3 words) and in `**Title Case**`. Always start headers with `**` and end with `**`\n- Leave no blank line before the first bullet under a header.\n- Section headers should only be used where they genuinely improve scanability; avoid fragmenting the answer.\n\n**Bullets**\n\n- Use `-` followed by a space for every bullet.\n- Merge related points when possible; avoid a bullet for every trivial detail.\n- Keep bullets to one line unless breaking for clarity is unavoidable.\n- Group into short lists (4–6 bullets) ordered by importance.\n- Use consistent keyword phrasing and formatting across sections.\n\n**Monospace**\n\n- Wrap all commands, file paths, env vars, code identifiers, and code samples in backticks (`` `...` ``).\n- Apply to inline examples and to bullet keywords if the keyword itself is a literal file/command.\n- Never mix monospace and bold markers; choose one based on whether it’s a keyword (`**`) or inline code/path (`` ` ``).\n\n**File References**\nWhen referencing files in your response, make sure to include the relevant start line and always follow the below rules:\n * Use inline code to make file paths clickable.\n * Each reference should have a stand alone path. Even if it's the same file.\n * Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix.\n * Line/column (1‑based, optional): :line[:column] or #Lline[Ccolumn] (column defaults to 1).\n * Do not use URIs like file://, vscode://, or https://.\n * Do not provide range of lines\n * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\\repo\\project\\main.rs:12:5\n\n**Structure**\n\n- Place related bullets together; don’t mix unrelated concepts in the same section.\n- Order sections from general → specific → supporting info.\n- For subsections (e.g., “Binaries” under “Rust Workspace”), introduce with a bolded keyword bullet, then list items under it.\n- Match structure to complexity:\n - Multi-part or detailed results → use clear headers and grouped bullets.\n - Simple results → minimal headers, possibly just a short list or paragraph.\n\n**Tone**\n\n- Keep the voice collaborative and natural, like a coding partner handing off work.\n- Be concise and factual — no filler or conversational commentary and avoid unnecessary repetition\n- Use present tense and active voice (e.g., “Runs tests” not “This will run tests”).\n- Keep descriptions self-contained; don’t refer to “above” or “below”.\n- Use parallel structure in lists for consistency.\n\n**Verbosity**\n- Final answer compactness rules (enforced):\n - Tiny/small single-file change (≤ ~10 lines): 2–5 sentences or ≤3 bullets. No headings. 0–1 short snippet (≤3 lines) only if essential.\n - Medium change (single area or a few files): ≤6 bullets or 6–10 sentences. At most 1–2 short snippets total (≤8 lines each).\n - Large/multi-file change: Summarize per file with 1–2 bullets; avoid inlining code unless critical (still ≤2 short snippets total).\n - Never include \"before/after\" pairs, full method bodies, or large/scrolling code blocks in the final message. Prefer referencing file/symbol names instead.\n\n**Don’t**\n\n- Don’t use literal words “bold” or “monospace” in the content.\n- Don’t nest bullets or create deep hierarchies.\n- Don’t output ANSI escape codes directly — the CLI renderer applies them.\n- Don’t cram unrelated keywords into a single bullet; split for clarity.\n- Don’t let keyword lists run long — wrap or reformat for scanability.\n\nGenerally, ensure your final answers adapt their shape and depth to the request. For example, answers to code explanations should have a precise, structured explanation with code references that answer the question directly. For tasks with a simple implementation, lead with the outcome and supplement only with what’s needed for clarity. Larger changes can be presented as a logical walkthrough of your approach, grouping related steps, explaining rationale where it adds value, and highlighting next actions to accelerate the user. Your answers should provide the right level of detail while being easily scannable.\n\nFor casual greetings, acknowledgements, or other one-off conversational messages that are not delivering substantive information or structured results, respond naturally without section headers or bullet formatting.\n\n# Tool Guidelines\n\n## Shell commands\n\nWhen using the shell, you must adhere to the following guidelines:\n\n- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)\n- Do not use python scripts to attempt to output larger chunks of a file.\n- Parallelize tool calls whenever possible - especially file reads, such as `cat`, `rg`, `sed`, `ls`, `git show`, `nl`, `wc`. Use `multi_tool_use.parallel` to parallelize tool calls and only this.\n\n## apply_patch\n\nUse the `apply_patch` tool to edit files. Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope:\n\n*** Begin Patch\n[ one or more file sections ]\n*** End Patch\n\nWithin that envelope, you get a sequence of file operations.\nYou MUST include a header to specify the action you are taking.\nEach operation starts with one of three headers:\n\n*** Add File: - create a new file. Every following line is a + line (the initial contents).\n*** Delete File: - remove an existing file. Nothing follows.\n*** Update File: - patch an existing file in place (optionally with a rename).\n\nExample patch:\n\n```\n*** Begin Patch\n*** Add File: hello.txt\n+Hello world\n*** Update File: src/app.py\n*** Move to: src/main.py\n@@ def greet():\n-print(\"Hi\")\n+print(\"Hello, world!\")\n*** Delete File: obsolete.txt\n*** End Patch\n```\n\nIt is important to remember:\n\n- You must include a header with your intended action (Add/Delete/Update)\n- You must prefix new lines with `+` even when creating a new file\n\n## `update_plan`\n\nA tool named `update_plan` is available to you. You can use it to keep an up‑to‑date, step‑by‑step plan for the task.\n\nTo create a new plan, call `update_plan` with a short list of 1‑sentence steps (no more than 5-7 words each) with a `status` for each step (`pending`, `in_progress`, or `completed`).\n\nWhen steps have been completed, use `update_plan` to mark each finished step as `completed` and the next step you are working on as `in_progress`. There should always be exactly one `in_progress` step until everything is done. You can mark multiple items as complete in a single `update_plan` call.\n\nIf all steps are complete, ensure you call `update_plan` to mark all steps as `completed`.\n", "model_messages": null, "experimental_supported_tools": [], "available_in_plans": [ diff --git a/codex-rs/models-manager/prompt.md b/codex-rs/models-manager/prompt.md index 4886c7ef44..907ff8b877 100644 --- a/codex-rs/models-manager/prompt.md +++ b/codex-rs/models-manager/prompt.md @@ -158,7 +158,7 @@ For all of testing, running, building, and formatting, do not attempt to fix unr Be mindful of whether to run validation commands proactively. In the absence of behavioral guidance: -- When running in non-interactive approval modes like **never** or **on-failure**, proactively run tests, lint and do whatever you need to ensure you've completed the task. +- When running in the non-interactive approval mode **never**, proactively run tests, lint and do whatever you need to ensure you've completed the task. - When working in interactive approval modes like **untrusted**, or **on-request**, hold off on running tests or lint commands until the user is ready for you to finalize your output, because these commands take time to run and slow down iteration. Instead suggest what you want to do next, and let the user confirm first. - When working on test-related tasks, such as adding tests, fixing tests, or reproducing a bug to verify behavior, you may proactively run tests regardless of approval mode. Use your judgement to decide whether this is a test-related task. diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index d5d0730227..484fa817aa 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -34,9 +34,9 @@ allow_upstream_proxy = true dangerously_allow_non_loopback_proxy = false mode = "full" # default when unset; use "limited" for read-only mode # HTTPS MITM is enabled automatically when `mode = "limited"` or when MITM hooks are configured. -# CA cert/key are managed internally under $CODEX_HOME/proxy/ (ca.pem + ca.key). -# When MITM is active, spawned commands receive CA bundle env vars pointing at -# immutable bundles under $CODEX_HOME/proxy/ so common HTTPS clients trust the managed CA. +# The CA private key remains in proxy memory. When MITM is active, spawned commands receive CA +# bundle env vars pointing at immutable public files under $CODEX_HOME/proxy/ so common HTTPS +# clients trust the managed CA. # If false, local/private networking is rejected. Explicit allowlisting of local IP literals # (or `localhost`) is required to permit them. diff --git a/codex-rs/network-proxy/src/certs.rs b/codex-rs/network-proxy/src/certs.rs index 2ff7cc03b4..442211f60b 100644 --- a/codex-rs/network-proxy/src/certs.rs +++ b/codex-rs/network-proxy/src/certs.rs @@ -32,6 +32,8 @@ use std::net::IpAddr; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use std::sync::LazyLock; +use std::sync::Mutex; use std::time::SystemTime; use std::time::UNIX_EPOCH; use tracing::info; @@ -39,15 +41,60 @@ use tracing::warn; pub(super) struct ManagedMitmCa { issuer: Issuer<'static, KeyPair>, + certificate_path: PathBuf, + _artifact_lease: File, } +static MANAGED_MITM_CAS: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + impl ManagedMitmCa { - pub(super) fn load_or_create() -> Result { - let (ca_cert_pem, ca_key_pem) = load_or_create_ca()?; - let ca_key = KeyPair::from_pem(&ca_key_pem).context("failed to parse CA key")?; - let issuer: Issuer<'static, KeyPair> = - Issuer::from_ca_cert_pem(&ca_cert_pem, ca_key).context("failed to parse CA cert")?; - Ok(Self { issuer }) + pub(super) fn load_or_create() -> Result> { + let proxy_dir = managed_ca_dir()?; + let mut managed_cas = MANAGED_MITM_CAS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(ca) = managed_cas.get(&proxy_dir) { + return Ok(ca.clone()); + } + + let ca = Arc::new(Self::create(&proxy_dir)?); + managed_cas.insert(proxy_dir, ca.clone()); + Ok(ca) + } + + fn create(proxy_dir: &Path) -> Result { + fs::create_dir_all(proxy_dir) + .with_context(|| format!("failed to create {}", proxy_dir.display()))?; + + let (certificate_pem, private_key) = generate_ca()?; + let artifact_lock = match lock_managed_ca_artifacts(proxy_dir) { + Ok(lock) => Some(lock), + Err(err) => { + warn!("failed to lock managed MITM CA artifacts; skipping pruning: {err}"); + None + } + }; + let certificate_path = persist_managed_ca_certificate(proxy_dir, &certificate_pem)?; + let issuer = Issuer::from_ca_cert_pem(&certificate_pem, private_key) + .context("failed to parse managed MITM CA certificate")?; + let artifact_lease = lock_managed_ca_certificate(&certificate_path)?; + if artifact_lock.is_some() { + prune_managed_ca_artifacts(proxy_dir); + } + info!( + cert_path = %certificate_path.display(), + "generated process-local MITM CA" + ); + Ok(Self { + issuer, + certificate_path, + _artifact_lease: artifact_lease, + }) + } + + fn certificate_path(&self) -> &Path { + &self.certificate_path } pub(super) fn tls_acceptor_data_for_host(&self, host: &str) -> Result { @@ -100,8 +147,8 @@ fn issue_host_certificate_pem( } const MANAGED_MITM_CA_DIR: &str = "proxy"; -const MANAGED_MITM_CA_CERT: &str = "ca.pem"; -const MANAGED_MITM_CA_KEY: &str = "ca.key"; +const MANAGED_MITM_CA_ARTIFACT_LOCK: &str = ".artifacts.lock"; +const MANAGED_MITM_CA_CERT_PREFIX: &str = "ca"; const MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX: &str = "ca-bundle"; pub(crate) const SSL_CERT_DIR_ENV_KEY: &str = "SSL_CERT_DIR"; @@ -135,22 +182,17 @@ pub(crate) struct ManagedMitmCaTrustBundle { pub(crate) startup_env_values: HashMap<&'static str, String>, } -fn managed_ca_paths() -> Result<(PathBuf, PathBuf)> { +fn managed_ca_dir() -> Result { let codex_home = find_codex_home().context("failed to resolve CODEX_HOME for managed MITM CA")?; - let proxy_dir = codex_home.join(MANAGED_MITM_CA_DIR); - Ok(( - proxy_dir.join(MANAGED_MITM_CA_CERT).to_path_buf(), - proxy_dir.join(MANAGED_MITM_CA_KEY).to_path_buf(), - )) + Ok(codex_home.join(MANAGED_MITM_CA_DIR).to_path_buf()) } pub(crate) fn managed_ca_trust_bundle( env: &HashMap<&'static str, String>, ) -> Result { - load_or_create_ca()?; - let (cert_path, _) = managed_ca_paths()?; - managed_ca_trust_bundle_for_cert_path(&cert_path, env) + let ca = ManagedMitmCa::load_or_create()?; + managed_ca_trust_bundle_for_cert_path(ca.certificate_path(), env) } fn managed_ca_trust_bundle_for_cert_path( @@ -175,8 +217,8 @@ fn managed_ca_trust_bundle_for_cert_path( pub(crate) fn upstream_tls_root_store( env: &HashMap<&'static str, String>, ) -> Result> { - let (managed_ca_cert_path, _) = managed_ca_paths()?; - upstream_tls_root_store_for_cert_path(&managed_ca_cert_path, env) + let ca = ManagedMitmCa::load_or_create()?; + upstream_tls_root_store_for_cert_path(ca.certificate_path(), env) } pub(crate) fn upstream_tls_root_store_for_cert_path( @@ -360,6 +402,9 @@ fn is_current_generated_trust_bundle_path(path: &Path, managed_ca_cert_path: &Pa let Some(proxy_dir) = managed_ca_cert_path.parent() else { return false; }; + if is_generated_trust_bundle_path(path, proxy_dir) { + return true; + } let Some(file_name) = path.file_name().and_then(|file_name| file_name.to_str()) else { return false; }; @@ -381,12 +426,39 @@ fn is_current_generated_trust_bundle_path(path: &Path, managed_ca_cert_path: &Pa .any(|window| window == managed_ca_cert) } -/// Returns whether `path` points at a current Codex-generated MITM CA bundle. -pub fn is_managed_mitm_ca_trust_bundle_path(path: &str) -> bool { - let Ok((managed_ca_cert_path, _)) = managed_ca_paths() else { +fn is_generated_trust_bundle_path(path: &Path, proxy_dir: &Path) -> bool { + is_generated_managed_ca_artifact_path(path, proxy_dir, MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX) +} + +fn is_generated_managed_ca_artifact_path(path: &Path, proxy_dir: &Path, prefix: &str) -> bool { + let Some(file_name) = path.file_name().and_then(|file_name| file_name.to_str()) else { return false; }; - is_current_generated_trust_bundle_path(Path::new(path), &managed_ca_cert_path) + let Some(expected_hash) = file_name + .strip_prefix(prefix) + .and_then(|suffix| suffix.strip_prefix('-')) + .and_then(|suffix| suffix.strip_suffix(".pem")) + else { + return false; + }; + if path.parent() != Some(proxy_dir) + || expected_hash.len() != 64 + || !expected_hash.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return false; + } + let Ok(trust_bundle) = fs::read(path) else { + return false; + }; + format!("{:x}", Sha256::digest(trust_bundle)) == expected_hash +} + +/// Returns whether `path` points at a current Codex-generated MITM CA bundle. +pub fn is_managed_mitm_ca_trust_bundle_path(path: &str) -> bool { + let Ok(proxy_dir) = managed_ca_dir() else { + return false; + }; + is_generated_trust_bundle_path(Path::new(path), &proxy_dir) } fn persist_managed_ca_trust_bundle( @@ -439,56 +511,160 @@ fn push_certificate_pem(bundle: &mut String, der: &[u8]) { bundle.push_str("-----END CERTIFICATE-----\n"); } -fn load_or_create_ca() -> Result<(String, String)> { - let (cert_path, key_path) = managed_ca_paths()?; - - if cert_path.exists() || key_path.exists() { - if !cert_path.exists() || !key_path.exists() { - return Err(anyhow!( - "both managed MITM CA files must exist (cert={}, key={})", - cert_path.display(), - key_path.display() - )); - } - validate_existing_ca_key_file(&key_path)?; - let cert_pem = fs::read_to_string(&cert_path) - .with_context(|| format!("failed to read CA cert {}", cert_path.display()))?; - let key_pem = fs::read_to_string(&key_path) - .with_context(|| format!("failed to read CA key {}", key_path.display()))?; - return Ok((cert_pem, key_pem)); - } - - if let Some(parent) = cert_path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; - } - if let Some(parent) = key_path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; - } - - let (cert_pem, key_pem) = generate_ca()?; - // The CA key is a high-value secret. Create it atomically with restrictive permissions. - // The cert can be world-readable, but we still write it atomically to avoid partial writes. - // - // We intentionally use create-new semantics: if a key already exists, we should not overwrite - // it silently (that would invalidate previously-trusted cert chains). - write_atomic_create_new(&key_path, key_pem.as_bytes(), /*mode*/ 0o600) - .with_context(|| format!("failed to persist CA key {}", key_path.display()))?; - if let Err(err) = write_atomic_create_new(&cert_path, cert_pem.as_bytes(), /*mode*/ 0o644) - .with_context(|| format!("failed to persist CA cert {}", cert_path.display())) - { - // Avoid leaving a partially-created CA around (cert missing) if the second write fails. - let _ = fs::remove_file(&key_path); - return Err(err); - } - let cert_path = cert_path.display(); - let key_path = key_path.display(); - info!("generated MITM CA (cert_path={cert_path}, key_path={key_path})"); - Ok((cert_pem, key_pem)) +fn persist_managed_ca_certificate(proxy_dir: &Path, cert_pem: &str) -> Result { + let hash = Sha256::digest(cert_pem.as_bytes()); + let cert_path = proxy_dir.join(format!("{MANAGED_MITM_CA_CERT_PREFIX}-{hash:x}.pem")); + write_atomic_create_new_or_reuse(&cert_path, cert_pem.as_bytes(), /*mode*/ 0o644) + .with_context(|| { + format!( + "failed to persist managed MITM CA certificate {}", + cert_path.display() + ) + })?; + Ok(cert_path) } -fn generate_ca() -> Result<(String, String)> { +fn lock_managed_ca_certificate(certificate_path: &Path) -> Result { + let lock_path = managed_ca_certificate_lock_path(certificate_path) + .ok_or_else(|| anyhow!("managed MITM CA certificate path is missing a file name"))?; + let file = open_managed_ca_lock(&lock_path)?; + file.lock_shared() + .with_context(|| format!("failed to lock {}", lock_path.display()))?; + Ok(file) +} + +fn lock_managed_ca_artifacts(proxy_dir: &Path) -> Result { + let lock_path = proxy_dir.join(MANAGED_MITM_CA_ARTIFACT_LOCK); + let file = open_managed_ca_lock(&lock_path)?; + file.lock() + .with_context(|| format!("failed to lock {}", lock_path.display()))?; + Ok(file) +} + +fn managed_ca_certificate_lock_path(certificate_path: &Path) -> Option { + let file_name = certificate_path.file_name()?.to_string_lossy(); + Some(certificate_path.with_file_name(format!(".{file_name}.lock"))) +} + +fn open_managed_ca_lock(path: &Path) -> Result { + if fs::symlink_metadata(path) + .ok() + .is_some_and(|metadata| metadata.file_type().is_symlink()) + { + return Err(anyhow!( + "refusing to use symlink lock file {}", + path.display() + )); + } + + #[cfg(unix)] + use std::os::unix::fs::OpenOptionsExt; + + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + options.mode(0o600); + options + .open(path) + .with_context(|| format!("failed to open {}", path.display())) +} + +fn prune_managed_ca_artifacts(proxy_dir: &Path) { + for certificate_path in + generated_managed_ca_artifact_paths(proxy_dir, MANAGED_MITM_CA_CERT_PREFIX) + { + remove_inactive_managed_ca_certificate(&certificate_path); + } + + let remaining_certificates = + generated_managed_ca_artifact_paths(proxy_dir, MANAGED_MITM_CA_CERT_PREFIX) + .into_iter() + .filter_map(|path| fs::read(path).ok()) + .filter(|certificate| !certificate.is_empty()) + .collect::>(); + let bundle_paths = + generated_managed_ca_artifact_paths(proxy_dir, MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX); + for bundle_path in bundle_paths { + let Ok(contents) = fs::read(&bundle_path) else { + continue; + }; + if remaining_certificates.iter().any(|certificate| { + contents + .windows(certificate.len()) + .any(|window| window == certificate) + }) { + continue; + } + if let Err(err) = fs::remove_file(&bundle_path) + && err.kind() != std::io::ErrorKind::NotFound + { + warn!( + path = %bundle_path.display(), + "failed to prune stale managed MITM CA trust bundle: {err}" + ); + } + } +} + +fn generated_managed_ca_artifact_paths(proxy_dir: &Path, prefix: &str) -> Vec { + let Ok(entries) = fs::read_dir(proxy_dir) else { + return Vec::new(); + }; + entries + .filter_map(std::result::Result::ok) + .filter_map(|entry| { + let path = entry.path(); + if !is_generated_managed_ca_artifact_path(&path, proxy_dir, prefix) { + return None; + } + Some(path) + }) + .collect() +} + +fn remove_inactive_managed_ca_certificate(certificate_path: &Path) { + let Some(lock_path) = managed_ca_certificate_lock_path(certificate_path) else { + return; + }; + let Ok(lock_file) = open_managed_ca_lock(&lock_path) else { + return; + }; + match lock_file.try_lock() { + Ok(()) => {} + Err(std::fs::TryLockError::WouldBlock) => return, + Err(err) => { + warn!( + path = %lock_path.display(), + "failed to inspect managed MITM CA artifact lease: {err}" + ); + return; + } + } + + let removed = match fs::remove_file(certificate_path) { + Ok(()) => true, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => true, + Err(err) => { + warn!( + path = %certificate_path.display(), + "failed to prune stale managed MITM CA certificate: {err}" + ); + false + } + }; + drop(lock_file); + if removed + && let Err(err) = fs::remove_file(&lock_path) + && err.kind() != std::io::ErrorKind::NotFound + { + warn!( + path = %lock_path.display(), + "failed to prune stale managed MITM CA artifact lease: {err}" + ); + } +} + +fn generate_ca() -> Result<(String, KeyPair)> { let mut params = CertificateParams::default(); params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); params.key_usages = vec![ @@ -505,7 +681,7 @@ fn generate_ca() -> Result<(String, String)> { let cert = params .self_signed(&key_pair) .map_err(|err| anyhow!("failed to generate CA cert: {err}"))?; - Ok((cert.pem(), key_pair.serialize_pem())) + Ok((cert.pem(), key_pair)) } fn write_atomic_create_new(path: &Path, contents: &[u8], mode: u32) -> Result<()> { @@ -604,41 +780,6 @@ fn write_atomic_create_new_or_reuse(path: &Path, contents: &[u8], mode: u32) -> } } -#[cfg(unix)] -fn validate_existing_ca_key_file(path: &Path) -> Result<()> { - use std::os::unix::fs::PermissionsExt; - - let metadata = fs::symlink_metadata(path) - .with_context(|| format!("failed to stat CA key {}", path.display()))?; - if metadata.file_type().is_symlink() { - return Err(anyhow!( - "refusing to use symlink for managed MITM CA key {}", - path.display() - )); - } - if !metadata.is_file() { - return Err(anyhow!( - "managed MITM CA key is not a regular file: {}", - path.display() - )); - } - - let mode = metadata.permissions().mode() & 0o777; - if mode & 0o077 != 0 { - return Err(anyhow!( - "managed MITM CA key {} must not be group/world accessible (mode={mode:o}; expected <= 600)", - path.display() - )); - } - - Ok(()) -} - -#[cfg(not(unix))] -fn validate_existing_ca_key_file(_path: &Path) -> Result<()> { - Ok(()) -} - #[cfg(unix)] fn open_create_new_with_mode(path: &Path, mode: u32) -> Result { use std::os::unix::fs::OpenOptionsExt; @@ -664,12 +805,83 @@ fn open_create_new_with_mode(path: &Path, _mode: u32) -> Result { mod tests { use super::*; - #[cfg(unix)] + use codex_utils_rustls_provider::ensure_rustls_crypto_provider; use pretty_assertions::assert_eq; - #[cfg(unix)] - use std::os::unix::fs::PermissionsExt; use tempfile::tempdir; + #[test] + fn managed_ca_private_key_is_not_persisted() { + ensure_rustls_crypto_provider(); + let dir = tempdir().unwrap(); + let ca = ManagedMitmCa::create(dir.path()).unwrap(); + ca.tls_acceptor_data_for_host("example.com").unwrap(); + let mut persisted_files = fs::read_dir(dir.path()) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect::>(); + persisted_files.sort(); + let mut expected_files = vec![ + ca.certificate_path().to_path_buf(), + managed_ca_certificate_lock_path(ca.certificate_path()).unwrap(), + dir.path().join(MANAGED_MITM_CA_ARTIFACT_LOCK), + ]; + expected_files.sort(); + + assert_eq!(persisted_files, expected_files); + assert_eq!( + fs::read(managed_ca_certificate_lock_path(ca.certificate_path()).unwrap()).unwrap(), + Vec::::new() + ); + } + + #[test] + fn managed_ca_artifact_pruning_preserves_only_active_certificates() { + let dir = tempdir().unwrap(); + let mut artifacts = Vec::new(); + let mut active_lease = None; + for index in 0..3 { + let certificate = format!("certificate {index}\n"); + let certificate_path = + persist_managed_ca_certificate(dir.path(), &certificate).unwrap(); + let lease = lock_managed_ca_certificate(&certificate_path).unwrap(); + if index == 0 { + active_lease = Some(lease); + } else { + drop(lease); + } + let bundle_path = persist_managed_ca_trust_bundle( + &certificate_path, + &format!("roots\n{certificate}"), + ) + .unwrap(); + artifacts.push((certificate_path, bundle_path)); + } + let unrelated_path = dir.path().join("ca-user.pem"); + fs::write(&unrelated_path, "user managed").unwrap(); + + prune_managed_ca_artifacts(dir.path()); + + let remaining_certificate_count = + generated_managed_ca_artifact_paths(dir.path(), MANAGED_MITM_CA_CERT_PREFIX).len(); + assert_eq!(remaining_certificate_count, 1); + assert!(artifacts[0].0.exists()); + assert!(artifacts[0].1.exists()); + assert!(!artifacts[1].0.exists()); + assert!(!artifacts[1].1.exists()); + assert!(!artifacts[2].0.exists()); + assert!(!artifacts[2].1.exists()); + assert!(unrelated_path.exists()); + + drop(active_lease.take()); + prune_managed_ca_artifacts(dir.path()); + + let remaining_certificates = + generated_managed_ca_artifact_paths(dir.path(), MANAGED_MITM_CA_CERT_PREFIX); + assert!(remaining_certificates.is_empty()); + assert!(!artifacts[0].0.exists()); + assert!(!artifacts[0].1.exists()); + } + #[test] fn current_generated_trust_bundle_path_rejects_stale_bundle() { let dir = tempdir().unwrap(); @@ -683,6 +895,24 @@ mod tests { )); } + #[test] + fn generated_trust_bundle_path_requires_matching_content_hash() { + let dir = tempdir().unwrap(); + let managed_ca_cert_path = dir.path().join("ca.pem"); + let trust_bundle_path = + persist_managed_ca_trust_bundle(&managed_ca_cert_path, "trusted roots").unwrap(); + + assert!(is_generated_trust_bundle_path( + &trust_bundle_path, + dir.path() + )); + fs::write(&trust_bundle_path, "tampered roots").unwrap(); + assert!(!is_generated_trust_bundle_path( + &trust_bundle_path, + dir.path() + )); + } + #[test] fn managed_ca_trust_bundle_appends_startup_file_and_directory_certificates() { let dir = tempdir().unwrap(); @@ -691,6 +921,7 @@ mod tests { let startup_ca_dir = dir.path().join("startup-certs"); let (managed_ca_cert, _) = generate_ca().unwrap(); let (startup_ca_cert, startup_ca_key) = generate_ca().unwrap(); + let startup_ca_key = startup_ca_key.serialize_pem(); let (directory_ca_cert, _) = generate_ca().unwrap(); let mut trusted_ca_der = CertificateDer::from_pem_slice(startup_ca_cert.as_bytes()) .unwrap() @@ -760,50 +991,6 @@ mod tests { assert_eq!(baseline_bundle.matches(&managed_ca_cert).count(), 1); } - #[cfg(unix)] - #[test] - fn validate_existing_ca_key_file_rejects_group_world_permissions() { - let dir = tempdir().unwrap(); - let key_path = dir.path().join("ca.key"); - fs::write(&key_path, "key").unwrap(); - fs::set_permissions(&key_path, fs::Permissions::from_mode(0o644)).unwrap(); - - let err = validate_existing_ca_key_file(&key_path).unwrap_err(); - assert!( - err.to_string().contains("group/world accessible"), - "unexpected error: {err:#}" - ); - } - - #[cfg(unix)] - #[test] - fn validate_existing_ca_key_file_rejects_symlink() { - use std::os::unix::fs::symlink; - - let dir = tempdir().unwrap(); - let target = dir.path().join("real.key"); - let link = dir.path().join("ca.key"); - fs::write(&target, "key").unwrap(); - symlink(&target, &link).unwrap(); - - let err = validate_existing_ca_key_file(&link).unwrap_err(); - assert!( - err.to_string().contains("symlink"), - "unexpected error: {err:#}" - ); - } - - #[cfg(unix)] - #[test] - fn validate_existing_ca_key_file_allows_private_permissions() { - let dir = tempdir().unwrap(); - let key_path = dir.path().join("ca.key"); - fs::write(&key_path, "key").unwrap(); - fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600)).unwrap(); - - validate_existing_ca_key_file(&key_path).unwrap(); - } - #[cfg(unix)] #[test] fn write_atomic_create_new_or_reuse_rejects_matching_symlink_target() { diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index c8950f1f55..5bce990303 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -47,6 +47,7 @@ pub use proxy::Args; #[cfg(target_os = "macos")] pub use proxy::CODEX_PROXY_GIT_SSH_COMMAND_MARKER; pub use proxy::DEFAULT_NO_PROXY_VALUE; +pub use proxy::ManagedNetworkSandboxContext; pub use proxy::NO_PROXY_ENV_KEYS; pub use proxy::NetworkProxy; pub use proxy::NetworkProxyBuilder; @@ -56,6 +57,7 @@ pub use proxy::PROXY_ENV_KEYS; #[cfg(target_os = "macos")] pub use proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY; pub use proxy::PROXY_URL_ENV_KEYS; +pub use proxy::PreparedManagedNetwork; pub use proxy::has_proxy_url_env_vars; pub use proxy::proxy_url_env_value; pub use runtime::BlockedRequest; diff --git a/codex-rs/network-proxy/src/mitm.rs b/codex-rs/network-proxy/src/mitm.rs index 70175ca706..6607d2ac08 100644 --- a/codex-rs/network-proxy/src/mitm.rs +++ b/codex-rs/network-proxy/src/mitm.rs @@ -53,7 +53,7 @@ use tracing::warn; /// State needed to terminate a CONNECT tunnel and enforce policy on inner HTTPS requests. pub struct MitmState { - ca: ManagedMitmCa, + ca: Arc, upstream: UpstreamClient, inspect: bool, max_body_bytes: usize, @@ -104,7 +104,7 @@ impl MitmState { // MITM exists when HTTPS policy depends on the inner request: limited-mode method clamps // and host-specific hooks both need visibility after CONNECT is established. We - // generate/load a local CA and issue per-host leaf certs so we can terminate TLS and + // generate a process-local CA and issue per-host leaf certs so we can terminate TLS and // apply policy. let ca = ManagedMitmCa::load_or_create()?; let upstream_tls_root_store = diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index 13998dd447..7d2372f179 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -10,6 +10,8 @@ use anyhow::Context; use anyhow::Result; use clap::Parser; use codex_utils_absolute_path::AbsolutePathBuf; +use serde::Deserialize; +use serde::Serialize; use std::collections::HashMap; use std::net::SocketAddr; use std::net::TcpListener as StdTcpListener; @@ -328,6 +330,27 @@ struct EnvironmentProxyAddrs { socks_addr: SocketAddr, } +/// Portable managed-network facts needed by an operating-system sandbox. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ManagedNetworkSandboxContext { + /// Loopback proxy ports that sandboxed commands may connect to. + #[serde(default)] + pub loopback_ports: Vec, + /// Whether the command may bind local sockets and exchange loopback traffic. + #[serde(default)] + pub allow_local_binding: bool, +} + +/// Environment-specific managed-network settings prepared for one command launch. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PreparedManagedNetwork { + /// Complete command environment with managed proxy variables applied. + pub env: HashMap, + /// Matching portable sandbox inputs for the command environment. + pub sandbox_context: ManagedNetworkSandboxContext, +} + struct EnvironmentProxy { addrs: EnvironmentProxyAddrs, http_task: JoinHandle>, @@ -653,22 +676,49 @@ impl NetworkProxy { }) } - fn apply_to_env_for_addrs( + fn prepare_for_addrs( &self, - env: &mut HashMap, + mut env: HashMap, addrs: EnvironmentProxyAddrs, - ) { + ) -> PreparedManagedNetwork { let runtime_settings = self.runtime_settings(); // Enforce proxying for child processes. Proxy endpoint values are always rewritten; // managed MITM CA vars preserve child-scoped overrides after proxy startup. apply_proxy_env_overrides( - env, + &mut env, addrs.http_addr, addrs.socks_addr, self.socks_enabled, runtime_settings.allow_local_binding, runtime_settings.mitm_ca_trust_bundle.as_ref(), ); + let mut loopback_ports = [ + Some(addrs.http_addr), + self.socks_enabled.then_some(addrs.socks_addr), + ] + .into_iter() + .flatten() + .filter(|addr| addr.ip().is_loopback()) + .map(|addr| addr.port()) + .collect::>(); + loopback_ports.sort_unstable(); + loopback_ports.dedup(); + PreparedManagedNetwork { + env, + sandbox_context: ManagedNetworkSandboxContext { + loopback_ports, + allow_local_binding: runtime_settings.allow_local_binding, + }, + } + } + + fn apply_to_env_for_addrs( + &self, + env: &mut HashMap, + addrs: EnvironmentProxyAddrs, + ) { + let prepared = self.prepare_for_addrs(std::mem::take(env), addrs); + *env = prepared.env; } pub fn apply_to_env(&self, env: &mut HashMap) { @@ -705,6 +755,23 @@ impl NetworkProxy { } } + /// Applies the environment-specific proxy settings and returns the matching portable sandbox + /// projection from the same runtime configuration snapshot. + pub fn prepare_for_optional_environment( + &self, + env: HashMap, + environment_id: Option<&str>, + ) -> Result { + let addrs = match environment_id { + Some(environment_id) => self.environment_proxy_addrs(environment_id)?, + None => EnvironmentProxyAddrs { + http_addr: self.http_addr, + socks_addr: self.socks_addr, + }, + }; + Ok(self.prepare_for_addrs(env, addrs)) + } + fn environment_proxy_addrs(&self, environment_id: &str) -> Result { let mut proxies = self .environment_proxies @@ -1071,27 +1138,59 @@ mod tests { } #[tokio::test] - async fn apply_to_env_for_environment_uses_distinct_proxy_ports() -> Result<()> { + async fn prepare_for_environment_keeps_env_and_sandbox_ports_in_sync() -> Result<()> { let state = Arc::new(network_proxy_state_for_policy( NetworkProxySettings::default(), )); let proxy = NetworkProxy::builder().state(state).build().await?; let handle = proxy.run().await?; - let mut local_env = HashMap::new(); - proxy.apply_to_env_for_environment(&mut local_env, "local")?; - let mut remote_env = HashMap::new(); - proxy.apply_to_env_for_environment(&mut remote_env, "remote")?; + let base_env = HashMap::from([("PRESERVED".to_string(), "value".to_string())]); + let local = proxy.prepare_for_optional_environment(base_env.clone(), Some("local"))?; + let remote = proxy.prepare_for_optional_environment(HashMap::new(), Some("remote"))?; - assert_ne!(local_env.get("HTTP_PROXY"), remote_env.get("HTTP_PROXY")); + assert_eq!( + local.env.get("PRESERVED").map(String::as_str), + Some("value") + ); + assert_ne!(local.env.get("HTTP_PROXY"), remote.env.get("HTTP_PROXY")); assert_ne!( - local_env.get("HTTP_PROXY"), + local.env.get("HTTP_PROXY"), Some(&format!("http://{}", proxy.http_addr())) ); assert_ne!( - remote_env.get("HTTP_PROXY"), + remote.env.get("HTTP_PROXY"), Some(&format!("http://{}", proxy.http_addr())) ); + for prepared in [&local, &remote] { + let http_port = prepared + .env + .get("HTTP_PROXY") + .and_then(|value| value.strip_prefix("http://")) + .and_then(|value| value.parse::().ok()) + .map(|addr| addr.port()) + .expect("managed HTTP proxy address"); + let socks_port = prepared + .env + .get("ALL_PROXY") + .and_then(|value| value.strip_prefix("socks5h://")) + .and_then(|value| value.parse::().ok()) + .map(|addr| addr.port()) + .expect("managed SOCKS proxy address"); + let mut expected_ports = vec![http_port, socks_port]; + expected_ports.sort_unstable(); + expected_ports.dedup(); + assert_eq!( + prepared.sandbox_context, + ManagedNetworkSandboxContext { + loopback_ports: expected_ports, + allow_local_binding: false, + } + ); + } + let mut legacy_env = base_env; + proxy.apply_to_env_for_environment(&mut legacy_env, "local")?; + assert_eq!(legacy_env, local.env); handle.shutdown().await?; Ok(()) diff --git a/codex-rs/plugin/src/lib.rs b/codex-rs/plugin/src/lib.rs index 1a4eb23af1..8a00d8c5ef 100644 --- a/codex-rs/plugin/src/lib.rs +++ b/codex-rs/plugin/src/lib.rs @@ -70,9 +70,10 @@ pub struct PluginHookSource { #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginTelemetryMetadata { - pub plugin_id: PluginId, - /// Optional backend identifier for remote plugins, used when analytics - /// should report the remote id instead of the local plugin cache id. + /// Local plugin identifier used by Codex configuration and the plugin cache, + /// when it has been resolved. + pub plugin_id: Option, + /// Optional backend identifier for remote plugins. pub remote_plugin_id: Option, pub capability_summary: Option, } diff --git a/codex-rs/prompts/src/permissions_instructions.rs b/codex-rs/prompts/src/permissions_instructions.rs index a4a043f24f..090254705e 100644 --- a/codex-rs/prompts/src/permissions_instructions.rs +++ b/codex-rs/prompts/src/permissions_instructions.rs @@ -18,8 +18,6 @@ const APPROVAL_POLICY_NEVER: &str = include_str!("../templates/permissions/approval_policy/never.md"); const APPROVAL_POLICY_UNLESS_TRUSTED: &str = include_str!("../templates/permissions/approval_policy/unless_trusted.md"); -const APPROVAL_POLICY_ON_FAILURE: &str = - include_str!("../templates/permissions/approval_policy/on_failure.md"); const APPROVAL_POLICY_ON_REQUEST_RULE: &str = include_str!("../templates/permissions/approval_policy/on_request.md"); const APPROVAL_POLICY_ON_REQUEST_AUTO_REVIEW: &str = @@ -231,7 +229,6 @@ fn approval_text( AskForApproval::UnlessTrusted => { with_request_permissions_tool(APPROVAL_POLICY_UNLESS_TRUSTED) } - AskForApproval::OnFailure => with_request_permissions_tool(APPROVAL_POLICY_ON_FAILURE), AskForApproval::OnRequest => on_request_instructions(), AskForApproval::Granular(granular_config) => granular_instructions( granular_config, diff --git a/codex-rs/prompts/src/permissions_instructions_tests.rs b/codex-rs/prompts/src/permissions_instructions_tests.rs index 8564a7cf1e..8250aea57d 100644 --- a/codex-rs/prompts/src/permissions_instructions_tests.rs +++ b/codex-rs/prompts/src/permissions_instructions_tests.rs @@ -175,26 +175,6 @@ fn includes_request_permissions_tool_instructions_for_unless_trusted_when_enable assert!(text.contains("# request_permissions Tool")); } -#[test] -fn includes_request_permissions_tool_instructions_for_on_failure_when_enabled() { - let instructions = PermissionsInstructions::from_permissions_with_network( - SandboxMode::WorkspaceWrite, - NetworkAccess::Enabled, - PermissionsPromptConfig { - approval_policy: AskForApproval::OnFailure, - approvals_reviewer: ApprovalsReviewer::User, - exec_policy: &Policy::empty(), - exec_permission_approvals_enabled: false, - request_permissions_tool_enabled: true, - }, - /*writable_roots*/ None, - ); - - let text = instructions.body(); - assert!(text.contains("`approval_policy` is `on-failure`")); - assert!(text.contains("# request_permissions Tool")); -} - #[test] fn includes_request_permission_rule_instructions_for_on_request_when_enabled() { let instructions = PermissionsInstructions::from_permissions_with_network( diff --git a/codex-rs/prompts/templates/permissions/approval_policy/on_failure.md b/codex-rs/prompts/templates/permissions/approval_policy/on_failure.md deleted file mode 100644 index 7ee26dbd49..0000000000 --- a/codex-rs/prompts/templates/permissions/approval_policy/on_failure.md +++ /dev/null @@ -1 +0,0 @@ -Approvals are your mechanism to get user consent to run shell commands without the sandbox. `approval_policy` is `on-failure`: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox. diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 76bc0e3992..508d71d4be 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -25,6 +25,7 @@ use crate::user_input::ByteRange; use crate::user_input::TextElement; use crate::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use quick_xml::de::from_str as from_xml_str; use quick_xml::se::to_string as to_xml_string; use schemars::JsonSchema; @@ -138,7 +139,11 @@ pub struct WebSearchItem { #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] pub struct ImageViewItem { pub id: String, - pub path: AbsolutePathBuf, + /// Path resolved within the selected execution environment. + /// + /// This core protocol type is not exposed directly in the app-server API. + /// App-server converts the path to `LegacyAppPathString` at its boundary. + pub path: PathUri, } #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] diff --git a/codex-rs/protocol/src/prompts/base_instructions/default.md b/codex-rs/protocol/src/prompts/base_instructions/default.md index 4886c7ef44..907ff8b877 100644 --- a/codex-rs/protocol/src/prompts/base_instructions/default.md +++ b/codex-rs/protocol/src/prompts/base_instructions/default.md @@ -158,7 +158,7 @@ For all of testing, running, building, and formatting, do not attempt to fix unr Be mindful of whether to run validation commands proactively. In the absence of behavioral guidance: -- When running in non-interactive approval modes like **never** or **on-failure**, proactively run tests, lint and do whatever you need to ensure you've completed the task. +- When running in the non-interactive approval mode **never**, proactively run tests, lint and do whatever you need to ensure you've completed the task. - When working in interactive approval modes like **untrusted**, or **on-request**, hold off on running tests or lint commands until the user is ready for you to finalize your output, because these commands take time to run and slow down iteration. Instead suggest what you want to do next, and let the user confirm first. - When working on test-related tasks, such as adding tests, fixing tests, or reproducing a bug to verify behavior, you may proactively run tests regardless of approval mode. Use your judgement to decide whether this is a test-related task. diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index ea9e783286..cc0d258075 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -870,15 +870,8 @@ pub enum AskForApproval { #[strum(serialize = "untrusted")] UnlessTrusted, - /// DEPRECATED: *All* commands are auto‑approved, but they are expected to - /// run inside a sandbox where network access is disabled and writes are - /// confined to a specific set of paths. If the command fails, it will be - /// escalated to the user to approve execution without a sandbox. - /// Prefer `OnRequest` for interactive runs or `Never` for non-interactive - /// runs. - OnFailure, - /// The model decides when to ask the user for approval. + #[serde(alias = "on-failure")] #[default] OnRequest, @@ -3422,8 +3415,11 @@ pub struct ExecCommandEndEvent { pub struct ViewImageToolCallEvent { /// Identifier for the originating tool call. pub call_id: String, - /// Local filesystem path provided to the tool. - pub path: AbsolutePathBuf, + /// Filesystem path resolved for the selected environment. + /// + /// This core event is not exposed directly in the app-server API. App-server + /// converts the path to `LegacyAppPathString` when building its public item. + pub path: PathUri, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -5455,6 +5451,20 @@ mod tests { Ok(()) } + #[test] + fn turn_context_item_deserializes_legacy_on_failure_as_on_request() -> Result<()> { + let item: TurnContextItem = serde_json::from_value(json!({ + "cwd": test_path_buf("/tmp"), + "approval_policy": "on-failure", + "sandbox_policy": { "type": "danger-full-access" }, + "model": "gpt-5", + "summary": "auto", + }))?; + + assert_eq!(item.approval_policy, AskForApproval::OnRequest); + Ok(()) + } + #[test] fn multi_agent_version_uses_newest_present_session_meta_value() -> Result<()> { let thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; diff --git a/codex-rs/protocol/src/thread_id.rs b/codex-rs/protocol/src/thread_id.rs index d6e9a8825e..dcfedc7f52 100644 --- a/codex-rs/protocol/src/thread_id.rs +++ b/codex-rs/protocol/src/thread_id.rs @@ -10,6 +10,9 @@ use uuid::Uuid; #[derive(Debug, Clone, Copy, PartialEq, Eq, TS, Hash)] #[ts(type = "string")] +/// Identifier for a Codex thread. +/// +/// Codex-generated thread IDs are UUIDv7, and some use cases rely on that. pub struct ThreadId { pub(crate) uuid: Uuid, } diff --git a/codex-rs/rmcp-client/src/stdio_server_launcher.rs b/codex-rs/rmcp-client/src/stdio_server_launcher.rs index bc1a8924d5..7d03e1437e 100644 --- a/codex-rs/rmcp-client/src/stdio_server_launcher.rs +++ b/codex-rs/rmcp-client/src/stdio_server_launcher.rs @@ -509,6 +509,7 @@ impl ExecutorStdioServerLauncher { arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await .map_err(io::Error::other)?; diff --git a/codex-rs/rollout/src/persistence_metrics.rs b/codex-rs/rollout/src/persistence_metrics.rs index b1989737e9..911186f682 100644 --- a/codex-rs/rollout/src/persistence_metrics.rs +++ b/codex-rs/rollout/src/persistence_metrics.rs @@ -257,6 +257,7 @@ fn response_item_type(item: &ResponseItem) -> &'static str { match item { ResponseItem::AdditionalTools { .. } => "response.additional_tools", ResponseItem::Message { .. } => "response.message", + ResponseItem::AdditionalTools { .. } => "response.additional_tools", ResponseItem::AgentMessage { .. } => "response.agent_message", ResponseItem::Reasoning { .. } => "response.reasoning", ResponseItem::LocalShellCall { .. } => "response.local_shell_call", diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs index 0688d0f920..c9109146bd 100644 --- a/codex-rs/sandboxing/src/lib.rs +++ b/codex-rs/sandboxing/src/lib.rs @@ -12,6 +12,7 @@ mod windows; pub use bwrap::find_system_bwrap_in_path; #[cfg(target_os = "linux")] pub use bwrap::system_bwrap_warning; +pub use codex_windows_sandbox::WindowsSandboxProxySettingsMode; pub use denial::is_likely_sandbox_denied; pub use manager::SandboxCommand; pub use manager::SandboxDirectSpawnTransformRequest; diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index 29b1177b11..51d2d6c98e 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -13,6 +13,7 @@ use crate::resolve_windows_elevated_filesystem_overrides; use crate::resolve_windows_restricted_token_filesystem_overrides; #[cfg(target_os = "windows")] use crate::windows_sandbox_uses_elevated_backend; +use codex_network_proxy::ManagedNetworkSandboxContext; use codex_network_proxy::NetworkProxy; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::AdditionalPermissionProfile; @@ -99,6 +100,7 @@ pub struct SandboxCommand { pub args: Vec, pub cwd: PathUri, pub env: HashMap, + pub managed_network: Option, pub additional_permissions: Option, } @@ -150,6 +152,7 @@ pub struct SandboxTransformRequest<'a> { pub struct SandboxDirectSpawnTransformRequest<'a> { pub transform: SandboxTransformRequest<'a>, pub workspace_roots: &'a [AbsolutePathBuf], + pub windows_sandbox_proxy_settings_mode: codex_windows_sandbox::WindowsSandboxProxySettingsMode, } // TODO(anp): Revisit this preparation type once this module's PathUri migration is complete. @@ -332,6 +335,8 @@ impl SandboxManager { windows_sandbox_level, windows_sandbox_private_desktop, } = request; + #[cfg(target_os = "macos")] + let managed_network = command.managed_network.as_ref(); let additional_permissions = command.additional_permissions.take(); let managed_mitm_ca_trust_bundle_path = network.and_then(NetworkProxy::managed_mitm_ca_trust_bundle_path); @@ -364,6 +369,7 @@ impl SandboxManager { network_sandbox_policy: pending.effective_network_policy, sandbox_policy_cwd: pending.native_sandbox_policy_cwd.as_path(), enforce_managed_network, + managed_network, environment_id, network, extra_allow_unix_sockets: &[], @@ -479,12 +485,14 @@ impl SandboxManager { codex_home: &Path, ) -> Result { let workspace_roots = request.workspace_roots; + let proxy_settings_mode = request.windows_sandbox_proxy_settings_mode; let mut request = self.transform(request.transform)?; if request.sandbox == SandboxType::WindowsRestrictedToken { wrap_windows_sandbox_exec_request_for_direct_spawn( &mut request, workspace_roots, codex_home, + proxy_settings_mode, )?; } Ok(request) @@ -496,6 +504,7 @@ fn wrap_windows_sandbox_exec_request_for_direct_spawn( request: &mut SandboxExecRequest, workspace_roots: &[AbsolutePathBuf], codex_home: &Path, + proxy_settings_mode: codex_windows_sandbox::WindowsSandboxProxySettingsMode, ) -> Result<(), SandboxTransformError> { // TODO(anp): Keep PathUri through the Windows sandbox wrapper boundary. let native_cwd = @@ -567,6 +576,7 @@ fn wrap_windows_sandbox_exec_request_for_direct_spawn( request.windows_sandbox_level, request.windows_sandbox_private_desktop, proxy_enforced, + proxy_settings_mode, read_roots_override, read_roots_include_platform_defaults, write_roots_override, diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs index 64fc87b6e4..c1a52182d7 100644 --- a/codex-rs/sandboxing/src/manager_tests.rs +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -92,6 +92,7 @@ fn unsandboxed_transform_preserves_foreign_cwd_and_unrestricted_file_system_poli args: Vec::new(), cwd: cwd_uri.clone(), env: HashMap::new(), + managed_network: None, additional_permissions: None, }, permissions: &permissions, @@ -139,6 +140,7 @@ fn transform_additional_permissions_enable_network_for_external_sandbox() { args: Vec::new(), cwd: cwd_uri.clone(), env: HashMap::new(), + managed_network: None, additional_permissions: Some(AdditionalPermissionProfile { network: Some(NetworkPermissions { enabled: Some(true), @@ -211,6 +213,7 @@ fn transform_additional_permissions_preserves_denied_entries() { args: Vec::new(), cwd: cwd_uri.clone(), env: HashMap::new(), + managed_network: None, additional_permissions: Some(AdditionalPermissionProfile { file_system: Some(FileSystemPermissions::from_read_write_roots( /*read*/ None, @@ -314,6 +317,7 @@ fn transform_linux_seccomp_request( args: Vec::new(), cwd: cwd_uri.clone(), env: HashMap::new(), + managed_network: None, additional_permissions: None, }, permissions: &permissions, @@ -495,6 +499,8 @@ fn transform_for_direct_spawn_windows_materializes_inner_helper() { .transform_for_direct_spawn_with_codex_home( SandboxDirectSpawnTransformRequest { workspace_roots: workspace_roots.as_slice(), + windows_sandbox_proxy_settings_mode: + codex_windows_sandbox::WindowsSandboxProxySettingsMode::Preserve, transform: SandboxTransformRequest { command: SandboxCommand { program: configured_helper.as_os_str().to_owned(), @@ -504,6 +510,7 @@ fn transform_for_direct_spawn_windows_materializes_inner_helper() { "Path".to_string(), r"C:\Windows\System32".to_string(), )]), + managed_network: None, additional_permissions: None, }, permissions: &permissions, @@ -539,6 +546,12 @@ fn transform_for_direct_spawn_windows_materializes_inner_helper() { .iter() .any(|arg| arg == "--run-as-windows-sandbox") ); + assert!( + exec_request + .command + .iter() + .any(|arg| arg == "--preserve-proxy-settings") + ); assert!( exec_request .command diff --git a/codex-rs/sandboxing/src/seatbelt.rs b/codex-rs/sandboxing/src/seatbelt.rs index d3233e705b..f53569de07 100644 --- a/codex-rs/sandboxing/src/seatbelt.rs +++ b/codex-rs/sandboxing/src/seatbelt.rs @@ -1,3 +1,4 @@ +use codex_network_proxy::ManagedNetworkSandboxContext; use codex_network_proxy::NetworkProxy; use codex_network_proxy::PROXY_URL_ENV_KEYS; use codex_network_proxy::has_proxy_url_env_vars; @@ -103,6 +104,7 @@ struct UnixSocketPathParam { } fn proxy_policy_inputs( + managed_network: Option<&ManagedNetworkSandboxContext>, network: Option<&NetworkProxy>, environment_id: Option<&str>, extra_allow_unix_sockets: &[AbsolutePathBuf], @@ -112,33 +114,47 @@ fn proxy_policy_inputs( .filter_map(|socket_path| normalize_path_for_sandbox(socket_path.as_path())) .collect::>(); + let unix_domain_socket_policy = match network { + Some(network) if network.dangerously_allow_all_unix_sockets() => { + UnixDomainSocketPolicy::AllowAll + } + Some(network) => { + let mut allowed = network + .allow_unix_sockets() + .iter() + .filter_map(|socket_path| { + match normalize_path_for_sandbox(Path::new(socket_path)) { + Some(path) => Some(path), + None => { + warn!( + "ignoring network.allow_unix_sockets entry because it could not be normalized: {socket_path}" + ); + None + } + } + }) + .collect::>(); + allowed.extend(extra_allowed); + UnixDomainSocketPolicy::Restricted { allowed } + } + None => UnixDomainSocketPolicy::Restricted { + allowed: extra_allowed, + }, + }; + if let Some(managed_network) = managed_network { + return Ok(ProxyPolicyInputs { + ports: managed_network.loopback_ports.clone(), + has_proxy_config: true, + allow_local_binding: managed_network.allow_local_binding, + unix_domain_socket_policy, + }); + } match network { Some(network) => { let mut env = HashMap::new(); network .apply_to_env_for_optional_environment(&mut env, environment_id) .map_err(|err| err.to_string())?; - let unix_domain_socket_policy = if network.dangerously_allow_all_unix_sockets() { - UnixDomainSocketPolicy::AllowAll - } else { - let mut allowed = network - .allow_unix_sockets() - .iter() - .filter_map(|socket_path| { - match normalize_path_for_sandbox(Path::new(socket_path)) { - Some(path) => Some(path), - None => { - warn!( - "ignoring network.allow_unix_sockets entry because it could not be normalized: {socket_path}" - ); - None - } - } - }) - .collect::>(); - allowed.extend(extra_allowed); - UnixDomainSocketPolicy::Restricted { allowed } - }; Ok(ProxyPolicyInputs { ports: proxy_loopback_ports_from_env(&env), has_proxy_config: has_proxy_url_env_vars(&env), @@ -147,9 +163,7 @@ fn proxy_policy_inputs( }) } None => Ok(ProxyPolicyInputs { - unix_domain_socket_policy: UnixDomainSocketPolicy::Restricted { - allowed: extra_allowed, - }, + unix_domain_socket_policy, ..Default::default() }), } @@ -586,6 +600,7 @@ fn create_seatbelt_command_args_for_legacy_policy( network_sandbox_policy: NetworkSandboxPolicy::from(sandbox_policy), sandbox_policy_cwd, enforce_managed_network, + managed_network: None, environment_id: None, network, extra_allow_unix_sockets: &[], @@ -599,6 +614,7 @@ pub struct CreateSeatbeltCommandArgsParams<'a> { pub network_sandbox_policy: NetworkSandboxPolicy, pub sandbox_policy_cwd: &'a Path, pub enforce_managed_network: bool, + pub managed_network: Option<&'a ManagedNetworkSandboxContext>, pub environment_id: Option<&'a str>, pub network: Option<&'a NetworkProxy>, pub extra_allow_unix_sockets: &'a [AbsolutePathBuf], @@ -613,6 +629,7 @@ pub fn create_seatbelt_command_args( network_sandbox_policy, sandbox_policy_cwd, enforce_managed_network, + managed_network, environment_id, network, extra_allow_unix_sockets, @@ -709,7 +726,12 @@ pub fn create_seatbelt_command_args( } }; - let proxy = proxy_policy_inputs(network, environment_id, extra_allow_unix_sockets)?; + let proxy = proxy_policy_inputs( + managed_network, + network, + environment_id, + extra_allow_unix_sockets, + )?; let network_policy = dynamic_network_policy_for_network(network_sandbox_policy, enforce_managed_network, &proxy); diff --git a/codex-rs/sandboxing/src/seatbelt_tests.rs b/codex-rs/sandboxing/src/seatbelt_tests.rs index e0ff0e6652..b15019a8cb 100644 --- a/codex-rs/sandboxing/src/seatbelt_tests.rs +++ b/codex-rs/sandboxing/src/seatbelt_tests.rs @@ -14,6 +14,7 @@ use super::unix_socket_policy; use codex_network_proxy::ConfigReloader; use codex_network_proxy::ConfigReloaderFuture; use codex_network_proxy::ConfigState; +use codex_network_proxy::ManagedNetworkSandboxContext; use codex_network_proxy::NetworkMode; use codex_network_proxy::NetworkProxy; use codex_network_proxy::NetworkProxyConfig; @@ -205,6 +206,7 @@ fn explicit_unreadable_paths_are_excluded_from_full_disk_read_and_write_access() network_sandbox_policy: NetworkSandboxPolicy::Restricted, sandbox_policy_cwd: Path::new("/"), enforce_managed_network: false, + managed_network: None, environment_id: None, network: None, extra_allow_unix_sockets: &[], @@ -258,6 +260,37 @@ fn explicit_unreadable_paths_are_excluded_from_full_disk_read_and_write_access() ); } +#[test] +fn prepared_managed_network_context_allows_only_its_proxy_ports() { + let file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( + &SandboxPolicy::new_read_only_policy(), + Path::new("/"), + ); + let managed_network = ManagedNetworkSandboxContext { + loopback_ports: vec![43123, 48081], + allow_local_binding: false, + }; + let args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams { + command: vec!["/bin/true".to_string()], + file_system_sandbox_policy: &file_system_policy, + network_sandbox_policy: NetworkSandboxPolicy::Restricted, + sandbox_policy_cwd: Path::new("/"), + enforce_managed_network: true, + managed_network: Some(&managed_network), + environment_id: None, + network: None, + extra_allow_unix_sockets: &[], + }) + .unwrap(); + + let policy = seatbelt_policy_arg(&args); + assert!(policy.contains("(allow network-outbound (remote ip \"localhost:43123\"))")); + assert!(policy.contains("(allow network-outbound (remote ip \"localhost:48081\"))")); + assert!(!policy.contains("(allow network-outbound (remote ip \"localhost:9999\"))")); + assert!(!policy.contains("(allow network-bind (local ip \"*:*\"))")); + assert!(!policy.contains("(allow network-outbound)\n")); +} + #[test] fn explicit_unreadable_paths_are_excluded_from_readable_roots() { let root = absolute_path("/tmp/codex-readable"); @@ -279,6 +312,7 @@ fn explicit_unreadable_paths_are_excluded_from_readable_roots() { network_sandbox_policy: NetworkSandboxPolicy::Restricted, sandbox_policy_cwd: Path::new("/"), enforce_managed_network: false, + managed_network: None, environment_id: None, network: None, extra_allow_unix_sockets: &[], @@ -585,6 +619,7 @@ fn create_seatbelt_args_allowlists_explicit_unix_socket_paths_without_proxy() { network_sandbox_policy: NetworkSandboxPolicy::Restricted, sandbox_policy_cwd: cwd.path(), enforce_managed_network: false, + managed_network: None, environment_id: None, network: None, extra_allow_unix_sockets: &extra_allow_unix_sockets, @@ -645,6 +680,7 @@ async fn create_seatbelt_args_merges_proxy_and_explicit_unix_socket_paths() -> a network_sandbox_policy: NetworkSandboxPolicy::Restricted, sandbox_policy_cwd: cwd.path(), enforce_managed_network: false, + managed_network: None, environment_id: None, network: Some(&network_proxy), extra_allow_unix_sockets: &extra_allow_unix_sockets, @@ -688,6 +724,7 @@ fn create_seatbelt_args_preserves_full_network_with_explicit_unix_socket_paths() network_sandbox_policy: NetworkSandboxPolicy::Enabled, sandbox_policy_cwd: cwd.path(), enforce_managed_network: false, + managed_network: None, environment_id: None, network: None, extra_allow_unix_sockets: &extra_allow_unix_sockets, diff --git a/codex-rs/tui/src/app/agent_status_feed.rs b/codex-rs/tui/src/app/agent_status_feed.rs index 544c0b7172..eb14551d5a 100644 --- a/codex-rs/tui/src/app/agent_status_feed.rs +++ b/codex-rs/tui/src/app/agent_status_feed.rs @@ -182,7 +182,8 @@ fn activity_summary(item: &ThreadItem) -> Option { return bounded_summary(&format!("Web search: {query}")); } ThreadItem::ImageView { path, .. } => { - return bounded_summary(&format!("Viewed {}", path.display())); + let path = path.render_for_ui(); + return bounded_summary(&format!("Viewed {path}")); } ThreadItem::ImageGeneration { .. } => return Some("Generated an image".to_string()), ThreadItem::EnteredReviewMode { .. } => return Some("Entered review mode".to_string()), diff --git a/codex-rs/tui/src/app/loaded_threads.rs b/codex-rs/tui/src/app/loaded_threads.rs index 6bd260c0b6..8e790170e6 100644 --- a/codex-rs/tui/src/app/loaded_threads.rs +++ b/codex-rs/tui/src/app/loaded_threads.rs @@ -128,6 +128,7 @@ mod tests { fn test_thread(thread_id: ThreadId, source: SessionSource) -> Thread { Thread { id: thread_id.to_string(), + extra: None, session_id: thread_id.to_string(), forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 6c55accab6..21aca66bcd 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2874,6 +2874,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re ServerNotification::ThreadStarted(ThreadStartedNotification { thread: Thread { id: agent_thread_id.to_string(), + extra: None, session_id: agent_thread_id.to_string(), forked_from_id: None, parent_thread_id: None, @@ -2967,6 +2968,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_ ServerNotification::ThreadStarted(ThreadStartedNotification { thread: Thread { id: agent_thread_id.to_string(), + extra: None, session_id: agent_thread_id.to_string(), forked_from_id: None, parent_thread_id: None, @@ -3027,6 +3029,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { let thread = Thread { id: read_thread_id.to_string(), + extra: None, session_id: read_thread_id.to_string(), forked_from_id: None, parent_thread_id: None, @@ -5640,6 +5643,7 @@ async fn thread_rollback_response_discards_queued_active_thread_events() { &ThreadRollbackResponse { thread: Thread { id: thread_id.to_string(), + extra: None, session_id: thread_id.to_string(), forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index ceafb71db9..58412bff08 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -409,6 +409,7 @@ mod tests { }; let read_thread = Thread { id: read_thread_id.to_string(), + extra: None, session_id: read_thread_id.to_string(), forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 527cde57ed..7181d1a560 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -2320,6 +2320,7 @@ mod tests { let response = ThreadResumeResponse { thread: codex_app_server_protocol::Thread { id: thread_id.to_string(), + extra: None, session_id: ThreadId::new().to_string(), forked_from_id: Some(forked_from_id.to_string()), parent_thread_id: None, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__foreign_image_attachment_history_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__foreign_image_attachment_history_snapshot.snap new file mode 100644 index 0000000000..1c2102e4ce --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__foreign_image_attachment_history_snapshot.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests/exec_flow.rs +assertion_line: 877 +expression: combined +--- +• Viewed Image + └ C:\workspace\assets\example.png diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 4f7d225dd8..99190ec726 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -862,6 +862,21 @@ async fn view_image_tool_call_adds_history_cell() { assert_chatwidget_snapshot!("local_image_attachment_history_snapshot", combined); } +#[tokio::test] +async fn view_image_tool_call_preserves_foreign_path() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let image_path: LegacyAppPathString = + serde_json::from_value(json!(r"C:\workspace\assets\example.png")) + .expect("valid legacy app path string"); + + handle_view_image_tool_call(&mut chat, "call-image", image_path); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected a single history cell"); + let combined = lines_to_single_string(&cells[0]); + assert_chatwidget_snapshot!("foreign_image_attachment_history_snapshot", combined); +} + #[tokio::test] async fn image_generation_call_adds_history_cell() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 06c440bfb2..5e23e9f96a 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -679,7 +679,7 @@ pub(super) fn handle_patch_apply_end( pub(super) fn handle_view_image_tool_call( chat: &mut ChatWidget, call_id: impl Into, - path: AbsolutePathBuf, + path: impl Into, ) { chat.handle_server_notification( ServerNotification::ItemCompleted(ItemCompletedNotification { @@ -688,7 +688,7 @@ pub(super) fn handle_view_image_tool_call( completed_at_ms: 0, item: AppServerThreadItem::ImageView { id: call_id.into(), - path, + path: path.into(), }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tool_lifecycle.rs b/codex-rs/tui/src/chatwidget/tool_lifecycle.rs index e37c44a4fa..99d84d155b 100644 --- a/codex-rs/tui/src/chatwidget/tool_lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/tool_lifecycle.rs @@ -4,6 +4,7 @@ //! events as transcript cells. use super::*; +use codex_utils_path_uri::LegacyAppPathString; impl ChatWidget { pub(super) fn on_patch_apply_begin(&mut self, changes: HashMap) { @@ -11,7 +12,7 @@ impl ChatWidget { self.add_to_history(history_cell::new_patch_event(changes, &self.config.cwd)); } - pub(super) fn on_view_image_tool_call(&mut self, path: AbsolutePathBuf) { + pub(super) fn on_view_image_tool_call(&mut self, path: LegacyAppPathString) { self.record_visible_turn_activity(); self.flush_answer_stream_with_separator(); self.add_to_history(history_cell::new_view_image_tool_call( diff --git a/codex-rs/tui/src/history_cell/patches.rs b/codex-rs/tui/src/history_cell/patches.rs index 8a2dcb33f3..7892b7c4d7 100644 --- a/codex-rs/tui/src/history_cell/patches.rs +++ b/codex-rs/tui/src/history_cell/patches.rs @@ -1,6 +1,7 @@ //! Patch summaries and image-tool transcript helpers. use super::*; +use codex_utils_path_uri::LegacyAppPathString; #[derive(Debug)] pub(crate) struct PatchHistoryCell { @@ -60,8 +61,12 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell { PlainHistoryCell { lines } } -pub(crate) fn new_view_image_tool_call(path: AbsolutePathBuf, cwd: &Path) -> PlainHistoryCell { - let display_path = display_path_for(path.as_path(), cwd); +pub(crate) fn new_view_image_tool_call(path: LegacyAppPathString, cwd: &Path) -> PlainHistoryCell { + let display_path = path + .to_inferred_path_uri() + .and_then(|path| path.to_abs_path().ok()) + .map(|path| display_path_for(path.as_path(), cwd)) + .unwrap_or_else(|| path.into_string()); let lines: Vec> = vec![ vec!["• ".dim(), "Viewed Image".bold()].into(), diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 08191eaf2b..888cc5b27a 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -5720,6 +5720,7 @@ session_picker_view = "dense" let thread_id = ThreadId::new(); let thread = Thread { id: thread_id.to_string(), + extra: None, session_id: thread_id.to_string(), forked_from_id: None, parent_thread_id: None, @@ -5756,6 +5757,7 @@ session_picker_view = "dense" let thread_id = ThreadId::new(); let thread = Thread { id: thread_id.to_string(), + extra: None, session_id: thread_id.to_string(), forked_from_id: None, parent_thread_id: None, @@ -5826,6 +5828,7 @@ session_picker_view = "dense" let thread_id = ThreadId::new(); let thread = Thread { id: thread_id.to_string(), + extra: None, session_id: thread_id.to_string(), forked_from_id: None, parent_thread_id: None, @@ -5885,6 +5888,7 @@ session_picker_view = "dense" let thread_id = ThreadId::new(); let thread = Thread { id: thread_id.to_string(), + extra: None, session_id: thread_id.to_string(), forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/tui/src/thread_transcript.rs b/codex-rs/tui/src/thread_transcript.rs index 13b2a3a389..cdffaab27c 100644 --- a/codex-rs/tui/src/thread_transcript.rs +++ b/codex-rs/tui/src/thread_transcript.rs @@ -203,7 +203,8 @@ fn fallback_transcript_cell(item: &ThreadItem) -> Option { vec![vec!["web search: ".dim(), query.clone().into()].into()] } ThreadItem::ImageView { path, .. } => { - vec![format!("image: {}", path.as_path().display()).dim().into()] + let path = path.render_for_ui(); + vec![format!("image: {path}").dim().into()] } ThreadItem::ImageGeneration { status, saved_path, .. diff --git a/codex-rs/utils/cli/src/approval_mode_cli_arg.rs b/codex-rs/utils/cli/src/approval_mode_cli_arg.rs index ee4ecabcb6..255ff6ea59 100644 --- a/codex-rs/utils/cli/src/approval_mode_cli_arg.rs +++ b/codex-rs/utils/cli/src/approval_mode_cli_arg.rs @@ -12,12 +12,6 @@ pub enum ApprovalModeCliArg { /// is not in the "trusted" set. Untrusted, - /// DEPRECATED: Run all commands without asking for user approval. - /// Only asks for approval if a command fails to execute, in which case it - /// will escalate to the user to ask for un-sandboxed execution. - /// Prefer `on-request` for interactive runs or `never` for non-interactive runs. - OnFailure, - /// The model decides when to ask the user for approval. OnRequest, @@ -30,7 +24,6 @@ impl From for AskForApproval { fn from(value: ApprovalModeCliArg) -> Self { match value { ApprovalModeCliArg::Untrusted => AskForApproval::UnlessTrusted, - ApprovalModeCliArg::OnFailure => AskForApproval::OnFailure, ApprovalModeCliArg::OnRequest => AskForApproval::OnRequest, ApprovalModeCliArg::Never => AskForApproval::Never, } diff --git a/codex-rs/utils/path-uri/src/api_path_string.rs b/codex-rs/utils/path-uri/src/api_path_string.rs index e4fdbe7c27..bc63990679 100644 --- a/codex-rs/utils/path-uri/src/api_path_string.rs +++ b/codex-rs/utils/path-uri/src/api_path_string.rs @@ -86,6 +86,17 @@ impl LegacyAppPathString { PathUri::try_from(self.clone()).ok() } + /// Renders this API path for display in a user interface. + /// + /// Absolute paths are normalized using their inferred native convention. + /// Strings that cannot be interpreted as absolute paths retain their raw + /// API spelling. + pub fn render_for_ui(&self) -> String { + self.to_inferred_path_uri() + .map(|path| path.inferred_native_path_string()) + .unwrap_or_else(|| self.0.clone()) + } + /// Parses this API string as a host-native absolute path. pub fn to_inferred_abs_path(&self) -> Option { AbsolutePathBuf::try_from(self.clone()).ok() diff --git a/codex-rs/utils/path-uri/src/api_path_string_tests.rs b/codex-rs/utils/path-uri/src/api_path_string_tests.rs index c71a65abf3..c6ee1ea6ae 100644 --- a/codex-rs/utils/path-uri/src/api_path_string_tests.rs +++ b/codex-rs/utils/path-uri/src/api_path_string_tests.rs @@ -458,6 +458,7 @@ fn converts_absolute_api_paths_using_the_inferred_convention() { path.to_inferred_path_uri(), Some(PathUri::parse(expected_uri).expect("expected URI should parse")), ); + assert_eq!(path.render_for_ui(), raw_path); assert_eq!( PathUri::try_from(path.clone()), path.to_path_uri(convention) diff --git a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs index 5b8bb3769a..1da6f32947 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs @@ -150,6 +150,7 @@ mod windows_impl { &deny_read_paths_override, &deny_write_paths_override, proxy_enforced, + crate::WindowsSandboxProxySettingsMode::Reconcile, )?; // Build capability SID for ACL grants. let caps = load_or_create_cap_sids(codex_home)?; @@ -214,6 +215,7 @@ mod windows_impl { &deny_read_paths_override, &deny_write_paths_override, proxy_enforced, + crate::WindowsSandboxProxySettingsMode::Reconcile, )?; spawn_runner_transport( codex_home, diff --git a/codex-rs/windows-sandbox-rs/src/identity.rs b/codex-rs/windows-sandbox-rs/src/identity.rs index fcba538cea..9ac3402be1 100644 --- a/codex-rs/windows-sandbox-rs/src/identity.rs +++ b/codex-rs/windows-sandbox-rs/src/identity.rs @@ -8,8 +8,8 @@ use crate::setup::SetupMarker; use crate::setup::gather_read_roots; use crate::setup::gather_write_roots_for_permissions; use crate::setup::offline_proxy_settings_from_env; -use crate::setup::run_elevated_setup; -use crate::setup::run_setup_refresh_with_overrides; +use crate::setup::run_elevated_setup_with_proxy_settings; +use crate::setup::run_setup_refresh_with_overrides_and_proxy_settings; use crate::setup::sandbox_users_path; use crate::setup::setup_marker_path; use anyhow::Context; @@ -154,6 +154,7 @@ pub fn require_logon_sandbox_creds( deny_read_paths_override: &[PathBuf], deny_write_paths_override: &[PathBuf], proxy_enforced: bool, + proxy_settings_mode: crate::WindowsSandboxProxySettingsMode, ) -> Result { let sandbox_dir = crate::setup::sandbox_dir(codex_home); let needed_read = read_roots_override @@ -163,13 +164,19 @@ pub fn require_logon_sandbox_creds( .map(<[PathBuf]>::to_vec) .unwrap_or_else(|| gather_write_roots_for_permissions(permissions, command_cwd, env_map)); let network_identity = SandboxNetworkIdentity::from_permissions(permissions, proxy_enforced); - let desired_offline_proxy_settings = offline_proxy_settings_from_env(env_map, network_identity); + let marker = load_marker(codex_home)?; + let desired_offline_proxy_settings = desired_offline_proxy_settings( + marker.as_ref(), + proxy_settings_mode, + env_map, + network_identity, + ); // NOTE: Do not add CODEX_HOME/.sandbox to `needed_write`; it must remain non-writable by the // restricted capability token. The setup helper's `lock_sandbox_dir` is responsible for // granting the sandbox group access to this directory without granting the capability SID. let mut setup_reason: Option = None; - let mut identity = match load_marker(codex_home)? { + let mut identity = match marker { Some(marker) if marker.version_matches() => { if let Some(reason) = marker.request_mismatch_reason(network_identity, &desired_offline_proxy_settings) @@ -201,7 +208,7 @@ pub fn require_logon_sandbox_creds( } else { crate::logging::log_note("sandbox setup required", Some(&sandbox_dir)); } - run_elevated_setup( + run_elevated_setup_with_proxy_settings( crate::setup::SandboxSetupRequest { permissions, command_cwd, @@ -216,11 +223,12 @@ pub fn require_logon_sandbox_creds( deny_read_paths: Some(deny_read_paths_override.to_vec()), deny_write_paths: Some(deny_write_paths_override.to_vec()), }, + &desired_offline_proxy_settings, )?; identity = select_identity(network_identity, codex_home)?; } // Always refresh ACLs (non-elevated) for current roots via the setup binary. - run_setup_refresh_with_overrides( + run_setup_refresh_with_overrides_and_proxy_settings( crate::setup::SandboxSetupRequest { permissions, command_cwd, @@ -235,6 +243,7 @@ pub fn require_logon_sandbox_creds( deny_read_paths: Some(deny_read_paths_override.to_vec()), deny_write_paths: Some(deny_write_paths_override.to_vec()), }, + &desired_offline_proxy_settings, )?; let identity = identity.ok_or_else(|| { anyhow!( @@ -247,6 +256,22 @@ pub fn require_logon_sandbox_creds( }) } +fn desired_offline_proxy_settings( + marker: Option<&SetupMarker>, + proxy_settings_mode: crate::WindowsSandboxProxySettingsMode, + env_map: &HashMap, + network_identity: SandboxNetworkIdentity, +) -> crate::setup::OfflineProxySettings { + match (marker, proxy_settings_mode) { + (Some(marker), crate::WindowsSandboxProxySettingsMode::Preserve) + if marker.version_matches() => + { + marker.offline_proxy_settings() + } + _ => offline_proxy_settings_from_env(env_map, network_identity), + } +} + #[allow(clippy::too_many_arguments)] pub(crate) fn refresh_logon_sandbox_creds( permissions: &ResolvedWindowsSandboxPermissions, @@ -259,6 +284,7 @@ pub(crate) fn refresh_logon_sandbox_creds( deny_read_paths_override: &[PathBuf], deny_write_paths_override: &[PathBuf], proxy_enforced: bool, + proxy_settings_mode: crate::WindowsSandboxProxySettingsMode, ) -> Result { remove_sandbox_users_file(codex_home, "sandbox user login failed")?; require_logon_sandbox_creds( @@ -272,13 +298,20 @@ pub(crate) fn refresh_logon_sandbox_creds( deny_read_paths_override, deny_write_paths_override, proxy_enforced, + proxy_settings_mode, ) } #[cfg(test)] mod tests { + use super::desired_offline_proxy_settings; use super::remove_sandbox_users_file; + use crate::WindowsSandboxProxySettingsMode; + use crate::setup::SandboxNetworkIdentity; + use crate::setup::SetupMarker; use crate::setup::sandbox_users_path; + use pretty_assertions::assert_eq; + use std::collections::HashMap; use std::fs; use tempfile::TempDir; @@ -302,4 +335,40 @@ mod tests { remove_sandbox_users_file(codex_home.path(), "stale creds").expect("remove users"); assert!(!users_path.exists()); } + + #[test] + fn preserving_proxy_settings_uses_the_existing_marker() { + let marker = SetupMarker { + version: crate::setup::SETUP_VERSION, + offline_username: "offline".to_string(), + online_username: "online".to_string(), + created_at: None, + proxy_ports: vec![7890], + allow_local_binding: true, + }; + let env_map = HashMap::from([( + "HTTP_PROXY".to_string(), + "http://127.0.0.1:8080".to_string(), + )]); + + assert_eq!( + desired_offline_proxy_settings( + Some(&marker), + WindowsSandboxProxySettingsMode::Preserve, + &env_map, + SandboxNetworkIdentity::Offline, + ), + marker.offline_proxy_settings() + ); + assert_eq!( + desired_offline_proxy_settings( + Some(&marker), + WindowsSandboxProxySettingsMode::Reconcile, + &env_map, + SandboxNetworkIdentity::Offline, + ) + .proxy_ports, + vec![8080] + ); + } } diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index a7f3df6e2a..766faf1a05 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -35,6 +35,15 @@ impl fmt::Debug for WindowsSandboxCancellationToken { } } +/// Controls whether a Windows sandbox launch reconciles persistent proxy +/// firewall settings or preserves the settings established by another launch. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum WindowsSandboxProxySettingsMode { + #[default] + Reconcile, + Preserve, +} + #[cfg(target_os = "windows")] mod acl; #[cfg(target_os = "windows")] diff --git a/codex-rs/windows-sandbox-rs/src/setup.rs b/codex-rs/windows-sandbox-rs/src/setup.rs index 428b5ff6a1..1995c33365 100644 --- a/codex-rs/windows-sandbox-rs/src/setup.rs +++ b/codex-rs/windows-sandbox-rs/src/setup.rs @@ -130,14 +130,16 @@ pub fn run_setup_refresh( proxy_enforced, }, SetupRootOverrides::default(), + /*offline_proxy_settings_override*/ None, ) } -pub fn run_setup_refresh_with_overrides( +pub(crate) fn run_setup_refresh_with_overrides_and_proxy_settings( request: SandboxSetupRequest<'_>, overrides: SetupRootOverrides, + offline_proxy_settings: &OfflineProxySettings, ) -> Result<()> { - run_setup_refresh_inner(request, overrides) + run_setup_refresh_inner(request, overrides, Some(offline_proxy_settings)) } pub fn run_setup_refresh_with_extra_read_roots( @@ -174,12 +176,14 @@ pub fn run_setup_refresh_with_extra_read_roots( deny_read_paths: None, deny_write_paths: None, }, + /*offline_proxy_settings_override*/ None, ) } fn run_setup_refresh_inner( request: SandboxSetupRequest<'_>, overrides: SetupRootOverrides, + offline_proxy_settings_override: Option<&OfflineProxySettings>, ) -> Result<()> { if !request.permissions.is_enforceable_by_windows_sandbox() { anyhow::bail!("unsupported filesystem permissions for Windows sandbox setup"); @@ -187,9 +191,8 @@ fn run_setup_refresh_inner( let (read_roots, write_roots) = build_payload_roots(&request, &overrides); let deny_read_paths = build_payload_deny_read_paths(overrides.deny_read_paths); let deny_write_paths = build_payload_deny_write_paths(&request, overrides.deny_write_paths); - let network_identity = - SandboxNetworkIdentity::from_permissions(request.permissions, request.proxy_enforced); - let offline_proxy_settings = offline_proxy_settings_from_env(request.env_map, network_identity); + let offline_proxy_settings = + offline_proxy_settings_for_request(&request, offline_proxy_settings_override); let payload = ElevationPayload { version: SETUP_VERSION, offline_username: OFFLINE_USERNAME.to_string(), @@ -283,6 +286,13 @@ impl SetupMarker { self.version == SETUP_VERSION } + pub(crate) fn offline_proxy_settings(&self) -> OfflineProxySettings { + OfflineProxySettings { + proxy_ports: self.proxy_ports.clone(), + allow_local_binding: self.allow_local_binding, + } + } + pub(crate) fn request_mismatch_reason( &self, network_identity: SandboxNetworkIdentity, @@ -593,6 +603,17 @@ pub(crate) fn offline_proxy_settings_from_env( } } +fn offline_proxy_settings_for_request( + request: &SandboxSetupRequest<'_>, + offline_proxy_settings_override: Option<&OfflineProxySettings>, +) -> OfflineProxySettings { + offline_proxy_settings_override.cloned().unwrap_or_else(|| { + let network_identity = + SandboxNetworkIdentity::from_permissions(request.permissions, request.proxy_enforced); + offline_proxy_settings_from_env(request.env_map, network_identity) + }) +} + pub(crate) fn proxy_ports_from_env(env_map: &HashMap) -> Vec { let mut ports = BTreeSet::new(); for key in PROXY_ENV_KEYS { @@ -821,6 +842,24 @@ fn run_setup_exe( pub fn run_elevated_setup( request: SandboxSetupRequest<'_>, overrides: SetupRootOverrides, +) -> Result<()> { + run_elevated_setup_inner( + request, overrides, /*offline_proxy_settings_override*/ None, + ) +} + +pub(crate) fn run_elevated_setup_with_proxy_settings( + request: SandboxSetupRequest<'_>, + overrides: SetupRootOverrides, + offline_proxy_settings: &OfflineProxySettings, +) -> Result<()> { + run_elevated_setup_inner(request, overrides, Some(offline_proxy_settings)) +} + +fn run_elevated_setup_inner( + request: SandboxSetupRequest<'_>, + overrides: SetupRootOverrides, + offline_proxy_settings_override: Option<&OfflineProxySettings>, ) -> Result<()> { if !request.permissions.is_enforceable_by_windows_sandbox() { anyhow::bail!("unsupported filesystem permissions for Windows sandbox setup"); @@ -836,9 +875,8 @@ pub fn run_elevated_setup( let (read_roots, write_roots) = build_payload_roots(&request, &overrides); let deny_read_paths = build_payload_deny_read_paths(overrides.deny_read_paths); let deny_write_paths = build_payload_deny_write_paths(&request, overrides.deny_write_paths); - let network_identity = - SandboxNetworkIdentity::from_permissions(request.permissions, request.proxy_enforced); - let offline_proxy_settings = offline_proxy_settings_from_env(request.env_map, network_identity); + let offline_proxy_settings = + offline_proxy_settings_for_request(&request, offline_proxy_settings_override); let payload = ElevationPayload { version: SETUP_VERSION, offline_username: OFFLINE_USERNAME.to_string(), @@ -1170,6 +1208,37 @@ mod tests { ) } + #[test] + fn setup_request_prefers_explicit_proxy_settings() { + let tmp = TempDir::new().expect("tempdir"); + let command_cwd = tmp.path().join("workspace"); + fs::create_dir_all(&command_cwd).expect("create workspace"); + let permissions = permissions_for( + &PermissionProfile::read_only(), + workspace_roots_for(&command_cwd).as_slice(), + ); + let env_map = HashMap::from([( + "HTTP_PROXY".to_string(), + "http://127.0.0.1:8080".to_string(), + )]); + let explicit = super::OfflineProxySettings { + proxy_ports: vec![7890], + allow_local_binding: true, + }; + let request = super::SandboxSetupRequest { + permissions: &permissions, + command_cwd: &command_cwd, + env_map: &env_map, + codex_home: tmp.path(), + proxy_enforced: false, + }; + + assert_eq!( + super::offline_proxy_settings_for_request(&request, Some(&explicit)), + explicit + ); + } + #[test] fn report_helper_failure_uses_setup_error_report_when_clear_succeeded() { let tmp = TempDir::new().expect("tempdir"); diff --git a/codex-rs/windows-sandbox-rs/src/spawn_prep.rs b/codex-rs/windows-sandbox-rs/src/spawn_prep.rs index 9668edb1ab..382a6a4531 100644 --- a/codex-rs/windows-sandbox-rs/src/spawn_prep.rs +++ b/codex-rs/windows-sandbox-rs/src/spawn_prep.rs @@ -357,6 +357,7 @@ pub(crate) fn prepare_elevated_spawn_context_for_permissions( deny_read_paths_override: &[PathBuf], deny_write_paths_override: &[PathBuf], proxy_enforced: bool, + proxy_settings_mode: crate::WindowsSandboxProxySettingsMode, ) -> Result { normalize_null_device_env(env_map); ensure_non_interactive_pager(env_map); @@ -412,6 +413,7 @@ pub(crate) fn prepare_elevated_spawn_context_for_permissions( deny_write_paths_override }, proxy_enforced, + proxy_settings_mode, )?; let caps = load_or_create_cap_sids(codex_home)?; let (psid_to_use, cap_sids) = if uses_write_capabilities { diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/elevated.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/elevated.rs index 2e7437ad33..efe4a3f428 100644 --- a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/elevated.rs +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/elevated.rs @@ -56,6 +56,7 @@ pub(crate) async fn spawn_windows_sandbox_session_elevated_for_permission_profil cwd: &Path, mut env_map: HashMap, proxy_enforced: bool, + proxy_settings_mode: crate::WindowsSandboxProxySettingsMode, timeout_ms: Option, read_roots_override: Option<&[PathBuf]>, read_roots_include_platform_defaults: bool, @@ -91,6 +92,7 @@ pub(crate) async fn spawn_windows_sandbox_session_elevated_for_permission_profil &deny_read_paths_override, &deny_write_paths_override, proxy_enforced, + proxy_settings_mode, )?; let spawn_request = SpawnRequest { @@ -133,6 +135,7 @@ pub(crate) async fn spawn_windows_sandbox_session_elevated_for_permission_profil &deny_read_paths_override, &deny_write_paths_override, /*proxy_enforced*/ false, + proxy_settings_mode, )?; spawn_runner_transport_task( codex_home, diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/mod.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/mod.rs index 02792d6cb7..84ccd0d8cc 100644 --- a/codex-rs/windows-sandbox-rs/src/unified_exec/mod.rs +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/mod.rs @@ -31,6 +31,7 @@ pub struct WindowsSandboxSessionRequest<'a> { pub env_map: HashMap, pub windows_sandbox_level: WindowsSandboxLevel, pub proxy_enforced: bool, + pub proxy_settings_mode: crate::WindowsSandboxProxySettingsMode, pub timeout_ms: Option, pub read_roots_override: Option<&'a [PathBuf]>, pub read_roots_include_platform_defaults: bool, @@ -48,7 +49,7 @@ pub async fn spawn_windows_sandbox_session_for_level( if request.proxy_enforced || matches!(request.windows_sandbox_level, WindowsSandboxLevel::Elevated) { - spawn_windows_sandbox_session_elevated_for_permission_profile( + backends::elevated::spawn_windows_sandbox_session_elevated_for_permission_profile( request.permission_profile, request.workspace_roots, request.codex_home, @@ -56,6 +57,7 @@ pub async fn spawn_windows_sandbox_session_for_level( request.cwd, request.env_map, request.proxy_enforced, + request.proxy_settings_mode, request.timeout_ms, request.read_roots_override, request.read_roots_include_platform_defaults, @@ -145,6 +147,7 @@ pub async fn spawn_windows_sandbox_session_elevated_for_permission_profile( cwd, env_map, proxy_enforced, + crate::WindowsSandboxProxySettingsMode::Reconcile, timeout_ms, read_roots_override, read_roots_include_platform_defaults, diff --git a/codex-rs/windows-sandbox-rs/src/wrapper.rs b/codex-rs/windows-sandbox-rs/src/wrapper.rs index 375fb07cf5..f02af26133 100644 --- a/codex-rs/windows-sandbox-rs/src/wrapper.rs +++ b/codex-rs/windows-sandbox-rs/src/wrapper.rs @@ -26,6 +26,7 @@ const DENY_WRITE_PATHS_JSON_FLAG: &str = "--deny-write-paths-json"; const ENV_JSON_FLAG: &str = "--env-json"; const PERMISSION_PROFILE_FLAG: &str = "--permission-profile"; const PRIVATE_DESKTOP_FLAG: &str = "--windows-sandbox-private-desktop"; +const PRESERVE_PROXY_SETTINGS_FLAG: &str = "--preserve-proxy-settings"; const PROXY_ENFORCED_FLAG: &str = "--proxy-enforced"; const READ_ROOTS_INCLUDE_PLATFORM_DEFAULTS_FLAG: &str = "--read-roots-include-platform-defaults"; const READ_ROOTS_JSON_FLAG: &str = "--read-roots-json"; @@ -43,6 +44,7 @@ pub fn create_windows_sandbox_command_args_for_permission_profile( windows_sandbox_level: WindowsSandboxLevel, windows_sandbox_private_desktop: bool, proxy_enforced: bool, + proxy_settings_mode: crate::WindowsSandboxProxySettingsMode, read_roots_override: Option<&[PathBuf]>, read_roots_include_platform_defaults: bool, write_roots_override: Option<&[PathBuf]>, @@ -82,6 +84,9 @@ pub fn create_windows_sandbox_command_args_for_permission_profile( if proxy_enforced { args.push(PROXY_ENFORCED_FLAG.to_string()); } + if proxy_settings_mode == crate::WindowsSandboxProxySettingsMode::Preserve { + args.push(PRESERVE_PROXY_SETTINGS_FLAG.to_string()); + } if let Some(read_roots_override) = read_roots_override { push_json_arg(&mut args, READ_ROOTS_JSON_FLAG, &read_roots_override); } @@ -154,6 +159,7 @@ struct WindowsSandboxWrapperRequest { windows_sandbox_level: WindowsSandboxLevel, windows_sandbox_private_desktop: bool, proxy_enforced: bool, + proxy_settings_mode: crate::WindowsSandboxProxySettingsMode, read_roots_override: Option>, read_roots_include_platform_defaults: bool, write_roots_override: Option>, @@ -176,6 +182,7 @@ async fn run_windows_sandbox_wrapper_request(request: WindowsSandboxWrapperReque env_map: request.env_map, windows_sandbox_level: request.windows_sandbox_level, proxy_enforced: request.proxy_enforced, + proxy_settings_mode: request.proxy_settings_mode, timeout_ms: None, read_roots_override: request.read_roots_override.as_deref(), read_roots_include_platform_defaults: request.read_roots_include_platform_defaults, @@ -201,6 +208,7 @@ fn parse_windows_sandbox_wrapper_args(args: Vec) -> Result) -> Result windows_sandbox_private_desktop = true, + PRESERVE_PROXY_SETTINGS_FLAG => { + proxy_settings_mode = crate::WindowsSandboxProxySettingsMode::Preserve; + } PROXY_ENFORCED_FLAG => proxy_enforced = true, READ_ROOTS_INCLUDE_PLATFORM_DEFAULTS_FLAG => { read_roots_include_platform_defaults = true; @@ -282,6 +293,7 @@ fn parse_windows_sandbox_wrapper_args(args: Vec) -> Result