From 44ebf4588f88ea03685a77f0566db89d6ce16541 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 10 Feb 2026 02:09:23 -0800 Subject: [PATCH] feat: retain NetworkProxy, when appropriate (#11207) As of this PR, `SessionServices` retains a `Option`, if appropriate. Now the `network` field on `Config` is `Option` instead of `Option`. Over in `Session::new()`, we invoke `NetworkProxySpec::start_proxy()` to create the `StartedNetworkProxy`, which is a new struct that retains the `NetworkProxy` as well as the `NetworkProxyHandle`. (Note that `Drop` is implemented for `NetworkProxyHandle` to ensure the proxies are shutdown when it is dropped.) The `NetworkProxy` from the `StartedNetworkProxy` is threaded through to the appropriate places. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/11207). * #11285 * __->__ #11207 --- .../schema/json/EventMsg.json | 43 ++++- .../schema/json/ServerNotification.json | 32 +++- .../codex_app_server_protocol.schemas.json | 32 +++- .../json/v1/ForkConversationResponse.json | 32 +++- .../json/v1/ResumeConversationResponse.json | 32 +++- .../v1/SessionConfiguredNotification.json | 32 +++- .../typescript/SessionConfiguredEvent.ts | 5 + .../typescript/SessionNetworkProxyRuntime.ts | 5 + .../schema/typescript/index.ts | 1 + .../app-server/src/codex_message_processor.rs | 21 ++- codex-rs/cli/src/debug_sandbox.rs | 22 ++- codex-rs/core/src/codex.rs | 32 ++++ codex-rs/core/src/config/mod.rs | 24 ++- .../core/src/config/network_proxy_spec.rs | 167 ++++++++++++++++++ codex-rs/core/src/state/service.rs | 2 + codex-rs/core/src/tasks/user_shell.rs | 2 +- codex-rs/core/src/tools/handlers/shell.rs | 6 +- .../core/src/tools/handlers/unified_exec.rs | 2 +- .../tests/event_processor_with_json_output.rs | 1 + codex-rs/mcp-server/src/outgoing_message.rs | 3 + codex-rs/network-proxy/src/config.rs | 46 ++++- codex-rs/network-proxy/src/lib.rs | 1 + codex-rs/network-proxy/src/proxy.rs | 12 ++ codex-rs/protocol/src/protocol.rs | 13 ++ codex-rs/tui/src/app.rs | 4 + codex-rs/tui/src/chatwidget.rs | 11 +- codex-rs/tui/src/chatwidget/tests.rs | 6 + codex-rs/tui/src/debug_config.rs | 24 ++- 28 files changed, 583 insertions(+), 30 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SessionNetworkProxyRuntime.ts create mode 100644 codex-rs/core/src/config/network_proxy_spec.rs diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index 59976887d..5991401b8 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -887,6 +887,17 @@ "model_provider_id": { "type": "string" }, + "network_proxy": { + "anyOf": [ + { + "$ref": "#/definitions/SessionNetworkProxyRuntime" + }, + { + "type": "null" + } + ], + "description": "Runtime proxy bind addresses, when the managed proxy was started for this session." + }, "reasoning_effort": { "anyOf": [ { @@ -4427,6 +4438,25 @@ } ] }, + "SessionNetworkProxyRuntime": { + "properties": { + "admin_addr": { + "type": "string" + }, + "http_addr": { + "type": "string" + }, + "socks_addr": { + "type": "string" + } + }, + "required": [ + "admin_addr", + "http_addr", + "socks_addr" + ], + "type": "object" + }, "SkillDependencies": { "properties": { "tools": { @@ -5600,6 +5630,17 @@ "model_provider_id": { "type": "string" }, + "network_proxy": { + "anyOf": [ + { + "$ref": "#/definitions/SessionNetworkProxyRuntime" + }, + { + "type": "null" + } + ], + "description": "Runtime proxy bind addresses, when the managed proxy was started for this session." + }, "reasoning_effort": { "anyOf": [ { @@ -7580,4 +7621,4 @@ } ], "title": "EventMsg" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index edaceb5a1..69c6c2cb9 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1531,6 +1531,17 @@ "model_provider_id": { "type": "string" }, + "network_proxy": { + "anyOf": [ + { + "$ref": "#/definitions/SessionNetworkProxyRuntime" + }, + { + "type": "null" + } + ], + "description": "Runtime proxy bind addresses, when the managed proxy was started for this session." + }, "reasoning_effort": { "anyOf": [ { @@ -5583,6 +5594,25 @@ ], "type": "object" }, + "SessionNetworkProxyRuntime": { + "properties": { + "admin_addr": { + "type": "string" + }, + "http_addr": { + "type": "string" + }, + "socks_addr": { + "type": "string" + } + }, + "required": [ + "admin_addr", + "http_addr", + "socks_addr" + ], + "type": "object" + }, "SessionSource": { "oneOf": [ { @@ -8197,4 +8227,4 @@ } ], "title": "ServerNotification" -} +} \ No newline at end of file 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 f6b072e7d..b7e922dd1 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 @@ -2900,6 +2900,17 @@ "model_provider_id": { "type": "string" }, + "network_proxy": { + "anyOf": [ + { + "$ref": "#/definitions/SessionNetworkProxyRuntime" + }, + { + "type": "null" + } + ], + "description": "Runtime proxy bind addresses, when the managed proxy was started for this session." + }, "reasoning_effort": { "anyOf": [ { @@ -8678,6 +8689,25 @@ "title": "SessionConfiguredNotification", "type": "object" }, + "SessionNetworkProxyRuntime": { + "properties": { + "admin_addr": { + "type": "string" + }, + "http_addr": { + "type": "string" + }, + "socks_addr": { + "type": "string" + } + }, + "required": [ + "admin_addr", + "http_addr", + "socks_addr" + ], + "type": "object" + }, "SessionSource": { "oneOf": [ { @@ -16361,4 +16391,4 @@ }, "title": "CodexAppServerProtocol", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json index 8204afaae..90949d56f 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json @@ -887,6 +887,17 @@ "model_provider_id": { "type": "string" }, + "network_proxy": { + "anyOf": [ + { + "$ref": "#/definitions/SessionNetworkProxyRuntime" + }, + { + "type": "null" + } + ], + "description": "Runtime proxy bind addresses, when the managed proxy was started for this session." + }, "reasoning_effort": { "anyOf": [ { @@ -4427,6 +4438,25 @@ } ] }, + "SessionNetworkProxyRuntime": { + "properties": { + "admin_addr": { + "type": "string" + }, + "http_addr": { + "type": "string" + }, + "socks_addr": { + "type": "string" + } + }, + "required": [ + "admin_addr", + "http_addr", + "socks_addr" + ], + "type": "object" + }, "SkillDependencies": { "properties": { "tools": { @@ -5186,4 +5216,4 @@ ], "title": "ForkConversationResponse", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json index e45cb6d35..b25d2dbed 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json @@ -887,6 +887,17 @@ "model_provider_id": { "type": "string" }, + "network_proxy": { + "anyOf": [ + { + "$ref": "#/definitions/SessionNetworkProxyRuntime" + }, + { + "type": "null" + } + ], + "description": "Runtime proxy bind addresses, when the managed proxy was started for this session." + }, "reasoning_effort": { "anyOf": [ { @@ -4427,6 +4438,25 @@ } ] }, + "SessionNetworkProxyRuntime": { + "properties": { + "admin_addr": { + "type": "string" + }, + "http_addr": { + "type": "string" + }, + "socks_addr": { + "type": "string" + } + }, + "required": [ + "admin_addr", + "http_addr", + "socks_addr" + ], + "type": "object" + }, "SkillDependencies": { "properties": { "tools": { @@ -5186,4 +5216,4 @@ ], "title": "ResumeConversationResponse", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json index 7d782f438..fa9e144c4 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json @@ -887,6 +887,17 @@ "model_provider_id": { "type": "string" }, + "network_proxy": { + "anyOf": [ + { + "$ref": "#/definitions/SessionNetworkProxyRuntime" + }, + { + "type": "null" + } + ], + "description": "Runtime proxy bind addresses, when the managed proxy was started for this session." + }, "reasoning_effort": { "anyOf": [ { @@ -4427,6 +4438,25 @@ } ] }, + "SessionNetworkProxyRuntime": { + "properties": { + "admin_addr": { + "type": "string" + }, + "http_addr": { + "type": "string" + }, + "socks_addr": { + "type": "string" + } + }, + "required": [ + "admin_addr", + "http_addr", + "socks_addr" + ], + "type": "object" + }, "SkillDependencies": { "properties": { "tools": { @@ -5208,4 +5238,4 @@ ], "title": "SessionConfiguredNotification", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/SessionConfiguredEvent.ts b/codex-rs/app-server-protocol/schema/typescript/SessionConfiguredEvent.ts index 2e1896a39..d964f4445 100644 --- a/codex-rs/app-server-protocol/schema/typescript/SessionConfiguredEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/SessionConfiguredEvent.ts @@ -5,6 +5,7 @@ import type { AskForApproval } from "./AskForApproval"; import type { EventMsg } from "./EventMsg"; import type { ReasoningEffort } from "./ReasoningEffort"; import type { SandboxPolicy } from "./SandboxPolicy"; +import type { SessionNetworkProxyRuntime } from "./SessionNetworkProxyRuntime"; import type { ThreadId } from "./ThreadId"; export type SessionConfiguredEvent = { session_id: ThreadId, forked_from_id: ThreadId | null, @@ -46,6 +47,10 @@ history_entry_count: number, * When present, UIs can use these to seed the history. */ initial_messages: Array | null, +/** + * Runtime proxy bind addresses, when the managed proxy was started for this session. + */ +network_proxy?: SessionNetworkProxyRuntime, /** * Path in which the rollout is stored. Can be `None` for ephemeral threads */ diff --git a/codex-rs/app-server-protocol/schema/typescript/SessionNetworkProxyRuntime.ts b/codex-rs/app-server-protocol/schema/typescript/SessionNetworkProxyRuntime.ts new file mode 100644 index 000000000..3f0c6d857 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SessionNetworkProxyRuntime.ts @@ -0,0 +1,5 @@ +// 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. + +export type SessionNetworkProxyRuntime = { http_addr: string, socks_addr: string, admin_addr: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index c0c00a0c8..895936de2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -175,6 +175,7 @@ export type { ServerNotification } from "./ServerNotification"; export type { ServerRequest } from "./ServerRequest"; export type { SessionConfiguredEvent } from "./SessionConfiguredEvent"; export type { SessionConfiguredNotification } from "./SessionConfiguredNotification"; +export type { SessionNetworkProxyRuntime } from "./SessionNetworkProxyRuntime"; export type { SessionSource } from "./SessionSource"; export type { SetDefaultModelParams } from "./SetDefaultModelParams"; export type { SetDefaultModelResponse } from "./SetDefaultModelResponse"; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 76656088a..c161636e0 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1634,13 +1634,30 @@ impl CodexMessageProcessor { let timeout_ms = params .timeout_ms .and_then(|timeout_ms| u64::try_from(timeout_ms).ok()); + let started_network_proxy = match self.config.network.as_ref() { + Some(spec) => match spec.start_proxy().await { + Ok(started) => Some(started), + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to start managed network proxy: {err}"), + data: None, + }; + self.outgoing.send_error(request, error).await; + return; + } + }, + None => None, + }; let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); let exec_params = ExecParams { command: params.command, cwd, expiration: timeout_ms.into(), env, - network: self.config.network.clone(), + network: started_network_proxy + .as_ref() + .map(codex_core::config::StartedNetworkProxy::proxy), sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level, justification: None, @@ -1668,9 +1685,11 @@ impl CodexMessageProcessor { let outgoing = self.outgoing.clone(); let request_for_task = request; let sandbox_cwd = self.config.cwd.clone(); + let started_network_proxy_for_task = started_network_proxy; let use_linux_sandbox_bwrap = self.config.features.enabled(Feature::UseLinuxSandboxBwrap); tokio::spawn(async move { + let _started_network_proxy = started_network_proxy_for_task; match codex_core::exec::process_exec_tool_call( exec_params, &effective_policy, diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 308e83583..39255b8d0 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -130,10 +130,7 @@ async fn run_command_under_sandbox( let sandbox_policy_cwd = cwd.clone(); let stdio_policy = StdioPolicy::Inherit; - let mut env = create_env(&config.shell_environment_policy, None); - if let Some(network) = config.network.as_ref() { - network.apply_to_env(&mut env); - } + let env = create_env(&config.shell_environment_policy, None); // Special-case Windows sandbox: execute and exit the process to emulate inherited stdio. if let SandboxType::Windows = sandbox_type { @@ -216,6 +213,19 @@ async fn run_command_under_sandbox( #[cfg(not(target_os = "macos"))] let _ = log_denials; + // This proxy should only live for the lifetime of the child process. + let network_proxy = match config.network.as_ref() { + Some(spec) => Some( + spec.start_proxy() + .await + .map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?, + ), + None => None, + }; + let network = network_proxy + .as_ref() + .map(codex_core::config::StartedNetworkProxy::proxy); + let mut child = match sandbox_type { #[cfg(target_os = "macos")] SandboxType::Seatbelt => { @@ -225,7 +235,7 @@ async fn run_command_under_sandbox( config.sandbox_policy.get(), sandbox_policy_cwd.as_path(), stdio_policy, - None, + network.as_ref(), env, ) .await? @@ -245,7 +255,7 @@ async fn run_command_under_sandbox( sandbox_policy_cwd.as_path(), use_bwrap_sandbox, stdio_policy, - None, + network.as_ref(), env, ) .await? diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b9ef007e8..9c2447621 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -44,6 +44,7 @@ use crate::turn_metadata::resolve_turn_metadata_header_with_timeout; use crate::util::error_or_panic; use async_channel::Receiver; use async_channel::Sender; +use codex_network_proxy::NetworkProxy; use codex_protocol::ThreadId; use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::config_types::ModeKind; @@ -113,6 +114,7 @@ use crate::config::Config; use crate::config::Constrained; use crate::config::ConstraintResult; use crate::config::GhostSnapshotConfig; +use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::config::types::McpServerConfig; use crate::config::types::ShellEnvironmentPolicy; @@ -172,6 +174,7 @@ use crate::protocol::RequestUserInputEvent; use crate::protocol::ReviewDecision; use crate::protocol::SandboxPolicy; use crate::protocol::SessionConfiguredEvent; +use crate::protocol::SessionNetworkProxyRuntime; use crate::protocol::SkillDependencies as ProtocolSkillDependencies; use crate::protocol::SkillErrorInfo; use crate::protocol::SkillInterface as ProtocolSkillInterface; @@ -539,6 +542,7 @@ pub(crate) struct TurnContext { pub(crate) personality: Option, pub(crate) approval_policy: AskForApproval, pub(crate) sandbox_policy: SandboxPolicy, + pub(crate) network: Option, pub(crate) windows_sandbox_level: WindowsSandboxLevel, pub(crate) shell_environment_policy: ShellEnvironmentPolicy, pub(crate) tools_config: ToolsConfig, @@ -803,6 +807,7 @@ impl Session { session_configuration: &SessionConfiguration, per_turn_config: Config, model_info: ModelInfo, + network: Option, sub_id: String, ) -> TurnContext { let reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); @@ -842,6 +847,7 @@ impl Session { personality: session_configuration.personality, approval_policy: session_configuration.approval_policy.value(), sandbox_policy: session_configuration.sandbox_policy.get().clone(), + network, windows_sandbox_level: session_configuration.windows_sandbox_level, shell_environment_policy: per_turn_config.shell_environment_policy.clone(), tools_config, @@ -1064,6 +1070,21 @@ impl Session { }; session_configuration.thread_name = thread_name.clone(); let mut state = SessionState::new(session_configuration.clone()); + let network_proxy = + match config.network.as_ref() { + Some(spec) => Some(spec.start_proxy().await.map_err(|err| { + anyhow::anyhow!("failed to start managed network proxy: {err}") + })?), + None => None, + }; + let session_network_proxy = network_proxy.as_ref().map(|started| { + let proxy = started.proxy(); + SessionNetworkProxyRuntime { + http_addr: proxy.http_addr().to_string(), + socks_addr: proxy.socks_addr().to_string(), + admin_addr: proxy.admin_addr().to_string(), + } + }); let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())), @@ -1086,6 +1107,7 @@ impl Session { skills_manager, file_watcher, agent_control, + network_proxy, state_db: state_db_ctx.clone(), model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), @@ -1144,6 +1166,7 @@ impl Session { history_log_id, history_entry_count, initial_messages, + network_proxy: session_network_proxy, rollout_path, }), }) @@ -1559,6 +1582,10 @@ impl Session { &session_configuration, per_turn_config, model_info, + self.services + .network_proxy + .as_ref() + .map(StartedNetworkProxy::proxy), sub_id, ); @@ -3702,6 +3729,7 @@ async fn spawn_review_thread( personality: parent_turn_context.personality, approval_policy: parent_turn_context.approval_policy, sandbox_policy: parent_turn_context.sandbox_policy.clone(), + network: parent_turn_context.network.clone(), windows_sandbox_level: parent_turn_context.windows_sandbox_level, shell_environment_policy: parent_turn_context.shell_environment_policy.clone(), cwd: parent_turn_context.cwd.clone(), @@ -6188,6 +6216,7 @@ mod tests { skills_manager, file_watcher, agent_control, + network_proxy: None, state_db: None, model_client: ModelClient::new( Some(auth_manager.clone()), @@ -6211,6 +6240,7 @@ mod tests { &session_configuration, per_turn_config, model_info, + None, "turn_id".to_string(), ); @@ -6321,6 +6351,7 @@ mod tests { skills_manager, file_watcher, agent_control, + network_proxy: None, state_db: None, model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), @@ -6344,6 +6375,7 @@ mod tests { &session_configuration, per_turn_config, model_info, + None, "turn_id".to_string(), )); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 40d8a1f04..a923b0ef7 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -47,7 +47,6 @@ use crate::protocol::SandboxPolicy; use crate::windows_sandbox::WindowsSandboxLevelExt; use codex_app_server_protocol::Tools; use codex_app_server_protocol::UserSavedConfig; -use codex_network_proxy::NetworkProxy; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ModeKind; @@ -80,6 +79,7 @@ use toml_edit::DocumentMut; mod constraint; pub mod edit; +mod network_proxy_spec; pub mod profile; pub mod schema; pub mod service; @@ -88,6 +88,8 @@ pub use constraint::Constrained; pub use constraint::ConstraintError; pub use constraint::ConstraintResult; +pub use network_proxy_spec::NetworkProxySpec; +pub use network_proxy_spec::StartedNetworkProxy; pub use service::ConfigService; pub use service::ConfigServiceError; @@ -154,7 +156,7 @@ pub struct Config { pub enforce_residency: Constrained>, /// Effective network configuration applied to all spawned processes. - pub network: Option, + pub network: Option, /// True if the user passed in an override or set a value in config.toml /// for either of approval_policy or sandbox_mode. @@ -1657,7 +1659,7 @@ impl Config { mcp_servers, exec_policy: _, enforce_residency, - network: _network_requirements, + network: network_requirements, } = requirements; apply_requirement_constrained_value( @@ -1682,6 +1684,20 @@ impl Config { let mcp_servers = constrain_mcp_servers(cfg.mcp_servers.clone(), mcp_servers.as_ref()) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?; + let network = match network_requirements { + Some(Sourced { value, source }) => { + let network = NetworkProxySpec::from_constraints(&config_layer_stack, value) + .map_err(|err| { + std::io::Error::new( + err.kind(), + format!("failed to build managed network proxy from {source}: {err}"), + ) + })?; + Some(network) + } + None => None, + }; + let config = Self { model, review_model, @@ -1694,7 +1710,7 @@ impl Config { approval_policy: constrained_approval_policy.value, sandbox_policy: constrained_sandbox_policy.value, enforce_residency: enforce_residency.value, - network: None, + network, did_user_set_custom_approval_policy_or_sandbox_mode, forced_auto_mode_downgraded_on_windows, shell_environment_policy, diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs new file mode 100644 index 000000000..e50a30d88 --- /dev/null +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -0,0 +1,167 @@ +use crate::config; +use crate::config_loader::NetworkConstraints; +use async_trait::async_trait; +use codex_network_proxy::ConfigReloader; +use codex_network_proxy::ConfigState; +use codex_network_proxy::NetworkProxy; +use codex_network_proxy::NetworkProxyConfig; +use codex_network_proxy::NetworkProxyConstraints; +use codex_network_proxy::NetworkProxyHandle; +use codex_network_proxy::NetworkProxyState; +use codex_network_proxy::build_config_state; +use codex_network_proxy::host_and_port_from_network_addr; +use codex_network_proxy::validate_policy_against_constraints; +use std::sync::Arc; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NetworkProxySpec { + config: NetworkProxyConfig, + constraints: NetworkProxyConstraints, +} + +pub struct StartedNetworkProxy { + proxy: NetworkProxy, + _handle: NetworkProxyHandle, +} + +impl StartedNetworkProxy { + fn new(proxy: NetworkProxy, handle: NetworkProxyHandle) -> Self { + Self { + proxy, + _handle: handle, + } + } + + pub fn proxy(&self) -> NetworkProxy { + self.proxy.clone() + } +} + +#[derive(Clone)] +struct StaticNetworkProxyReloader { + state: ConfigState, +} + +impl StaticNetworkProxyReloader { + fn new(state: ConfigState) -> Self { + Self { state } + } +} + +#[async_trait] +impl ConfigReloader for StaticNetworkProxyReloader { + async fn maybe_reload(&self) -> anyhow::Result> { + Ok(None) + } + + async fn reload_now(&self) -> anyhow::Result { + Ok(self.state.clone()) + } + + fn source_label(&self) -> String { + "StaticNetworkProxyReloader".to_string() + } +} + +impl NetworkProxySpec { + pub fn proxy_host_and_port(&self) -> String { + host_and_port_from_network_addr(&self.config.network.proxy_url, 3128) + } + + pub(crate) fn from_constraints( + _config_layer_stack: &config::ConfigLayerStack, + requirements: NetworkConstraints, + ) -> std::io::Result { + // TODO(mbolin): Use ConfigLayerStack once we are ready to start + // honoring network configuration in config.toml. + let config = NetworkProxyConfig::default(); + let (config, constraints) = Self::apply_requirements(config, &requirements); + validate_policy_against_constraints(&config, &constraints).map_err(|err| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("network proxy constraints are invalid: {err}"), + ) + })?; + Ok(Self { + config, + constraints, + }) + } + + pub async fn start_proxy(&self) -> std::io::Result { + let state = + build_config_state(self.config.clone(), self.constraints.clone()).map_err(|err| { + std::io::Error::other(format!("failed to build network proxy state: {err}")) + })?; + let reloader = Arc::new(StaticNetworkProxyReloader::new(state.clone())); + let state = NetworkProxyState::with_reloader(state, reloader); + let proxy = NetworkProxy::builder() + .state(Arc::new(state)) + .build() + .await + .map_err(|err| { + std::io::Error::other(format!("failed to build network proxy: {err}")) + })?; + let handle = proxy + .run() + .await + .map_err(|err| std::io::Error::other(format!("failed to run network proxy: {err}")))?; + Ok(StartedNetworkProxy::new(proxy, handle)) + } + + fn apply_requirements( + mut config: NetworkProxyConfig, + requirements: &NetworkConstraints, + ) -> (NetworkProxyConfig, NetworkProxyConstraints) { + let mut constraints = NetworkProxyConstraints::default(); + + if let Some(enabled) = requirements.enabled { + config.network.enabled = enabled; + constraints.enabled = Some(enabled); + } + if let Some(http_port) = requirements.http_port { + config.network.proxy_url = format!("http://127.0.0.1:{http_port}"); + } + if let Some(socks_port) = requirements.socks_port { + config.network.socks_url = format!("http://127.0.0.1:{socks_port}"); + } + if let Some(allow_upstream_proxy) = requirements.allow_upstream_proxy { + config.network.allow_upstream_proxy = allow_upstream_proxy; + constraints.allow_upstream_proxy = Some(allow_upstream_proxy); + } + if let Some(dangerously_allow_non_loopback_proxy) = + requirements.dangerously_allow_non_loopback_proxy + { + config.network.dangerously_allow_non_loopback_proxy = + dangerously_allow_non_loopback_proxy; + constraints.dangerously_allow_non_loopback_proxy = + Some(dangerously_allow_non_loopback_proxy); + } + if let Some(dangerously_allow_non_loopback_admin) = + requirements.dangerously_allow_non_loopback_admin + { + config.network.dangerously_allow_non_loopback_admin = + dangerously_allow_non_loopback_admin; + constraints.dangerously_allow_non_loopback_admin = + Some(dangerously_allow_non_loopback_admin); + } + if let Some(allowed_domains) = requirements.allowed_domains.clone() { + config.network.allowed_domains = allowed_domains.clone(); + constraints.allowed_domains = Some(allowed_domains); + } + if let Some(denied_domains) = requirements.denied_domains.clone() { + config.network.denied_domains = denied_domains.clone(); + constraints.denied_domains = Some(denied_domains); + } + if let Some(allow_unix_sockets) = requirements.allow_unix_sockets.clone() { + config.network.allow_unix_sockets = allow_unix_sockets.clone(); + constraints.allow_unix_sockets = Some(allow_unix_sockets); + } + if let Some(allow_local_binding) = requirements.allow_local_binding { + config.network.allow_local_binding = allow_local_binding; + constraints.allow_local_binding = Some(allow_local_binding); + } + + (config, constraints) + } +} diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 0438119d4..04beef77b 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -5,6 +5,7 @@ use crate::RolloutRecorder; use crate::agent::AgentControl; use crate::analytics_client::AnalyticsEventsClient; use crate::client::ModelClient; +use crate::config::StartedNetworkProxy; use crate::exec_policy::ExecPolicyManager; use crate::file_watcher::FileWatcher; use crate::hooks::Hooks; @@ -38,6 +39,7 @@ pub(crate) struct SessionServices { pub(crate) skills_manager: Arc, pub(crate) file_watcher: Arc, pub(crate) agent_control: AgentControl, + pub(crate) network_proxy: Option, pub(crate) state_db: Option, /// Session-scoped model client shared across turns. pub(crate) model_client: ModelClient, diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 02dd5fdf2..b0c505b64 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -147,7 +147,7 @@ pub(crate) async fn execute_user_shell_command( &turn_context.shell_environment_policy, Some(session.conversation_id), ), - network: turn_context.config.network.clone(), + network: turn_context.network.clone(), // TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we // should use that instead of an "arbitrarily large" timeout here. expiration: USER_SHELL_TIMEOUT_MS.into(), diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 18c594d99..b9e2a97d6 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -53,7 +53,7 @@ impl ShellHandler { cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), - network: turn_context.config.network.clone(), + network: turn_context.network.clone(), sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), windows_sandbox_level: turn_context.windows_sandbox_level, justification: params.justification.clone(), @@ -82,7 +82,7 @@ impl ShellCommandHandler { cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), - network: turn_context.config.network.clone(), + network: turn_context.network.clone(), sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), windows_sandbox_level: turn_context.windows_sandbox_level, justification: params.justification.clone(), @@ -444,7 +444,7 @@ mod tests { assert_eq!(exec_params.command, expected_command); assert_eq!(exec_params.cwd, expected_cwd); assert_eq!(exec_params.env, expected_env); - assert_eq!(exec_params.network, turn_context.config.network); + assert_eq!(exec_params.network, turn_context.network); assert_eq!(exec_params.expiration.timeout_ms(), timeout_ms); assert_eq!(exec_params.sandbox_permissions, sandbox_permissions); assert_eq!(exec_params.justification, justification); diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 0a88283fe..c06889b37 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -192,7 +192,7 @@ impl ToolHandler for UnifiedExecHandler { yield_time_ms, max_output_tokens, workdir, - network: context.turn.config.network.clone(), + network: context.turn.network.clone(), tty, sandbox_permissions, justification, diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 8c1c73e57..f349e4835 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -98,6 +98,7 @@ fn session_configured_produces_thread_started_event() { history_log_id: 0, history_entry_count: 0, initial_messages: None, + network_proxy: None, rollout_path: Some(rollout_path), }), ); diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index e512eedbd..1bf98534b 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -307,6 +307,7 @@ mod tests { history_log_id: 1, history_entry_count: 1000, initial_messages: None, + network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }), }; @@ -348,6 +349,7 @@ mod tests { history_log_id: 1, history_entry_count: 1000, initial_messages: None, + network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; let event = Event { @@ -413,6 +415,7 @@ mod tests { history_log_id: 1, history_entry_count: 1000, initial_messages: None, + network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; let event = Event { diff --git a/codex-rs/network-proxy/src/config.rs b/codex-rs/network-proxy/src/config.rs index e4ef202a4..505fa96d3 100644 --- a/codex-rs/network-proxy/src/config.rs +++ b/codex-rs/network-proxy/src/config.rs @@ -8,13 +8,13 @@ use std::net::SocketAddr; use tracing::warn; use url::Url; -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] pub struct NetworkProxyConfig { #[serde(default)] pub network: NetworkProxySettings, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct NetworkProxySettings { #[serde(default)] pub enabled: bool, @@ -205,6 +205,30 @@ fn resolve_addr(url: &str, default_port: u16) -> Result { } } +pub fn host_and_port_from_network_addr(value: &str, default_port: u16) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + return "".to_string(); + } + + let parts = match parse_host_port(trimmed, default_port) { + Ok(parts) => parts, + Err(_) => { + return format_host_and_port(trimmed, default_port); + } + }; + + format_host_and_port(&parts.host, parts.port) +} + +fn format_host_and_port(host: &str, port: u16) -> String { + if host.contains(':') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SocketAddressParts { host: String, @@ -280,14 +304,13 @@ fn parse_host_port_fallback(input: &str, default_port: u16) -> Result() { if host.is_empty() { bail!("missing host in network proxy address: {input}"); } return Ok(SocketAddressParts { host: host.to_string(), - port, + port: port.parse::().ok().unwrap_or(default_port), }); } @@ -376,12 +399,25 @@ mod tests { assert_eq!( parse_host_port("example.com:notaport", 3128).unwrap(), SocketAddressParts { - host: "example.com:notaport".to_string(), + host: "example.com".to_string(), port: 3128, } ); } + #[test] + fn host_and_port_from_network_addr_defaults_for_empty_string() { + assert_eq!(host_and_port_from_network_addr("", 1234), ""); + } + + #[test] + fn host_and_port_from_network_addr_formats_ipv6() { + assert_eq!( + host_and_port_from_network_addr("http://[::1]:8080", 3128), + "[::1]:8080" + ); + } + #[test] fn resolve_addr_maps_localhost_to_loopback() { assert_eq!( diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 570a8a994..8ca0d969c 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -15,6 +15,7 @@ mod upstream; pub use config::NetworkMode; pub use config::NetworkProxyConfig; +pub use config::host_and_port_from_network_addr; pub use network_policy::NetworkDecision; pub use network_policy::NetworkPolicyDecider; pub use network_policy::NetworkPolicyRequest; diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index c4f3d97dc..0a95e4460 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -363,6 +363,18 @@ impl NetworkProxy { NetworkProxyBuilder::default() } + pub fn http_addr(&self) -> SocketAddr { + self.http_addr + } + + pub fn socks_addr(&self) -> SocketAddr { + self.socks_addr + } + + pub fn admin_addr(&self) -> SocketAddr { + self.admin_addr + } + pub fn apply_to_env(&self, env: &mut HashMap) { // Enforce proxying for child processes. We intentionally override existing values so // command-level environment cannot bypass the managed proxy endpoint. diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 462730f98..8f6e99879 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2265,6 +2265,13 @@ pub struct SkillsListEntry { pub errors: Vec, } +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, PartialEq, Eq)] +pub struct SessionNetworkProxyRuntime { + pub http_addr: String, + pub socks_addr: String, + pub admin_addr: String, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct SessionConfiguredEvent { pub session_id: ThreadId, @@ -2306,6 +2313,11 @@ pub struct SessionConfiguredEvent { #[serde(skip_serializing_if = "Option::is_none")] pub initial_messages: Option>, + /// Runtime proxy bind addresses, when the managed proxy was started for this session. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub network_proxy: Option, + /// Path in which the rollout is stored. Can be `None` for ephemeral threads #[serde(skip_serializing_if = "Option::is_none")] pub rollout_path: Option, @@ -2696,6 +2708,7 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, + network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }), }; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6294df834..d18ec2354 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2353,6 +2353,7 @@ impl App { history_log_id: 0, history_entry_count: 0, initial_messages: None, + network_proxy: None, rollout_path: thread.rollout_path(), }), }; @@ -3042,6 +3043,7 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, + network_proxy: None, rollout_path: Some(PathBuf::new()), }; Arc::new(new_session_info( @@ -3096,6 +3098,7 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, + network_proxy: None, rollout_path: Some(PathBuf::new()), }), }); @@ -3142,6 +3145,7 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, + network_proxy: None, rollout_path: Some(PathBuf::new()), }; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 30f3d17b2..c381686c9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -613,6 +613,8 @@ pub(crate) struct ChatWidget { current_rollout_path: Option, // Current working directory (if known) current_cwd: Option, + // Runtime network proxy bind addresses from SessionConfigured. + session_network_proxy: Option, // Shared latch so we only warn once about invalid status-line item IDs. status_line_invalid_items_warned: Arc, // Cached git branch name for the status line (None if unknown). @@ -1015,6 +1017,7 @@ impl ChatWidget { self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); self.set_skills(None); + self.session_network_proxy = event.network_proxy.clone(); self.thread_id = Some(event.session_id); self.thread_name = event.thread_name.clone(); self.forked_from = event.forked_from_id; @@ -2658,6 +2661,7 @@ impl ChatWidget { feedback_audience, current_rollout_path: None, current_cwd, + session_network_proxy: None, status_line_invalid_items_warned, status_line_branch: None, status_line_branch_cwd: None, @@ -2822,6 +2826,7 @@ impl ChatWidget { feedback_audience, current_rollout_path: None, current_cwd, + session_network_proxy: None, status_line_invalid_items_warned, status_line_branch: None, status_line_branch_cwd: None, @@ -2975,6 +2980,7 @@ impl ChatWidget { feedback_audience, current_rollout_path: None, current_cwd, + session_network_proxy: None, status_line_invalid_items_warned, status_line_branch: None, status_line_branch_cwd: None, @@ -4239,7 +4245,10 @@ impl ChatWidget { } pub(crate) fn add_debug_config_output(&mut self) { - self.add_to_history(crate::debug_config::new_debug_config_output(&self.config)); + self.add_to_history(crate::debug_config::new_debug_config_output( + &self.config, + self.session_network_proxy.as_ref(), + )); } fn open_status_line_setup(&mut self) { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ad4c4fc62..78f9dd308 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -164,6 +164,7 @@ async fn resumed_initial_messages_render_history() { message: "assistant reply".to_string(), }), ]), + network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -226,6 +227,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { text_elements: text_elements.clone(), local_images: local_images.clone(), })]), + network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -338,6 +340,7 @@ async fn submission_preserves_text_elements_and_local_images() { history_log_id: 0, history_entry_count: 0, initial_messages: None, + network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; chat.handle_codex_event(Event { @@ -417,6 +420,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { history_log_id: 0, history_entry_count: 0, initial_messages: None, + network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; chat.handle_codex_event(Event { @@ -1108,6 +1112,7 @@ async fn make_chatwidget_manual( feedback_audience: FeedbackAudience::External, current_rollout_path: None, current_cwd: None, + session_network_proxy: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), status_line_branch: None, status_line_branch_cwd: None, @@ -2973,6 +2978,7 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { history_log_id: 0, history_entry_count: 0, initial_messages: None, + network_proxy: None, rollout_path: None, }; chat.handle_codex_event(Event { diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 908ee1d24..b5edeeb23 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -8,11 +8,31 @@ use codex_core::config_loader::RequirementSource; use codex_core::config_loader::ResidencyRequirement; use codex_core::config_loader::SandboxModeRequirement; use codex_core::config_loader::WebSearchModeRequirement; +use codex_core::protocol::SessionNetworkProxyRuntime; use ratatui::style::Stylize; use ratatui::text::Line; -pub(crate) fn new_debug_config_output(config: &Config) -> PlainHistoryCell { - PlainHistoryCell::new(render_debug_config_lines(&config.config_layer_stack)) +pub(crate) fn new_debug_config_output( + config: &Config, + session_network_proxy: Option<&SessionNetworkProxyRuntime>, +) -> PlainHistoryCell { + let mut lines = render_debug_config_lines(&config.config_layer_stack); + + if let Some(proxy) = session_network_proxy { + lines.push("".into()); + lines.push("Session runtime:".bold().into()); + lines.push(" - network_proxy".into()); + let SessionNetworkProxyRuntime { + http_addr, + socks_addr, + admin_addr, + } = proxy; + lines.push(format!(" - HTTP_PROXY = http://{http_addr}").into()); + lines.push(format!(" - ALL_PROXY = socks5h://{socks_addr}").into()); + lines.push(format!(" - ADMIN_PROXY = http://{admin_addr}").into()); + } + + PlainHistoryCell::new(lines) } fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec> {