Compare commits

...

19 Commits

Author SHA1 Message Date
starr-openai
97e1ace5b9 Persist selected environments in turn context replay
Co-authored-by: Codex <noreply@openai.com>
2026-05-01 04:33:37 +00:00
starr-openai
4deeb8dca8 Restore turn-local cwd override for selected environments
Apply turn-level cwd overrides to the primary selected environment only for the active turn so runtime cwd, environment context diffs, and app-server turn-start behavior stay aligned without rewriting stored sticky environment selections.

Co-authored-by: Codex <noreply@openai.com>
2026-04-30 20:11:55 -07:00
starr-openai
ab4f20622c Fix turn environment cwd update semantics
Co-authored-by: Codex <noreply@openai.com>
2026-04-30 19:09:39 -07:00
starr-openai
66af15aa9a Drop RMCP cwd integration test change
Remove the RMCP integration test added during the environment-selection review pass so this PR does not carry that broader test change.

Co-authored-by: Codex <noreply@openai.com>
2026-04-30 18:18:52 -07:00
starr-openai
4bfd313759 Keep cwd updates from rewriting environments
Preserve environment cwd as an explicit environment-selection value, and keep session cwd updates scoped to session configuration only.

Co-authored-by: Codex <noreply@openai.com>
2026-04-30 18:16:47 -07:00
starr-openai
5d0e227315 Simplify environment cwd handling
Keep selected environment cwd scoped to resolved turn environments, remove fallback helpers added during review, and preserve existing MCP default/local fallback behavior inline.

Co-authored-by: Codex <noreply@openai.com>
2026-04-30 18:12:19 -07:00
starr-openai
0599906cf4 codex: fix CI failure on PR #20281
Co-authored-by: Codex <noreply@openai.com>
2026-05-01 00:08:23 +00:00
starr-openai
1333d56574 codex: fix CI failure on PR #20281
Co-authored-by: Codex <noreply@openai.com>
2026-04-30 23:43:10 +00:00
starr-openai
0a90ac58cd Fix MCP runtime lint
Add the required argument comment for the default-environment fallback flag.

Co-authored-by: Codex <noreply@openai.com>
2026-04-30 16:33:36 -07:00
starr-openai
fddf257b83 Tighten turn environment MCP semantics
Use turn-local environments for pending MCP refreshes, keep disabled-environment sessions startable when no MCP servers are enabled, and reject ambiguous cwd plus explicit environment updates.

Co-authored-by: Codex <noreply@openai.com>
2026-04-30 16:19:01 -07:00
starr-openai
56f259e8ec Fix environment selection CI test harness
Use the shared test PathBufExt import for RMCP integration tests and run environment selection tests that touch the local executor under Tokio.

Co-authored-by: Codex <noreply@openai.com>
2026-04-30 16:02:34 -07:00
starr-openai
d0592337e8 Fix environment selection CI compile errors
Add the test-only imports and current McpServerConfig fields required by the rebased branch, and derive Debug for ResolvedTurnEnvironments so resolver error tests compile under Bazel.

Co-authored-by: Codex <noreply@openai.com>
2026-04-30 15:43:42 -07:00
starr-openai
cc129d8a20 Align environment selection tests with cwd invariants
Keep the explicit turn-environment cwd assertion focused on the selected environment cwd, and remove fallback wording from the RMCP stored-environment test identifiers.

Co-authored-by: Codex <noreply@openai.com>
2026-04-30 15:35:17 -07:00
starr-openai
29fff15f08 Remove redundant environment validation wrapper
Route the remaining validation convenience API through resolve_environment_selections and keep the resolver tests attached to the resolver itself.

Co-authored-by: Codex <noreply@openai.com>
2026-04-30 15:27:53 -07:00
starr-openai
3065a55995 Refine environment selection resolution
Keep environment cwd ownership on explicit environment selections, tighten MCP runtime environment fallback, and clarify resolved environment projection naming.

Co-authored-by: Codex <noreply@openai.com>
2026-04-30 15:27:53 -07:00
starr-openai
fc26e1b683 Collapse duplicate startup environment state
Co-authored-by: Codex <noreply@openai.com>
2026-04-30 15:27:18 -07:00
starr-openai
d9d6623508 Address turn environment review feedback
Co-authored-by: Codex <noreply@openai.com>
2026-04-30 15:26:59 -07:00
starr-openai
fe4c6a8d7a Fix turn-local environment cwd coherence
Resolve relative cwd updates against turn-local environment selections, and keep the TurnContext helper surface minimal.

Co-authored-by: Codex <noreply@openai.com>
2026-04-30 15:26:38 -07:00
starr-openai
e7067dc6fd Use selected turn environments for runtime context
Route session runtime cwd and MCP startup state through selected turn environments so multi-environment turns avoid a parallel legacy Environment field.

Keep fallback behavior for local/no-selection sessions and add focused coverage for duplicate selections, cwd resolution, and MCP runtime environment selection.

Validation: git diff --check

Co-authored-by: Codex <noreply@openai.com>
2026-04-30 15:26:38 -07:00
27 changed files with 605 additions and 269 deletions

View File

