feat: include [experimental_network] in <environment_context> (#11044)

If `NetworkConstraints` is set, then include the relevant settings on `<environment_context>`. Example:

```xml
<environment_context>
  <cwd>/repo</cwd>
  <shell>bash</shell>
  <network enabled="true">
    <allowed>api.example.com</allowed>
    <allowed>*.openai.com</allowed>
    <denied>blocked.example.com</denied>
  </network>
</environment_context>
```
This commit is contained in:
Michael Bolin
2026-02-08 15:16:50 -08:00
committed by GitHub
parent 9f1009540b
commit 181b721ba5
4 changed files with 166 additions and 37 deletions

View File

@@ -2204,9 +2204,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
}

View File

@@ -489,11 +489,15 @@ mod tests {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "# AGENTS.md instructions for project\n\n<INSTRUCTIONS>\ndo things\n</INSTRUCTIONS>"
text: r#"# AGENTS.md instructions for project
<INSTRUCTIONS>
do things
</INSTRUCTIONS>"#
.to_string(),
}],
end_turn: None,
phase: None,
phase: None,
},
ResponseItem::Message {
id: None,
@@ -502,7 +506,7 @@ mod tests {
text: "<ENVIRONMENT_CONTEXT>cwd=/tmp</ENVIRONMENT_CONTEXT>".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,11 @@ mod tests {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "<environment_context>cwd=/tmp</environment_context>".to_string(),
text: r#"<environment_context>
<cwd>/tmp</cwd>
<shell>zsh</shell>
</environment_context>"#
.to_string(),
}],
end_turn: None,
phase: None,
@@ -660,7 +668,11 @@ mod tests {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "<environment_context>cwd=/tmp</environment_context>".to_string(),
text: r#"<environment_context>
<cwd>/tmp</cwd>
<shell>zsh</shell>
</environment_context>"#
.to_string(),
}],
end_turn: None,
phase: None,
@@ -712,7 +724,12 @@ mod tests {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "# AGENTS.md instructions for /repo\n\n<INSTRUCTIONS>\nkeep me updated\n</INSTRUCTIONS>".to_string(),
text: r#"# AGENTS.md instructions for /repo
<INSTRUCTIONS>
keep me updated
</INSTRUCTIONS>"#
.to_string(),
}],
end_turn: None,
phase: None,
@@ -721,7 +738,11 @@ mod tests {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "<environment_context>\n <cwd>/repo</cwd>\n <shell>zsh</shell>\n</environment_context>".to_string(),
text: r#"<environment_context>
<cwd>/repo</cwd>
<shell>zsh</shell>
</environment_context>"#
.to_string(),
}],
end_turn: None,
phase: None,
@@ -730,7 +751,11 @@ mod tests {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "<turn_aborted>\n <turn_id>turn-1</turn_id>\n <reason>interrupted</reason>\n</turn_aborted>".to_string(),
text: r#"<turn_aborted>
<turn_id>turn-1</turn_id>
<reason>interrupted</reason>
</turn_aborted>"#
.to_string(),
}],
end_turn: None,
phase: None,
@@ -752,7 +777,12 @@ mod tests {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "# AGENTS.md instructions for /repo\n\n<INSTRUCTIONS>\nkeep me updated\n</INSTRUCTIONS>".to_string(),
text: r#"# AGENTS.md instructions for /repo
<INSTRUCTIONS>
keep me updated
</INSTRUCTIONS>"#
.to_string(),
}],
end_turn: None,
phase: None,
@@ -761,7 +791,11 @@ mod tests {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "<environment_context>\n <cwd>/repo</cwd>\n <shell>zsh</shell>\n</environment_context>".to_string(),
text: r#"<environment_context>
<cwd>/repo</cwd>
<shell>zsh</shell>
</environment_context>"#
.to_string(),
}],
end_turn: None,
phase: None,
@@ -770,7 +804,10 @@ mod tests {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "<turn_aborted>\n <turn_id>turn-1</turn_id>\n <reason>interrupted</reason>\n</turn_aborted>"
text: r#"<turn_aborted>
<turn_id>turn-1</turn_id>
<reason>interrupted</reason>
</turn_aborted>"#
.to_string(),
}],
end_turn: None,
@@ -796,7 +833,12 @@ mod tests {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "# AGENTS.md instructions for /repo\n\n<INSTRUCTIONS>\nkeep me updated\n</INSTRUCTIONS>".to_string(),
text: r#"# AGENTS.md instructions for /repo
<INSTRUCTIONS>
keep me updated
</INSTRUCTIONS>"#
.to_string(),
}],
end_turn: None,
phase: None,
@@ -805,7 +847,11 @@ mod tests {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "<environment_context>\n <cwd>/repo</cwd>\n <shell>zsh</shell>\n</environment_context>".to_string(),
text: r#"<environment_context>
<cwd>/repo</cwd>
<shell>zsh</shell>
</environment_context>"#
.to_string(),
}],
end_turn: None,
phase: None,
@@ -814,7 +860,11 @@ mod tests {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "<turn_aborted>\n <turn_id>turn-1</turn_id>\n <reason>interrupted</reason>\n</turn_aborted>".to_string(),
text: r#"<turn_aborted>
<turn_id>turn-1</turn_id>
<reason>interrupted</reason>
</turn_aborted>"#
.to_string(),
}],
end_turn: None,
phase: None,

