migrate environment context to world state

This commit is contained in:
pakrym-oai
2026-06-20 11:38:26 -07:00
parent e0049a47f5
commit 8ed21ed0d4
12 changed files with 633 additions and 979 deletions
@@ -3,7 +3,7 @@ use codex_protocol::items::parse_hook_prompt_fragment;
use codex_protocol::models::ContentItem;
use super::AdditionalContextUserFragment;
use super::EnvironmentContext;
use super::EnvironmentsState;
use super::FragmentRegistration;
use super::FragmentRegistrationProxy;
use super::InternalModelContextFragment;
@@ -19,7 +19,7 @@ use super::UserShellCommand;
static USER_INSTRUCTIONS_REGISTRATION: FragmentRegistrationProxy<UserInstructions> =
FragmentRegistrationProxy::new();
static ENVIRONMENT_CONTEXT_REGISTRATION: FragmentRegistrationProxy<EnvironmentContext> =
static ENVIRONMENT_CONTEXT_REGISTRATION: FragmentRegistrationProxy<EnvironmentsState> =
FragmentRegistrationProxy::new();
static ADDITIONAL_CONTEXT_REGISTRATION: FragmentRegistrationProxy<AdditionalContextUserFragment> =
FragmentRegistrationProxy::new();
@@ -1,585 +0,0 @@
use crate::session::turn_context::TurnContext;
use crate::session::turn_context::TurnEnvironment;
use crate::shell::Shell;
use codex_protocol::models::ManagedFileSystemPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::protocol::TurnContextItem;
use codex_protocol::protocol::TurnContextNetworkItem;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use std::collections::HashSet;
use std::path::PathBuf;
use super::ContextualUserFragment;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct EnvironmentContext {
pub(crate) environments: EnvironmentContextEnvironments,
pub(crate) current_date: Option<String>,
pub(crate) timezone: Option<String>,
pub(crate) network: Option<NetworkContext>,
pub(crate) filesystem: Option<FileSystemContext>,
pub(crate) subagents: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct EnvironmentContextEnvironment {
pub(crate) id: String,
pub(crate) cwd: PathUri,
pub(crate) shell: String,
}
impl EnvironmentContextEnvironment {
fn legacy(cwd: PathUri, shell: String) -> Self {
Self {
id: String::new(),
cwd,
shell,
}
}
fn from_turn_environments(environments: &[TurnEnvironment], shell: &Shell) -> Vec<Self> {
environments
.iter()
.map(|environment| Self {
id: environment.environment_id.clone(),
cwd: environment.cwd().clone(),
shell: environment
.shell
.as_ref()
.map(|shell| shell.name().to_string())
.unwrap_or_else(|| shell.name().to_string()),
})
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum EnvironmentContextEnvironments {
None,
Single(EnvironmentContextEnvironment),
Multiple(Vec<EnvironmentContextEnvironment>),
}
impl EnvironmentContextEnvironments {
fn from_vec(environments: Vec<EnvironmentContextEnvironment>) -> Self {
let mut environments = environments;
match environments.pop() {
None => Self::None,
Some(environment) if environments.is_empty() => Self::Single(environment),
Some(environment) => {
environments.push(environment);
Self::Multiple(environments)
}
}
}
fn equals_except_shell(&self, other: &Self) -> bool {
match (self, other) {
(Self::None, Self::None) => true,
(Self::Single(left), Self::Single(right)) => left.cwd == right.cwd,
(Self::Multiple(left), Self::Multiple(right)) => {
left.len() == right.len()
&& left
.iter()
.zip(right.iter())
.all(|(left, right)| left.id == right.id && left.cwd == right.cwd)
}
_ => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FileSystemContext {
workspace_roots: Vec<String>,
permission_profile: FileSystemPermissionProfileContext,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum FileSystemPermissionProfileContext {
Managed(ManagedFileSystemContext),
Disabled,
External,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ManagedFileSystemContext {
Restricted {
entries: Vec<FileSystemSandboxEntry>,
glob_scan_max_depth: Option<usize>,
},
Unrestricted,
}
impl FileSystemContext {
pub(super) fn from_permission_profile(
permission_profile: &PermissionProfile,
workspace_roots: &[AbsolutePathBuf],
) -> Self {
let permission_profile = permission_profile
.clone()
.materialize_project_roots_with_workspace_roots(workspace_roots);
let workspace_roots = workspace_roots
.iter()
.map(|root| root.to_string_lossy().into_owned())
.collect();
let permission_profile = match permission_profile {
PermissionProfile::Managed { file_system, .. } => {
FileSystemPermissionProfileContext::Managed(ManagedFileSystemContext::from(
file_system,
))
}
PermissionProfile::Disabled => FileSystemPermissionProfileContext::Disabled,
PermissionProfile::External { .. } => FileSystemPermissionProfileContext::External,
};
Self {
workspace_roots,
permission_profile,
}
}
pub(super) fn render(&self) -> String {
let mut rendered = "<filesystem>".to_string();
if !self.workspace_roots.is_empty() {
rendered.push_str("<workspace_roots>");
for root in &self.workspace_roots {
push_text_element(&mut rendered, "root", root);
}
rendered.push_str("</workspace_roots>");
}
self.permission_profile.render(&mut rendered);
rendered.push_str("</filesystem>");
rendered
}
}
impl From<ManagedFileSystemPermissions> for ManagedFileSystemContext {
fn from(file_system: ManagedFileSystemPermissions) -> Self {
match file_system {
ManagedFileSystemPermissions::Restricted {
mut entries,
glob_scan_max_depth,
} => {
dedupe_file_system_entries(&mut entries);
Self::Restricted {
entries,
glob_scan_max_depth: glob_scan_max_depth.map(usize::from),
}
}
ManagedFileSystemPermissions::Unrestricted => Self::Unrestricted,
}
}
}
impl FileSystemPermissionProfileContext {
fn render(&self, rendered: &mut String) {
match self {
Self::Managed(file_system) => {
rendered.push_str("<permission_profile type=\"managed\">");
file_system.render(rendered);
rendered.push_str("</permission_profile>");
}
Self::Disabled => {
rendered.push_str(
"<permission_profile type=\"disabled\"><file_system type=\"unrestricted\" /></permission_profile>",
);
}
Self::External => {
rendered.push_str(
"<permission_profile type=\"external\"><file_system type=\"external\" /></permission_profile>",
);
}
}
}
}
impl ManagedFileSystemContext {
fn render(&self, rendered: &mut String) {
match self {
Self::Restricted {
entries,
glob_scan_max_depth,
} => {
if entries.is_empty() && glob_scan_max_depth.is_none() {
rendered.push_str("<file_system type=\"restricted\" />");
return;
}
rendered.push_str("<file_system type=\"restricted\"");
if let Some(glob_scan_max_depth) = glob_scan_max_depth {
rendered.push_str(&format!(" glob_scan_max_depth=\"{glob_scan_max_depth}\""));
}
rendered.push('>');
for entry in entries {
render_file_system_entry(rendered, entry);
}
rendered.push_str("</file_system>");
}
Self::Unrestricted => {
rendered.push_str("<file_system type=\"unrestricted\" />");
}
}
}
}
fn render_file_system_entry(rendered: &mut String, entry: &FileSystemSandboxEntry) {
rendered.push_str("<entry access=\"");
let access = entry.access.to_string();
rendered.push_str(&access);
if entry.access == FileSystemAccessMode::Deny {
rendered.push_str("\" escalatable=\"false");
}
rendered.push_str("\">");
match &entry.path {
FileSystemPath::Path { path } => {
push_text_element(rendered, "path", path.to_string_lossy().as_ref());
}
FileSystemPath::GlobPattern { pattern } => {
push_text_element(rendered, "glob", pattern);
}
FileSystemPath::Special { value } => {
let value = render_special_path(value);
push_text_element(rendered, "special", &value);
}
}
rendered.push_str("</entry>");
}
fn render_special_path(value: &FileSystemSpecialPath) -> String {
match value {
FileSystemSpecialPath::Root => ":root".to_string(),
FileSystemSpecialPath::Minimal => ":minimal".to_string(),
FileSystemSpecialPath::ProjectRoots { subpath } => {
render_special_path_with_subpath(":workspace_roots", subpath)
}
FileSystemSpecialPath::Tmpdir => ":tmpdir".to_string(),
FileSystemSpecialPath::SlashTmp => ":slash_tmp".to_string(),
FileSystemSpecialPath::Unknown { path, subpath } => {
render_special_path_with_subpath(path, subpath)
}
}
}
fn render_special_path_with_subpath(base: &str, subpath: &Option<PathBuf>) -> String {
match subpath {
Some(subpath) => format!("{base}/{}", subpath.display()),
None => base.to_string(),
}
}
fn dedupe_file_system_entries(entries: &mut Vec<FileSystemSandboxEntry>) {
let mut seen = HashSet::new();
entries.retain(|entry| seen.insert(entry.clone()));
}
fn push_text_element(rendered: &mut String, name: &str, value: &str) {
rendered.push_str(&format!("<{name}>"));
push_xml_escaped_text(rendered, value);
rendered.push_str(&format!("</{name}>"));
}
pub(super) fn push_xml_escaped_text(rendered: &mut String, value: &str) {
for ch in value.chars() {
match ch {
'&' => rendered.push_str("&amp;"),
'<' => rendered.push_str("&lt;"),
'>' => rendered.push_str("&gt;"),
'"' => rendered.push_str("&quot;"),
'\'' => rendered.push_str("&apos;"),
_ => rendered.push(ch),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct NetworkContext {
allowed_domains: Vec<String>,
denied_domains: Vec<String>,
}
impl NetworkContext {
pub(crate) fn new(allowed_domains: Vec<String>, denied_domains: Vec<String>) -> Self {
Self {
allowed_domains,
denied_domains,
}
}
pub(super) fn render(&self) -> String {
let mut rendered = "<network enabled=\"true\">".to_string();
Self::push_rendered_domain_element(&mut rendered, "allowed", &self.allowed_domains);
Self::push_rendered_domain_element(&mut rendered, "denied", &self.denied_domains);
rendered.push_str("</network>");
rendered
}
fn push_rendered_domain_element(rendered_network: &mut String, name: &str, domains: &[String]) {
if domains.is_empty() {
return;
}
rendered_network.push_str(&format!("<{name}>"));
rendered_network.push_str(&domains.join(","));
rendered_network.push_str(&format!("</{name}>"));
}
}
impl EnvironmentContext {
pub(crate) fn new(
environments: Vec<EnvironmentContextEnvironment>,
current_date: Option<String>,
timezone: Option<String>,
network: Option<NetworkContext>,
subagents: Option<String>,
) -> Self {
Self {
environments: EnvironmentContextEnvironments::from_vec(environments),
current_date,
timezone,
network,
filesystem: None,
subagents,
}
}
fn new_with_environments(
environments: EnvironmentContextEnvironments,
current_date: Option<String>,
timezone: Option<String>,
network: Option<NetworkContext>,
filesystem: Option<FileSystemContext>,
subagents: Option<String>,
) -> Self {
Self {
environments,
current_date,
timezone,
network,
filesystem,
subagents,
}
}
/// 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(crate) fn equals_except_shell(&self, other: &EnvironmentContext) -> bool {
self.environments.equals_except_shell(&other.environments)
&& self.current_date == other.current_date
&& self.timezone == other.timezone
&& self.network == other.network
&& self.filesystem == other.filesystem
&& self.subagents == other.subagents
}
pub(crate) fn diff_from_turn_context_item(
before: &TurnContextItem,
after: &EnvironmentContext,
) -> Self {
let before_network = Self::network_from_turn_context_item(before);
let before_filesystem = Self::filesystem_from_turn_context_item(before);
let environments = match &after.environments {
EnvironmentContextEnvironments::Single(environment) => {
if PathUri::from_abs_path(&before.cwd) != environment.cwd {
EnvironmentContextEnvironments::Single(EnvironmentContextEnvironment::legacy(
environment.cwd.clone(),
environment.shell.clone(),
))
} else {
EnvironmentContextEnvironments::None
}
}
EnvironmentContextEnvironments::Multiple(environments) => {
EnvironmentContextEnvironments::Multiple(environments.clone())
}
EnvironmentContextEnvironments::None => EnvironmentContextEnvironments::None,
};
let network = if before_network != after.network {
after.network.clone()
} else {
before_network
};
let filesystem = if before_filesystem != after.filesystem {
after.filesystem.clone()
} else {
before_filesystem
};
EnvironmentContext::new_with_environments(
environments,
after.current_date.clone(),
after.timezone.clone(),
network,
filesystem,
/*subagents*/ None,
)
}
pub(crate) fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self {
let mut context = Self::new(
EnvironmentContextEnvironment::from_turn_environments(
&turn_context.environments.turn_environments,
shell,
),
turn_context.current_date.clone(),
turn_context.timezone.clone(),
Self::network_from_turn_context(turn_context),
/*subagents*/ None,
);
context.filesystem = Some(FileSystemContext::from_permission_profile(
&turn_context.permission_profile,
&turn_context.config.effective_workspace_roots(),
));
context
}
pub(crate) fn from_turn_context_item(
turn_context_item: &TurnContextItem,
shell: String,
) -> Self {
Self::new_with_environments(
EnvironmentContextEnvironments::from_vec(vec![EnvironmentContextEnvironment::legacy(
PathUri::from_abs_path(&turn_context_item.cwd),
shell,
)]),
turn_context_item.current_date.clone(),
turn_context_item.timezone.clone(),
Self::network_from_turn_context_item(turn_context_item),
Self::filesystem_from_turn_context_item(turn_context_item),
/*subagents*/ None,
)
}
pub(crate) fn with_subagents(mut self, subagents: String) -> Self {
if !subagents.is_empty() {
self.subagents = Some(subagents);
}
self
}
fn network_from_turn_context(turn_context: &TurnContext) -> Option<NetworkContext> {
let network = turn_context
.config
.config_layer_stack
.requirements()
.network
.as_ref()?;
Some(NetworkContext::new(
network
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains)
.unwrap_or_default(),
network
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::denied_domains)
.unwrap_or_default(),
))
}
fn network_from_turn_context_item(
turn_context_item: &TurnContextItem,
) -> Option<NetworkContext> {
let TurnContextNetworkItem {
allowed_domains,
denied_domains,
} = turn_context_item.network.as_ref()?;
Some(NetworkContext::new(
allowed_domains.clone(),
denied_domains.clone(),
))
}
fn filesystem_from_turn_context_item(
turn_context_item: &TurnContextItem,
) -> Option<FileSystemContext> {
Some(FileSystemContext::from_permission_profile(
&turn_context_item.permission_profile(),
&workspace_roots_from_turn_context_item(turn_context_item),
))
}
}
fn workspace_roots_from_turn_context_item(
turn_context_item: &TurnContextItem,
) -> Vec<AbsolutePathBuf> {
if let Some(workspace_roots) = turn_context_item.workspace_roots.as_ref() {
return workspace_roots.clone();
}
vec![turn_context_item.cwd.clone()]
}
impl ContextualUserFragment for EnvironmentContext {
fn role(&self) -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
(
codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG,
codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG,
)
}
fn body(&self) -> String {
let mut lines = Vec::new();
match &self.environments {
EnvironmentContextEnvironments::Single(environment) => {
let cwd = environment.cwd.inferred_native_path_string();
lines.push(format!(" <cwd>{cwd}</cwd>"));
lines.push(format!(" <shell>{}</shell>", environment.shell));
}
EnvironmentContextEnvironments::Multiple(environments) => {
lines.push(" <environments>".to_string());
for environment in environments {
lines.push(format!(" <environment id=\"{}\">", environment.id));
let cwd = environment.cwd.inferred_native_path_string();
lines.push(format!(" <cwd>{cwd}</cwd>"));
lines.push(format!(" <shell>{}</shell>", environment.shell));
lines.push(" </environment>".to_string());
}
lines.push(" </environments>".to_string());
}
EnvironmentContextEnvironments::None => {}
}
if let Some(current_date) = &self.current_date {
lines.push(format!(" <current_date>{current_date}</current_date>"));
}
if let Some(timezone) = &self.timezone {
lines.push(format!(" <timezone>{timezone}</timezone>"));
}
match &self.network {
Some(network) => {
lines.push(format!(" {}", network.render()));
}
None => {
// TODO(mbolin): Include this line if it helps the model.
// lines.push(" <network enabled=\"false\" />".to_string());
}
}
if let Some(filesystem) = &self.filesystem {
lines.push(format!(" {}", filesystem.render()));
}
if let Some(subagents) = &self.subagents {
lines.push(" <subagents>".to_string());
lines.extend(subagents.lines().map(|line| format!(" {line}")));
lines.push(" </subagents>".to_string());
}
format!("\n{}\n", lines.join("\n"))
}
}
#[cfg(test)]
#[path = "environment_context_tests.rs"]
mod tests;
+1 -2
View File
@@ -7,7 +7,6 @@ mod available_skills_instructions;
mod collaboration_mode_instructions;
mod contextual_user_message;
mod current_time_reminder;
mod environment_context;
mod guardian_followup_review_reminder;
mod hook_additional_context;
mod image_generation_instructions;
@@ -49,7 +48,6 @@ pub(crate) use collaboration_mode_instructions::CollaborationModeInstructions;
pub(crate) use contextual_user_message::is_contextual_user_fragment;
pub(crate) use contextual_user_message::parse_visible_hook_prompt_message;
pub(crate) use current_time_reminder::CurrentTimeReminder;
pub(crate) use environment_context::EnvironmentContext;
pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder;
pub(crate) use hook_additional_context::HookAdditionalContext;
pub(crate) use image_generation_instructions::ImageGenerationInstructions;
@@ -78,3 +76,4 @@ pub(crate) use token_budget_context::TokenBudgetRemainingContext;
pub(crate) use turn_aborted::TurnAborted;
pub(crate) use user_instructions::UserInstructions;
pub(crate) use user_shell_command::UserShellCommand;
pub(crate) use world_state::EnvironmentsState;
@@ -1,14 +1,13 @@
use super::WorldStateSection;
use super::environment_support::FileSystemContext;
use super::environment_support::NetworkContext;
use super::environment_support::push_xml_escaped_text;
use crate::context::ContextualUserFragment;
use crate::context::environment_context::EnvironmentContext;
use crate::context::environment_context::EnvironmentContextEnvironment;
use crate::context::environment_context::EnvironmentContextEnvironments;
use crate::context::environment_context::FileSystemContext;
use crate::context::environment_context::NetworkContext;
use crate::context::environment_context::push_xml_escaped_text;
use crate::session::turn_context::TurnContext;
use crate::shell::Shell;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::TurnContextItem;
use codex_protocol::protocol::TurnContextNetworkItem;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use serde::Deserialize;
use serde::Serialize;
@@ -40,14 +39,35 @@ impl PartialEq for EnvironmentsState {
impl Eq for EnvironmentsState {}
impl EnvironmentsState {
pub(crate) fn from_turn_context(
turn_context: &TurnContext,
default_shell: &Shell,
subagents: String,
) -> Self {
let context = EnvironmentContext::from_turn_context(turn_context, default_shell)
.with_subagents(subagents);
let mut state = Self::from_environment_context(&context);
pub(crate) fn from_turn_context(turn_context: &TurnContext) -> Self {
let mut state = Self {
environments: turn_context
.environments
.turn_environments
.iter()
.map(|environment| {
(
environment.environment_id.clone(),
EnvironmentState {
cwd: environment.cwd().clone(),
status: Some(EnvironmentStatus::Available),
shell: environment
.shell
.as_ref()
.map(|shell| shell.name().to_string()),
},
)
})
.collect(),
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
network: network_from_turn_context(turn_context),
filesystem: Some(FileSystemContext::from_permission_profile(
&turn_context.permission_profile,
&turn_context.config.effective_workspace_roots(),
)),
subagents: None,
};
for environment in &turn_context.environments.starting {
state
.environments
@@ -61,26 +81,55 @@ impl EnvironmentsState {
state
}
pub(crate) fn from_environment_context(context: &EnvironmentContext) -> Self {
let mut environments = BTreeMap::new();
match &context.environments {
EnvironmentContextEnvironments::None => {}
EnvironmentContextEnvironments::Single(environment) => {
insert_environment(&mut environments, environment);
}
EnvironmentContextEnvironments::Multiple(current) => {
for environment in current {
insert_environment(&mut environments, environment);
}
}
}
pub(crate) fn from_turn_context_item(turn_context_item: &TurnContextItem) -> Self {
Self {
environments,
current_date: context.current_date.clone(),
timezone: context.timezone.clone(),
network: context.network.clone(),
filesystem: context.filesystem.clone(),
subagents: context.subagents.clone(),
environments: [(
String::new(),
EnvironmentState {
cwd: PathUri::from_abs_path(&turn_context_item.cwd),
status: Some(EnvironmentStatus::Available),
shell: None,
},
)]
.into_iter()
.collect(),
current_date: turn_context_item.current_date.clone(),
timezone: turn_context_item.timezone.clone(),
network: network_from_turn_context_item(turn_context_item),
filesystem: Some(FileSystemContext::from_permission_profile(
&turn_context_item.permission_profile(),
&workspace_roots_from_turn_context_item(turn_context_item),
)),
subagents: None,
}
}
pub(crate) fn with_subagents(mut self, subagents: String) -> Self {
if !subagents.is_empty() {
self.subagents = Some(subagents);
}
self
}
pub(crate) fn render_diff(&self, previous: &Self) -> Option<ResponseItem> {
WorldStateSection::render_diff(self, previous)
}
fn rendered_full(&self) -> RenderedEnvironments {
RenderedEnvironments {
updates: self
.environments
.iter()
.map(|(id, environment)| {
(id.clone(), EnvironmentUpdate::Current(environment.clone()))
})
.collect(),
legacy_single: is_legacy_single(&self.environments),
current_date: self.current_date.clone(),
timezone: self.timezone.clone(),
network: self.network.clone(),
filesystem: self.filesystem.clone(),
subagents: self.subagents.clone(),
}
}
}
@@ -89,22 +138,32 @@ impl WorldStateSection for EnvironmentsState {
const NAME: &'static str = "environments";
fn render_diff(&self, previous: &Self) -> Option<ResponseItem> {
let legacy_single =
is_legacy_single(&self.environments) && previous.environments.len() <= 1;
if legacy_single
&& self.environments.values().next() == previous.environments.values().next()
{
return None;
}
let mut updates = self
.environments
.iter()
.filter(|(id, environment)| previous.environments.get(*id) != Some(*environment))
.map(|(id, environment)| (id.clone(), EnvironmentUpdate::Current(environment.clone())))
.collect::<BTreeMap<_, _>>();
updates.extend(
previous
.environments
.keys()
.filter(|id| !self.environments.contains_key(*id))
.map(|id| (id.clone(), EnvironmentUpdate::Unavailable)),
);
if !legacy_single {
updates.extend(
previous
.environments
.keys()
.filter(|id| !id.is_empty() && !self.environments.contains_key(*id))
.map(|id| (id.clone(), EnvironmentUpdate::Unavailable)),
);
}
(!updates.is_empty()).then(|| {
ContextualUserFragment::into(RenderedEnvironments {
updates,
legacy_single,
current_date: self.current_date.clone(),
timezone: self.timezone.clone(),
network: self.network.clone(),
@@ -115,8 +174,27 @@ impl WorldStateSection for EnvironmentsState {
}
}
impl ContextualUserFragment for EnvironmentsState {
fn role(&self) -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
environment_context_markers()
}
fn body(&self) -> String {
self.rendered_full().body()
}
}
struct RenderedEnvironments {
updates: BTreeMap<String, EnvironmentUpdate>,
legacy_single: bool,
current_date: Option<String>,
timezone: Option<String>,
network: Option<NetworkContext>,
@@ -139,15 +217,16 @@ impl ContextualUserFragment for RenderedEnvironments {
}
fn type_markers() -> (&'static str, &'static str) {
(
codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG,
codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG,
)
environment_context_markers()
}
fn body(&self) -> String {
let mut rendered = "\n".to_string();
if !self.updates.is_empty() {
if self.legacy_single {
if let Some(EnvironmentUpdate::Current(environment)) = self.updates.values().next() {
push_environment_values(&mut rendered, environment, " ");
}
} else if !self.updates.is_empty() {
rendered.push_str(" <environments>\n");
for (id, update) in &self.updates {
match update {
@@ -160,18 +239,9 @@ impl ContextualUserFragment for RenderedEnvironments {
rendered.push_str(status.as_str());
rendered.push('"');
}
rendered.push_str(">\n <cwd>");
push_xml_escaped_text(
&mut rendered,
&environment.cwd.inferred_native_path_string(),
);
rendered.push_str("</cwd>");
if let Some(shell) = &environment.shell {
rendered.push_str("\n <shell>");
push_xml_escaped_text(&mut rendered, shell);
rendered.push_str("</shell>");
}
rendered.push_str("\n </environment>\n");
rendered.push_str(">\n");
push_environment_values(&mut rendered, environment, " ");
rendered.push_str(" </environment>\n");
}
EnvironmentUpdate::Unavailable => {
rendered.push_str(" <environment id=\"");
@@ -207,18 +277,17 @@ impl ContextualUserFragment for RenderedEnvironments {
}
}
fn insert_environment(
environments: &mut BTreeMap<String, EnvironmentState>,
environment: &EnvironmentContextEnvironment,
) {
environments.insert(
environment.id.clone(),
EnvironmentState {
cwd: environment.cwd.clone(),
status: Some(EnvironmentStatus::Available),
shell: Some(environment.shell.clone()),
},
);
fn push_environment_values(rendered: &mut String, environment: &EnvironmentState, indent: &str) {
rendered.push_str(indent);
rendered.push_str("<cwd>");
push_xml_escaped_text(rendered, &environment.cwd.inferred_native_path_string());
rendered.push_str("</cwd>\n");
if let Some(shell) = &environment.shell {
rendered.push_str(indent);
rendered.push_str("<shell>");
push_xml_escaped_text(rendered, shell);
rendered.push_str("</shell>\n");
}
}
fn push_optional_element(rendered: &mut String, name: &str, value: Option<&str>) {
@@ -266,6 +335,71 @@ impl EnvironmentStatus {
}
}
fn is_legacy_single(environments: &BTreeMap<String, EnvironmentState>) -> bool {
environments.len() == 1
&& matches!(
environments
.values()
.next()
.and_then(|environment| environment.status),
Some(EnvironmentStatus::Available)
)
}
fn environment_context_markers() -> (&'static str, &'static str) {
(
codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG,
codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG,
)
}
fn network_from_turn_context(turn_context: &TurnContext) -> Option<NetworkContext> {
let network = turn_context
.config
.config_layer_stack
.requirements()
.network
.as_ref()?;
Some(NetworkContext::new(
network
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains)
.unwrap_or_default(),
network
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::denied_domains)
.unwrap_or_default(),
))
}
fn network_from_turn_context_item(turn_context_item: &TurnContextItem) -> Option<NetworkContext> {
let TurnContextNetworkItem {
allowed_domains,
denied_domains,
} = turn_context_item.network.as_ref()?;
Some(NetworkContext::new(
allowed_domains.clone(),
denied_domains.clone(),
))
}
fn workspace_roots_from_turn_context_item(
turn_context_item: &TurnContextItem,
) -> Vec<AbsolutePathBuf> {
if let Some(workspace_roots) = turn_context_item.workspace_roots.as_ref() {
return workspace_roots.clone();
}
vec![turn_context_item.cwd.clone()]
}
#[cfg(test)]
#[path = "environment_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "environment_render_tests.rs"]
mod render_tests;
@@ -30,15 +30,43 @@ fn test_abs_path(unix_path: &str) -> AbsolutePathBuf {
test_path_buf(unix_path).abs()
}
fn environment(id: &str, cwd: PathUri, shell: impl Into<String>) -> (String, EnvironmentState) {
(
id.to_string(),
EnvironmentState {
cwd,
status: Some(EnvironmentStatus::Available),
shell: Some(shell.into()),
},
)
}
fn environment_state(
environments: impl IntoIterator<Item = (String, EnvironmentState)>,
current_date: Option<String>,
timezone: Option<String>,
network: Option<NetworkContext>,
subagents: Option<String>,
) -> EnvironmentsState {
EnvironmentsState {
environments: environments.into_iter().collect(),
current_date,
timezone,
network,
filesystem: None,
subagents,
}
}
#[test]
fn serialize_workspace_write_environment_context() {
let cwd = test_path_buf("/repo");
let context = EnvironmentContext::new(
vec![EnvironmentContextEnvironment {
id: "local".to_string(),
cwd: PathUri::from_abs_path(&cwd.abs()),
shell: fake_shell_name(),
}],
let context = environment_state(
[environment(
"local",
PathUri::from_abs_path(&cwd.abs()),
fake_shell_name(),
)],
Some("2026-02-26".to_string()),
Some("America/Los_Angeles".to_string()),
/*network*/ None,
@@ -60,12 +88,12 @@ fn serialize_workspace_write_environment_context() {
#[test]
fn serialize_environment_context_with_foreign_windows_cwd() {
let context = EnvironmentContext::new(
vec![EnvironmentContextEnvironment {
id: "remote".to_string(),
cwd: PathUri::parse("file:///C:/windows").expect("Windows cwd URI"),
shell: "powershell".to_string(),
}],
let context = environment_state(
[environment(
"remote",
PathUri::parse("file:///C:/windows").expect("Windows cwd URI"),
"powershell",
)],
/*current_date*/ None,
/*timezone*/ None,
/*network*/ None,
@@ -87,12 +115,12 @@ fn serialize_environment_context_with_network() {
vec!["api.example.com".to_string(), "*.openai.com".to_string()],
vec!["blocked.example.com".to_string()],
);
let context = EnvironmentContext::new(
vec![EnvironmentContextEnvironment {
id: "local".to_string(),
cwd: PathUri::from_abs_path(&test_abs_path("/repo")),
shell: fake_shell_name(),
}],
let context = environment_state(
[environment(
"local",
PathUri::from_abs_path(&test_abs_path("/repo")),
fake_shell_name(),
)],
Some("2026-02-26".to_string()),
Some("America/Los_Angeles".to_string()),
Some(network),
@@ -149,12 +177,12 @@ fn serialize_environment_context_with_full_filesystem_profile() {
AbsolutePathBuf::resolve_path_against_base(Path::new("private/**"), repo.as_path());
let other_repo_private_glob =
AbsolutePathBuf::resolve_path_against_base(Path::new("private/**"), other_repo.as_path());
let mut context = EnvironmentContext::new(
vec![EnvironmentContextEnvironment {
id: "local".to_string(),
cwd: PathUri::from_abs_path(&test_abs_path("/repo")),
shell: fake_shell_name(),
}],
let mut context = environment_state(
[environment(
"local",
PathUri::from_abs_path(&test_abs_path("/repo")),
fake_shell_name(),
)],
/*current_date*/ None,
/*timezone*/ None,
/*network*/ None,
@@ -210,7 +238,7 @@ fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() {
summary: codex_protocol::config_types::ReasoningSummary::Auto,
};
let context = EnvironmentContext::from_turn_context_item(&item, fake_shell_name()).render();
let context = EnvironmentsState::from_turn_context_item(&item).render();
assert!(
context.contains(&format!(
@@ -237,7 +265,7 @@ fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() {
#[test]
fn serialize_read_only_environment_context() {
let context = EnvironmentContext::new(
let context = environment_state(
Vec::new(),
Some("2026-02-26".to_string()),
Some("America/Los_Angeles".to_string()),
@@ -253,97 +281,14 @@ fn serialize_read_only_environment_context() {
assert_eq!(context.render(), expected);
}
#[test]
fn equals_except_shell_compares_cwd() {
let context1 = EnvironmentContext::new(
vec![EnvironmentContextEnvironment {
id: "local".to_string(),
cwd: PathUri::from_abs_path(&test_abs_path("/repo")),
shell: fake_shell_name(),
}],
/*current_date*/ None,
/*timezone*/ None,
/*network*/ None,
/*subagents*/ None,
);
let context2 = EnvironmentContext::new(
vec![EnvironmentContextEnvironment {
id: "local".to_string(),
cwd: PathUri::from_abs_path(&test_abs_path("/repo")),
shell: fake_shell_name(),
}],
/*current_date*/ None,
/*timezone*/ None,
/*network*/ None,
/*subagents*/ None,
);
assert!(context1.equals_except_shell(&context2));
}
#[test]
fn equals_except_shell_compares_cwd_differences() {
let context1 = EnvironmentContext::new(
vec![EnvironmentContextEnvironment {
id: "local".to_string(),
cwd: PathUri::from_abs_path(&test_abs_path("/repo1")),
shell: fake_shell_name(),
}],
/*current_date*/ None,
/*timezone*/ None,
/*network*/ None,
/*subagents*/ None,
);
let context2 = EnvironmentContext::new(
vec![EnvironmentContextEnvironment {
id: "local".to_string(),
cwd: PathUri::from_abs_path(&test_abs_path("/repo2")),
shell: fake_shell_name(),
}],
/*current_date*/ None,
/*timezone*/ None,
/*network*/ None,
/*subagents*/ None,
);
assert!(!context1.equals_except_shell(&context2));
}
#[test]
fn equals_except_shell_ignores_shell() {
let context1 = EnvironmentContext::new(
vec![EnvironmentContextEnvironment {
id: "local".to_string(),
cwd: PathUri::from_abs_path(&test_abs_path("/repo")),
shell: "bash".to_string(),
}],
/*current_date*/ None,
/*timezone*/ None,
/*network*/ None,
/*subagents*/ None,
);
let context2 = EnvironmentContext::new(
vec![EnvironmentContextEnvironment {
id: "other".to_string(),
cwd: PathUri::from_abs_path(&test_abs_path("/repo")),
shell: "zsh".to_string(),
}],
/*current_date*/ None,
/*timezone*/ None,
/*network*/ None,
/*subagents*/ None,
);
assert!(context1.equals_except_shell(&context2));
}
#[test]
fn serialize_environment_context_with_subagents() {
let context = EnvironmentContext::new(
vec![EnvironmentContextEnvironment {
id: "local".to_string(),
cwd: PathUri::from_abs_path(&test_abs_path("/repo")),
shell: fake_shell_name(),
}],
let context = environment_state(
[environment(
"local",
PathUri::from_abs_path(&test_abs_path("/repo")),
fake_shell_name(),
)],
Some("2026-02-26".to_string()),
Some("America/Los_Angeles".to_string()),
/*network*/ None,
@@ -371,18 +316,10 @@ fn serialize_environment_context_with_subagents() {
fn serialize_environment_context_with_multiple_selected_environments() {
let local_cwd = test_path_buf("/repo/local");
let remote_cwd = test_path_buf("/repo/remote");
let context = EnvironmentContext::new(
vec![
EnvironmentContextEnvironment {
id: "local".to_string(),
cwd: PathUri::from_abs_path(&local_cwd.abs()),
shell: "bash".to_string(),
},
EnvironmentContextEnvironment {
id: "remote".to_string(),
cwd: PathUri::from_abs_path(&remote_cwd.abs()),
shell: "bash".to_string(),
},
let context = environment_state(
[
environment("local", PathUri::from_abs_path(&local_cwd.abs()), "bash"),
environment("remote", PathUri::from_abs_path(&remote_cwd.abs()), "bash"),
],
Some("2026-02-26".to_string()),
Some("America/Los_Angeles".to_string()),
@@ -393,11 +330,11 @@ fn serialize_environment_context_with_multiple_selected_environments() {
let expected = format!(
r#"<environment_context>
<environments>
<environment id="local">
<environment id="local" status="available">
<cwd>{}</cwd>
<shell>bash</shell>
</environment>
<environment id="remote">
<environment id="remote" status="available">
<cwd>{}</cwd>
<shell>bash</shell>
</environment>
@@ -416,18 +353,14 @@ fn serialize_environment_context_with_multiple_selected_environments() {
fn serialize_environment_context_prefers_environment_shell_when_present() {
let local_cwd = test_path_buf("/repo/local");
let remote_cwd = test_path_buf("/repo/remote");
let context = EnvironmentContext::new(
vec![
EnvironmentContextEnvironment {
id: "local".to_string(),
cwd: PathUri::from_abs_path(&local_cwd.abs()),
shell: "powershell".to_string(),
},
EnvironmentContextEnvironment {
id: "remote".to_string(),
cwd: PathUri::from_abs_path(&remote_cwd.abs()),
shell: "cmd".to_string(),
},
let context = environment_state(
[
environment(
"local",
PathUri::from_abs_path(&local_cwd.abs()),
"powershell",
),
environment("remote", PathUri::from_abs_path(&remote_cwd.abs()), "cmd"),
],
/*current_date*/ None,
/*timezone*/ None,
@@ -438,11 +371,11 @@ fn serialize_environment_context_prefers_environment_shell_when_present() {
let expected = format!(
r#"<environment_context>
<environments>
<environment id="local">
<environment id="local" status="available">
<cwd>{}</cwd>
<shell>powershell</shell>
</environment>
<environment id="remote">
<environment id="remote" status="available">
<cwd>{}</cwd>
<shell>cmd</shell>
</environment>
@@ -0,0 +1,244 @@
use codex_protocol::models::ManagedFileSystemPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashSet;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FileSystemContext {
workspace_roots: Vec<String>,
permission_profile: FileSystemPermissionProfileContext,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum FileSystemPermissionProfileContext {
Managed(ManagedFileSystemContext),
Disabled,
External,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ManagedFileSystemContext {
Restricted {
entries: Vec<FileSystemSandboxEntry>,
glob_scan_max_depth: Option<usize>,
},
Unrestricted,
}
impl FileSystemContext {
pub(super) fn from_permission_profile(
permission_profile: &PermissionProfile,
workspace_roots: &[AbsolutePathBuf],
) -> Self {
let permission_profile = permission_profile
.clone()
.materialize_project_roots_with_workspace_roots(workspace_roots);
let workspace_roots = workspace_roots
.iter()
.map(|root| root.to_string_lossy().into_owned())
.collect();
let permission_profile = match permission_profile {
PermissionProfile::Managed { file_system, .. } => {
FileSystemPermissionProfileContext::Managed(ManagedFileSystemContext::from(
file_system,
))
}
PermissionProfile::Disabled => FileSystemPermissionProfileContext::Disabled,
PermissionProfile::External { .. } => FileSystemPermissionProfileContext::External,
};
Self {
workspace_roots,
permission_profile,
}
}
pub(super) fn render(&self) -> String {
let mut rendered = "<filesystem>".to_string();
if !self.workspace_roots.is_empty() {
rendered.push_str("<workspace_roots>");
for root in &self.workspace_roots {
push_text_element(&mut rendered, "root", root);
}
rendered.push_str("</workspace_roots>");
}
self.permission_profile.render(&mut rendered);
rendered.push_str("</filesystem>");
rendered
}
}
impl From<ManagedFileSystemPermissions> for ManagedFileSystemContext {
fn from(file_system: ManagedFileSystemPermissions) -> Self {
match file_system {
ManagedFileSystemPermissions::Restricted {
mut entries,
glob_scan_max_depth,
} => {
dedupe_file_system_entries(&mut entries);
Self::Restricted {
entries,
glob_scan_max_depth: glob_scan_max_depth.map(usize::from),
}
}
ManagedFileSystemPermissions::Unrestricted => Self::Unrestricted,
}
}
}
impl FileSystemPermissionProfileContext {
fn render(&self, rendered: &mut String) {
match self {
Self::Managed(file_system) => {
rendered.push_str("<permission_profile type=\"managed\">");
file_system.render(rendered);
rendered.push_str("</permission_profile>");
}
Self::Disabled => {
rendered.push_str(
"<permission_profile type=\"disabled\"><file_system type=\"unrestricted\" /></permission_profile>",
);
}
Self::External => {
rendered.push_str(
"<permission_profile type=\"external\"><file_system type=\"external\" /></permission_profile>",
);
}
}
}
}
impl ManagedFileSystemContext {
fn render(&self, rendered: &mut String) {
match self {
Self::Restricted {
entries,
glob_scan_max_depth,
} => {
if entries.is_empty() && glob_scan_max_depth.is_none() {
rendered.push_str("<file_system type=\"restricted\" />");
return;
}
rendered.push_str("<file_system type=\"restricted\"");
if let Some(glob_scan_max_depth) = glob_scan_max_depth {
rendered.push_str(&format!(" glob_scan_max_depth=\"{glob_scan_max_depth}\""));
}
rendered.push('>');
for entry in entries {
render_file_system_entry(rendered, entry);
}
rendered.push_str("</file_system>");
}
Self::Unrestricted => {
rendered.push_str("<file_system type=\"unrestricted\" />");
}
}
}
}
fn render_file_system_entry(rendered: &mut String, entry: &FileSystemSandboxEntry) {
rendered.push_str("<entry access=\"");
let access = entry.access.to_string();
rendered.push_str(&access);
if entry.access == FileSystemAccessMode::Deny {
rendered.push_str("\" escalatable=\"false");
}
rendered.push_str("\">");
match &entry.path {
FileSystemPath::Path { path } => {
push_text_element(rendered, "path", path.to_string_lossy().as_ref());
}
FileSystemPath::GlobPattern { pattern } => {
push_text_element(rendered, "glob", pattern);
}
FileSystemPath::Special { value } => {
let value = render_special_path(value);
push_text_element(rendered, "special", &value);
}
}
rendered.push_str("</entry>");
}
fn render_special_path(value: &FileSystemSpecialPath) -> String {
match value {
FileSystemSpecialPath::Root => ":root".to_string(),
FileSystemSpecialPath::Minimal => ":minimal".to_string(),
FileSystemSpecialPath::ProjectRoots { subpath } => {
render_special_path_with_subpath(":workspace_roots", subpath)
}
FileSystemSpecialPath::Tmpdir => ":tmpdir".to_string(),
FileSystemSpecialPath::SlashTmp => ":slash_tmp".to_string(),
FileSystemSpecialPath::Unknown { path, subpath } => {
render_special_path_with_subpath(path, subpath)
}
}
}
fn render_special_path_with_subpath(base: &str, subpath: &Option<PathBuf>) -> String {
match subpath {
Some(subpath) => format!("{base}/{}", subpath.display()),
None => base.to_string(),
}
}
fn dedupe_file_system_entries(entries: &mut Vec<FileSystemSandboxEntry>) {
let mut seen = HashSet::new();
entries.retain(|entry| seen.insert(entry.clone()));
}
fn push_text_element(rendered: &mut String, name: &str, value: &str) {
rendered.push_str(&format!("<{name}>"));
push_xml_escaped_text(rendered, value);
rendered.push_str(&format!("</{name}>"));
}
pub(super) fn push_xml_escaped_text(rendered: &mut String, value: &str) {
for ch in value.chars() {
match ch {
'&' => rendered.push_str("&amp;"),
'<' => rendered.push_str("&lt;"),
'>' => rendered.push_str("&gt;"),
'"' => rendered.push_str("&quot;"),
'\'' => rendered.push_str("&apos;"),
_ => rendered.push(ch),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct NetworkContext {
allowed_domains: Vec<String>,
denied_domains: Vec<String>,
}
impl NetworkContext {
pub(crate) fn new(allowed_domains: Vec<String>, denied_domains: Vec<String>) -> Self {
Self {
allowed_domains,
denied_domains,
}
}
pub(super) fn render(&self) -> String {
let mut rendered = "<network enabled=\"true\">".to_string();
Self::push_rendered_domain_element(&mut rendered, "allowed", &self.allowed_domains);
Self::push_rendered_domain_element(&mut rendered, "denied", &self.denied_domains);
rendered.push_str("</network>");
rendered
}
fn push_rendered_domain_element(rendered_network: &mut String, name: &str, domains: &[String]) {
if domains.is_empty() {
return;
}
rendered_network.push_str(&format!("<{name}>"));
rendered_network.push_str(&domains.join(","));
rendered_network.push_str(&format!("</{name}>"));
}
}
@@ -8,34 +8,31 @@ use serde_json::json;
#[test]
fn renders_full_environment_state() -> Result<()> {
let mut context = EnvironmentContext::new(
vec![
EnvironmentContextEnvironment {
id: "laptop".to_string(),
cwd: PathUri::parse("file:///repo")?,
shell: "zsh".to_string(),
},
EnvironmentContextEnvironment {
id: "devbox".to_string(),
cwd: PathUri::parse("file:///workspace")?,
shell: "bash".to_string(),
},
],
Some("2026-06-20".to_string()),
Some("America/Los_Angeles".to_string()),
Some(NetworkContext::new(
let context = EnvironmentsState {
environments: [
("laptop".to_string(), available("file:///repo", "zsh")?),
(
"devbox".to_string(),
available("file:///workspace", "bash")?,
),
]
.into_iter()
.collect(),
current_date: Some("2026-06-20".to_string()),
timezone: Some("America/Los_Angeles".to_string()),
network: Some(NetworkContext::new(
vec!["api.example.com".to_string()],
vec!["blocked.example.com".to_string()],
)),
Some("task_1: running\ntask_2: completed".to_string()),
);
context.filesystem = Some(FileSystemContext::from_permission_profile(
&PermissionProfile::Disabled,
&[],
));
filesystem: Some(FileSystemContext::from_permission_profile(
&PermissionProfile::Disabled,
&[],
)),
subagents: Some("task_1: running\ntask_2: completed".to_string()),
};
let mut world_state = WorldState::default();
world_state.add_section(EnvironmentsState::from_environment_context(&context));
world_state.add_section(context);
assert_eq!(
vec![user_message(
@@ -153,6 +150,25 @@ fn unchanged_environments_do_not_render_a_diff() -> Result<()> {
Ok(())
}
#[test]
fn single_environment_diff_ignores_id_and_shell() -> Result<()> {
let previous = EnvironmentsState {
environments: [(String::new(), available("file:///repo", "bash")?)]
.into_iter()
.collect(),
..Default::default()
};
let current = EnvironmentsState {
environments: [("local".to_string(), available("file:///repo", "zsh")?)]
.into_iter()
.collect(),
..Default::default()
};
assert_eq!(None, current.render_diff(&previous));
Ok(())
}
#[test]
fn loaded_environment_state_produces_no_diff_with_live_state() -> Result<()> {
let live_state = EnvironmentsState {
+3 -1
View File
@@ -1,7 +1,7 @@
mod environment;
mod environment_support;
use codex_protocol::models::ResponseItem;
use environment::EnvironmentsState;
use indexmap::IndexMap;
use serde::Serialize;
use serde::de::DeserializeOwned;
@@ -10,6 +10,8 @@ use std::any::Any;
use std::any::TypeId;
use std::collections::BTreeMap;
pub(crate) use environment::EnvironmentsState;
trait ErasedWorldStateSection: Send + Sync {
fn as_any(&self) -> &dyn Any;
@@ -5,33 +5,47 @@ use serde::Deserialize;
use serde::Serialize;
use serde_json::json;
macro_rules! mock_section {
($type:ident, $name:literal) => {
#[derive(Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub(super) struct $type {
value: String,
}
impl $type {
fn new(value: &str) -> Self {
Self {
value: value.to_string(),
}
}
}
impl WorldStateSection for $type {
const NAME: &'static str = $name;
fn render_diff(&self, _previous: &Self) -> Option<ResponseItem> {
None
}
}
};
#[derive(Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub(super) struct AlphaSection {
value: String,
}
mock_section!(AlphaSection, "alpha");
mock_section!(BetaSection, "beta");
impl AlphaSection {
fn new(value: &str) -> Self {
Self {
value: value.to_string(),
}
}
}
impl WorldStateSection for AlphaSection {
const NAME: &'static str = "alpha";
fn render_diff(&self, _previous: &Self) -> Option<ResponseItem> {
None
}
}
#[derive(Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub(super) struct BetaSection {
value: String,
}
impl BetaSection {
fn new(value: &str) -> Self {
Self {
value: value.to_string(),
}
}
}
impl WorldStateSection for BetaSection {
const NAME: &'static str = "beta";
fn render_diff(&self, _previous: &Self) -> Option<ResponseItem> {
None
}
}
#[test]
fn full_json_reloads_registered_sections() -> Result<()> {
+4 -14
View File
@@ -1,6 +1,6 @@
use crate::context::CollaborationModeInstructions;
use crate::context::ContextualUserFragment;
use crate::context::EnvironmentContext;
use crate::context::EnvironmentsState;
use crate::context::ModelSwitchInstructions;
use crate::context::MultiAgentModeInstructions;
use crate::context::PermissionsInstructions;
@@ -10,7 +10,6 @@ use crate::context::RealtimeStartInstructions;
use crate::context::RealtimeStartWithInstructions;
use crate::session::PreviousTurnSettings;
use crate::session::turn_context::TurnContext;
use crate::shell::Shell;
use codex_execpolicy::Policy;
use codex_features::Feature;
use codex_protocol::config_types::MultiAgentMode;
@@ -23,22 +22,14 @@ use codex_protocol::protocol::TurnContextItem;
fn build_environment_update_item(
previous: Option<&TurnContextItem>,
next: &TurnContext,
shell: &Shell,
) -> Option<ResponseItem> {
if !next.config.include_environment_context {
return None;
}
let prev = previous?;
let prev_context = EnvironmentContext::from_turn_context_item(prev, shell.name().to_string());
let next_context = EnvironmentContext::from_turn_context(next, shell);
if prev_context.equals_except_shell(&next_context) {
return None;
}
Some(ContextualUserFragment::into(
EnvironmentContext::diff_from_turn_context_item(prev, &next_context),
))
let previous = EnvironmentsState::from_turn_context_item(prev);
EnvironmentsState::from_turn_context(next).render_diff(&previous)
}
fn build_permissions_update_item(
@@ -242,7 +233,6 @@ pub(crate) fn build_settings_update_items(
previous: Option<&TurnContextItem>,
previous_turn_settings: Option<&PreviousTurnSettings>,
next: &TurnContext,
shell: &Shell,
exec_policy: &Policy,
personality_feature_enabled: bool,
) -> Vec<ResponseItem> {
@@ -250,7 +240,7 @@ pub(crate) fn build_settings_update_items(
// model-visible item emitted by build_initial_context. Persist the remaining
// inputs or add explicit replay events so fork/resume can diff everything
// deterministically.
let contextual_user_message = build_environment_update_item(previous, next, shell);
let contextual_user_message = build_environment_update_item(previous, next);
let developer_update_sections = [
// Keep model-switch instructions first so model-specific guidance is read before
// any other context diffs on this turn.
+2 -5
View File
@@ -1668,19 +1668,17 @@ impl Session {
) -> Vec<ResponseItem> {
// TODO: Make context updates a pure diff of persisted previous/current TurnContextItem
// state so replay/backtracking is deterministic. Runtime inputs that affect model-visible
// context (shell, exec policy, feature gates, previous-turn bridge) should be persisted
// context (exec policy, feature gates, previous-turn bridge) should be persisted
// state or explicit non-state replay events.
let previous_turn_settings = {
let state = self.state.lock().await;
state.previous_turn_settings()
};
let shell = self.user_shell();
let exec_policy = self.services.exec_policy.current();
crate::context_manager::updates::build_settings_update_items(
reference_context_item,
previous_turn_settings.as_ref(),
current_context,
shell.as_ref(),
exec_policy.as_ref(),
self.features.enabled(Feature::Personality),
)
@@ -3217,14 +3215,13 @@ impl Session {
);
}
if turn_context.config.include_environment_context {
let shell = self.user_shell();
let subagents = self
.services
.agent_control
.format_environment_context_subagents(self.thread_id)
.await;
contextual_user_sections.push(
crate::context::EnvironmentContext::from_turn_context(turn_context, shell.as_ref())
crate::context::EnvironmentsState::from_turn_context(turn_context)
.with_subagents(subagents)
.render(),
);
+19 -109
View File
@@ -7326,7 +7326,7 @@ async fn spawn_task_does_not_update_previous_turn_settings_for_non_run_turn_task
}
#[tokio::test]
async fn build_settings_update_items_emits_environment_item_for_network_changes() {
async fn build_settings_update_items_emits_environment_item_for_cwd_changes() {
let (session, previous_context) = make_session_and_context().await;
let previous_context = Arc::new(previous_context);
let mut current_context = previous_context
@@ -7335,43 +7335,14 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes(
&session.services.models_manager,
)
.await;
let mut config = (*current_context.config).clone();
let mut requirements = config.config_layer_stack.requirements().clone();
requirements.network = Some(Sourced::new(
NetworkConstraints {
domains: Some(NetworkDomainPermissionsToml {
entries: std::collections::BTreeMap::from([
(
"api.example.com".to_string(),
NetworkDomainPermissionToml::Allow,
),
(
"blocked.example.com".to_string(),
NetworkDomainPermissionToml::Deny,
),
]),
}),
..Default::default()
},
RequirementSource::LegacyManagedConfigTomlFromMdm,
));
let layers = config
.config_layer_stack
.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ true,
)
.into_iter()
.cloned()
.collect();
config.config_layer_stack = ConfigLayerStack::new(
layers,
requirements,
config.config_layer_stack.requirements_toml().clone(),
)
.expect("rebuild config layer stack with network requirements");
current_context.config = Arc::new(config);
let cwd = test_path_buf("/new-repo").abs();
let environment = current_context.environments.turn_environments[0].clone();
current_context.environments.turn_environments[0] = TurnEnvironment::new(
environment.environment_id,
environment.environment,
PathUri::from_abs_path(&cwd),
environment.shell,
);
let reference_context_item = previous_context.to_turn_context_item();
let update_items = session
@@ -7382,78 +7353,11 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes(
.into_iter()
.find(|text| text.contains("<environment_context>"))
.expect("environment update item should be emitted");
assert!(environment_update.contains(
"<network enabled=\"true\"><allowed>api.example.com</allowed><denied>blocked.example.com</denied></network>"
));
}
#[tokio::test]
async fn environment_context_uses_session_shell_when_environment_shell_is_absent() {
let (mut session, mut turn_context) = make_session_and_context().await;
session.services.user_shell = Arc::new(crate::shell::Shell {
shell_type: crate::shell::ShellType::PowerShell,
shell_path: PathBuf::from("powershell"),
});
for environment in &mut turn_context.environments.turn_environments {
environment.shell = None;
}
let session_shell = session.user_shell();
let environment_context = crate::context::EnvironmentContext::from_turn_context(
&turn_context,
session_shell.as_ref(),
)
.render();
assert!(
environment_context.contains("<shell>powershell</shell>"),
"{environment_context}"
environment_update.contains(&format!("<cwd>{}</cwd>", cwd.display())),
"{environment_update}"
);
let primary_environment = turn_context
.environments
.turn_environments
.first_mut()
.expect("primary environment");
primary_environment.shell = Some(crate::shell::Shell {
shell_type: crate::shell::ShellType::Cmd,
shell_path: PathBuf::from("cmd"),
});
let environment_context = crate::context::EnvironmentContext::from_turn_context(
&turn_context,
session_shell.as_ref(),
)
.render();
assert!(
environment_context.contains("<shell>cmd</shell>"),
"{environment_context}"
);
}
#[tokio::test]
async fn build_settings_update_items_emits_environment_item_for_time_changes() {
let (session, previous_context) = make_session_and_context().await;
let previous_context = Arc::new(previous_context);
let mut current_context = previous_context
.with_model(
previous_context.model_info.slug.clone(),
&session.services.models_manager,
)
.await;
current_context.current_date = Some("2026-02-27".to_string());
current_context.timezone = Some("Europe/Berlin".to_string());
let reference_context_item = previous_context.to_turn_context_item();
let update_items = session
.build_settings_update_items(Some(&reference_context_item), &current_context)
.await;
let environment_update = user_input_texts(&update_items)
.into_iter()
.find(|text| text.contains("<environment_context>"))
.expect("environment update item should be emitted");
assert!(environment_update.contains("<current_date>2026-02-27</current_date>"));
assert!(environment_update.contains("<timezone>Europe/Berlin</timezone>"));
assert!(!environment_update.contains("<environments>"));
}
#[tokio::test]
@@ -7469,7 +7373,13 @@ async fn build_settings_update_items_omits_environment_item_when_disabled() {
let mut config = (*current_context.config).clone();
config.include_environment_context = false;
current_context.config = Arc::new(config);
current_context.current_date = Some("2026-02-27".to_string());
let environment = current_context.environments.turn_environments[0].clone();
current_context.environments.turn_environments[0] = TurnEnvironment::new(
environment.environment_id,
environment.environment,
PathUri::from_abs_path(&test_path_buf("/new-repo").abs()),
environment.shell,
);
let reference_context_item = previous_context.to_turn_context_item();
let update_items = session