mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
Scope environment readiness config to thread attachments (#38067)
## Why Threads that share an executor environment can require different capability roots and login-shell policies. Treating readiness configuration as executor-wide can expose one thread's settings to another. ## What changed - Add `CodexThread::environment_ready` and `EnvironmentConfig` so hosts can install capability roots and login-shell policy for an exact thread, environment, and working-directory attachment. - Validate attachment readiness updates, merge ready attachment roots with persisted thread roots, and refresh MCP capabilities when those roots change. - Preserve installed environment policy across thread settings updates and child-thread inheritance while resetting it when the selected working directory changes. ## Testing - Cover attachment config inheritance and reset behavior. - Verify that threads sharing an executor retain independent model-visible capability roots and login-shell tool schemas across updates. GitOrigin-RevId: 60e7a296b3e41b835b0413aa2837451e8a55c230
This commit is contained in:
@@ -27,6 +27,7 @@ pub use codex_config::types::TuiPetAnchor;
|
||||
pub use codex_config::types::UriBasedFileOpener;
|
||||
pub use codex_core::CodexAppsToolsCache;
|
||||
pub use codex_core::CodexThread;
|
||||
pub use codex_core::EnvironmentConfig;
|
||||
pub use codex_core::ForkSnapshot;
|
||||
pub use codex_core::LoadedAgentsMd;
|
||||
pub use codex_core::McpManager;
|
||||
@@ -88,6 +89,8 @@ pub use codex_model_provider_info::built_in_model_providers;
|
||||
pub use codex_models_manager::manager::RefreshStrategy;
|
||||
pub use codex_models_manager::manager::SharedModelsManager;
|
||||
pub use codex_protocol::ThreadId;
|
||||
pub use codex_protocol::capabilities::CapabilityRootLocation;
|
||||
pub use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||
pub use codex_protocol::config_types::AltScreenMode;
|
||||
pub use codex_protocol::config_types::ApprovalsReviewer;
|
||||
pub use codex_protocol::config_types::AutoCompactTokenLimitScope;
|
||||
|
||||
@@ -345,6 +345,7 @@ fn resolved_local_environments<const N: usize>(
|
||||
permission_profile: PermissionProfileSnapshot::legacy(
|
||||
PermissionProfile::read_only(),
|
||||
),
|
||||
selected_capability_roots: None,
|
||||
},
|
||||
))
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::config::ConstraintResult;
|
||||
use crate::elicitation::ElicitationRegistration;
|
||||
use crate::environment_config::EnvironmentConfig;
|
||||
use crate::session::SessionIo;
|
||||
use crate::session::SessionSettingsUpdate;
|
||||
use crate::session::SteerInputError;
|
||||
@@ -766,13 +767,18 @@ impl CodexThread {
|
||||
self.session.thread_environment_selections().await
|
||||
}
|
||||
|
||||
/// Installs resolved environment configuration and capability roots on this thread.
|
||||
pub async fn environment_ready(
|
||||
&self,
|
||||
selection: &TurnEnvironmentSelection,
|
||||
config: EnvironmentConfig,
|
||||
) -> CodexResult<()> {
|
||||
self.session.environment_ready(selection, config).await
|
||||
}
|
||||
|
||||
/// Passively inspects the selected capability roots whose environments are ready now.
|
||||
pub fn inspect_selected_capability_roots(&self) -> SelectedCapabilityRootsStatus {
|
||||
self.session
|
||||
.services
|
||||
.turn_environments
|
||||
.environment_manager()
|
||||
.inspect_selected_capability_roots(&self.session.services.selected_capability_roots)
|
||||
self.session.inspect_selected_capability_roots()
|
||||
}
|
||||
|
||||
pub async fn read_mcp_resource(
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||
|
||||
/// Resolved configuration supplied by the owner of a thread/environment attachment.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct EnvironmentConfig {
|
||||
/// Whether shell tools may start login shells in this environment.
|
||||
pub allow_login_shell: bool,
|
||||
/// Capability roots selected for this thread's environment attachment.
|
||||
pub selected_capability_roots: Vec<SelectedCapabilityRoot>,
|
||||
}
|
||||
@@ -11,6 +11,11 @@ use codex_exec_server::EnvironmentConnectionState;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_exec_server::ExecServerError;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_exec_server::SelectedCapabilityRootsStatus;
|
||||
use codex_protocol::capabilities::CapabilityRootLocation;
|
||||
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
use codex_protocol::protocol::EnvironmentConnectionEvent;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -22,6 +27,7 @@ use futures::future::BoxFuture;
|
||||
use futures::future::Shared;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
use crate::environment_config::EnvironmentConfig;
|
||||
use crate::session::turn_context::ShellSnapshotTask;
|
||||
use crate::session::turn_context::TurnEnvironment;
|
||||
use crate::session::turn_context::TurnEnvironmentConfig;
|
||||
@@ -59,7 +65,6 @@ struct ResolvedEnvironment {
|
||||
#[derive(Clone)]
|
||||
struct SelectedTurnEnvironment {
|
||||
selection: TurnEnvironmentSelection,
|
||||
// Temporary: copied from thread settings until environments supply their own config.
|
||||
config: TurnEnvironmentConfig,
|
||||
environment: Arc<Environment>,
|
||||
// Selection clones share one listener; the final handle drop aborts it.
|
||||
@@ -74,6 +79,21 @@ pub(crate) struct StartingTurnEnvironment {
|
||||
resolution: TurnEnvironmentResolution,
|
||||
}
|
||||
|
||||
impl SelectedTurnEnvironment {
|
||||
fn has_installed_environment_config(&self) -> bool {
|
||||
self.config.selected_capability_roots.is_some()
|
||||
}
|
||||
|
||||
// Thread settings still own permissions, but cannot overwrite shell policy
|
||||
// that was installed specifically for this environment attachment.
|
||||
fn update_thread_config(&mut self, config: &TurnEnvironmentConfig) {
|
||||
if !self.has_installed_environment_config() {
|
||||
self.config.allow_login_shell = config.allow_login_shell;
|
||||
}
|
||||
self.config.permission_profile = config.permission_profile.clone();
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for StartingTurnEnvironment {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
@@ -118,6 +138,7 @@ impl ThreadEnvironments {
|
||||
};
|
||||
let selection = environment.selection();
|
||||
let selected_environment = Arc::clone(&environment.environment);
|
||||
let inherited_config = environment.config;
|
||||
let resolution: TurnEnvironmentResolution =
|
||||
futures::future::ready(Ok(ResolvedEnvironment {
|
||||
environment: environment.environment,
|
||||
@@ -126,14 +147,17 @@ impl ThreadEnvironments {
|
||||
}))
|
||||
.boxed()
|
||||
.shared();
|
||||
Some(SelectedTurnEnvironment {
|
||||
let mut inherited_environment = SelectedTurnEnvironment {
|
||||
selection,
|
||||
// Child threads can have different environment config from their parent.
|
||||
config: thread_environment_config.clone(),
|
||||
config: inherited_config,
|
||||
environment: selected_environment,
|
||||
connection_events_task: None,
|
||||
resolution,
|
||||
})
|
||||
};
|
||||
// Child threads get their own settings, but inherit any
|
||||
// environment-owned policy that was already installed.
|
||||
inherited_environment.update_thread_config(&thread_environment_config);
|
||||
Some(inherited_environment)
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
@@ -164,7 +188,7 @@ impl ThreadEnvironments {
|
||||
&& !matches!(environment.resolution.clone().now_or_never(), Some(Err(_)))
|
||||
{
|
||||
let mut environment = environment.clone();
|
||||
environment.config = thread_environment_config.clone();
|
||||
environment.update_thread_config(thread_environment_config);
|
||||
next.push(environment);
|
||||
continue;
|
||||
}
|
||||
@@ -234,13 +258,68 @@ impl ThreadEnvironments {
|
||||
.iter()
|
||||
.map(|environment| {
|
||||
let mut environment = environment.clone();
|
||||
environment.config = config.clone();
|
||||
environment.update_thread_config(config);
|
||||
environment
|
||||
})
|
||||
.collect();
|
||||
self.environments.store(Arc::new(environments));
|
||||
}
|
||||
|
||||
/// Installs owner-provided config and roots on their exact thread attachment.
|
||||
/// Additional environment-owned settings should be applied to its config here.
|
||||
pub(crate) fn environment_ready(
|
||||
&self,
|
||||
selection: &TurnEnvironmentSelection,
|
||||
config: EnvironmentConfig,
|
||||
) -> CodexResult<()> {
|
||||
let mut environments = Vec::clone(&self.environments.load());
|
||||
let Some(environment) = environments.iter_mut().find(|environment| {
|
||||
environment.selection.environment_id == selection.environment_id
|
||||
&& environment.selection.cwd == selection.cwd
|
||||
}) else {
|
||||
return Err(CodexErr::InvalidRequest(format!(
|
||||
"environment `{}` is not selected on this thread with the requested cwd",
|
||||
selection.environment_id
|
||||
)));
|
||||
};
|
||||
|
||||
environment.config.allow_login_shell = config.allow_login_shell;
|
||||
environment.config.selected_capability_roots = Some(config.selected_capability_roots);
|
||||
self.environments.store(Arc::new(environments));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Combines persisted thread roots with installed attachment roots, keeping
|
||||
/// thread roots first and hiding attachments that are not ready yet.
|
||||
pub(crate) fn inspect_selected_capability_roots(
|
||||
&self,
|
||||
thread_selected_capability_roots: &[SelectedCapabilityRoot],
|
||||
) -> SelectedCapabilityRootsStatus {
|
||||
let environments = self.environments.load();
|
||||
let mut selected_capability_roots = thread_selected_capability_roots.to_vec();
|
||||
for environment in environments.iter() {
|
||||
if let Some(roots) = &environment.config.selected_capability_roots {
|
||||
selected_capability_roots.extend(roots.iter().cloned());
|
||||
}
|
||||
}
|
||||
let mut seen_root_ids = HashSet::with_capacity(selected_capability_roots.len());
|
||||
selected_capability_roots.retain(|root| seen_root_ids.insert(root.id.clone()));
|
||||
|
||||
let mut status = self
|
||||
.environment_manager
|
||||
.inspect_selected_capability_roots(&selected_capability_roots);
|
||||
status.ready_roots.retain(|root| {
|
||||
let CapabilityRootLocation::Environment { environment_id, .. } = &root.location;
|
||||
environments
|
||||
.iter()
|
||||
.find(|environment| &environment.selection.environment_id == environment_id)
|
||||
.is_none_or(|environment| {
|
||||
matches!(environment.resolution.clone().now_or_never(), Some(Ok(_)))
|
||||
})
|
||||
});
|
||||
status
|
||||
}
|
||||
|
||||
fn spawn_connection_event_listener(
|
||||
environment: &Environment,
|
||||
environment_id: String,
|
||||
@@ -558,6 +637,7 @@ mod tests {
|
||||
TurnEnvironmentConfig {
|
||||
allow_login_shell: true,
|
||||
permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::read_only()),
|
||||
selected_capability_roots: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -725,6 +805,7 @@ url = "ws://127.0.0.1:8765"
|
||||
ActivePermissionProfile::read_only(),
|
||||
vec![cwd.join("profile-root")],
|
||||
),
|
||||
selected_capability_roots: None,
|
||||
};
|
||||
let turn_environments = ThreadEnvironments::new(
|
||||
Arc::new(EnvironmentManager::default_for_tests()),
|
||||
@@ -917,6 +998,7 @@ url = "ws://127.0.0.1:8765"
|
||||
ActivePermissionProfile::read_only(),
|
||||
vec![cwd.join("profile-root")],
|
||||
),
|
||||
selected_capability_roots: None,
|
||||
};
|
||||
let cwd = PathUri::from_abs_path(&cwd);
|
||||
let remote = TurnEnvironmentSelection {
|
||||
@@ -1042,7 +1124,28 @@ url = "ws://127.0.0.1:8765"
|
||||
environments
|
||||
.update_selections(std::slice::from_ref(&selection), &test_environment_config());
|
||||
let failed_resolution = environments.environments.load()[0].resolution.clone();
|
||||
assert!(failed_resolution.clone().await.is_err());
|
||||
let error = failed_resolution
|
||||
.clone()
|
||||
.await
|
||||
.err()
|
||||
.expect("environment should fail to start");
|
||||
let selected_root = SelectedCapabilityRoot {
|
||||
id: "failed-root".to_string(),
|
||||
location: CapabilityRootLocation::Environment {
|
||||
environment_id: selection.environment_id.clone(),
|
||||
path: selection.cwd.clone(),
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
environments.inspect_selected_capability_roots(&[selected_root]),
|
||||
SelectedCapabilityRootsStatus {
|
||||
ready_roots: Vec::new(),
|
||||
warnings: vec![format!(
|
||||
"selected capability environment `{}` is unavailable: {error}",
|
||||
selection.environment_id
|
||||
)],
|
||||
}
|
||||
);
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
@@ -1211,6 +1314,7 @@ url = "ws://127.0.0.1:8765"
|
||||
ActivePermissionProfile::read_only(),
|
||||
vec![cwd.join("child-profile-root")],
|
||||
),
|
||||
selected_capability_roots: None,
|
||||
};
|
||||
let environments = ThreadEnvironments::new(
|
||||
manager,
|
||||
@@ -1231,6 +1335,92 @@ url = "ws://127.0.0.1:8765"
|
||||
assert_eq!(inherited.config, child_config);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn installed_environment_config_is_inherited_and_reset_for_new_cwd() {
|
||||
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
|
||||
let selection = TurnEnvironmentSelection {
|
||||
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
|
||||
cwd: PathUri::from_abs_path(&cwd),
|
||||
workspace_roots: Vec::new(),
|
||||
};
|
||||
let root = |id: &str| SelectedCapabilityRoot {
|
||||
id: id.to_string(),
|
||||
location: CapabilityRootLocation::Environment {
|
||||
environment_id: selection.environment_id.clone(),
|
||||
path: selection.cwd.clone(),
|
||||
},
|
||||
};
|
||||
let manager = Arc::new(EnvironmentManager::default_for_tests());
|
||||
let parent =
|
||||
resolve_turn_environments(Arc::clone(&manager), std::slice::from_ref(&selection)).await;
|
||||
parent
|
||||
.environment_ready(
|
||||
&selection,
|
||||
EnvironmentConfig {
|
||||
allow_login_shell: false,
|
||||
selected_capability_roots: vec![root("parent-root")],
|
||||
},
|
||||
)
|
||||
.expect("install environment config");
|
||||
|
||||
let child_thread_config = TurnEnvironmentConfig {
|
||||
permission_profile: PermissionProfileSnapshot::legacy(
|
||||
PermissionProfile::workspace_write(),
|
||||
),
|
||||
..test_environment_config()
|
||||
};
|
||||
let child = ThreadEnvironments::new(
|
||||
manager,
|
||||
crate::shell::default_user_shell(),
|
||||
child_thread_config.clone(),
|
||||
ShellSnapshot::disabled(),
|
||||
parent.snapshot().await,
|
||||
/*non_blocking_snapshots*/ false,
|
||||
);
|
||||
let child_snapshot = child.snapshot().await;
|
||||
let child_environment = child_snapshot.primary().expect("child environment");
|
||||
|
||||
parent
|
||||
.environment_ready(
|
||||
&selection,
|
||||
EnvironmentConfig {
|
||||
allow_login_shell: false,
|
||||
selected_capability_roots: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("clear parent roots");
|
||||
let cleared_snapshot = parent.snapshot().await;
|
||||
assert_eq!(
|
||||
cleared_snapshot
|
||||
.primary()
|
||||
.expect("environment with cleared roots")
|
||||
.config,
|
||||
TurnEnvironmentConfig {
|
||||
allow_login_shell: false,
|
||||
selected_capability_roots: Some(Vec::new()),
|
||||
..test_environment_config()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
child_environment.config,
|
||||
TurnEnvironmentConfig {
|
||||
allow_login_shell: false,
|
||||
selected_capability_roots: Some(vec![root("parent-root")]),
|
||||
..child_thread_config
|
||||
}
|
||||
);
|
||||
|
||||
let changed_selection = TurnEnvironmentSelection {
|
||||
cwd: PathUri::from_abs_path(&cwd.join("changed")),
|
||||
..selection
|
||||
};
|
||||
let new_thread_config = test_environment_config();
|
||||
parent.update_selections(std::slice::from_ref(&changed_selection), &new_thread_config);
|
||||
let changed_snapshot = parent.snapshot().await;
|
||||
let changed_environment = changed_snapshot.primary().expect("changed environment");
|
||||
assert_eq!(changed_environment.config, new_thread_config);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn single_local_environment_cwd_requires_exactly_one_local_environment() {
|
||||
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
|
||||
|
||||
@@ -32,6 +32,7 @@ pub use codex_thread::CodexThreadSettingsOverrides;
|
||||
pub use codex_thread::ThreadConfigSnapshot;
|
||||
pub use codex_thread::TryStartTurnIfIdleError;
|
||||
pub use codex_thread::TryStartTurnIfIdleRejectionReason;
|
||||
pub use environment_config::EnvironmentConfig;
|
||||
pub use session::turn_context::TurnContext;
|
||||
pub use user_message_admission::UserMessageAdmission;
|
||||
pub use user_message_admission::UserMessageAdmissionError;
|
||||
@@ -46,6 +47,7 @@ pub mod context;
|
||||
mod context_manager;
|
||||
mod current_time;
|
||||
mod elicitation;
|
||||
mod environment_config;
|
||||
mod environment_selection;
|
||||
pub mod exec;
|
||||
pub mod exec_env;
|
||||
|
||||
@@ -1107,6 +1107,7 @@ async fn mcp_sandbox_cwd_uses_matching_server_environment_uri() -> anyhow::Resul
|
||||
.permissions
|
||||
.permission_profile_state()
|
||||
.snapshot(),
|
||||
selected_capability_roots: None,
|
||||
},
|
||||
)));
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use codex_exec_server::MAX_SELECTED_CAPABILITY_ROOTS;
|
||||
use codex_exec_server::SelectedCapabilityRootsStatus;
|
||||
use codex_protocol::capabilities::CapabilityRootLocation;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||
|
||||
use crate::environment_config::EnvironmentConfig;
|
||||
use crate::session::session::Session;
|
||||
|
||||
impl Session {
|
||||
pub(crate) async fn environment_ready(
|
||||
&self,
|
||||
selection: &TurnEnvironmentSelection,
|
||||
config: EnvironmentConfig,
|
||||
) -> CodexResult<()> {
|
||||
if config.selected_capability_roots.len() > MAX_SELECTED_CAPABILITY_ROOTS {
|
||||
return Err(CodexErr::InvalidRequest(format!(
|
||||
"environment readiness contains more than {MAX_SELECTED_CAPABILITY_ROOTS} selected capability roots"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut root_ids = HashSet::with_capacity(config.selected_capability_roots.len());
|
||||
for root in &config.selected_capability_roots {
|
||||
let CapabilityRootLocation::Environment { environment_id, .. } = &root.location;
|
||||
if root.id.trim().is_empty()
|
||||
|| environment_id != &selection.environment_id
|
||||
|| !root_ids.insert(root.id.as_str())
|
||||
{
|
||||
return Err(CodexErr::InvalidRequest(format!(
|
||||
"selected capability roots must have unique non-empty IDs and belong to environment `{}`",
|
||||
selection.environment_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// grab session lock so installation can't race w/ thread settings updates
|
||||
let _state = self.state.lock().await;
|
||||
self.services
|
||||
.turn_environments
|
||||
.environment_ready(selection, config)?;
|
||||
// mark mcp runtime for refresh because available capabilities could've changed
|
||||
self.mark_mcp_runtime_dirty();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Combines this session's persisted roots with ready environment attachments.
|
||||
pub(crate) fn inspect_selected_capability_roots(&self) -> SelectedCapabilityRootsStatus {
|
||||
self.services
|
||||
.turn_environments
|
||||
.inspect_selected_capability_roots(&self.services.selected_capability_roots)
|
||||
}
|
||||
}
|
||||
@@ -442,11 +442,13 @@ impl Session {
|
||||
.selected_capability_roots
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(
|
||||
environments
|
||||
.turn_environments()
|
||||
.flat_map(|environment| environment.environment.selected_capability_roots()),
|
||||
)
|
||||
.chain(environments.turn_environments().flat_map(|environment| {
|
||||
environment
|
||||
.config
|
||||
.selected_capability_roots
|
||||
.clone()
|
||||
.unwrap_or_else(|| environment.environment.selected_capability_roots())
|
||||
}))
|
||||
.enumerate()
|
||||
{
|
||||
if let Some(kept_location) = root_locations_by_id.get(&root.id) {
|
||||
|
||||
@@ -205,6 +205,7 @@ use codex_protocol::exec_output::StreamOutput;
|
||||
|
||||
mod code_mode_warning;
|
||||
pub(crate) mod context_window;
|
||||
mod environment;
|
||||
mod extension_metrics;
|
||||
mod handlers;
|
||||
mod inject;
|
||||
|
||||
@@ -164,6 +164,7 @@ impl SessionConfiguration {
|
||||
.permissions
|
||||
.allow_login_shell,
|
||||
permission_profile: self.permission_profile_state.snapshot(),
|
||||
selected_capability_roots: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use codex_file_system::FileSystemSandboxContext;
|
||||
use codex_model_provider::SharedModelProvider;
|
||||
use codex_protocol::SessionId;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||
use codex_protocol::models::AdditionalPermissionProfile;
|
||||
use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
@@ -34,6 +35,8 @@ pub(crate) type ShellSnapshotTask = Shared<BoxFuture<'static, Option<Arc<ShellSn
|
||||
pub(crate) struct TurnEnvironmentConfig {
|
||||
pub(crate) allow_login_shell: bool,
|
||||
pub(crate) permission_profile: PermissionProfileSnapshot,
|
||||
/// None preserves legacy executor roots; Some, including empty, is owner-installed.
|
||||
pub(crate) selected_capability_roots: Option<Vec<SelectedCapabilityRoot>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -120,6 +120,7 @@ async fn shell_command_handler_to_exec_params_uses_selected_environment() {
|
||||
permission_profile,
|
||||
active_permission_profile.clone(),
|
||||
),
|
||||
selected_capability_roots: None,
|
||||
},
|
||||
);
|
||||
let mut expected_env = create_env(
|
||||
|
||||
@@ -26,6 +26,7 @@ fn test_turn_environment(environment_id: &str) -> crate::session::turn_context::
|
||||
TurnEnvironmentConfig {
|
||||
allow_login_shell: true,
|
||||
permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::read_only()),
|
||||
selected_capability_roots: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ async fn approval_key_uses_path_uri_and_includes_environment_id() {
|
||||
permission_profile: PermissionProfileSnapshot::legacy(
|
||||
PermissionProfile::read_only(),
|
||||
),
|
||||
selected_capability_roots: None,
|
||||
},
|
||||
),
|
||||
shell_type: None,
|
||||
|
||||
@@ -507,6 +507,7 @@ mod tests {
|
||||
permission_profile: PermissionProfileSnapshot::legacy(
|
||||
PermissionProfile::read_only(),
|
||||
),
|
||||
selected_capability_roots: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -919,6 +919,7 @@ async fn zsh_fork_unified_exec_keeps_shell_parameter_when_remote_environment_ava
|
||||
.permissions
|
||||
.permission_profile_state()
|
||||
.snapshot(),
|
||||
selected_capability_roots: None,
|
||||
},
|
||||
),
|
||||
));
|
||||
|
||||
@@ -4,6 +4,8 @@ use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_api::AuthProvider;
|
||||
use codex_config::types::ApprovalsReviewer;
|
||||
use codex_core::EnvironmentConfig;
|
||||
use codex_core::StartThreadOptions;
|
||||
use codex_core::WaitForEnvironmentToolConfig;
|
||||
use codex_core::compact::SUMMARIZATION_PROMPT;
|
||||
use codex_core::config::Config;
|
||||
@@ -22,6 +24,7 @@ use codex_exec_server::REMOTE_ENVIRONMENT_ID;
|
||||
use codex_exec_server::RemoteEnvironmentConfig;
|
||||
use codex_exec_server::RemoveOptions;
|
||||
use codex_extension_api::ContextContributor;
|
||||
use codex_extension_api::ExtensionDataInit;
|
||||
use codex_extension_api::ExtensionFuture;
|
||||
use codex_extension_api::ExtensionRegistryBuilder;
|
||||
use codex_extension_api::RenderedWorldStateFragment;
|
||||
@@ -953,6 +956,164 @@ async fn wait_for_response_request_count(response_mock: &ResponseMock, expected_
|
||||
.expect("timed out waiting for Responses API request");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shared_executor_keeps_ready_capability_roots_scoped_to_each_attachment() -> Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let mut extensions = ExtensionRegistryBuilder::new();
|
||||
extensions.prompt_contributor(Arc::new(ReadyCapabilityRootsTestExtension));
|
||||
let mut builder = test_codex()
|
||||
.with_extensions(Arc::new(extensions.build()))
|
||||
.with_config(|config| {
|
||||
config.use_experimental_unified_exec_tool = true;
|
||||
assert!(config.features.enable(Feature::UnifiedExec).is_ok());
|
||||
});
|
||||
let test = builder.build_with_auto_env(&server).await?;
|
||||
let selection = test
|
||||
.codex
|
||||
.environment_selections()
|
||||
.await
|
||||
.into_iter()
|
||||
.next()
|
||||
.context("thread should select its executor environment")?;
|
||||
let root = |id: &str| SelectedCapabilityRoot {
|
||||
id: id.to_string(),
|
||||
location: CapabilityRootLocation::Environment {
|
||||
environment_id: selection.environment_id.clone(),
|
||||
path: selection.cwd.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
let mut second_thread_init = ExtensionDataInit::new();
|
||||
second_thread_init.insert(vec![root("startup-root")]);
|
||||
let second = test
|
||||
.thread_manager
|
||||
.start_thread(StartThreadOptions {
|
||||
environments: Some(vec![selection.clone()]),
|
||||
thread_extension_init: second_thread_init,
|
||||
..StartThreadOptions::new(test.config.clone())
|
||||
})
|
||||
.await?;
|
||||
|
||||
test.codex
|
||||
.environment_ready(
|
||||
&selection,
|
||||
EnvironmentConfig {
|
||||
allow_login_shell: false,
|
||||
selected_capability_roots: vec![root("first-root")],
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
second
|
||||
.thread
|
||||
.environment_ready(
|
||||
&selection,
|
||||
EnvironmentConfig {
|
||||
allow_login_shell: true,
|
||||
selected_capability_roots: vec![root("startup-root"), root("second-root")],
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
test.codex.inspect_selected_capability_roots().ready_roots,
|
||||
vec![root("first-root")]
|
||||
);
|
||||
assert_eq!(
|
||||
second
|
||||
.thread
|
||||
.inspect_selected_capability_roots()
|
||||
.ready_roots,
|
||||
vec![root("startup-root"), root("second-root")]
|
||||
);
|
||||
|
||||
let response_mock = mount_sse_sequence(
|
||||
&server,
|
||||
["first", "second", "first-updated", "second-again"]
|
||||
.into_iter()
|
||||
.map(|response_id| {
|
||||
sse(vec![
|
||||
ev_response_created(response_id),
|
||||
ev_completed(response_id),
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.await;
|
||||
|
||||
for (index, (thread, prompt)) in [
|
||||
(&test.codex, "first"),
|
||||
(&second.thread, "second"),
|
||||
(&test.codex, "first-updated"),
|
||||
(&second.thread, "second-again"),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
if index == 2 {
|
||||
test.codex
|
||||
.environment_ready(
|
||||
&selection,
|
||||
EnvironmentConfig {
|
||||
allow_login_shell: false,
|
||||
selected_capability_roots: vec![root("first-updated-root")],
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
thread
|
||||
.submit(
|
||||
vec![UserInput::Text {
|
||||
text: prompt.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into(),
|
||||
)
|
||||
.await?;
|
||||
wait_for_event(thread, |event| matches!(event, EventMsg::TurnComplete(_))).await;
|
||||
}
|
||||
|
||||
let requests = response_mock.requests();
|
||||
let root_fragments = requests
|
||||
.iter()
|
||||
.map(|request| {
|
||||
request
|
||||
.message_input_texts("user")
|
||||
.into_iter()
|
||||
.rfind(|text| text.contains("<ready_capability_roots>"))
|
||||
.context("ready capability roots should be model-visible")
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
assert_eq!(
|
||||
root_fragments,
|
||||
vec![
|
||||
"<ready_capability_roots>first-root</ready_capability_roots>",
|
||||
"<ready_capability_roots>startup-root,second-root</ready_capability_roots>",
|
||||
"<ready_capability_roots>first-updated-root</ready_capability_roots>",
|
||||
"<ready_capability_roots>startup-root,second-root</ready_capability_roots>",
|
||||
]
|
||||
);
|
||||
|
||||
let login_shells = requests
|
||||
.iter()
|
||||
.map(|request| {
|
||||
let body = request.body_json();
|
||||
let exec_command = body["tools"]
|
||||
.as_array()
|
||||
.context("tools should be an array")?
|
||||
.iter()
|
||||
.find(|tool| tool["name"] == "exec_command")
|
||||
.context("exec_command should be available")?;
|
||||
Ok(exec_command["parameters"]["properties"]
|
||||
.get("login")
|
||||
.is_some())
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
assert_eq!(login_shells, vec![false, true, false, true]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn ready_before_selection_exposes_remote_tools_and_capability_context_after_wait()
|
||||
-> Result<()> {
|
||||
|
||||
Reference in New Issue
Block a user