View File

@@ -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<bool>,
pub http_port: Option<u16>,

View File

@@ -13,11 +13,22 @@ use std::path::PathBuf;
pub(crate) struct EnvironmentContext {
pub cwd: Option<PathBuf>,
pub shell: Shell,
pub network: Option<NetworkContext>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub(crate) struct NetworkContext {
allowed_domains: Vec<String>,
denied_domains: Vec<String>,
}
impl EnvironmentContext {
pub fn new(cwd: Option<PathBuf>, shell: Shell) -> Self {
Self { cwd, shell }
pub fn new(cwd: Option<PathBuf>, shell: Shell, network: Option<NetworkContext>) -> Self {
Self {
cwd,
shell,
network,
}
}
/// Compares two environment contexts, ignoring the shell. Useful when
@@ -26,25 +37,49 @@ impl EnvironmentContext {
pub fn equals_except_shell(&self, other: &EnvironmentContext) -> bool {
let EnvironmentContext {
cwd,
network,
// should compare all fields except shell
shell: _,
..
} = other;
self.cwd == *cwd
self.cwd == *cwd && self.network == *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<NetworkContext> {
let network = turn_context
.config
.config_layer_stack
.requirements()
.network
.as_ref()?;
Some(NetworkContext {
allowed_domains: network.allowed_domains.clone().unwrap_or_default(),
denied_domains: network.denied_domains.clone().unwrap_or_default(),
})
}
}
@@ -67,6 +102,22 @@ impl EnvironmentContext {
let shell_name = self.shell.name();
lines.push(format!(" <shell>{shell_name}</shell>"));
match self.network {
Some(ref network) => {
lines.push(" <network enabled=\"true\">".to_string());
for allowed in &network.allowed_domains {
lines.push(format!(" <allowed>{allowed}</allowed>"));
}
for denied in &network.denied_domains {
lines.push(format!(" <denied>{denied}</denied>"));
}
lines.push(" </network>".to_string());
}
None => {
// TODO(mbolin): Include this line if it helps the model.
// lines.push(" <network enabled=\"false\" />".to_string());
}
}
lines.push(ENVIRONMENT_CONTEXT_CLOSE_TAG.to_string());
lines.join("\n")
}
@@ -105,7 +156,7 @@ 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#"<environment_context>
@@ -118,9 +169,34 @@ 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#"<environment_context>
<cwd>{}</cwd>
<shell>bash</shell>
<network enabled="true">
<allowed>api.example.com</allowed>
<allowed>*.openai.com</allowed>
<denied>blocked.example.com</denied>
</network>
</environment_context>"#,
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#"<environment_context>
<shell>bash</shell>
@@ -131,7 +207,7 @@ 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#"<environment_context>
<shell>bash</shell>
@@ -142,7 +218,7 @@ 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#"<environment_context>
<shell>bash</shell>
@@ -153,7 +229,7 @@ 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#"<environment_context>
<shell>bash</shell>
@@ -164,23 +240,23 @@ 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 +270,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 +279,7 @@ mod tests {
shell_path: "/bin/zsh".into(),
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
},
None,
);
assert!(context1.equals_except_shell(&context2));