Describe context and permissions per environment behind a feature flag (#61494)

## Why

Selected environments can have different filesystem and network permissions, but context previously described only the primary environment's rules.

## What changed

- Add the disabled-by-default `per_environment_context` feature.
- Group filesystem and network access under each environment ID, including when only one environment is selected. Label shared network requirements separately.
- Scope permission instructions and incremental updates to each environment. Withdraw stale descriptions and use labeled thread defaults when no environment is ready.
- Track approved command prefixes separately so new approvals do not require repeating unchanged permission instructions.

## Testing

Extend rendering and permission snapshots for scoped context, removed scopes, thread defaults, and format transitions. Update the active-turn scenario to cover two environments with different permissions, changing only the second environment's workspace and permissions and restoring its defaults on the next turn.

GitOrigin-RevId: c948089c12da7b15eee378d8c5a23a233d24502b
This commit is contained in:
sayan-oai
2026-11-14 09:43:57 +00:00
committed by copyberry
parent d659419fe7
commit b1573f1024
12 changed files with 673 additions and 119 deletions
+6
View File
@@ -1247,6 +1247,9 @@
"output_token_replay": {
"type": "boolean"
},
"per_environment_context": {
"type": "boolean"
},
"personality": {
"type": "boolean"
},
@@ -8423,6 +8426,9 @@
"output_token_replay": {
"type": "boolean"
},
"per_environment_context": {
"type": "boolean"
},
"personality": {
"type": "boolean"
},
@@ -17,6 +17,7 @@ use codex_protocol::AgentPath;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ContentItemKind;
use codex_protocol::models::input_text;
use codex_protocol::protocol::NetworkAccess;
use codex_utils_path_uri::PathUri;
use serde::Deserialize;
use serde::Serialize;
@@ -36,6 +37,8 @@ static POWERSHELL_VERSIONS: LazyLock<Mutex<BTreeMap<PathBuf, Option<String>>>> =
#[derive(Clone, Debug, Default)]
pub(crate) struct EnvironmentsState {
environments: BTreeMap<String, EnvironmentState>,
// Keep each environment named and its rules grouped together, even when there is only one.
per_environment: bool,
shell_version: Option<String>,
current_date: Option<String>,
timezone: Option<String>,
@@ -50,6 +53,10 @@ impl EnvironmentsState {
environments: &TurnEnvironmentSnapshot,
current_date: Option<String>,
) -> Self {
let per_environment = turn_context
.config
.features
.enabled(Feature::PerEnvironmentContext);
let shell_version = if turn_context
.config
.features
@@ -63,7 +70,8 @@ impl EnvironmentsState {
None
};
Self {
environments: environment_states(environments),
environments: environment_states(environments, per_environment),
per_environment,
shell_version,
current_date,
timezone: turn_context
@@ -71,12 +79,15 @@ impl EnvironmentsState {
.clone()
.filter(|_| turn_context.config.include_environment_context_time),
network: network_from_turn_context(turn_context),
filesystem: environments.primary().map(|environment| {
FileSystemContext::from_permission_profile(
environment.permission_profile(),
environment.workspace_roots(),
)
}),
filesystem: environments
.primary()
.filter(|_| !per_environment)
.map(|environment| {
FileSystemContext::from_permission_profile(
environment.permission_profile(),
environment.workspace_roots(),
)
}),
subagents: None,
}
}
@@ -88,6 +99,18 @@ impl EnvironmentsState {
self
}
fn rendered_network(&self) -> Option<String> {
self.network.as_ref().map(|network| {
let network = network.render();
if self.per_environment {
// This shared list does not include each environment's own domain restrictions.
format!("<thread_network_requirements>{network}</thread_network_requirements>")
} else {
network
}
})
}
fn rendered_full(&self) -> RenderedEnvironments {
RenderedEnvironments {
updates: self
@@ -97,14 +120,14 @@ impl EnvironmentsState {
(id.clone(), EnvironmentUpdate::Current(environment.clone()))
})
.collect(),
legacy_single: is_legacy_single(&self.environments),
legacy_single: !self.per_environment && is_legacy_single(&self.environments),
include_primary: self.environments.len() > 1,
shell_version: self.shell_version.clone(),
shell_version_removed: false,
current_date: self.current_date.clone(),
current_date_removed: false,
timezone: self.timezone.clone(),
network: self.network.clone(),
network: self.rendered_network(),
filesystem: self.filesystem.clone(),
subagents: self.subagents.clone(),
}
@@ -120,6 +143,7 @@ impl WorldStateSection for EnvironmentsState {
previous: PreviousSectionState<'_, Self::Snapshot>,
) -> SectionTransition<Self::Snapshot> {
let current = EnvironmentsSnapshot {
per_environment: self.per_environment,
environments: self
.environments
.iter()
@@ -132,6 +156,8 @@ impl WorldStateSection for EnvironmentsState {
error: environment.error.clone(),
shell: environment.shell.clone(),
is_primary: self.environments.len() > 1 && environment.is_primary,
filesystem: environment.filesystem.clone(),
network_access: environment.network_access,
},
)
})
@@ -139,7 +165,7 @@ impl WorldStateSection for EnvironmentsState {
shell_version: self.shell_version.clone(),
current_date: self.current_date.clone(),
timezone: self.timezone.clone(),
network: self.network.as_ref().map(NetworkContext::render),
network: self.rendered_network(),
filesystem: self.filesystem.as_ref().map(FileSystemContext::render),
subagents: self.subagents.clone(),
};
@@ -150,7 +176,9 @@ impl WorldStateSection for EnvironmentsState {
};
let shell_version_added =
current.shell_version.is_some() && previous.shell_version.is_none();
let turn_context_values_changed = current.shell_version != previous.shell_version
let format_changed = current.per_environment != previous.per_environment;
let turn_context_values_changed = format_changed
|| current.shell_version != previous.shell_version
|| current.current_date != previous.current_date
|| current.timezone != previous.timezone
|| current.network != previous.network
@@ -163,7 +191,8 @@ impl WorldStateSection for EnvironmentsState {
.filter(|(id, _)| {
let environment = &current.environments[*id];
previous.environments.get(*id).is_none_or(|previous| {
multiple_environments != previous_multiple_environments
format_changed
|| multiple_environments != previous_multiple_environments
|| (shell_version_added && previous.shell.is_none())
|| !environment.has_same_diff_value(previous)
})
@@ -177,7 +206,8 @@ impl WorldStateSection for EnvironmentsState {
.filter(|id| !self.environments.contains_key(*id))
.map(|id| (id.clone(), EnvironmentUpdate::Unavailable)),
);
let legacy_single = is_legacy_single(&self.environments)
let legacy_single = !self.per_environment
&& is_legacy_single(&self.environments)
&& updates
.values()
.all(|update| matches!(update, EnvironmentUpdate::Current(_)));
@@ -193,7 +223,7 @@ impl WorldStateSection for EnvironmentsState {
current_date_removed: self.current_date.is_none()
&& previous.current_date.is_some(),
timezone: self.timezone.clone(),
network: self.network.clone(),
network: self.rendered_network(),
filesystem: self.filesystem.clone(),
subagents: self.subagents.clone(),
});
@@ -237,7 +267,7 @@ pub(crate) struct RenderedEnvironments {
current_date: Option<String>,
current_date_removed: bool,
timezone: Option<String>,
network: Option<NetworkContext>,
network: Option<String>,
filesystem: Option<FileSystemContext>,
pub(crate) subagents: Option<String>,
}
@@ -312,7 +342,7 @@ impl ContextualUserFragment for RenderedEnvironments {
push_optional_element(&mut rendered, "timezone", self.timezone.as_deref());
if let Some(network) = &self.network {
rendered.push_str(" ");
rendered.push_str(&network.render());
rendered.push_str(network);
rendered.push('\n');
}
if let Some(filesystem) = &self.filesystem {
@@ -358,6 +388,15 @@ fn push_environment_values(rendered: &mut String, environment: &EnvironmentState
push_xml_escaped_text(rendered, shell);
rendered.push_str("</shell>\n");
}
if let Some(access) = environment.network_access {
rendered.push_str(indent);
rendered.push_str(&format!("<network access=\"{access}\" />\n"));
}
if let Some(filesystem) = &environment.filesystem {
rendered.push_str(indent);
rendered.push_str(filesystem);
rendered.push('\n');
}
}
fn push_optional_element(rendered: &mut String, name: &str, value: Option<&str>) {
@@ -380,11 +419,15 @@ struct EnvironmentState {
shell: Option<String>,
error: Option<String>,
is_primary: bool,
filesystem: Option<String>,
network_access: Option<NetworkAccess>,
}
#[derive(Default, Deserialize, Serialize)]
pub(crate) struct EnvironmentsSnapshot {
environments: BTreeMap<String, EnvironmentSnapshot>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
per_environment: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
shell_version: Option<String>,
current_date: Option<String>,
@@ -403,6 +446,10 @@ struct EnvironmentSnapshot {
error: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
is_primary: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
filesystem: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
network_access: Option<NetworkAccess>,
}
impl EnvironmentSnapshot {
@@ -411,6 +458,8 @@ impl EnvironmentSnapshot {
&& self.status == other.status
&& self.error == other.error
&& self.is_primary == other.is_primary
&& self.filesystem == other.filesystem
&& self.network_access == other.network_access
&& self
.shell
.as_ref()
@@ -467,7 +516,10 @@ async fn powershell_version(shell_path: &Path) -> Option<String> {
version
}
fn environment_states(snapshot: &TurnEnvironmentSnapshot) -> BTreeMap<String, EnvironmentState> {
fn environment_states(
snapshot: &TurnEnvironmentSnapshot,
per_environment: bool,
) -> BTreeMap<String, EnvironmentState> {
let mut environments = snapshot
.turn_environments()
.enumerate()
@@ -483,6 +535,25 @@ fn environment_states(snapshot: &TurnEnvironmentSnapshot) -> BTreeMap<String, En
.as_ref()
.map(|shell| shell.name().to_string()),
is_primary: index == 0,
// These are this captured environment's rules, not the primary's defaults.
filesystem: per_environment.then(|| {
FileSystemContext::from_permission_profile(
environment.permission_profile(),
environment.workspace_roots(),
)
.render()
}),
network_access: per_environment.then(|| {
if environment
.permission_profile()
.network_sandbox_policy()
.is_enabled()
{
NetworkAccess::Enabled
} else {
NetworkAccess::Restricted
}
}),
},
)
})
@@ -496,6 +567,8 @@ fn environment_states(snapshot: &TurnEnvironmentSnapshot) -> BTreeMap<String, En
error: None,
shell: None,
is_primary: false,
filesystem: None,
network_access: None,
});
}
// Bound the additional model context even when many selected environments fail.
@@ -519,6 +592,8 @@ fn environment_states(snapshot: &TurnEnvironmentSnapshot) -> BTreeMap<String, En
shell: None,
error: (!detail.is_empty()).then_some(detail),
is_primary: false,
filesystem: None,
network_access: None,
},
);
}
@@ -39,6 +39,8 @@ fn environment(id: &str, cwd: PathUri, shell: impl Into<String>) -> (String, Env
error: None,
shell: Some(shell.into()),
is_primary: false,
filesystem: None,
network_access: None,
},
)
}
@@ -60,6 +62,7 @@ fn environment_state(
.collect();
EnvironmentsState {
environments,
per_environment: false,
shell_version: None,
current_date,
timezone,
@@ -110,17 +113,28 @@ fn serialize_environment_context_with_foreign_windows_cwd() {
/*network*/ None,
/*subagents*/ None,
);
context.filesystem = Some(FileSystemContext::from_permission_profile(
&PermissionProfile::Disabled,
&[PathUri::parse("file:///D:/workspace").expect("Windows workspace root URI")],
));
context.per_environment = true;
let environment = context.environments.get_mut("remote").unwrap();
environment.network_access = Some(NetworkAccess::Enabled);
environment.filesystem = Some(
FileSystemContext::from_permission_profile(
&PermissionProfile::Disabled,
&[PathUri::parse("file:///D:/workspace").expect("Windows workspace root URI")],
)
.render(),
);
assert_eq!(
context.render().expect_text(),
r#"<environment_context>
<cwd>C:\windows</cwd>
<shell>powershell</shell>
<filesystem><workspace_roots><root>D:\workspace</root></workspace_roots><permission_profile type="disabled"><file_system type="unrestricted" /></permission_profile></filesystem>
<environments>
<environment id="remote">
<cwd>C:\windows</cwd>
<shell>powershell</shell>
<network access="enabled" />
<filesystem><workspace_roots><root>D:\workspace</root></workspace_roots><permission_profile type="disabled"><file_system type="unrestricted" /></permission_profile></filesystem>
</environment>
</environments>
</environment_context>"#
);
}
@@ -110,6 +110,8 @@ fn snapshots() -> Result<()> {
error: None,
shell: None,
is_primary: false,
filesystem: None,
network_access: None,
},
)]
.into_iter()
@@ -340,6 +342,8 @@ fn available(cwd: &str, shell: &str) -> Result<EnvironmentState> {
error: None,
shell: Some(shell.to_string()),
is_primary: false,
filesystem: None,
network_access: None,
})
}
@@ -357,6 +361,8 @@ fn starting(cwd: &str) -> Result<EnvironmentState> {
error: None,
shell: None,
is_primary: false,
filesystem: None,
network_access: None,
})
}
@@ -371,6 +377,8 @@ fn failure_context_is_escaped_incremental_and_cleared_on_recovery() -> Result<()
error: Some("Repository <empty> & unavailable".to_string()),
shell: None,
is_primary: false,
filesystem: None,
network_access: None,
},
)]
.into_iter()
@@ -440,7 +448,7 @@ fn failure_context_limits_total_detail_bytes_at_utf8_boundaries() {
})
.collect(),
};
let states = environment_states(&snapshot);
let states = environment_states(&snapshot, /*per_environment*/ false);
let details: Vec<_> = states
.values()
.map(|state| state.error.as_deref())
@@ -61,6 +61,7 @@ pub(crate) use model::ModelInstructionsState;
pub(crate) use model_catalog::ModelCatalogState;
pub(crate) use multi_agent_mode::MultiAgentModeState;
pub(crate) use multi_agent_usage_hint::MultiAgentUsageHintState;
pub(crate) use permissions::PermissionDescription;
pub(crate) use permissions::PermissionsState;
pub(crate) use persistent_mode::PersistentModeState;
pub(crate) use plugins_instructions::PluginsInstructionsState;
@@ -2,20 +2,27 @@
use super::PreviousSectionState;
use super::SectionTransition;
use super::WorldStateContextFragment;
use super::WorldStateHash;
use super::WorldStateSection;
use super::WorldStateUpdate;
use crate::context::ApprovedCommandPrefixSaved;
use crate::context::ContextualUserFragment;
use crate::context::PermissionsInstructions;
use crate::context::environment_context::push_xml_escaped_text;
use codex_execpolicy::Policy;
use codex_extension_api::RenderedWorldStateFragment;
use codex_prompts::ApprovalPromptContext;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ContentItemKind;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::format_allow_prefixes;
use codex_protocol::models::input_text;
use codex_protocol::permissions::FileSystemSandboxPolicyContext;
use codex_protocol::protocol::AskForApproval;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::path::Path;
@@ -24,12 +31,26 @@ use std::path::Path;
pub(crate) struct PermissionsState {
saved_prefix_heading: Option<String>,
snapshot: PermissionsSnapshot,
// None is unscoped text: the legacy description or the thread-default fallback.
descriptions: BTreeMap<Option<String>, PermissionDescription>,
}
/// One permission description and its comparison value, excluding saved prefixes.
#[derive(Clone, Debug)]
pub(crate) struct PermissionDescription {
instructions: PermissionsInstructions,
fingerprint: WorldStateHash,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub(crate) enum PermissionsSnapshot {
Scoped {
environments: BTreeMap<String, WorldStateHash>,
#[serde(default, skip_serializing_if = "Option::is_none")]
thread_defaults: Option<WorldStateHash>,
approved_command_prefixes: BTreeSet<Vec<String>>,
},
Current {
instructions: WorldStateHash,
approved_command_prefixes: BTreeSet<Vec<String>>,
@@ -37,7 +58,7 @@ pub(crate) enum PermissionsSnapshot {
Legacy(WorldStateHash),
}
impl PermissionsState {
impl PermissionDescription {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
permission_profile: &PermissionProfile,
@@ -63,16 +84,46 @@ impl PermissionsState {
};
let instructions = build_instructions(exec_policy);
let instructions_without_approved_prefixes = build_instructions(&Policy::empty());
let snapshot = PermissionsSnapshot::Current {
instructions: WorldStateHash::from_fragment(&instructions_without_approved_prefixes),
approved_command_prefixes: exec_policy.get_allowed_prefixes().into_iter().collect(),
Self {
instructions,
fingerprint: WorldStateHash::from_fragment(&instructions_without_approved_prefixes),
}
}
}
impl PermissionsState {
pub(crate) fn new(
descriptions: BTreeMap<Option<String>, PermissionDescription>,
exec_policy: &Policy,
approval_context: ApprovalPromptContext<'_>,
per_environment: bool,
) -> Self {
let approved_command_prefixes = exec_policy.get_allowed_prefixes().into_iter().collect();
let snapshot = if per_environment {
PermissionsSnapshot::Scoped {
environments: descriptions
.iter()
.filter_map(|(id, description)| {
Some((id.clone()?, description.fingerprint.clone()))
})
.collect(),
thread_defaults: descriptions
.get(&None)
.map(|description| description.fingerprint.clone()),
approved_command_prefixes,
}
} else {
PermissionsSnapshot::Current {
instructions: descriptions[&None].fingerprint.clone(),
approved_command_prefixes,
}
};
Self {
saved_prefix_heading: approval_context
.approved_command_prefix_saved_heading()
.map(str::to_owned),
snapshot,
instructions,
descriptions,
}
}
}
@@ -97,7 +148,95 @@ impl WorldStateSection for PermissionsState {
&self,
previous: PreviousSectionState<'_, Self::Snapshot>,
) -> SectionTransition<Self::Snapshot> {
if let PreviousSectionState::Known(previous) = &previous
&& *previous == &self.snapshot
{
return (None, Vec::new());
}
let current = self.snapshot.clone();
if let PermissionsSnapshot::Scoped {
approved_command_prefixes,
..
} = &self.snapshot
{
let mut updates = Vec::new();
let mut previous_descriptions = BTreeMap::new();
let mut previous_prefixes = None;
if let PreviousSectionState::Known(PermissionsSnapshot::Scoped {
environments,
thread_defaults,
approved_command_prefixes,
}) = previous
{
previous_descriptions.extend(
environments
.iter()
.map(|(id, hash)| (Some(id.clone()), hash)),
);
previous_descriptions.extend(thread_defaults.as_ref().map(|hash| (None, hash)));
previous_prefixes = Some(approved_command_prefixes);
}
// An environment can remain selected but stop being ready. Withdraw its old
// description even when the separate environment-context section is disabled.
for id in previous_descriptions
.keys()
.filter(|id| !self.descriptions.contains_key(*id))
{
updates.push(scoped_permissions_update(
id.as_deref(),
/*instructions*/ None,
));
}
// Switching models can make saved command approvals stop applying.
// We track these separately from each environment's other rules.
// Additions get a short notice; removals require the full current rules.
let prefixes_removed = previous_prefixes
.is_some_and(|prefixes| !prefixes.is_subset(approved_command_prefixes));
for (id, description) in &self.descriptions {
if prefixes_removed
|| previous_descriptions.get(id).copied() != Some(&description.fingerprint)
{
updates.push(scoped_permissions_update(
id.as_deref(),
Some(&description.instructions),
));
}
}
// Saved prefixes apply to every environment, including those whose own
// description did not change. A removal instead refreshes the full text.
if !prefixes_removed
&& let Some(previous_prefixes) = previous_prefixes
&& previous_prefixes != approved_command_prefixes
{
let added = approved_command_prefixes
.difference(previous_prefixes)
.cloned()
.collect();
if let Some(prefixes) = format_allow_prefixes(added) {
updates.push(WorldStateUpdate::fragment(ApprovedCommandPrefixSaved::new(
prefixes,
self.saved_prefix_heading.as_deref(),
)));
}
}
return (Some(current), updates);
}
let instructions = &self.descriptions[&None].instructions;
if let PreviousSectionState::Known(PermissionsSnapshot::Scoped {
environments,
thread_defaults,
..
}) = &previous
{
let mut updates = environments
.keys()
.map(|id| Some(id.as_str()))
.chain(thread_defaults.as_ref().map(|_| None))
.map(|id| scoped_permissions_update(id, /*instructions*/ None))
.collect::<Vec<_>>();
updates.push(WorldStateUpdate::fragment(instructions.clone()));
return (Some(current), updates);
}
match (previous, &self.snapshot) {
(
PreviousSectionState::Known(PermissionsSnapshot::Current {
@@ -109,9 +248,6 @@ impl WorldStateSection for PermissionsState {
approved_command_prefixes,
},
) if previous_instructions == instructions => {
if previous_prefixes == approved_command_prefixes {
return (None, Vec::new());
}
if previous_prefixes.is_subset(approved_command_prefixes) {
let added_prefixes = approved_command_prefixes
.difference(previous_prefixes)
@@ -131,7 +267,7 @@ impl WorldStateSection for PermissionsState {
(
PreviousSectionState::Known(PermissionsSnapshot::Legacy(previous)),
PermissionsSnapshot::Current { .. },
) if previous == &WorldStateHash::from_fragment(&self.instructions) => {
) if previous == &WorldStateHash::from_fragment(instructions) => {
return (Some(current), Vec::new());
}
_ => {}
@@ -139,11 +275,46 @@ impl WorldStateSection for PermissionsState {
(
Some(current),
vec![WorldStateUpdate::fragment(self.instructions.clone())],
vec![WorldStateUpdate::fragment(instructions.clone())],
)
}
}
/// Labels an environment's rules, or thread defaults when no environment ID is supplied.
/// Missing instructions withdraw the previous description for that scope.
fn scoped_permissions_update(
environment_id: Option<&str>,
instructions: Option<&PermissionsInstructions>,
) -> WorldStateUpdate {
let mut text = String::new();
let end = if let Some(id) = environment_id {
text.push_str("<environment id=\"");
push_xml_escaped_text(&mut text, id);
text.push_str("\">\n");
"</environment>"
} else {
text.push_str("<thread_defaults>\n");
"</thread_defaults>"
};
if let Some(instructions) = instructions {
let ContentItem::InputText { text: body } = instructions.body() else {
unreachable!("permission instructions are text")
};
text.push_str(&body);
} else {
text.push_str("The previous permission description for this scope is no longer current.\n");
}
text.push_str(end);
WorldStateUpdate::fragment(WorldStateContextFragment {
fragment: RenderedWorldStateFragment::new(
"developer",
PermissionsInstructions::type_markers(),
input_text(text),
),
content_kind: ContentItemKind("permissions.instructions".to_string()),
})
}
#[cfg(test)]
#[path = "permissions_tests.rs"]
mod tests;
@@ -26,6 +26,32 @@ fn snapshots() {
let read_only = permissions_state(PermissionProfile::read_only(), AskForApproval::OnRequest);
let full_access = permissions_state(PermissionProfile::Disabled, AskForApproval::OnRequest);
let never_ask = permissions_state(PermissionProfile::read_only(), AskForApproval::Never);
let approval_context =
ApprovalPromptContext::new(ApprovalsReviewer::User, ResolvedModelMessages::bundled());
let scoped = PermissionsState::new(
BTreeMap::from([
(Some("A".to_string()), read_only.descriptions[&None].clone()),
(
Some("B".to_string()),
full_access.descriptions[&None].clone(),
),
]),
&Policy::empty(),
approval_context,
/*per_environment*/ true,
);
let only_a = PermissionsState::new(
BTreeMap::from([(Some("A".to_string()), read_only.descriptions[&None].clone())]),
&Policy::empty(),
approval_context,
/*per_environment*/ true,
);
let fallback = PermissionsState::new(
read_only.descriptions.clone(),
&Policy::empty(),
approval_context,
/*per_environment*/ true,
);
insta::assert_snapshot!(render_section_cases(&[
(Absent, Absent),
@@ -34,6 +60,11 @@ fn snapshots() {
(Known(&read_only), Known(&full_access)),
(Known(&read_only), Known(&never_ask)),
(Unknown, Known(&read_only)),
(Known(&read_only), Known(&only_a)),
(Known(&scoped), Known(&only_a)),
(Known(&only_a), Known(&only_a)),
(Known(&only_a), Known(&fallback)),
(Known(&only_a), Known(&read_only)),
]));
}
@@ -68,18 +99,24 @@ fn approved_prefix_is_rendered_without_reinjecting_permissions() {
assert_ne!(without_snapshot, with_snapshot);
assert_ne!(
without_approved_prefix.instructions.body().expect_text(),
with_approved_prefix.instructions.body().expect_text()
without_approved_prefix.descriptions[&None]
.instructions
.body()
.expect_text(),
with_approved_prefix.descriptions[&None]
.instructions
.body()
.expect_text()
);
assert!(
!without_approved_prefix
!without_approved_prefix.descriptions[&None]
.instructions
.body()
.expect_text()
.contains(approved_prefix)
);
assert!(
with_approved_prefix
with_approved_prefix.descriptions[&None]
.instructions
.body()
.expect_text()
@@ -92,7 +129,8 @@ fn approved_prefix_is_rendered_without_reinjecting_permissions() {
assert!(!rendered_update.contains("<permissions instructions>"));
let before_approval_permissions =
permissions_state(PermissionProfile::read_only(), AskForApproval::OnRequest)
permissions_state(PermissionProfile::read_only(), AskForApproval::OnRequest).descriptions
[&None]
.instructions
.render()
.expect_text();
@@ -109,15 +147,26 @@ fn approved_prefix_is_rendered_without_reinjecting_permissions() {
fn renders_only_newly_approved_prefixes() {
use PreviousSectionState::Known;
let scoped = |policy: &Policy| {
let mut legacy = permissions_state_with_default_messages(policy);
let description = legacy.descriptions.remove(&None).unwrap();
PermissionsState::new(
BTreeMap::from([(Some("A".to_string()), description)]),
policy,
ApprovalPromptContext::new(ApprovalsReviewer::User, ResolvedModelMessages::bundled()),
/*per_environment*/ true,
)
};
let mut exec_policy = Policy::empty();
exec_policy
.add_prefix_rule(&["git".to_string(), "pull".to_string()], Decision::Allow)
.expect("test prefix should be valid");
let with_existing_prefix = permissions_state_with_default_messages(&exec_policy);
let with_existing_prefix = scoped(&exec_policy);
exec_policy
.add_prefix_rule(&["cargo".to_string(), "test".to_string()], Decision::Allow)
.expect("test prefix should be valid");
let with_new_prefix = permissions_state_with_default_messages(&exec_policy);
let with_new_prefix = scoped(&exec_policy);
let existing_snapshot = with_existing_prefix
.render_fragment_diff(PreviousSectionState::Absent)
.0
@@ -156,15 +205,22 @@ fn legacy_snapshot_deserializes_and_only_suppresses_matching_full_permissions()
.expect("test prefix should be valid");
let with_approved_prefix = permissions_state_with_default_messages(&exec_policy);
let matching_legacy: WorldStateSnapshot = serde_json::from_value(json!({
"permissions": WorldStateHash::from_fragment(&with_approved_prefix.instructions),
"permissions": WorldStateHash::from_fragment(&with_approved_prefix.descriptions[&None].instructions),
}))
.expect("legacy world-state snapshot should deserialize");
let stale_legacy: WorldStateSnapshot = serde_json::from_value(json!({
"permissions": WorldStateHash::from_fragment(&without_approved_prefix.instructions),
"permissions": WorldStateHash::from_fragment(&without_approved_prefix.descriptions[&None].instructions),
}))
.expect("legacy world-state snapshot should deserialize");
let expected_permissions = with_approved_prefix.instructions.render().expect_text();
let retained = ContextualUserFragment::into(with_approved_prefix.instructions.clone());
let expected_permissions = with_approved_prefix.descriptions[&None]
.instructions
.render()
.expect_text();
let retained = ContextualUserFragment::into(
with_approved_prefix.descriptions[&None]
.instructions
.clone(),
);
let mut world_state = WorldState::default();
world_state.add_section(with_approved_prefix);
@@ -210,14 +266,17 @@ fn removing_an_approved_prefix_renders_full_permissions() {
assert_eq!(
rendered,
without_approved_prefix.instructions.render().expect_text()
without_approved_prefix.descriptions[&None]
.instructions
.render()
.expect_text()
);
}
#[test]
fn persisted_permissions_are_detected_inside_bundled_developer_messages() {
let state = permissions_state(PermissionProfile::read_only(), AskForApproval::OnRequest);
let retained = ContextualUserFragment::into(state.instructions.clone());
let retained = ContextualUserFragment::into(state.descriptions[&None].instructions.clone());
let mut world_state = super::super::WorldState::default();
world_state.add_section(state);
let snapshot = world_state.render_full().0;
@@ -280,28 +339,42 @@ fn permissions_state(
..Default::default()
});
let model_messages = ResolvedModelMessages::from_model(&model);
PermissionsState::new(
let approval_context = ApprovalPromptContext::new(ApprovalsReviewer::User, model_messages);
let description = PermissionDescription::new(
&permission_profile,
approval_policy,
ApprovalPromptContext::new(ApprovalsReviewer::User, model_messages),
approval_context,
&Policy::empty(),
Path::new("/workspace"),
/*paths*/ None,
/*exec_permission_approvals_enabled*/ false,
/*request_permissions_tool_enabled*/ false,
);
PermissionsState::new(
BTreeMap::from([(None, description)]),
&Policy::empty(),
approval_context,
/*per_environment*/ false,
)
}
fn permissions_state_with_default_messages(exec_policy: &Policy) -> PermissionsState {
let model_messages = ResolvedModelMessages::bundled();
PermissionsState::new(
let approval_context = ApprovalPromptContext::new(ApprovalsReviewer::User, model_messages);
let description = PermissionDescription::new(
&PermissionProfile::read_only(),
AskForApproval::OnRequest,
ApprovalPromptContext::new(ApprovalsReviewer::User, model_messages),
approval_context,
exec_policy,
Path::new("/workspace"),
/*paths*/ None,
/*exec_permission_approvals_enabled*/ false,
/*request_permissions_tool_enabled*/ false,
);
PermissionsState::new(
BTreeMap::from([(None, description)]),
exec_policy,
approval_context,
/*per_environment*/ false,
)
}
@@ -1,6 +1,6 @@
---
source: core/src/context/world_state/permissions_tests.rs
expression: "render_section_cases(&[(Absent, Absent), (Absent, Known(&read_only)),\n(Known(&read_only), Known(&read_only)),\n(Known(&read_only), Known(&full_access)),\n(Known(&read_only), Known(&never_ask)), (Unknown, Known(&read_only)),])"
expression: "render_section_cases(&[(Absent, Absent), (Absent, Known(&read_only)),\n(Known(&read_only), Known(&read_only)),\n(Known(&read_only), Known(&full_access)),\n(Known(&read_only), Known(&never_ask)), (Unknown, Known(&read_only)),\n(Known(&read_only), Known(&only_a)), (Known(&scoped), Known(&only_a)),\n(Known(&only_a), Known(&only_a)), (Known(&only_a), Known(&fallback)),\n(Known(&only_a), Known(&read_only)),])"
---
Absent -> Absent
None
@@ -31,3 +31,39 @@ Unknown -> {"approved_command_prefixes":[],"instructions":"0ccde536dd8b4cfebb1df
Read only.
Ask for approval.
</permissions instructions>
{"approved_command_prefixes":[],"instructions":"0ccde536dd8b4cfebb1df573f679dfe86b5718e3"} -> {"approved_command_prefixes":[],"environments":{"A":"0ccde536dd8b4cfebb1df573f679dfe86b5718e3"}} (role - developer)
<permissions instructions><environment id="A">
Read only.
Ask for approval.
</environment></permissions instructions>
{"approved_command_prefixes":[],"environments":{"A":"0ccde536dd8b4cfebb1df573f679dfe86b5718e3","B":"7e17d9a0df61e5f0c032ed7f0bf1dda58c9d5e85"}} -> {"approved_command_prefixes":[],"environments":{"A":"0ccde536dd8b4cfebb1df573f679dfe86b5718e3"}} (role - developer)
<permissions instructions><environment id="B">
The previous permission description for this scope is no longer current.
</environment></permissions instructions>
{"approved_command_prefixes":[],"environments":{"A":"0ccde536dd8b4cfebb1df573f679dfe86b5718e3"}} -> {"approved_command_prefixes":[],"environments":{"A":"0ccde536dd8b4cfebb1df573f679dfe86b5718e3"}}
None
{"approved_command_prefixes":[],"environments":{"A":"0ccde536dd8b4cfebb1df573f679dfe86b5718e3"}} -> {"approved_command_prefixes":[],"environments":{},"thread_defaults":"0ccde536dd8b4cfebb1df573f679dfe86b5718e3"} (role - developer)
<permissions instructions><environment id="A">
The previous permission description for this scope is no longer current.
</environment></permissions instructions>
(role - developer)
<permissions instructions><thread_defaults>
Read only.
Ask for approval.
</thread_defaults></permissions instructions>
{"approved_command_prefixes":[],"environments":{"A":"0ccde536dd8b4cfebb1df573f679dfe86b5718e3"}} -> {"approved_command_prefixes":[],"instructions":"0ccde536dd8b4cfebb1df573f679dfe86b5718e3"} (role - developer)
<permissions instructions><environment id="A">
The previous permission description for this scope is no longer current.
</environment></permissions instructions>
(role - developer)
<permissions instructions>
Read only.
Ask for approval.
</permissions instructions>
+65 -28
View File
@@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use super::session::Session;
@@ -19,6 +20,7 @@ use crate::context::world_state::ModelCatalogState;
use crate::context::world_state::ModelInstructionsState;
use crate::context::world_state::MultiAgentModeState;
use crate::context::world_state::MultiAgentUsageHintState;
use crate::context::world_state::PermissionDescription;
use crate::context::world_state::PermissionsState;
use crate::context::world_state::PersistentModeState;
use crate::context::world_state::PluginsInstructionsState;
@@ -267,36 +269,71 @@ impl Session {
let approval_context =
ApprovalPromptContext::new(settings.approvals_reviewer(), model_messages);
if turn_context.config.include_permissions_instructions {
let environment = step_context.environments.primary();
let sandbox = environment
.filter(|environment| environment.environment().is_remote())
.map(|environment| {
environment.sandbox_context(/*additional_permissions*/ None)
});
let paths = sandbox
.as_ref()
.map(FileSystemSandboxContext::policy_context);
let permission_profile =
turn_context.permission_profile_for_environments(&step_context.environments);
#[allow(deprecated)]
let cwd = environment
.and_then(|environment| environment.cwd().to_abs_path().ok())
.unwrap_or_else(|| turn_context.cwd.clone());
let per_environment = turn_context
.config
.features
.enabled(Feature::PerEnvironmentContext);
let mut environments = step_context
.environments
.turn_environments()
.map(Some)
.collect::<Vec<_>>();
if !per_environment {
environments.truncate(1);
}
// Until a computer is ready, describe the thread defaults without assigning
// those permissions to a computer whose configuration is still unknown.
if environments.is_empty() {
environments.push(None);
}
let mut descriptions = BTreeMap::new();
for environment in environments {
let sandbox = environment
.filter(|environment| per_environment || environment.environment().is_remote())
.map(|environment| {
environment.sandbox_context(/*additional_permissions*/ None)
});
let paths = sandbox
.as_ref()
.map(FileSystemSandboxContext::policy_context);
let permission_profile = environment.map_or_else(
|| turn_context.permission_profile_for_environments(&step_context.environments),
super::turn_context::TurnEnvironment::permission_profile_with_workspace_roots,
);
#[allow(deprecated)]
let cwd = environment
.and_then(|environment| environment.cwd().to_abs_path().ok())
.unwrap_or_else(|| turn_context.cwd.clone());
let id = if per_environment {
environment.map(|environment| environment.selection.environment_id.clone())
} else {
None
};
descriptions.insert(
id,
PermissionDescription::new(
&permission_profile,
settings.approval_policy(),
approval_context,
exec_policy.as_ref(),
&cwd,
paths.as_ref(),
turn_context
.config
.features
.enabled(Feature::ExecPermissionApprovals),
turn_context
.config
.features
.enabled(Feature::RequestPermissionsTool),
),
);
}
world_state.add_section(PermissionsState::new(
&permission_profile,
settings.approval_policy(),
approval_context,
descriptions,
exec_policy.as_ref(),
&cwd,
paths.as_ref(),
turn_context
.config
.features
.enabled(Feature::ExecPermissionApprovals),
turn_context
.config
.features
.enabled(Feature::RequestPermissionsTool),
approval_context,
per_environment,
));
} else {
world_state.add_section(CompactPermissionsState::new(
+66 -15
View File
@@ -57,12 +57,15 @@ use codex_protocol::items::TurnItem;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ImageDetail;
use codex_protocol::models::ImageReference;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::PermissionProfileSnapshot;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::CodeModeToolMessages;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ToolMessage;
use codex_protocol::openai_models::ToolMode;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AdditionalContextEntry;
use codex_protocol::protocol::AdditionalContextKind;
use codex_protocol::protocol::EnvironmentConfigState;
@@ -70,7 +73,9 @@ use codex_protocol::protocol::EventMsg;
#[cfg(not(target_os = "windows"))]
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::protocol::TurnEnvironmentSelections;
use codex_protocol::protocol::TurnSettingsUpdate;
use codex_protocol::protocol::TurnSettingsUpdateOutcome;
use codex_protocol::request_user_input::RequestUserInputAnswer;
@@ -85,6 +90,7 @@ use codex_utils_path_uri::PathUri;
use core_test_support::context_snapshot;
use core_test_support::context_snapshot::ContextSnapshotOptions;
use core_test_support::context_snapshot::SnapshotEntry;
use core_test_support::exec_server::ExecServerProcess;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_completed_with_tokens;
@@ -1177,15 +1183,48 @@ async fn astra_switches_environments_for_the_rest_of_the_active_turn() -> Result
.features
.enable(Feature::DefaultModeRequestUserInput)
.expect("enable request_user_input");
config
.features
.enable(Feature::PerEnvironmentContext)
.expect("enable per-environment context");
})
.build_with_auto_env(&server)
.await?;
let original = test.executor_environment().selection().clone();
let executor = ExecServerProcess::start().await?;
let first_workspace = TempDir::new()?;
test.thread_manager
.environment_manager()
.upsert_environment(
"environment-a".to_string(),
executor.websocket_url.clone(),
/*connect_timeout*/ None,
)?;
let mut first = test.executor_environment().selection().clone();
first.environment_id = "environment-a".to_string();
first.cwd = PathUri::from_host_native_path(first_workspace.path())?;
first.workspace_roots = vec![first.cwd.clone()];
let mut first_config = environment_config_for_selection(&test.config, &first);
first_config.permission_profile =
PermissionProfileSnapshot::legacy(PermissionProfile::read_only());
first.config = EnvironmentConfigState::Ready(first_config);
let mut original = test.executor_environment().selection().clone();
let mut original_config = environment_config_for_selection(&test.config, &original);
original_config.permission_profile =
PermissionProfileSnapshot::legacy(PermissionProfile::workspace_write_with(
&[],
NetworkSandboxPolicy::Enabled,
/*exclude_tmpdir_env_var*/ true,
/*exclude_slash_tmp*/ true,
));
original.config = EnvironmentConfigState::Ready(original_config);
let mut other = original.clone();
other.cwd = original.cwd.join("other-workspace")?;
other.workspace_roots = vec![other.cwd.clone()];
other.config =
EnvironmentConfigState::Ready(environment_config_for_selection(&test.config, &other));
let mut other_config = environment_config_for_selection(&test.config, &other);
other_config.permission_profile =
PermissionProfileSnapshot::legacy(PermissionProfile::read_only());
other.config = EnvironmentConfigState::Ready(other_config);
test.fs()
.create_directory(
&other.cwd,
@@ -1198,9 +1237,21 @@ async fn astra_switches_environments_for_the_rest_of_the_active_turn() -> Result
.await?;
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![text(
"Ask before continuing in the other workspace.",
)]))
.start_or_steer_turn(
TurnInputRequest::user_input(vec![text(
"Ask before continuing in the other workspace.",
)])
.with_thread_settings(ThreadSettingsOverrides {
environments: Some(
TurnEnvironmentSelections::new(
test.config.cwd.clone(),
vec![first.clone(), original],
)
.into_requests(),
),
..Default::default()
}),
)
.await?;
let request = wait_for_event_match(&test.codex, |event| match event {
EventMsg::RequestUserInput(request) => Some(request.clone()),
@@ -1212,7 +1263,7 @@ async fn astra_switches_environments_for_the_rest_of_the_active_turn() -> Result
.submit(Op::TurnSettings {
turn_id: request.turn_id.clone(),
update: TurnSettingsUpdate {
environments: Some(vec![other.clone().into_request()]),
environments: Some(vec![first.into_request(), other.into_request()]),
..Default::default()
},
reply,
@@ -1243,14 +1294,14 @@ async fn astra_switches_environments_for_the_rest_of_the_active_turn() -> Result
.await?;
let requests = mock.requests();
insta::assert_snapshot!(
"astra_active_environment_selection",
context_snapshot::format_request_history_snapshot(
"Astra starts in one workspace, switches for the next step of the active turn, and returns to the original workspace on the next turn.",
&requests,
&ContextSnapshotOptions::default(),
)
);
let snapshot = context_snapshot::format_request_history_snapshot(
"Astra sees two environments with different permissions. Only the second environment's workspace and permissions change for the next step; the original defaults return on the next turn.",
&requests,
&ContextSnapshotOptions::default(),
)
.replace(r#"<environment id="local""#, r#"<environment id="environment-b""#)
.replace(r#"<environment id="remote""#, r#"<environment id="environment-b""#);
insta::assert_snapshot!("astra_active_environment_selection", snapshot);
Ok(())
}
@@ -1,13 +1,13 @@
---
source: core/tests/suite/scenarios.rs
expression: "context_snapshot::format_request_history_snapshot(\"Astra starts in one workspace, switches for the next step of the active turn, and returns to the original workspace on the next turn.\",\n&requests, &ContextSnapshotOptions::default(),)"
expression: snapshot
---
Scenario: Astra starts in one workspace, switches for the next step of the active turn, and returns to the original workspace on the next turn.
Scenario: Astra sees two environments with different permissions. Only the second environment's workspace and permissions change for the next step; the original defaults return on the next turn.
## Window 1
-- request 1 (turn) --
00:additional_tools/developer (3; hash=73b17e628bb8aee1):
- namespace/functions; hash=7c1b7203dcaa5b6f
00:additional_tools/developer (3; hash=0cc901506b5f741d):
- namespace/functions; hash=8543a3220444e2bf
- custom/exec
- function/wait
- function/request_user_input
@@ -39,16 +39,16 @@ Scenario: Astra starts in one workspace, switches for the next step of the activ
- Relevance: Determine what a plugin can help with from explicit user mention or from the plugin-associa..., MCP tools, and apps exposed elsewhere in this turn. [hash=620459a4e7b590f0]
- Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for t..., say so briefly and continue with the best fallback. [hash=746b0469a8f6cb3e]
02:message/developer[2]:
[01] <permissions instructions>
02:message/developer[3]:
[01] <permissions instructions><environment id="environment-a">
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The san... permits reading files. Network access is restricted. [hash=be7da4425f89053c]
# Escalation Requests
Commands are run outside the sandbox if they are approved by the user, or match an existing rule that al...hell control operators, including but not limited to: [hash=d24ebf5fa4e4b9bb]
- Pipes: |
- Logical operators: &&, ||
<OMITTED 44 LINES; ~790 TOKENS; hash=3a8900343e968d61>
<OMITTED 45 LINES; ~797 TOKENS; hash=a1abe5164ddd4caa>
NEVER provide a prefix_rule if your command uses a heredoc or herestring.
### Examples
@@ -56,8 +56,25 @@ Scenario: Astra starts in one workspace, switches for the next step of the activ
- ["npm", "run", "dev"]
- ["gh", "pr", "check"]
- ["cargo", "test"]
</permissions instructions>
[02] <collaboration_mode># Collaboration Mode: Default
</environment></permissions instructions>
[02] <permissions instructions><environment id="environment-b">
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `workspace-write`: T...ctories requires approval. Network access is enabled. [hash=4bea877c3f99797a]
# Escalation Requests
Commands are run outside the sandbox if they are approved by the user, or match an existing rule that al...hell control operators, including but not limited to: [hash=d24ebf5fa4e4b9bb]
- Pipes: |
<OMITTED 46 LINES; ~815 TOKENS; hash=4c54d779374ab213>
### Examples
Good examples of prefixes:
- ["npm", "run", "dev"]
- ["gh", "pr", "check"]
- ["cargo", "test"]
The writable root is `<CWD 2>`.
</environment></permissions instructions>
[03] <collaboration_mode># Collaboration Mode: Default
You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active.
@@ -95,11 +112,22 @@ Scenario: Astra starts in one workspace, switches for the next step of the activ
<multi_agent_mode>Any earlier instruction enabling proactive multi-agent delegation no longer applies. D...elegation, or parallel agent work.</multi_agent_mode> [hash=2b6e037067b4390c]
05:message/user:
<environment_context>
<cwd><CWD></cwd>
<shell><HOST_SHELL></shell>
<environments>
<environment id="environment-a" primary="true">
<cwd><CWD></cwd>
<shell><HOST_SHELL></shell>
<network access="restricted" />
<filesystem><workspace_roots><root><CWD></root></workspace_roots><permission_profile type="managed...ntry></file_system></permission_profile></filesystem> [hash=63cf3068a193704d]
</environment>
<environment id="environment-b" primary="false">
<cwd><CWD 2></cwd>
<shell><HOST_SHELL></shell>
<network access="enabled" />
<filesystem><workspace_roots><root><CWD 2></root></workspace_roots><permission_profile type="manag...ntry></file_system></permission_profile></filesystem> [hash=0c8916c7f2106891]
</environment>
</environments>
<current_date><CURRENT_DATE></current_date>
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><CWD></root></workspace_roots><permission_profile type="managed"><f...ntry></file_system></permission_profile></filesystem> [hash=e8c1de60307d0a3d]
</environment_context>
06:message/user:
Ask before continuing in the other workspace.
@@ -107,24 +135,70 @@ Scenario: Astra starts in one workspace, switches for the next step of the activ
-- request 2 (turn) --
08:function_call/functions.request_user_input:{"questions":[{"header":"Continue","id":"continue","options":[{"description":"Continue working.","label"...o"}],"question":"Continue in the other workspace?"}]} [hash=028e565351844d96]
09:function_call_output:{"answers":{"continue":{"answers":["Yes"]}}}
10:message/user:
10:message/developer:
<permissions instructions><environment id="environment-b">
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The san... permits reading files. Network access is restricted. [hash=be7da4425f89053c]
# Escalation Requests
Commands are run outside the sandbox if they are approved by the user, or match an existing rule that al...hell control operators, including but not limited to: [hash=d24ebf5fa4e4b9bb]
- Pipes: |
<OMITTED 45 LINES; ~797 TOKENS; hash=a1abe5164ddd4caa>
NEVER provide a prefix_rule if your command uses a heredoc or herestring.
### Examples
Good examples of prefixes:
- ["npm", "run", "dev"]
- ["gh", "pr", "check"]
- ["cargo", "test"]
</environment></permissions instructions>
11:message/user:
<environment_context>
<cwd><CWD>/other-workspace</cwd>
<shell><HOST_SHELL></shell>
<environments>
<environment id="environment-b" primary="false">
<cwd><CWD 2>/other-workspace</cwd>
<shell><HOST_SHELL></shell>
<network access="restricted" />
<filesystem><workspace_roots><root><CWD 2>/other-workspace</root></workspace_roots><permission_pro...ntry></file_system></permission_profile></filesystem> [hash=018d5ca32808e0f8]
</environment>
</environments>
<current_date><CURRENT_DATE></current_date>
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><CWD>/other-workspace</root></workspace_roots><permission_profile t...ntry></file_system></permission_profile></filesystem> [hash=0be8191a0451377a]
</environment_context>
-- request 3 (turn) --
11:message/assistant:
12:message/assistant:
I continued in the other workspace.
12:message/user:
13:message/developer:
<permissions instructions><environment id="environment-b">
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `workspace-write`: T...ctories requires approval. Network access is enabled. [hash=4bea877c3f99797a]
# Escalation Requests
Commands are run outside the sandbox if they are approved by the user, or match an existing rule that al...hell control operators, including but not limited to: [hash=d24ebf5fa4e4b9bb]
- Pipes: |
<OMITTED 46 LINES; ~815 TOKENS; hash=4c54d779374ab213>
### Examples
Good examples of prefixes:
- ["npm", "run", "dev"]
- ["gh", "pr", "check"]
- ["cargo", "test"]
The writable root is `<CWD 2>`.
</environment></permissions instructions>
14:message/user:
<environment_context>
<cwd><CWD></cwd>
<shell><HOST_SHELL></shell>
<environments>
<environment id="environment-b" primary="false">
<cwd><CWD 2></cwd>
<shell><HOST_SHELL></shell>
<network access="enabled" />
<filesystem><workspace_roots><root><CWD 2></root></workspace_roots><permission_profile type="manag...ntry></file_system></permission_profile></filesystem> [hash=0c8916c7f2106891]
</environment>
</environments>
<current_date><CURRENT_DATE></current_date>
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><CWD></root></workspace_roots><permission_profile type="managed"><f...ntry></file_system></permission_profile></filesystem> [hash=e8c1de60307d0a3d]
</environment_context>
13:message/user:
15:message/user:
Which workspace are you using now?
+8
View File
@@ -214,6 +214,8 @@ pub enum Feature {
ShellSnapshotV2,
/// Allow turns to start while selected executors are still starting.
DeferredExecutor,
/// Describe each selected environment's filesystem and permissions separately.
PerEnvironmentContext,
/// Use the current working directory for turn diff display paths.
CwdRelativeTurnDiffs,
/// Enable runtime metrics snapshots via a manual reader.
@@ -1276,6 +1278,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::PerEnvironmentContext,
key: "per_environment_context",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::CwdRelativeTurnDiffs,
key: "cwd_relative_turn_diffs",