@@ -222,6 +222,7 @@ use codex_app_server_protocol::ThreadUnsubscribeParams;
use codex_app_server_protocol::ThreadUnsubscribeResponse;
use codex_app_server_protocol::ThreadUnsubscribeStatus;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnEnvironmentParams;
use codex_app_server_protocol::TurnError;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnInterruptResponse;
@@ -2478,28 +2479,13 @@ impl CodexMessageProcessor {
.await;
return;
}
let environments = environments.map(|environments| {
environments
.into_iter()
.map(|environment| TurnEnvironmentSelection {
environment_id: environment.environment_id,
cwd: environment.cwd,
})
.collect::<Vec<_>>()
});
if let Some(environments) = environments.as_ref()
&& let Err(err) = self
.thread_manager
.validate_environment_selections(environments)
{
self.outgoing
.send_error(
request_id,
invalid_request(environment_selection_error_message(err)),
)
.await;
return;
}
let environment_selections = match self.parse_environment_selections(environments) {
Ok(environment_selections) => environment_selections,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
let mut typesafe_overrides = self.build_thread_config_overrides(
model,
model_provider,
@@ -2538,7 +2524,7 @@ impl CodexMessageProcessor {
typesafe_overrides,
dynamic_tools,
session_start_source,
environments,
environment_selections,
persist_extended_history,
service_name,
experimental_raw_events,
@@ -2965,6 +2951,27 @@ impl CodexMessageProcessor {
overrides
}
fn parse_environment_selections(
&self,
environments: Option<Vec<TurnEnvironmentParams>>,
) -> Result<Option<Vec<TurnEnvironmentSelection>>, JSONRPCErrorError> {
let environment_selections = environments.map(|environments| {
environments
.into_iter()
.map(|environment| TurnEnvironmentSelection {
environment_id: environment.environment_id,
cwd: environment.cwd,
})
.collect::<Vec<_>>()
});
if let Some(environment_selections) = environment_selections.as_ref() {
self.thread_manager
.validate_environment_selections(environment_selections)
.map_err(|err| invalid_request(environment_selection_error_message(err)))?;
}
Ok(environment_selections)
}
async fn thread_archive(&self, request_id: ConnectionRequestId, params: ThreadArchiveParams) {
let _thread_list_state_permit = match self.acquire_thread_list_state_permit().await {
Ok(permit) => permit,
@@ -6603,21 +6610,7 @@ impl CodexMessageProcessor {
let collaboration_mode = params
.collaboration_mode
.map(|mode| self.normalize_turn_start_collaboration_mode(mode));
let environments: Option<Vec<TurnEnvironmentSelection>> =
params.environments.map(|environments| {
environments
.into_iter()
.map(|environment| TurnEnvironmentSelection {
environment_id: environment.environment_id,
cwd: environment.cwd,
})
.collect()
});
if let Some(environments) = environments.as_ref() {
self.thread_manager
.validate_environment_selections(environments)
.map_err(|err| invalid_request(environment_selection_error_message(err)))?;
}
let environment_selections = self.parse_environment_selections(params.environments)?;
// Map v2 input items to core input items.
let mapped_items: Vec<CoreInputItem> = params
@@ -6726,7 +6719,7 @@ impl CodexMessageProcessor {
let turn_op = if has_any_overrides {
Op::UserInputWithTurnContext {
items: mapped_items,
environments,
environments: environment_selections,
final_output_json_schema: params.output_schema,
responsesapi_client_metadata: params.responsesapi_client_metadata,
cwd,
@@ -6746,7 +6739,7 @@ impl CodexMessageProcessor {
} else {
Op::UserInput {
items: mapped_items,
environments,
environments: environment_selections,
final_output_json_schema: params.output_schema,
responsesapi_client_metadata: params.responsesapi_client_metadata,
}

View File

@@ -30,6 +30,7 @@ use tokio::time::timeout;
use tokio_util::sync::CancellationToken;
use crate::config::Config;
use crate::environment_selection::ResolvedTurnEnvironments;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::new_guardian_review_id;
use crate::guardian::routes_approval_to_guardian;
@@ -47,7 +48,6 @@ use crate::session::SUBMISSION_CHANNEL_CAPACITY;
use crate::session::emit_subagent_session_started;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::session::turn_context::TurnEnvironment;
use codex_login::AuthManager;
use codex_models_manager::manager::SharedModelsManager;
use codex_protocol::error::CodexErr;
@@ -94,11 +94,9 @@ pub(crate) async fn run_codex_thread_interactive(
inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)),
parent_rollout_thread_trace: codex_rollout_trace::ThreadTraceContext::disabled(),
parent_trace: None,
environments: parent_ctx
.environments
.iter()
.map(TurnEnvironment::selection)
.collect(),
environment_selections: ResolvedTurnEnvironments {
turn_environments: parent_ctx.environments.clone(),
},
analytics_events_client: Some(parent_session.services.analytics_events_client.clone()),
thread_store: Arc::clone(&parent_session.services.thread_store),
}))

View File

@@ -123,6 +123,7 @@ fn reference_context_item() -> TurnContextItem {
turn_id: Some("reference-turn".to_string()),
trace_id: None,
cwd: PathBuf::from("/tmp/reference-cwd"),
environments: None,
current_date: Some("2026-03-23".to_string()),
timezone: Some("America/Los_Angeles".to_string()),
approval_policy: AskForApproval::OnRequest,

View File

@@ -1,12 +1,15 @@
use std::collections::HashSet;
use std::sync::Arc;
use codex_exec_server::Environment;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::ExecutorFileSystem;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::session::turn_context::TurnEnvironment;
pub(crate) fn default_thread_environment_selections(
environment_manager: &EnvironmentManager,
cwd: &AbsolutePathBuf,
@@ -21,42 +24,61 @@ pub(crate) fn default_thread_environment_selections(
.collect()
}
pub(crate) fn validate_environment_selections(
#[derive(Clone, Debug)]
pub(crate) struct ResolvedTurnEnvironments {
pub(crate) turn_environments: Vec<TurnEnvironment>,
}
impl ResolvedTurnEnvironments {
pub(crate) fn to_selections(&self) -> Vec<TurnEnvironmentSelection> {
self.turn_environments
.iter()
.map(TurnEnvironment::selection)
.collect()
}
pub(crate) fn primary_turn_environment(&self) -> Option<&TurnEnvironment> {
self.turn_environments.first()
}
pub(crate) fn primary_environment(&self) -> Option<Arc<codex_exec_server::Environment>> {
self.primary_turn_environment()
.map(|environment| Arc::clone(&environment.environment))
}
pub(crate) fn primary_filesystem(&self) -> Option<Arc<dyn ExecutorFileSystem>> {
self.primary_turn_environment()
.map(|environment| environment.environment.get_filesystem())
}
}
pub(crate) fn resolve_environment_selections(
environment_manager: &EnvironmentManager,
environments: &[TurnEnvironmentSelection],
) -> CodexResult<()> {
) -> CodexResult<ResolvedTurnEnvironments> {
let mut seen_environment_ids = HashSet::with_capacity(environments.len());
let mut turn_environments = Vec::with_capacity(environments.len());
for selected_environment in environments {
if environment_manager
.get_environment(&selected_environment.environment_id)
.is_none()
{
if !seen_environment_ids.insert(selected_environment.environment_id.as_str()) {
return Err(CodexErr::InvalidRequest(format!(
"unknown turn environment id `{}`",
"duplicate turn environment id `{}`",
selected_environment.environment_id
)));
}
let environment_id = selected_environment.environment_id.clone();
let environment = environment_manager
.get_environment(&environment_id)
.ok_or_else(|| {
CodexErr::InvalidRequest(format!("unknown turn environment id `{environment_id}`"))
})?;
turn_environments.push(TurnEnvironment {
environment_id,
environment,
cwd: selected_environment.cwd.clone(),
});
}
Ok(())
}
pub(crate) fn selected_primary_environment(
environment_manager: &EnvironmentManager,
environments: &[TurnEnvironmentSelection],
) -> CodexResult<Option<Arc<Environment>>> {
environments
.first()
.map(|selected_environment| {
environment_manager
.get_environment(&selected_environment.environment_id)
.ok_or_else(|| {
CodexErr::InvalidRequest(format!(
"unknown turn environment id `{}`",
selected_environment.environment_id
))
})
})
.transpose()
Ok(ResolvedTurnEnvironments { turn_environments })
}
#[cfg(test)]
@@ -105,4 +127,51 @@ mod tests {
Vec::<TurnEnvironmentSelection>::new()
);
}
#[tokio::test]
async fn resolve_environment_selections_rejects_duplicate_ids() {
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
let manager = EnvironmentManager::default_for_tests();
let err = resolve_environment_selections(
&manager,
&[
TurnEnvironmentSelection {
environment_id: "local".to_string(),
cwd: cwd.clone(),
},
TurnEnvironmentSelection {
environment_id: "local".to_string(),
cwd: cwd.join("other"),
},
],
)
.expect_err("duplicate environment id should fail");
assert!(err.to_string().contains("duplicate"));
}
#[tokio::test]
async fn resolved_environment_selections_use_first_selection_as_primary() {
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
let selected_cwd = cwd.join("selected");
let manager = EnvironmentManager::default_for_tests();
let resolved = resolve_environment_selections(
&manager,
&[TurnEnvironmentSelection {
environment_id: "local".to_string(),
cwd: selected_cwd,
}],
)
.expect("environment selections should resolve");
assert_eq!(
resolved
.primary_turn_environment()
.expect("primary environment")
.environment_id,
"local"
);
}
}

View File

@@ -221,6 +221,19 @@ impl Session {
let mcp_servers = with_codex_apps_mcp(mcp_servers, auth.as_ref(), &mcp_config);
let auth_statuses =
compute_auth_statuses(mcp_servers.iter(), store_mode, auth.as_ref()).await;
let mcp_runtime_environment = match turn_context.primary_environment() {
Some(turn_environment) => McpRuntimeEnvironment::new(
Arc::clone(&turn_environment.environment),
turn_environment.cwd.to_path_buf(),
),
None => McpRuntimeEnvironment::new(
self.services
.environment_manager
.default_environment()
.unwrap_or_else(|| self.services.environment_manager.local_environment()),
turn_context.cwd.to_path_buf(),
),
};
{
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
guard.cancel();
@@ -234,13 +247,7 @@ impl Session {
turn_context.sub_id.clone(),
self.get_tx_event(),
turn_context.permission_profile(),
McpRuntimeEnvironment::new(
turn_context
.environment
.clone()
.unwrap_or_else(|| self.services.environment_manager.local_environment()),
turn_context.cwd.to_path_buf(),
),
mcp_runtime_environment,
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth.as_ref()),
tool_plugin_provenance,

View File

@@ -30,8 +30,7 @@ use crate::context::NetworkRuleSaved;
use crate::context::PermissionsInstructions;
use crate::context::PersonalitySpecInstructions;
use crate::default_skill_metadata_budget;
use crate::environment_selection::selected_primary_environment;
use crate::environment_selection::validate_environment_selections;
use crate::environment_selection::ResolvedTurnEnvironments;
use crate::exec_policy::ExecPolicyManager;
use crate::installation_id::resolve_installation_id;
use crate::parse_turn_item;
@@ -113,7 +112,6 @@ use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnContextItem;
use codex_protocol::protocol::TurnContextNetworkItem;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::protocol::W3cTraceContext;
use codex_protocol::request_permissions::PermissionGrantScope;
use codex_protocol::request_permissions::RequestPermissionProfile;
@@ -406,7 +404,7 @@ pub(crate) struct CodexSpawnArgs {
pub(crate) parent_rollout_thread_trace: ThreadTraceContext,
pub(crate) user_shell_override: Option<shell::Shell>,
pub(crate) parent_trace: Option<W3cTraceContext>,
pub(crate) environments: Vec<TurnEnvironmentSelection>,
pub(crate) environment_selections: ResolvedTurnEnvironments,
pub(crate) analytics_events_client: Option<AnalyticsEventsClient>,
pub(crate) thread_store: Arc<dyn ThreadStore>,
}
@@ -463,18 +461,13 @@ impl Codex {
inherited_exec_policy,
parent_rollout_thread_trace,
parent_trace: _,
environments,
environment_selections,
analytics_events_client,
thread_store,
} = args;
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_event, rx_event) = async_channel::unbounded();
validate_environment_selections(environment_manager.as_ref(), &environments)?;
let environment =
selected_primary_environment(environment_manager.as_ref(), &environments)?;
let fs = environment
.as_ref()
.map(|environment| environment.get_filesystem());
let fs = environment_selections.primary_filesystem();
let plugins_input = config.plugins_config_input();
let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await;
let effective_skill_roots = plugin_outcome.effective_skill_roots();
@@ -497,8 +490,9 @@ impl Codex {
let _ = config.features.disable(Feature::Collab);
}
let primary_environment = environment_selections.primary_environment();
let user_instructions = AgentsMdManager::new(&config)
.user_instructions(environment.as_deref())
.user_instructions(primary_environment.as_deref())
.await;
let exec_policy = if crate::guardian::is_guardian_reviewer_source(&session_source) {
@@ -610,7 +604,7 @@ impl Codex {
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
thread_name: None,
environments,
environments: environment_selections.to_selections(),
original_config_do_not_use: Arc::clone(&config),
metrics_service_name,
app_server_client_name: None,

View File

@@ -123,7 +123,6 @@ pub(super) async fn spawn_review_thread(
reasoning_effort,
reasoning_summary,
session_source,
environment: parent_turn_context.environment.clone(),
environments: parent_turn_context.environments.clone(),
tools_config,
features: parent_turn_context.features.clone(),

View File

@@ -61,6 +61,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
turn_id: Some(turn_context.sub_id.clone()),
trace_id: turn_context.trace_id.clone(),
cwd: turn_context.cwd.to_path_buf(),
environments: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -102,6 +103,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
turn_id: Some(turn_context.sub_id.clone()),
trace_id: turn_context.trace_id.clone(),
cwd: turn_context.cwd.to_path_buf(),
environments: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -912,6 +914,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
turn_id: Some(turn_context.sub_id.clone()),
trace_id: turn_context.trace_id.clone(),
cwd: turn_context.cwd.to_path_buf(),
environments: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -990,6 +993,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
turn_id: Some(turn_context.sub_id.clone()),
trace_id: turn_context.trace_id.clone(),
cwd: turn_context.cwd.to_path_buf(),
environments: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -1021,6 +1025,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
turn_id: Some(turn_context.sub_id.clone()),
trace_id: turn_context.trace_id.clone(),
cwd: turn_context.cwd.to_path_buf(),
environments: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -1136,6 +1141,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
turn_id: Some(current_turn_id.clone()),
trace_id: turn_context.trace_id.clone(),
cwd: turn_context.cwd.to_path_buf(),
environments: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -1250,6 +1256,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
turn_id: Some(turn_context.sub_id.clone()),
trace_id: turn_context.trace_id.clone(),
cwd: turn_context.cwd.to_path_buf(),
environments: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -1402,6 +1409,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
turn_id: Some(turn_context.sub_id.clone()),
trace_id: turn_context.trace_id.clone(),
cwd: turn_context.cwd.to_path_buf(),
environments: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),

View File

@@ -2,6 +2,7 @@ use super::*;
use crate::goals::GoalRuntimeState;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::protocol::TurnEnvironmentSelection;
use tokio::sync::Semaphore;
/// Context for an initialized model agent
@@ -206,12 +207,7 @@ impl SessionConfiguration {
.unwrap_or_else(|| self.cwd.clone());
let cwd_changed = absolute_cwd.as_path() != self.cwd.as_path();
next_configuration.cwd = absolute_cwd.clone();
if cwd_changed
&& let Some(primary_environment) = next_configuration.environments.first_mut()
{
primary_environment.cwd = absolute_cwd;
}
next_configuration.cwd = absolute_cwd;
if let Some(permission_profile) = updates.permission_profile.clone() {
let active_permission_profile =
@@ -920,6 +916,31 @@ impl Session {
cancel_guard.cancel();
*cancel_guard = CancellationToken::new();
}
let turn_environment = crate::environment_selection::resolve_environment_selections(
sess.services.environment_manager.as_ref(),
&session_configuration.environments,
)
.map_err(|err| {
CodexErr::InvalidRequest(err.to_string().replace(
"unknown turn environment id",
"unknown stored MCP environment id",
))
})?
.primary_turn_environment()
.cloned();
let mcp_runtime_environment = match turn_environment {
Some(turn_environment) => McpRuntimeEnvironment::new(
Arc::clone(&turn_environment.environment),
turn_environment.cwd.to_path_buf(),
),
None => McpRuntimeEnvironment::new(
sess.services
.environment_manager
.default_environment()
.unwrap_or_else(|| sess.services.environment_manager.local_environment()),
session_configuration.cwd.to_path_buf(),
),
};
let (mcp_connection_manager, cancel_token) = McpConnectionManager::new(
&mcp_servers,
config.mcp_oauth_credentials_store_mode,
@@ -928,13 +949,7 @@ impl Session {
INITIAL_SUBMIT_ID.to_owned(),
tx_event.clone(),
session_configuration.permission_profile(),
McpRuntimeEnvironment::new(
sess.services
.environment_manager
.default_environment()
.unwrap_or_else(|| sess.services.environment_manager.local_environment()),
session_configuration.cwd.to_path_buf(),
),
mcp_runtime_environment,
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth),
tool_plugin_provenance,

View File

@@ -47,6 +47,7 @@ use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::protocol::NonSteerableTurnKind;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::request_permissions::PermissionGrantScope;
use codex_protocol::request_permissions::RequestPermissionProfile;
use tracing::Span;
@@ -1759,6 +1760,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
turn_id: Some(turn_context.sub_id.clone()),
trace_id: turn_context.trace_id.clone(),
cwd: turn_context.cwd.to_path_buf(),
environments: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -3279,7 +3281,7 @@ async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_up
}
#[tokio::test]
async fn session_update_settings_keeps_runtime_cwds_absolute() {
async fn session_update_settings_does_not_rewrite_sticky_environment_cwds() {
let (session, turn_context) = make_session_and_context().await;
let updated_cwd = turn_context.cwd.join("project");
std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir");
@@ -3305,6 +3307,91 @@ async fn session_update_settings_keeps_runtime_cwds_absolute() {
assert_eq!(next_turn.config.cwd, updated_cwd);
}
#[tokio::test]
async fn relative_cwd_update_without_environments_resolves_under_session_cwd() {
let (session, _turn_context) = make_session_and_context().await;
let original_cwd = {
let mut state = session.state.lock().await;
state.session_configuration.environments = Vec::new();
state.session_configuration.cwd.clone()
};
let updated_cwd = original_cwd.join("project");
std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir");
session
.update_settings(SessionSettingsUpdate {
cwd: Some(PathBuf::from("project")),
..Default::default()
})
.await
.expect("cwd update should succeed");
let state = session.state.lock().await;
assert_eq!(state.session_configuration.cwd, updated_cwd);
assert!(state.session_configuration.environments.is_empty());
}
#[tokio::test]
async fn cwd_update_does_not_rewrite_sticky_environment_cwd() {
let (session, _turn_context) = make_session_and_context().await;
let (original_cwd, environment_cwd) = {
let mut state = session.state.lock().await;
let original_cwd = state.session_configuration.cwd.clone();
let environment_cwd = original_cwd.join("environment");
state.session_configuration.environments = vec![TurnEnvironmentSelection {
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
cwd: environment_cwd.clone(),
}];
(original_cwd, environment_cwd)
};
let updated_cwd = original_cwd.join("project");
std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir");
session
.update_settings(SessionSettingsUpdate {
cwd: Some(PathBuf::from("project")),
..Default::default()
})
.await
.expect("cwd update should succeed");
let state = session.state.lock().await;
assert_eq!(state.session_configuration.cwd, updated_cwd);
assert_eq!(
state.session_configuration.environments[0].cwd,
environment_cwd
);
}
#[tokio::test]
async fn absolute_cwd_update_with_turn_environment_is_allowed() {
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
let absolute_cwd = {
let state = session.state.lock().await;
state.session_configuration.cwd.join("absolute-turn")
};
std::fs::create_dir_all(absolute_cwd.as_path()).expect("create absolute turn dir");
let turn_context = session
.new_turn_with_sub_id(
"sub-1".to_string(),
SessionSettingsUpdate {
cwd: Some(absolute_cwd.to_path_buf()),
environments: Some(vec![TurnEnvironmentSelection {
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
cwd: absolute_cwd.clone(),
}]),
..Default::default()
},
)
.await
.expect("absolute cwd with explicit environments should succeed");
assert_eq!(turn_context.cwd, absolute_cwd);
assert_eq!(turn_context.config.cwd, absolute_cwd);
assert_eq!(turn_context.environments.len(), 1);
}
#[tokio::test]
async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
let codex_home = tempfile::tempdir().expect("create temp dir");
@@ -3584,7 +3671,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
model_info,
&models_manager,
/*network*/ None,
Some(environment),
turn_environments,
session_configuration.cwd.clone(),
"turn_id".to_string(),
@@ -4323,23 +4409,24 @@ async fn turn_environments_set_primary_environment() {
let turn_environments = &turn_context.environments;
assert_eq!(turn_environments.len(), 1);
let turn_environment = turn_context
.primary_environment()
.expect("primary environment should be set");
assert!(std::sync::Arc::ptr_eq(
turn_context
.environment
.as_ref()
.expect("primary environment should be set"),
&turn_environment.environment,
&turn_environments[0].environment
));
assert!(!turn_context.environments.is_empty());
assert_eq!(turn_context.cwd.as_path(), selected_cwd.as_path());
assert_eq!(turn_context.config.cwd.as_path(), selected_cwd.as_path());
}
#[tokio::test]
async fn default_turn_uses_stored_thread_environments() {
async fn default_turn_overlays_session_cwd_onto_stored_thread_environments() {
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
let session_cwd = session.get_config().await.cwd.clone();
let selected_cwd =
AbsolutePathBuf::try_from(session.get_config().await.cwd.as_path().join("selected"))
.expect("absolute path");
AbsolutePathBuf::try_from(session_cwd.as_path().join("selected")).expect("absolute path");
{
let mut state = session.state.lock().await;
@@ -4353,15 +4440,15 @@ async fn default_turn_uses_stored_thread_environments() {
let turn_environments = &turn_context.environments;
assert_eq!(turn_environments.len(), 1);
let turn_environment = turn_context
.primary_environment()
.expect("primary environment should be set");
assert!(std::sync::Arc::ptr_eq(
turn_context
.environment
.as_ref()
.expect("primary environment should be set"),
&turn_environment.environment,
&turn_environments[0].environment
));
assert_eq!(turn_context.cwd, selected_cwd);
assert_eq!(turn_context.config.cwd, selected_cwd);
assert_eq!(turn_context.cwd, session_cwd);
assert_eq!(turn_context.config.cwd, session_cwd);
}
#[tokio::test]
@@ -4376,54 +4463,42 @@ async fn default_turn_honors_empty_stored_thread_environments() {
let turn_context = session.new_default_turn().await;
assert!(turn_context.environment.is_none());
assert!(turn_context.primary_environment().is_none());
assert!(turn_context.environments.is_empty());
assert_eq!(turn_context.cwd, session_cwd);
assert_eq!(turn_context.config.cwd, session_cwd);
assert_eq!(turn_context.environments.len(), 0);
}
#[tokio::test]
async fn multiple_turn_environments_use_first_as_primary_environment() {
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
let session_cwd = session.get_config().await.cwd.clone();
let first_cwd =
AbsolutePathBuf::try_from(session_cwd.as_path().join("first")).expect("absolute path");
let second_cwd =
AbsolutePathBuf::try_from(session_cwd.as_path().join("second")).expect("absolute path");
async fn primary_environment_uses_first_turn_environment() {
let (_session, mut turn_context) = make_session_and_context().await;
let first_environment = turn_context.environments[0].clone();
let second_cwd = turn_context.cwd.join("second");
turn_context.environments.push(TurnEnvironment {
environment_id: "second".to_string(),
environment: Arc::clone(&first_environment.environment),
cwd: second_cwd.clone(),
});
let turn_context = session
.new_turn_with_sub_id(
"sub-1".to_string(),
SessionSettingsUpdate {
environments: Some(vec![
TurnEnvironmentSelection {
environment_id: "local".to_string(),
cwd: first_cwd.clone(),
},
TurnEnvironmentSelection {
environment_id: "local".to_string(),
cwd: second_cwd.clone(),
},
]),
..Default::default()
},
)
.await
.expect("turn should start");
let turn_environments = &turn_context.environments;
assert_eq!(turn_environments.len(), 2);
assert_eq!(turn_environments[0].cwd, first_cwd);
assert_eq!(turn_environments[1].cwd, second_cwd);
assert!(std::sync::Arc::ptr_eq(
assert_eq!(
turn_context
.environment
.as_ref()
.expect("primary environment should be set"),
&turn_environments[0].environment
));
assert_eq!(turn_context.cwd, first_cwd);
assert_eq!(turn_context.config.cwd, first_cwd);
.primary_environment()
.expect("primary environment")
.environment_id,
first_environment.environment_id
);
assert_eq!(
turn_context
.environments
.iter()
.find(|environment| environment.environment_id == "second")
.expect("second environment")
.cwd,
second_cwd
);
assert_eq!(turn_context.environments.len(), 2);
assert_eq!(turn_context.environments[1].cwd, second_cwd);
}
#[tokio::test]
@@ -4441,15 +4516,19 @@ async fn empty_turn_environments_clear_primary_environment() {
.await
.expect("turn should start");
assert!(turn_context.environment.is_none());
assert!(turn_context.primary_environment().is_none());
assert!(turn_context.environments.is_empty());
assert_eq!(turn_context.cwd, session.get_config().await.cwd);
assert_eq!(turn_context.config.cwd, session.get_config().await.cwd);
assert_eq!(turn_context.environments.len(), 0);
}
#[tokio::test]
async fn unknown_turn_environment_returns_error() {
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
let original_configuration = {
let state = session.state.lock().await;
state.session_configuration.clone()
};
let err = session
.new_turn_with_sub_id(
@@ -4457,7 +4536,7 @@ async fn unknown_turn_environment_returns_error() {
SessionSettingsUpdate {
environments: Some(vec![TurnEnvironmentSelection {
environment_id: "missing".to_string(),
cwd: session.get_config().await.cwd.clone(),
cwd: original_configuration.cwd.clone(),
}]),
..Default::default()
},
@@ -4465,8 +4544,58 @@ async fn unknown_turn_environment_returns_error() {
.await
.expect_err("unknown environment should fail");
let current_configuration = {
let state = session.state.lock().await;
state.session_configuration.clone()
};
assert!(matches!(err, CodexErr::InvalidRequest(_)));
assert!(err.to_string().contains("missing"));
assert_eq!(current_configuration.cwd, original_configuration.cwd);
assert_eq!(
current_configuration.environments,
original_configuration.environments
);
}
#[tokio::test]
async fn duplicate_turn_environment_returns_error_without_mutating_session() {
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
let original_configuration = {
let state = session.state.lock().await;
state.session_configuration.clone()
};
let err = session
.new_turn_with_sub_id(
"sub-1".to_string(),
SessionSettingsUpdate {
environments: Some(vec![
TurnEnvironmentSelection {
environment_id: "local".to_string(),
cwd: original_configuration.cwd.clone(),
},
TurnEnvironmentSelection {
environment_id: "local".to_string(),
cwd: original_configuration.cwd.join("second"),
},
]),
..Default::default()
},
)
.await
.expect_err("duplicate environment should fail");
let current_configuration = {
let state = session.state.lock().await;
state.session_configuration.clone()
};
assert!(matches!(err, CodexErr::InvalidRequest(_)));
assert!(err.to_string().contains("duplicate"));
assert_eq!(current_configuration.cwd, original_configuration.cwd);
assert_eq!(
current_configuration.environments,
original_configuration.environments
);
}
#[tokio::test]
@@ -5013,7 +5142,6 @@ where
model_info,
&models_manager,
/*network*/ None,
Some(environment),
turn_environments,
session_configuration.cwd.clone(),
"turn_id".to_string(),

View File

@@ -1,5 +1,6 @@
use super::*;
use crate::compact::InitialContextInjection;
use crate::environment_selection::ResolvedTurnEnvironments;
use crate::exec::ExecCapturePolicy;
use crate::exec::ExecParams;
use crate::exec_policy::ExecPolicyManager;
@@ -754,7 +755,9 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
parent_rollout_thread_trace: codex_rollout_trace::ThreadTraceContext::disabled(),
user_shell_override: None,
parent_trace: None,
environments: Vec::new(),
environment_selections: ResolvedTurnEnvironments {
turn_environments: Vec::new(),
},
analytics_events_client: None,
thread_store,
})

View File

@@ -59,7 +59,6 @@ pub(crate) struct TurnContext {
pub(crate) reasoning_effort: Option<ReasoningEffortConfig>,
pub(crate) reasoning_summary: ReasoningSummaryConfig,
pub(crate) session_source: SessionSource,
pub(crate) environment: Option<Arc<Environment>>,
pub(crate) environments: Vec<TurnEnvironment>,
/// The session's absolute working directory. All relative paths provided
/// by the model as well as sandbox policies are resolved against this path
@@ -106,6 +105,10 @@ impl TurnContext {
self.permission_profile.network_sandbox_policy()
}
pub(crate) fn primary_environment(&self) -> Option<&TurnEnvironment> {
self.environments.first()
}
pub(crate) fn sandbox_policy(&self) -> SandboxPolicy {
let file_system_sandbox_policy = self.file_system_sandbox_policy();
let network_sandbox_policy = self.network_sandbox_policy();
@@ -230,7 +233,6 @@ impl TurnContext {
reasoning_effort,
reasoning_summary: self.reasoning_summary,
session_source: self.session_source.clone(),
environment: self.environment.clone(),
environments: self.environments.clone(),
cwd: self.cwd.clone(),
current_date: self.current_date.clone(),
@@ -329,6 +331,12 @@ impl TurnContext {
turn_id: Some(self.sub_id.clone()),
trace_id: self.trace_id.clone(),
cwd: self.cwd.to_path_buf(),
environments: (!self.environments.is_empty()).then(|| {
self.environments
.iter()
.map(TurnEnvironment::selection)
.collect()
}),
current_date: self.current_date.clone(),
timezone: self.timezone.clone(),
approval_policy: self.approval_policy.value(),
@@ -432,7 +440,6 @@ impl Session {
model_info: ModelInfo,
models_manager: &SharedModelsManager,
network: Option<NetworkProxy>,
environment: Option<Arc<Environment>>,
environments: Vec<TurnEnvironment>,
cwd: AbsolutePathBuf,
sub_id: String,
@@ -474,7 +481,7 @@ impl Session {
)
.with_web_search_config(per_turn_config.web_search_config.clone())
.with_allow_login_shell(per_turn_config.permissions.allow_login_shell)
.with_has_environment(environment.is_some())
.with_has_environment(!environments.is_empty())
.with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled)
.with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone())
.with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata)
@@ -522,7 +529,6 @@ impl Session {
reasoning_effort,
reasoning_summary,
session_source,
environment,
environments,
cwd,
current_date: Some(current_date),
@@ -564,10 +570,16 @@ impl Session {
let mut state = self.state.lock().await;
match state.session_configuration.clone().apply(&updates) {
Ok(next) => {
let effective_environments = updates
let mut effective_environments = updates
.environments
.clone()
.unwrap_or_else(|| next.environments.clone());
if updates.environments.is_none() {
Self::overlay_runtime_cwd_on_primary_environment(
&mut effective_environments,
&next.cwd,
);
}
let turn_environments =
self.resolve_turn_environments(&effective_environments)?;
let previous_cwd = state.session_configuration.cwd.clone();
@@ -641,27 +653,11 @@ impl Session {
&self,
environments: &[TurnEnvironmentSelection],
) -> CodexResult<Vec<TurnEnvironment>> {
let mut turn_environments = Vec::with_capacity(environments.len());
for selected_environment in environments {
let environment_id = selected_environment.environment_id.clone();
let environment = self
.services
.environment_manager
.get_environment(&environment_id)
.ok_or_else(|| {
CodexErr::InvalidRequest(format!(
"unknown turn environment id `{environment_id}`"
))
})?;
let cwd = selected_environment.cwd.clone();
turn_environments.push(TurnEnvironment {
environment_id,
environment,
cwd,
});
}
Ok(turn_environments)
crate::environment_selection::resolve_environment_selections(
self.services.environment_manager.as_ref(),
environments,
)
.map(|resolved| resolved.turn_environments)
}
async fn new_turn_from_configuration(
@@ -672,8 +668,6 @@ impl Session {
turn_environments: Vec<TurnEnvironment>,
) -> Arc<TurnContext> {
let primary_turn_environment = turn_environments.first();
let environment = primary_turn_environment
.map(|turn_environment| Arc::clone(&turn_environment.environment));
let cwd = primary_turn_environment
.map(|turn_environment| turn_environment.cwd.clone())
.unwrap_or_else(|| session_configuration.cwd.clone());
@@ -700,9 +694,8 @@ impl Session {
.await;
let effective_skill_roots = plugin_outcome.effective_skill_roots();
let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots);
let fs = environment
.as_ref()
.map(|environment| environment.get_filesystem());
let fs = primary_turn_environment
.map(|turn_environment| turn_environment.environment.get_filesystem());
let skills_outcome = Arc::new(
self.services
.skills_manager
@@ -731,7 +724,6 @@ impl Session {
)
.then(|| started_proxy.proxy())
}),
environment,
turn_environments,
cwd,
sub_id,
@@ -773,14 +765,18 @@ impl Session {
let state = self.state.lock().await;
state.session_configuration.clone()
};
let turn_environments =
match self.resolve_turn_environments(&session_configuration.environments) {
Ok(turn_environments) => turn_environments,
Err(err) => {
warn!("failed to resolve stored session environments: {err}");
Vec::new()
}
};
let mut effective_environments = session_configuration.environments.clone();
Self::overlay_runtime_cwd_on_primary_environment(
&mut effective_environments,
&session_configuration.cwd,
);
let turn_environments = match self.resolve_turn_environments(&effective_environments) {
Ok(turn_environments) => turn_environments,
Err(err) => {
warn!("failed to resolve stored session environments: {err}");
Vec::new()
}
};
self.new_turn_from_configuration(
sub_id,
@@ -790,4 +786,15 @@ impl Session {
)
.await
}
fn overlay_runtime_cwd_on_primary_environment(
environments: &mut [TurnEnvironmentSelection],
runtime_cwd: &AbsolutePathBuf,
) {
if let Some(turn_environment) = environments.first_mut()
&& turn_environment.cwd != *runtime_cwd
{
turn_environment.cwd = runtime_cwd.clone();
}
}
}

View File

@@ -4,8 +4,7 @@ use crate::codex_thread::CodexThread;
use crate::config::Config;
use crate::config::ThreadStoreConfig;
use crate::environment_selection::default_thread_environment_selections;
use crate::environment_selection::selected_primary_environment;
use crate::environment_selection::validate_environment_selections;
use crate::environment_selection::resolve_environment_selections;
use crate::file_watcher::FileWatcher;
use crate::mcp::McpManager;
use crate::rollout::RolloutRecorder;
@@ -419,7 +418,8 @@ impl ThreadManager {
&self,
environments: &[TurnEnvironmentSelection],
) -> CodexResult<()> {
validate_environment_selections(self.state.environment_manager.as_ref(), environments)
resolve_environment_selections(self.state.environment_manager.as_ref(), environments)
.map(|_| ())
}
pub fn get_models_manager(&self) -> SharedModelsManager {
@@ -613,10 +613,13 @@ impl ThreadManager {
persist_extended_history: bool,
parent_trace: Option<W3cTraceContext>,
) -> CodexResult<NewThread> {
let environments = default_thread_environment_selections(
self.state.environment_manager.as_ref(),
&config.cwd,
);
let environments = restore_thread_environment_selections_from_history(&initial_history)
.unwrap_or_else(|| {
default_thread_environment_selections(
self.state.environment_manager.as_ref(),
&config.cwd,
)
});
Box::pin(self.state.spawn_thread(
config,
thread_store,
@@ -668,10 +671,13 @@ impl ThreadManager {
user_shell_override: crate::shell::Shell,
) -> CodexResult<NewThread> {
let initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?;
let environments = default_thread_environment_selections(
self.state.environment_manager.as_ref(),
&config.cwd,
);
let environments = restore_thread_environment_selections_from_history(&initial_history)
.unwrap_or_else(|| {
default_thread_environment_selections(
self.state.environment_manager.as_ref(),
&config.cwd,
)
});
Box::pin(self.state.spawn_thread(
config,
thread_store,
@@ -810,10 +816,13 @@ impl ThreadManager {
) -> CodexResult<NewThread> {
let interrupted_marker = InterruptedTurnHistoryMarker::from_config(&config);
let history = fork_history_from_snapshot(snapshot, history, interrupted_marker);
let environments = default_thread_environment_selections(
self.state.environment_manager.as_ref(),
&config.cwd,
);
let environments = restore_thread_environment_selections_from_history(&history)
.unwrap_or_else(|| {
default_thread_environment_selections(
self.state.environment_manager.as_ref(),
&config.cwd,
)
});
Box::pin(self.state.spawn_thread(
config,
thread_store,
@@ -962,8 +971,13 @@ impl ThreadManagerState {
inherited_exec_policy,
} = options;
let initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?;
let environments =
default_thread_environment_selections(self.environment_manager.as_ref(), &config.cwd);
let environments = restore_thread_environment_selections_from_history(&initial_history)
.unwrap_or_else(|| {
default_thread_environment_selections(
self.environment_manager.as_ref(),
&config.cwd,
)
});
Box::pin(self.spawn_thread_with_source(
config,
thread_store,
@@ -996,9 +1010,14 @@ impl ThreadManagerState {
inherited_exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
environments: Option<Vec<TurnEnvironmentSelection>>,
) -> CodexResult<NewThread> {
let environments = environments.unwrap_or_else(|| {
default_thread_environment_selections(self.environment_manager.as_ref(), &config.cwd)
});
let environments = environments
.or_else(|| restore_thread_environment_selections_from_history(&initial_history))
.unwrap_or_else(|| {
default_thread_environment_selections(
self.environment_manager.as_ref(),
&config.cwd,
)
});
Box::pin(self.spawn_thread_with_source(
config,
thread_store,
@@ -1072,16 +1091,16 @@ impl ThreadManagerState {
user_shell_override: Option<crate::shell::Shell>,
) -> CodexResult<NewThread> {
let is_resumed_thread = matches!(&initial_history, InitialHistory::Resumed(_));
let environment =
selected_primary_environment(self.environment_manager.as_ref(), &environments)?;
let watch_registration = match environment.as_ref() {
Some(environment) if !environment.is_remote() => {
let environment_selections =
resolve_environment_selections(self.environment_manager.as_ref(), &environments)?;
let watch_registration = match environment_selections.primary_turn_environment() {
Some(turn_environment) if !turn_environment.environment.is_remote() => {
self.skills_watcher
.register_config(
&config,
self.skills_manager.as_ref(),
self.plugins_manager.as_ref(),
Some(environment.get_filesystem()),
Some(turn_environment.environment.get_filesystem()),
)
.await
}
@@ -1113,7 +1132,7 @@ impl ThreadManagerState {
parent_rollout_thread_trace,
user_shell_override,
parent_trace,
environments,
environment_selections,
analytics_events_client: self.analytics_events_client.clone(),
thread_store,
})
@@ -1362,6 +1381,22 @@ fn append_interrupted_boundary(
}
}
fn restore_thread_environment_selections_from_history(
history: &InitialHistory,
) -> Option<Vec<TurnEnvironmentSelection>> {
history
.get_rollout_items()
.iter()
.rev()
.find_map(|item| match item {
RolloutItem::TurnContext(context) => context
.environments
.clone()
.filter(|environments| !environments.is_empty()),
_ => None,
})
}
#[cfg(test)]
#[path = "thread_manager_tests.rs"]
mod tests;

View File

@@ -378,7 +378,7 @@ async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() {
}
#[tokio::test]
async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
async fn resume_and_fork_restore_thread_environments_from_rollout() {
let temp_dir = tempdir().expect("tempdir");
let mut config = test_config().await;
config.codex_home = temp_dir.path().join("codex-home").abs();
@@ -401,6 +401,10 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
cwd: selected_cwd.clone(),
}];
let default_cwd = config.cwd.clone();
let runtime_environments = vec![TurnEnvironmentSelection {
environment_id: "local".to_string(),
cwd: default_cwd.clone(),
}];
let thread_store = thread_store_from_config(&config);
let source = manager
@@ -418,6 +422,19 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
.await
.expect("start source thread");
source.thread.ensure_rollout_materialized().await;
let source_turn = source
.thread
.codex
.session
.new_turn_with_sub_id("source-turn".to_string(), SessionSettingsUpdate::default())
.await
.expect("build source turn context");
source
.thread
.codex
.session
.record_context_updates_and_set_reference_context_item(&source_turn)
.await;
source
.thread
.flush_rollout()
@@ -427,11 +444,52 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
.thread
.rollout_path()
.expect("source rollout path should exist");
let InitialHistory::Resumed(persisted_history) =
RolloutRecorder::get_rollout_history(&rollout_path)
.await
.expect("read source rollout history")
else {
panic!("expected resumed source rollout history");
};
let persisted_turn_context = persisted_history
.history
.iter()
.find_map(|item| match item {
RolloutItem::TurnContext(ctx) => Some(ctx.clone()),
_ => None,
});
assert_eq!(
persisted_turn_context
.as_ref()
.and_then(|ctx| ctx.environments.clone()),
Some(runtime_environments.clone())
);
source
.thread
.shutdown_and_wait()
.await
.expect("shutdown source thread before resume");
let InitialHistory::Resumed(post_shutdown_history) =
RolloutRecorder::get_rollout_history(&rollout_path)
.await
.expect("read post-shutdown source rollout history")
else {
panic!("expected resumed post-shutdown source rollout history");
};
let post_shutdown_turn_context =
post_shutdown_history
.history
.iter()
.find_map(|item| match item {
RolloutItem::TurnContext(ctx) => Some(ctx.clone()),
_ => None,
});
assert_eq!(
post_shutdown_turn_context
.as_ref()
.and_then(|ctx| ctx.environments.clone()),
Some(runtime_environments.clone())
);
let _ = manager.remove_thread(&source.thread_id).await;
let resumed = manager

View File

@@ -363,13 +363,14 @@ impl ToolHandler for ApplyPatchHandler {
// Avoid building temporary ExecParams/command vectors; derive directly from inputs.
let cwd = turn.cwd.clone();
let command = vec!["apply_patch".to_string(), patch_input.clone()];
let Some(environment) = turn.environment.as_ref() else {
let Some(turn_environment) = turn.primary_environment() else {
return Err(FunctionCallError::RespondToModel(
"apply_patch is unavailable in this session".to_string(),
));
};
let fs = environment.get_filesystem();
let sandbox = environment
let fs = turn_environment.environment.get_filesystem();
let sandbox = turn_environment
.environment
.is_remote()
.then(|| turn.file_system_sandbox_context(/*additional_permissions*/ None));
match codex_apply_patch::maybe_parse_apply_patch_verified(
@@ -474,9 +475,8 @@ pub(crate) async fn intercept_apply_patch(
tool_name: &str,
) -> Result<Option<FunctionToolOutput>, FunctionCallError> {
let sandbox = turn
.environment
.as_ref()
.filter(|env| env.is_remote())
.primary_environment()
.filter(|env| env.environment.is_remote())
.map(|_| turn.file_system_sandbox_context(/*additional_permissions*/ None));
match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd, fs, sandbox.as_ref())
.await

View File

@@ -412,12 +412,12 @@ impl ShellHandler {
} = args;
let mut exec_params = exec_params;
let Some(environment) = turn.environment.as_ref() else {
let Some(turn_environment) = turn.primary_environment() else {
return Err(FunctionCallError::RespondToModel(
"shell is unavailable in this session".to_string(),
));
};
let fs = environment.get_filesystem();
let fs = turn_environment.environment.get_filesystem();
let dependency_env = session.dependency_env().await;
if !dependency_env.is_empty() {

View File

@@ -196,12 +196,12 @@ impl ToolHandler for UnifiedExecHandler {
}
};
let Some(environment) = turn.environment.as_ref() else {
let Some(turn_environment) = turn.primary_environment() else {
return Err(FunctionCallError::RespondToModel(
"unified exec is unavailable in this session".to_string(),
));
};
let fs = environment.get_filesystem();
let fs = turn_environment.environment.get_filesystem();
let manager: &UnifiedExecProcessManager = &session.services.unified_exec_manager;
let context = UnifiedExecContext::new(session.clone(), turn.clone(), call_id.clone());

View File

@@ -88,16 +88,18 @@ impl ToolHandler for ViewImageHandler {
};
let abs_path = turn.resolve_path(Some(args.path));
let Some(environment) = turn.environment.as_ref() else {
let Some(environment) = turn.primary_environment() else {
return Err(FunctionCallError::RespondToModel(
"view_image is unavailable in this session".to_string(),
));
};
let sandbox = environment
.environment
.is_remote()
.then(|| turn.file_system_sandbox_context(/*additional_permissions*/ None));
let metadata = environment
.environment
.get_filesystem()
.get_metadata(&abs_path, sandbox.as_ref())
.await
@@ -115,6 +117,7 @@ impl ToolHandler for ViewImageHandler {
)));
}
let file_bytes = environment
.environment
.get_filesystem()
.read_file(&abs_path, sandbox.as_ref())
.await

View File

@@ -191,11 +191,11 @@ impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
) -> Result<ExecToolCallOutput, ToolError> {
let environment = ctx.turn.environment.as_ref().ok_or_else(|| {
let turn_environment = ctx.turn.primary_environment().ok_or_else(|| {
ToolError::Rejected("apply_patch is unavailable in this session".to_string())
})?;
let started_at = Instant::now();
let fs = environment.get_filesystem();
let fs = turn_environment.environment.get_filesystem();
let sandbox = Self::file_system_sandbox_context_for_attempt(req, attempt);
let mut stdout = Vec::new();
let mut stderr = Vec::new();

View File

@@ -254,9 +254,8 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
}
let environment_is_remote = ctx
.turn
.environment
.as_ref()
.is_some_and(|environment| environment.is_remote());
.primary_environment()
.is_some_and(|turn_environment| turn_environment.environment.is_remote());
let command = if environment_is_remote {
base_command.to_vec()
} else {
@@ -293,12 +292,12 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
.await?
{
Some(prepared) => {
let Some(environment) = ctx.turn.environment.as_ref() else {
let Some(turn_environment) = ctx.turn.primary_environment() else {
return Err(ToolError::Rejected(
"exec_command is unavailable in this session".to_string(),
));
};
if environment.is_remote() {
if turn_environment.environment.is_remote() {
return Err(ToolError::Rejected(
"unified_exec zsh-fork is not supported when exec_server_url is configured".to_string(),
));
@@ -310,7 +309,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
&prepared.exec_request,
req.tty,
prepared.spawn_lifecycle,
environment.as_ref(),
turn_environment.environment.as_ref(),
)
.await
.map_err(|err| match err {
@@ -338,7 +337,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
.env_for(command, options, managed_network)
.map_err(|err| ToolError::Codex(err.into()))?;
exec_env.exec_server_env_config = req.exec_server_env_config.clone();
let Some(environment) = ctx.turn.environment.as_ref() else {
let Some(turn_environment) = ctx.turn.primary_environment() else {
return Err(ToolError::Rejected(
"exec_command is unavailable in this session".to_string(),
));
@@ -349,7 +348,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
&exec_env,
req.tty,
Box::new(NoopSpawnLifecycle),
environment.as_ref(),
turn_environment.environment.as_ref(),
)
.await
.map_err(|err| match err {

View File

@@ -96,7 +96,10 @@ async fn exec_command_with_tty(
&request,
tty,
Box::new(NoopSpawnLifecycle),
turn.environment.as_ref().expect("turn environment"),
turn.primary_environment()
.expect("turn environment")
.environment
.as_ref(),
)
.await?,
);
@@ -591,7 +594,7 @@ async fn remote_exec_server_rejects_inherited_fd_launches() -> anyhow::Result<()
let remote_test_env = remote_test_env().await?;
let (_, mut turn) = make_session_and_context().await;
turn.environment = Some(Arc::new(remote_test_env.environment().clone()));
turn.environments[0].environment = Arc::new(remote_test_env.environment().clone());
let request = test_exec_request(
&turn,
@@ -609,7 +612,10 @@ async fn remote_exec_server_rejects_inherited_fd_launches() -> anyhow::Result<()
Box::new(TestSpawnLifecycle {
inherited_fds: vec![42],
}),
turn.environment.as_ref().expect("turn environment"),
turn.primary_environment()
.expect("turn environment")
.environment
.as_ref(),
)
.await
.expect_err("expected inherited fd rejection");

View File

@@ -68,7 +68,7 @@ const DUMMY_FUNCTION_NAME: &str = "test_tool";
const DUMMY_CALL_ID: &str = "call-multi-auto";
const FUNCTION_CALL_LIMIT_MSG: &str = "function call limit push";
const POST_AUTO_USER_MSG: &str = "post auto follow-up";
const PRETURN_CONTEXT_DIFF_CWD: &str = "/tmp/PRETURN_CONTEXT_DIFF_CWD";
const PRETURN_CONTEXT_DIFF_CWD: &str = "PRETURN_CONTEXT_DIFF_CWD";
pub(super) const COMPACT_WARNING_MESSAGE: &str = "Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted.";
@@ -3008,7 +3008,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess
let request_log = mount_sse_sequence(&server, vec![sse1, sse2, sse3, sse4]).await;
let model_provider = non_openai_model_provider(&server);
let codex = test_codex()
let test = test_codex()
.with_config(move |config| {
config.model_provider = model_provider;
set_test_compact_prompt(config);
@@ -3016,8 +3016,11 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess
})
.build(&server)
.await
.expect("build codex")
.codex;
.expect("build codex");
let preturn_context_diff_cwd = test.cwd_path().join(PRETURN_CONTEXT_DIFF_CWD);
std::fs::create_dir_all(&preturn_context_diff_cwd)
.expect("create pre-turn context override cwd");
let codex = test.codex;
for user in ["USER_ONE", "USER_TWO"] {
codex
@@ -3036,7 +3039,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess
}
codex
.submit(Op::OverrideTurnContext {
cwd: Some(PathBuf::from(PRETURN_CONTEXT_DIFF_CWD)),
cwd: Some(preturn_context_diff_cwd.clone()),
approval_policy: None,
approvals_reviewer: None,
sandbox_policy: None,

View File

@@ -1,7 +1,6 @@
#![allow(clippy::expect_used)]
use std::fs;
use std::path::PathBuf;
use anyhow::Result;
use codex_core::compact::SUMMARY_PREFIX;
@@ -104,7 +103,7 @@ fn contains_defer_loading(value: &Value) -> bool {
}
}
const PRETURN_CONTEXT_DIFF_CWD: &str = "/tmp/PRETURN_CONTEXT_DIFF_CWD";
const PRETURN_CONTEXT_DIFF_CWD: &str = "PRETURN_CONTEXT_DIFF_CWD";
const DUMMY_FUNCTION_NAME: &str = "test_tool";
const REMOTE_COMPACT_TURN_COMPLETE_TIMEOUT: Duration = Duration::from_secs(30);
@@ -2213,6 +2212,9 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us
}),
)
.await?;
let preturn_context_diff_cwd = harness.test().cwd_path().join(PRETURN_CONTEXT_DIFF_CWD);
std::fs::create_dir_all(&preturn_context_diff_cwd)
.expect("create pre-turn context override cwd");
let codex = harness.test().codex.clone();
let responses_mock = responses::mount_sse_sequence(
@@ -2244,7 +2246,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us
if user == "USER_THREE" {
codex
.submit(Op::OverrideTurnContext {
cwd: Some(PathBuf::from(PRETURN_CONTEXT_DIFF_CWD)),
cwd: Some(preturn_context_diff_cwd.clone()),
approval_policy: None,
approvals_reviewer: None,
sandbox_policy: None,

View File

@@ -29,6 +29,7 @@ fn resume_history(
turn_id: Some(turn_id.clone()),
trace_id: None,
cwd: config.cwd.to_path_buf(),
environments: None,
current_date: None,
timezone: None,
approval_policy: config.permissions.approval_policy.value(),

View File

@@ -107,7 +107,7 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = "<realtime_conversation>";
pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = "</realtime_conversation>";
pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:";
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)]
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
pub struct TurnEnvironmentSelection {
pub environment_id: String,
pub cwd: AbsolutePathBuf,
@@ -2854,6 +2854,8 @@ pub struct TurnContextItem {
pub trace_id: Option<String>,
pub cwd: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environments: Option<Vec<TurnEnvironmentSelection>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_date: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timezone: Option<String>,
@@ -5116,6 +5118,7 @@ mod tests {
turn_id: None,
trace_id: None,
cwd: test_path_buf("/tmp"),
environments: None,
current_date: None,
timezone: None,
approval_policy: AskForApproval::Never,

View File

@@ -1101,6 +1101,7 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re
turn_id: Some("turn-1".to_string()),
trace_id: None,
cwd: latest_cwd.clone(),
environments: None,
current_date: None,
timezone: None,
approval_policy: AskForApproval::Never,

View File

@@ -296,6 +296,7 @@ mod tests {
turn_id: Some("turn-1".to_string()),
trace_id: None,
cwd: PathBuf::from("/parent/workspace"),
environments: None,
current_date: None,
timezone: None,
approval_policy: AskForApproval::Never,
@@ -336,6 +337,7 @@ mod tests {
turn_id: Some("turn-1".to_string()),
trace_id: None,
cwd: PathBuf::from("/fallback/workspace"),
environments: None,
current_date: None,
timezone: None,
approval_policy: AskForApproval::OnRequest,
@@ -370,6 +372,7 @@ mod tests {
turn_id: Some("turn-1".to_string()),
trace_id: None,
cwd: PathBuf::from("/fallback/workspace"),
environments: None,
current_date: None,
timezone: None,
approval_policy: AskForApproval::OnRequest,