mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +00:00
migrate environment context to world state
This commit is contained in:
@@ -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("&"),
|
||||
'<' => rendered.push_str("<"),
|
||||
'>' => rendered.push_str(">"),
|
||||
'"' => rendered.push_str("""),
|
||||
'\'' => rendered.push_str("'"),
|
||||
_ => 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;
|
||||
@@ -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;
|
||||
|
||||
+76
-143
@@ -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("&"),
|
||||
'<' => rendered.push_str("<"),
|
||||
'>' => rendered.push_str(">"),
|
||||
'"' => rendered.push_str("""),
|
||||
'\'' => rendered.push_str("'"),
|
||||
_ => 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 {
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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), ¤t_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
|
||||
|
||||
Reference in New Issue
Block a user