feat: include [experimental_network] in <environment_context>

This commit is contained in:
Michael Bolin
2026-02-08 14:08:29 -08:00
parent e6662d6387
commit c2b786e38e
5 changed files with 179 additions and 42 deletions

View File

@@ -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
}

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,12 @@ 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>
<network />
</environment_context>"#
.to_string(),
}],
end_turn: None,
phase: None,
@@ -660,7 +669,12 @@ 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>
<network />
</environment_context>"#
.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<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 +740,12 @@ 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>
<network />
</environment_context>"#
.to_string(),
}],
end_turn: None,
phase: None,
@@ -730,7 +754,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 +780,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 +794,12 @@ 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>
<network />
</environment_context>"#
.to_string(),
}],
end_turn: None,
phase: None,
@@ -770,7 +808,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 +837,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 +851,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 +864,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,38 +13,70 @@ 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
/// 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<NetworkContext> {
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,23 @@ impl EnvironmentContext {
let shell_name = self.shell.name();
lines.push(format!(" <shell>{shell_name}</shell>"));
match self.network {
Some(ref network)
if !network.allowed_domains.is_empty() || !network.denied_domains.is_empty() =>
{
lines.push(" <network>".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());
}
_ => {
lines.push(" <network />".to_string());
}
}
lines.push(ENVIRONMENT_CONTEXT_CLOSE_TAG.to_string());
lines.join("\n")
}
@@ -105,12 +154,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#"<environment_context>
<cwd>{cwd}</cwd>
<shell>bash</shell>
<network />
</environment_context>"#,
cwd = cwd.display(),
);
@@ -118,12 +168,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#"<environment_context>
<cwd>{}</cwd>
<shell>bash</shell>
<network>
<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>
<network />
</environment_context>"#;
assert_eq!(context.serialize_to_xml(), expected);
@@ -131,10 +207,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#"<environment_context>
<shell>bash</shell>
<network />
</environment_context>"#;
assert_eq!(context.serialize_to_xml(), expected);
@@ -142,10 +219,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#"<environment_context>
<shell>bash</shell>
<network />
</environment_context>"#;
assert_eq!(context.serialize_to_xml(), expected);
@@ -153,10 +231,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#"<environment_context>
<shell>bash</shell>
<network />
</environment_context>"#;
assert_eq!(context.serialize_to_xml(), expected);
@@ -164,23 +243,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 +273,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 +282,7 @@ mod tests {
shell_path: "/bin/zsh".into(),
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
},
None,
);
assert!(context1.equals_except_shell(&context2));

View File

@@ -44,6 +44,7 @@ fn default_env_context_str(cwd: &str, shell: &Shell) -> String {
r#"<environment_context>
<cwd>{cwd}</cwd>
<shell>{shell_name}</shell>
<network />
</environment_context>"#
)
}