From 09053f7629c2f800dc0acf5c5e0828b69410bc98 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 8 Feb 2026 14:08:29 -0800 Subject: [PATCH] feat: include [experimental_network] in --- codex-rs/core/src/codex.rs | 6 +- codex-rs/core/src/compact.rs | 84 +++++++-- .../src/config_loader/config_requirements.rs | 3 +- codex-rs/core/src/environment_context.rs | 164 +++++++++++++++--- codex-rs/core/tests/suite/prompt_caching.rs | 1 + 5 files changed, 216 insertions(+), 42 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 9fbbcd312d..aceca2d3b9 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2184,9 +2184,9 @@ impl Session { .into(), ); } - items.push(ResponseItem::from(EnvironmentContext::new( - Some(turn_context.cwd.clone()), - shell.as_ref().clone(), + items.push(ResponseItem::from(EnvironmentContext::from_turn_context( + turn_context, + shell.as_ref(), ))); items } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 99f7896031..7cab3d32e0 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -489,11 +489,15 @@ mod tests { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "# AGENTS.md instructions for project\n\n\ndo things\n" + text: r#"# AGENTS.md instructions for project + + +do things +"# .to_string(), }], end_turn: None, - phase: None, + phase: None, }, ResponseItem::Message { id: None, @@ -502,7 +506,7 @@ mod tests { text: "cwd=/tmp".to_string(), }], end_turn: None, - phase: None, + phase: None, }, ResponseItem::Message { id: None, @@ -511,7 +515,7 @@ mod tests { text: "real user message".to_string(), }], end_turn: None, - phase: None, + phase: None, }, ]; @@ -629,7 +633,12 @@ mod tests { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "cwd=/tmp".to_string(), + text: r#" + /tmp + zsh + +"# + .to_string(), }], end_turn: None, phase: None, @@ -660,7 +669,12 @@ mod tests { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "cwd=/tmp".to_string(), + text: r#" + /tmp + zsh + +"# + .to_string(), }], end_turn: None, phase: None, @@ -712,7 +726,12 @@ mod tests { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "# AGENTS.md instructions for /repo\n\n\nkeep me updated\n".to_string(), + text: r#"# AGENTS.md instructions for /repo + + +keep me updated +"# + .to_string(), }], end_turn: None, phase: None, @@ -721,7 +740,12 @@ mod tests { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "\n /repo\n zsh\n".to_string(), + text: r#" + /repo + zsh + +"# + .to_string(), }], end_turn: None, phase: None, @@ -730,7 +754,11 @@ mod tests { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "\n turn-1\n interrupted\n".to_string(), + text: r#" + turn-1 + interrupted +"# + .to_string(), }], end_turn: None, phase: None, @@ -752,7 +780,12 @@ mod tests { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "# AGENTS.md instructions for /repo\n\n\nkeep me updated\n".to_string(), + text: r#"# AGENTS.md instructions for /repo + + +keep me updated +"# + .to_string(), }], end_turn: None, phase: None, @@ -761,7 +794,12 @@ mod tests { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "\n /repo\n zsh\n".to_string(), + text: r#" + /repo + zsh + +"# + .to_string(), }], end_turn: None, phase: None, @@ -770,7 +808,10 @@ mod tests { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "\n turn-1\n interrupted\n" + text: r#" + turn-1 + interrupted +"# .to_string(), }], end_turn: None, @@ -796,7 +837,12 @@ mod tests { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "# AGENTS.md instructions for /repo\n\n\nkeep me updated\n".to_string(), + text: r#"# AGENTS.md instructions for /repo + + +keep me updated +"# + .to_string(), }], end_turn: None, phase: None, @@ -805,7 +851,11 @@ mod tests { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "\n /repo\n zsh\n".to_string(), + text: r#" + /repo + zsh +"# + .to_string(), }], end_turn: None, phase: None, @@ -814,7 +864,11 @@ mod tests { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "\n turn-1\n interrupted\n".to_string(), + text: r#" + turn-1 + interrupted +"# + .to_string(), }], end_turn: None, phase: None, diff --git a/codex-rs/core/src/config_loader/config_requirements.rs b/codex-rs/core/src/config_loader/config_requirements.rs index 21dbdb3d7f..c0810ff304 100644 --- a/codex-rs/core/src/config_loader/config_requirements.rs +++ b/codex-rs/core/src/config_loader/config_requirements.rs @@ -4,6 +4,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; +use serde::Serialize; use std::collections::BTreeMap; use std::fmt; @@ -141,7 +142,7 @@ pub struct NetworkRequirementsToml { } /// Normalized network constraints derived from requirements TOML. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct NetworkConstraints { pub enabled: Option, pub http_port: Option, diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index 9f5455a69f..c0a3559c18 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -13,38 +13,70 @@ use std::path::PathBuf; pub(crate) struct EnvironmentContext { pub cwd: Option, pub shell: Shell, + pub network: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub(crate) struct NetworkContext { + allowed_domains: Vec, + denied_domains: Vec, } impl EnvironmentContext { - pub fn new(cwd: Option, shell: Shell) -> Self { - Self { cwd, shell } + pub fn new(cwd: Option, shell: Shell, network: Option) -> Self { + Self { + cwd, + shell, + network, + } } /// Compares two environment contexts, ignoring the shell. Useful when /// comparing turn to turn, since the initial environment_context will /// include the shell, and then it is not configurable from turn to turn. pub fn equals_except_shell(&self, other: &EnvironmentContext) -> bool { - let EnvironmentContext { - cwd, - // should compare all fields except shell - shell: _, - .. - } = other; - - self.cwd == *cwd + self.cwd == other.cwd && self.network == other.network } pub fn diff(before: &TurnContext, after: &TurnContext, shell: &Shell) -> Self { + let before_network = Self::network_from_turn_context(before); + let after_network = Self::network_from_turn_context(after); let cwd = if before.cwd != after.cwd { Some(after.cwd.clone()) } else { None }; - EnvironmentContext::new(cwd, shell.clone()) + let network = if before_network != after_network { + after_network + } else { + before_network + }; + EnvironmentContext::new(cwd, shell.clone(), network) } pub fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self { - Self::new(Some(turn_context.cwd.clone()), shell.clone()) + Self::new( + Some(turn_context.cwd.clone()), + shell.clone(), + Self::network_from_turn_context(turn_context), + ) + } + + fn network_from_turn_context(turn_context: &TurnContext) -> Option { + let maybe_network = turn_context + .config + .config_layer_stack + .requirements() + .network + .as_ref(); + let Some(network) = maybe_network else { + return None; + }; + + Some(NetworkContext { + allowed_domains: network.allowed_domains.clone().unwrap_or_default(), + denied_domains: network.denied_domains.clone().unwrap_or_default(), + }) } } @@ -67,6 +99,36 @@ impl EnvironmentContext { let shell_name = self.shell.name(); lines.push(format!(" {shell_name}")); + match self.network { + Some(ref network) + if !network.allowed_domains.is_empty() || !network.denied_domains.is_empty() => + { + lines.push(" ".to_string()); + for allowed in &network.allowed_domains { + lines.push(format!(" {allowed}")); + } + for denied in &network.denied_domains { + lines.push(format!(" {denied}")); + } + lines.push(" ".to_string()); + } + _ => { + lines.push(" ".to_string()); + } + } + lines.push(ENVIRONMENT_CONTEXT_CLOSE_TAG.to_string()); + lines.join("\n") + } +} + lines.push(format!(" {allowed}")); + } + for denied in &self.network.denied_domains { + lines.push(format!(" {denied}")); + } + lines.push(" ".to_string()); + } else { + lines.push(" ".to_string()); + } lines.push(ENVIRONMENT_CONTEXT_CLOSE_TAG.to_string()); lines.join("\n") } @@ -105,12 +167,13 @@ mod tests { #[test] fn serialize_workspace_write_environment_context() { let cwd = test_path_buf("/repo"); - let context = EnvironmentContext::new(Some(cwd.clone()), fake_shell()); + let context = EnvironmentContext::new(Some(cwd.clone()), fake_shell(), None); let expected = format!( r#" {cwd} bash + "#, cwd = cwd.display(), ); @@ -118,12 +181,38 @@ mod tests { assert_eq!(context.serialize_to_xml(), expected); } + #[test] + fn serialize_environment_context_with_network() { + let network = NetworkContext { + allowed_domains: vec!["api.example.com".to_string(), "*.openai.com".to_string()], + denied_domains: vec!["blocked.example.com".to_string()], + }; + let context = + EnvironmentContext::new(Some(test_path_buf("/repo")), fake_shell(), Some(network)); + + let expected = format!( + r#" + {} + bash + + api.example.com + *.openai.com + blocked.example.com + +"#, + test_path_buf("/repo").display() + ); + + assert_eq!(context.serialize_to_xml(), expected); + } + #[test] fn serialize_read_only_environment_context() { - let context = EnvironmentContext::new(None, fake_shell()); + let context = EnvironmentContext::new(None, fake_shell(), None); let expected = r#" bash + "#; assert_eq!(context.serialize_to_xml(), expected); @@ -131,10 +220,11 @@ mod tests { #[test] fn serialize_external_sandbox_environment_context() { - let context = EnvironmentContext::new(None, fake_shell()); + let context = EnvironmentContext::new(None, fake_shell(), None); let expected = r#" bash + "#; assert_eq!(context.serialize_to_xml(), expected); @@ -142,10 +232,11 @@ mod tests { #[test] fn serialize_external_sandbox_with_restricted_network_environment_context() { - let context = EnvironmentContext::new(None, fake_shell()); + let context = EnvironmentContext::new(None, fake_shell(), None); let expected = r#" bash + "#; assert_eq!(context.serialize_to_xml(), expected); @@ -153,10 +244,11 @@ mod tests { #[test] fn serialize_full_access_environment_context() { - let context = EnvironmentContext::new(None, fake_shell()); + let context = EnvironmentContext::new(None, fake_shell(), None); let expected = r#" bash + "#; assert_eq!(context.serialize_to_xml(), expected); @@ -164,23 +256,47 @@ mod tests { #[test] fn equals_except_shell_compares_cwd() { - let context1 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell()); - let context2 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell()); + let context1 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + fake_shell(), + None, + ); + let context2 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + fake_shell(), + None, + ); assert!(context1.equals_except_shell(&context2)); } #[test] fn equals_except_shell_ignores_sandbox_policy() { - let context1 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell()); - let context2 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell()); + let context1 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + fake_shell(), + None, + ); + let context2 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + fake_shell(), + None, + ); assert!(context1.equals_except_shell(&context2)); } #[test] fn equals_except_shell_compares_cwd_differences() { - let context1 = EnvironmentContext::new(Some(PathBuf::from("/repo1")), fake_shell()); - let context2 = EnvironmentContext::new(Some(PathBuf::from("/repo2")), fake_shell()); + let context1 = EnvironmentContext::new( + Some(PathBuf::from("/repo1")), + fake_shell(), + None, + ); + let context2 = EnvironmentContext::new( + Some(PathBuf::from("/repo2")), + fake_shell(), + None, + ); assert!(!context1.equals_except_shell(&context2)); } @@ -194,6 +310,7 @@ mod tests { shell_path: "/bin/bash".into(), shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }, + None, ); let context2 = EnvironmentContext::new( Some(PathBuf::from("/repo")), @@ -202,6 +319,7 @@ mod tests { shell_path: "/bin/zsh".into(), shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }, + None, ); assert!(context1.equals_except_shell(&context2)); diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 242f931406..71d534ba20 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -44,6 +44,7 @@ fn default_env_context_str(cwd: &str, shell: &Shell) -> String { r#" {cwd} {shell_name} + "# ) }