diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index f556890a96..9056eb9f3a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -7,6 +7,7 @@ use super::NetworkApprovalProtocol; use super::NetworkPolicyAmendment; use super::RequestPermissionProfile; use super::UserInput; +use super::WorkspaceMutationApprovalRequest; use super::shared::v2_enum_from_core; use crate::protocol::item_builders::convert_patch_changes; use codex_experimental_api_macros::ExperimentalApi; @@ -573,6 +574,7 @@ pub struct GuardianMcpToolCallReviewAction { pub struct GuardianRequestPermissionsReviewAction { pub reason: Option, pub permissions: RequestPermissionProfile, + pub workspace_mutation: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -623,6 +625,7 @@ pub enum GuardianApprovalReviewAction { RequestPermissions { reason: Option, permissions: RequestPermissionProfile, + workspace_mutation: Option, }, } @@ -679,9 +682,11 @@ impl From for GuardianApprovalReviewAction { CoreGuardianAssessmentAction::RequestPermissions { reason, permissions, + workspace_mutation, } => Self::RequestPermissions { reason, permissions: permissions.into(), + workspace_mutation: workspace_mutation.map(Into::into), }, } } @@ -740,9 +745,11 @@ impl From for CoreGuardianAssessmentAction { GuardianApprovalReviewAction::RequestPermissions { reason, permissions, + workspace_mutation, } => Self::RequestPermissions { reason, permissions: permissions.into(), + workspace_mutation: workspace_mutation.map(Into::into), }, } } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index 45fc5c7e14..54181a8a0f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -15,6 +15,8 @@ use codex_protocol::permissions::FileSystemSpecialPath as CoreFileSystemSpecialP use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; +use codex_protocol::request_permissions::WorkspaceMutationApprovalRequest as CoreWorkspaceMutationApprovalRequest; +use codex_protocol::request_permissions::WorkspaceMutationOperation as CoreWorkspaceMutationOperation; use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use serde::Deserialize; @@ -656,7 +658,7 @@ impl From for NetworkPolicyAmendment { } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct PermissionsRequestApprovalParams { @@ -671,6 +673,43 @@ pub struct PermissionsRequestApprovalParams { pub cwd: AbsolutePathBuf, pub reason: Option, pub permissions: RequestPermissionProfile, + pub workspace_mutation: Option, +} + +v2_enum_from_core!( + pub enum WorkspaceMutationOperation from CoreWorkspaceMutationOperation { + SetWorkingDirectory, + AddWorkspaceRoot + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WorkspaceMutationApprovalRequest { + pub operation: WorkspaceMutationOperation, + pub target: AbsolutePathBuf, + pub resulting_workspace_roots: Vec, +} + +impl From for WorkspaceMutationApprovalRequest { + fn from(value: CoreWorkspaceMutationApprovalRequest) -> Self { + Self { + operation: value.operation.into(), + target: value.target, + resulting_workspace_roots: value.resulting_workspace_roots, + } + } +} + +impl From for CoreWorkspaceMutationApprovalRequest { + fn from(value: WorkspaceMutationApprovalRequest) -> Self { + Self { + operation: value.operation.to_core(), + target: value.target, + resulting_workspace_roots: value.resulting_workspace_roots, + } + } } v2_enum_from_core!( diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 135909bf00..8c7868e343 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -770,13 +770,30 @@ async fn handle_request_permissions( #[allow(deprecated)] parent_ctx.cwd.clone() }); - let response_fut = parent_session.request_permissions_for_cwd( - parent_ctx, - call_id.clone(), - args, - cwd, - cancel_token.clone(), - ); + let response_fut = async { + if let Some(workspace_mutation) = event.workspace_mutation { + parent_session + .request_workspace_permissions_for_cwd( + parent_ctx, + call_id.clone(), + args, + cwd, + workspace_mutation, + cancel_token.clone(), + ) + .await + } else { + parent_session + .request_permissions_for_cwd( + parent_ctx, + call_id.clone(), + args, + cwd, + cancel_token.clone(), + ) + .await + } + }; let response = await_request_permissions_with_cancel(response_fut, parent_session, &call_id, cancel_token) .await; diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index c881823d80..5d389888c7 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -240,6 +240,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { ..RequestPermissionProfile::default() }, cwd: Some(request_cwd), + workspace_mutation: None, }, &cancel_token, ) diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index 0f830f9e01..45cc20c15f 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -21,6 +21,7 @@ pub(crate) fn default_thread_environment_selections( .map(|environment_id| TurnEnvironmentSelection { environment_id, cwd: cwd.clone(), + workspace_roots: Vec::new(), }) .collect() } @@ -100,6 +101,7 @@ pub(crate) async fn resolve_environment_selections( environment_id, environment, cwd: selected_environment.cwd.clone(), + workspace_roots: selected_environment.workspace_roots.clone(), shell, }); } @@ -140,6 +142,7 @@ mod tests { vec![TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd, + workspace_roots: Vec::new(), }] ); } @@ -168,10 +171,12 @@ url = "ws://127.0.0.1:8765" TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: cwd.clone(), + workspace_roots: Vec::new(), }, TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd, + workspace_roots: Vec::new(), }, ] ); @@ -199,10 +204,12 @@ url = "ws://127.0.0.1:8765" TurnEnvironmentSelection { environment_id: "local".to_string(), cwd: cwd.clone(), + workspace_roots: Vec::new(), }, TurnEnvironmentSelection { environment_id: "local".to_string(), cwd: cwd.join("other"), + workspace_roots: Vec::new(), }, ], ) @@ -223,6 +230,7 @@ url = "ws://127.0.0.1:8765" &[TurnEnvironmentSelection { environment_id: "local".to_string(), cwd: selected_cwd, + workspace_roots: Vec::new(), }], ) .await diff --git a/codex-rs/core/src/guardian/approval_request.rs b/codex-rs/core/src/guardian/approval_request.rs index fba227834a..3fd21d09ec 100644 --- a/codex-rs/core/src/guardian/approval_request.rs +++ b/codex-rs/core/src/guardian/approval_request.rs @@ -6,6 +6,7 @@ use codex_protocol::approvals::GuardianCommandSource; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::request_permissions::RequestPermissionProfile; +use codex_protocol::request_permissions::WorkspaceMutationApprovalRequest; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Serialize; use serde_json::Value; @@ -73,6 +74,7 @@ pub(crate) enum GuardianApprovalRequest { turn_id: String, reason: Option, permissions: RequestPermissionProfile, + workspace_mutation: Option, }, } @@ -167,6 +169,8 @@ struct RequestPermissionsApprovalAction<'a> { #[serde(skip_serializing_if = "Option::is_none")] reason: Option<&'a String>, permissions: &'a RequestPermissionProfile, + #[serde(skip_serializing_if = "Option::is_none")] + workspace_mutation: Option<&'a WorkspaceMutationApprovalRequest>, } fn serialize_guardian_action(value: impl Serialize) -> serde_json::Result { @@ -363,11 +367,13 @@ pub(crate) fn guardian_approval_request_to_json( turn_id, reason, permissions, + workspace_mutation, } => serialize_guardian_action(RequestPermissionsApprovalAction { tool: "request_permissions", turn_id, reason: reason.as_ref(), permissions, + workspace_mutation: workspace_mutation.as_ref(), }), } } @@ -432,10 +438,12 @@ pub(crate) fn guardian_assessment_action( GuardianApprovalRequest::RequestPermissions { reason, permissions, + workspace_mutation, .. } => GuardianAssessmentAction::RequestPermissions { reason: reason.clone(), permissions: permissions.clone(), + workspace_mutation: workspace_mutation.clone(), }, } } diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index bfb87a0ff3..5f277b3c64 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -155,7 +155,7 @@ async fn thread_settings_update( } } -async fn thread_settings_applied_event(sess: &Session) -> EventMsg { +pub(crate) async fn thread_settings_applied_event(sess: &Session) -> EventMsg { let snapshot = { let state = sess.state.lock().await; state.session_configuration.thread_config_snapshot() @@ -171,6 +171,7 @@ async fn thread_settings_applied_event(sess: &Session) -> EventMsg { permission_profile: snapshot.permission_profile, active_permission_profile: snapshot.active_permission_profile, cwd, + workspace_roots: snapshot.workspace_roots, reasoning_effort: snapshot.reasoning_effort, reasoning_summary: snapshot.reasoning_summary, personality: snapshot.personality, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index d82bac1041..9d41308c68 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -129,6 +129,7 @@ use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsArgs; use codex_protocol::request_permissions::RequestPermissionsEvent; use codex_protocol::request_permissions::RequestPermissionsResponse; +use codex_protocol::request_permissions::WorkspaceMutationApprovalRequest; use codex_protocol::request_user_input::RequestUserInputArgs; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationResponse; @@ -208,6 +209,7 @@ mod mcp; mod multi_agents; mod review; mod rollout_reconstruction; +pub(crate) use rollout_reconstruction::runtime_workspace_baseline_from_initial_history; #[allow(clippy::module_inception)] pub(crate) mod session; mod token_budget; @@ -1491,6 +1493,54 @@ impl Session { .map(|configuration| configuration.thread_config_snapshot()) } + pub(crate) async fn update_runtime_workspace( + &self, + turn_context: &TurnContext, + cwd: Option, + workspace_roots: Vec, + ) -> ConstraintResult { + let environments = { + let state = self.state.lock().await; + let mut environments = state.session_configuration.environments.clone(); + if let Some(cwd) = cwd { + environments.legacy_fallback_cwd = cwd.clone(); + if let Some(environment) = environments.environments.first_mut() { + environment.cwd = cwd; + } + } + if let Some(environment) = environments.environments.first_mut() { + environment.workspace_roots = workspace_roots.clone(); + } + environments + }; + self.update_settings(SessionSettingsUpdate { + environments: Some(environments), + workspace_roots: Some(workspace_roots.clone()), + ..Default::default() + }) + .await?; + let snapshot = { + let state = self.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; + let runtime_workspace = crate::session::turn_context::RuntimeWorkspaceSnapshot { + cwd: snapshot.cwd().clone(), + workspace_roots: snapshot.workspace_roots.clone(), + permission_profile: snapshot.permission_profile.clone(), + }; + turn_context + .runtime_workspace + .replace(runtime_workspace.clone()) + .await; + let turn_context_item = + turn_context.to_turn_context_item_with_runtime_workspace(&runtime_workspace); + self.persist_rollout_items(&[RolloutItem::TurnContext(turn_context_item.clone())]) + .await; + let mut state = self.state.lock().await; + state.set_reference_context_item(Some(turn_context_item)); + Ok(snapshot) + } + pub(crate) async fn set_session_startup_prewarm( &self, startup_prewarm: SessionStartupPrewarmHandle, @@ -2171,6 +2221,67 @@ impl Session { args: RequestPermissionsArgs, environment: TurnEnvironmentSelection, cancellation_token: CancellationToken, + ) -> Option { + self.request_permissions_for_environment_with_workspace_mutation( + turn_context, + call_id, + args, + environment, + /*workspace_mutation*/ None, + cancellation_token, + ) + .await + } + + pub(crate) async fn request_workspace_permissions_for_cwd( + self: &Arc, + turn_context: &Arc, + call_id: String, + args: RequestPermissionsArgs, + cwd: AbsolutePathBuf, + workspace_mutation: WorkspaceMutationApprovalRequest, + cancellation_token: CancellationToken, + ) -> Option { + let turn_environment = match args.environment_id.as_deref() { + Some(environment_id) => turn_context + .environments + .turn_environments + .iter() + .find(|environment| environment.environment_id == environment_id), + None => turn_context.environments.primary(), + }; + let Some(turn_environment) = turn_environment else { + return Some(RequestPermissionsResponse { + permissions: RequestPermissionProfile::default(), + scope: PermissionGrantScope::Turn, + strict_auto_review: false, + }); + }; + let mut environment = turn_environment.selection(); + environment.cwd = cwd; + self.request_permissions_for_environment_with_workspace_mutation( + turn_context, + call_id, + args, + environment, + Some(workspace_mutation), + cancellation_token, + ) + .await + } + + #[expect( + clippy::await_holding_invalid_type, + reason = "active turn checks and turn state updates must remain atomic" + )] + async fn request_permissions_for_environment_with_workspace_mutation( + self: &Arc, + turn_context: &Arc, + call_id: String, + args: RequestPermissionsArgs, + environment: TurnEnvironmentSelection, + workspace_mutation: Option, + cancellation_token: CancellationToken, ) -> Option { match turn_context.as_ref().approval_policy.value() { AskForApproval::Never => { @@ -2210,6 +2321,7 @@ impl Session { turn_id: turn_context.sub_id.clone(), reason: args.reason, permissions: requested_permissions.clone(), + workspace_mutation: workspace_mutation.clone(), }; let review_rx = crate::guardian::spawn_approval_request_review( session, @@ -2225,11 +2337,16 @@ impl Session { _ = cancellation_token.cancelled() => return None, decision = review_rx => decision.unwrap_or(ReviewDecision::Denied), }; + let approved_scope = if workspace_mutation.is_some() { + PermissionGrantScope::Session + } else { + PermissionGrantScope::Turn + }; let response = match decision { ReviewDecision::Approved | ReviewDecision::ApprovedExecpolicyAmendment { .. } => { RequestPermissionsResponse { permissions: requested_permissions.clone(), - scope: PermissionGrantScope::Turn, + scope: approved_scope, strict_auto_review: false, } } @@ -2304,6 +2421,7 @@ impl Session { reason: args.reason, permissions: requested_permissions, cwd: Some(environment.cwd), + workspace_mutation, }); self.send_event(turn_context.as_ref(), event).await; tokio::select! { diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 9a7dfc8298..588aa71511 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -138,6 +138,7 @@ pub(super) async fn spawn_review_thread( personality: parent_turn_context.personality, approval_policy: parent_turn_context.approval_policy.clone(), permission_profile: parent_turn_context.permission_profile(), + runtime_workspace: Arc::clone(&parent_turn_context.runtime_workspace), network: parent_turn_context.network.clone(), windows_sandbox_level: parent_turn_context.windows_sandbox_level, shell_environment_policy: parent_turn_context.shell_environment_policy.clone(), diff --git a/codex-rs/core/src/session/rollout_reconstruction.rs b/codex-rs/core/src/session/rollout_reconstruction.rs index e886648512..ca2019120e 100644 --- a/codex-rs/core/src/session/rollout_reconstruction.rs +++ b/codex-rs/core/src/session/rollout_reconstruction.rs @@ -11,6 +11,21 @@ pub(super) struct RolloutReconstruction { pub(super) window_id: u64, } +#[derive(Debug)] +struct RolloutReplayMetadata<'a> { + base_replacement_history: Option<&'a [ResponseItem]>, + previous_turn_settings: Option, + reference_context_item: Option, + rollout_suffix: &'a [RolloutItem], + window_id: Option, +} + +#[derive(Debug)] +pub(crate) struct RuntimeWorkspaceBaseline { + pub(crate) cwd: AbsolutePathBuf, + pub(crate) workspace_roots: Vec, +} + #[derive(Debug, Default)] enum TurnReferenceContextItem { /// No `TurnContextItem` has been seen for this replay span yet. @@ -90,160 +105,211 @@ fn finalize_active_segment<'a>( } } +fn reconstruct_rollout_metadata(rollout_items: &[RolloutItem]) -> RolloutReplayMetadata<'_> { + // Replay metadata should already match the shape of the future lazy reverse loader, even + // while history materialization still uses an eager bridge. Scan newest-to-oldest, + // stopping once a surviving replacement-history checkpoint and the required resume metadata + // are both known; then replay only the buffered surviving tail forward to preserve exact + // history semantics. + let mut base_replacement_history: Option<&[ResponseItem]> = None; + let mut previous_turn_settings = None; + let mut reference_context_item = TurnReferenceContextItem::NeverSet; + let mut window_id = None; + // Rollback is "drop the newest N user turns". While scanning in reverse, that becomes + // "skip the next N user-turn segments we finalize". + let mut pending_rollback_turns = 0usize; + // Borrowed suffix of rollout items newer than the newest surviving replacement-history + // checkpoint. If no such checkpoint exists, this remains the full rollout. + let mut rollout_suffix = rollout_items; + // Reverse replay accumulates rollout items into the newest in-progress turn segment until + // we hit its matching `TurnStarted`, at which point the segment can be finalized. + let mut active_segment: Option> = None; + + for (index, item) in rollout_items.iter().enumerate().rev() { + match item { + RolloutItem::Compacted(compacted) => { + let active_segment = + active_segment.get_or_insert_with(ActiveReplaySegment::default); + if active_segment.window_id.is_none() { + active_segment.window_id = compacted.window_id; + } + // Looking backward, compaction clears any older baseline unless a newer + // `TurnContextItem` in this same segment has already re-established it. + if matches!( + active_segment.reference_context_item, + TurnReferenceContextItem::NeverSet + ) { + active_segment.reference_context_item = TurnReferenceContextItem::Cleared; + } + if active_segment.base_replacement_history.is_none() + && let Some(replacement_history) = &compacted.replacement_history + { + active_segment.base_replacement_history = Some(replacement_history); + rollout_suffix = &rollout_items[index + 1..]; + } + } + RolloutItem::EventMsg(EventMsg::ThreadRolledBack(rollback)) => { + pending_rollback_turns = pending_rollback_turns + .saturating_add(usize::try_from(rollback.num_turns).unwrap_or(usize::MAX)); + } + RolloutItem::EventMsg(EventMsg::TurnComplete(event)) => { + let active_segment = + active_segment.get_or_insert_with(ActiveReplaySegment::default); + // Reverse replay often sees `TurnComplete` before any turn-scoped metadata. + // Capture the turn id early so later `TurnContext` / abort items can match it. + if active_segment.turn_id.is_none() { + active_segment.turn_id = Some(event.turn_id.clone()); + } + } + RolloutItem::EventMsg(EventMsg::TurnAborted(event)) => { + if let Some(active_segment) = active_segment.as_mut() { + if active_segment.turn_id.is_none() + && let Some(turn_id) = &event.turn_id + { + active_segment.turn_id = Some(turn_id.clone()); + } + } else if let Some(turn_id) = &event.turn_id { + active_segment = Some(ActiveReplaySegment { + turn_id: Some(turn_id.clone()), + ..Default::default() + }); + } + } + RolloutItem::EventMsg(EventMsg::UserMessage(_)) => { + let active_segment = + active_segment.get_or_insert_with(ActiveReplaySegment::default); + active_segment.counts_as_user_turn = true; + } + RolloutItem::TurnContext(ctx) => { + let active_segment = + active_segment.get_or_insert_with(ActiveReplaySegment::default); + // `TurnContextItem` can attach metadata to an existing segment, but only a + // real `UserMessage` event should make the segment count as a user turn. + if active_segment.turn_id.is_none() { + active_segment.turn_id = ctx.turn_id.clone(); + } + if turn_ids_are_compatible( + active_segment.turn_id.as_deref(), + ctx.turn_id.as_deref(), + ) { + active_segment.previous_turn_settings = Some(PreviousTurnSettings { + model: ctx.model.clone(), + comp_hash: ctx.comp_hash.clone(), + realtime_active: ctx.realtime_active, + }); + if matches!( + active_segment.reference_context_item, + TurnReferenceContextItem::NeverSet + ) { + active_segment.reference_context_item = + TurnReferenceContextItem::Latest(Box::new(ctx.clone())); + } + } + } + RolloutItem::EventMsg(EventMsg::TurnStarted(event)) => { + // `TurnStarted` is the oldest boundary of the active reverse segment. + if active_segment.as_ref().is_some_and(|active_segment| { + turn_ids_are_compatible( + active_segment.turn_id.as_deref(), + Some(event.turn_id.as_str()), + ) + }) && let Some(active_segment) = active_segment.take() + { + finalize_active_segment( + active_segment, + &mut base_replacement_history, + &mut previous_turn_settings, + &mut reference_context_item, + &mut window_id, + &mut pending_rollback_turns, + ); + } + } + RolloutItem::ResponseItem(response_item) => { + let active_segment = + active_segment.get_or_insert_with(ActiveReplaySegment::default); + active_segment.counts_as_user_turn |= is_user_turn_boundary(response_item); + } + RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => {} + } + + if base_replacement_history.is_some() + && previous_turn_settings.is_some() + && !matches!(reference_context_item, TurnReferenceContextItem::NeverSet) + { + // At this point we have both eager resume metadata values and the replacement- + // history base for the surviving tail, so older rollout items cannot affect this + // result. + break; + } + } + + if let Some(active_segment) = active_segment.take() { + finalize_active_segment( + active_segment, + &mut base_replacement_history, + &mut previous_turn_settings, + &mut reference_context_item, + &mut window_id, + &mut pending_rollback_turns, + ); + } + + let reference_context_item = match reference_context_item { + TurnReferenceContextItem::NeverSet | TurnReferenceContextItem::Cleared => None, + TurnReferenceContextItem::Latest(turn_reference_context_item) => { + Some(*turn_reference_context_item) + } + }; + let saw_legacy_compaction_without_replacement_history = rollout_suffix.iter().any(|item| { + matches!( + item, + RolloutItem::Compacted(compacted) if compacted.replacement_history.is_none() + ) + }); + + RolloutReplayMetadata { + base_replacement_history, + previous_turn_settings, + reference_context_item: if saw_legacy_compaction_without_replacement_history { + None + } else { + reference_context_item + }, + rollout_suffix, + window_id, + } +} + +pub(crate) fn runtime_workspace_baseline_from_initial_history( + initial_history: &InitialHistory, +) -> Option { + let rollout_items = match initial_history { + InitialHistory::New | InitialHistory::Cleared => return None, + InitialHistory::Resumed(resumed) => resumed.history.as_slice(), + InitialHistory::Forked(rollout_items) => rollout_items.as_slice(), + }; + let reference_context_item = + reconstruct_rollout_metadata(rollout_items).reference_context_item?; + Some(RuntimeWorkspaceBaseline { + cwd: AbsolutePathBuf::try_from(reference_context_item.cwd).ok()?, + workspace_roots: reference_context_item.workspace_roots?, + }) +} + impl Session { pub(super) async fn reconstruct_history_from_rollout( &self, turn_context: &TurnContext, rollout_items: &[RolloutItem], ) -> RolloutReconstruction { - // Replay metadata should already match the shape of the future lazy reverse loader, even - // while history materialization still uses an eager bridge. Scan newest-to-oldest, - // stopping once a surviving replacement-history checkpoint and the required resume metadata - // are both known; then replay only the buffered surviving tail forward to preserve exact - // history semantics. - let mut base_replacement_history: Option<&[ResponseItem]> = None; - let mut previous_turn_settings = None; - let mut reference_context_item = TurnReferenceContextItem::NeverSet; - let mut window_id = None; - // Rollback is "drop the newest N user turns". While scanning in reverse, that becomes - // "skip the next N user-turn segments we finalize". - let mut pending_rollback_turns = 0usize; - // Borrowed suffix of rollout items newer than the newest surviving replacement-history - // checkpoint. If no such checkpoint exists, this remains the full rollout. - let mut rollout_suffix = rollout_items; - // Reverse replay accumulates rollout items into the newest in-progress turn segment until - // we hit its matching `TurnStarted`, at which point the segment can be finalized. - let mut active_segment: Option> = None; - - for (index, item) in rollout_items.iter().enumerate().rev() { - match item { - RolloutItem::Compacted(compacted) => { - let active_segment = - active_segment.get_or_insert_with(ActiveReplaySegment::default); - if active_segment.window_id.is_none() { - active_segment.window_id = compacted.window_id; - } - // Looking backward, compaction clears any older baseline unless a newer - // `TurnContextItem` in this same segment has already re-established it. - if matches!( - active_segment.reference_context_item, - TurnReferenceContextItem::NeverSet - ) { - active_segment.reference_context_item = TurnReferenceContextItem::Cleared; - } - if active_segment.base_replacement_history.is_none() - && let Some(replacement_history) = &compacted.replacement_history - { - active_segment.base_replacement_history = Some(replacement_history); - rollout_suffix = &rollout_items[index + 1..]; - } - } - RolloutItem::EventMsg(EventMsg::ThreadRolledBack(rollback)) => { - pending_rollback_turns = pending_rollback_turns - .saturating_add(usize::try_from(rollback.num_turns).unwrap_or(usize::MAX)); - } - RolloutItem::EventMsg(EventMsg::TurnComplete(event)) => { - let active_segment = - active_segment.get_or_insert_with(ActiveReplaySegment::default); - // Reverse replay often sees `TurnComplete` before any turn-scoped metadata. - // Capture the turn id early so later `TurnContext` / abort items can match it. - if active_segment.turn_id.is_none() { - active_segment.turn_id = Some(event.turn_id.clone()); - } - } - RolloutItem::EventMsg(EventMsg::TurnAborted(event)) => { - if let Some(active_segment) = active_segment.as_mut() { - if active_segment.turn_id.is_none() - && let Some(turn_id) = &event.turn_id - { - active_segment.turn_id = Some(turn_id.clone()); - } - } else if let Some(turn_id) = &event.turn_id { - active_segment = Some(ActiveReplaySegment { - turn_id: Some(turn_id.clone()), - ..Default::default() - }); - } - } - RolloutItem::EventMsg(EventMsg::UserMessage(_)) => { - let active_segment = - active_segment.get_or_insert_with(ActiveReplaySegment::default); - active_segment.counts_as_user_turn = true; - } - RolloutItem::TurnContext(ctx) => { - let active_segment = - active_segment.get_or_insert_with(ActiveReplaySegment::default); - // `TurnContextItem` can attach metadata to an existing segment, but only a - // real `UserMessage` event should make the segment count as a user turn. - if active_segment.turn_id.is_none() { - active_segment.turn_id = ctx.turn_id.clone(); - } - if turn_ids_are_compatible( - active_segment.turn_id.as_deref(), - ctx.turn_id.as_deref(), - ) { - active_segment.previous_turn_settings = Some(PreviousTurnSettings { - model: ctx.model.clone(), - comp_hash: ctx.comp_hash.clone(), - realtime_active: ctx.realtime_active, - }); - if matches!( - active_segment.reference_context_item, - TurnReferenceContextItem::NeverSet - ) { - active_segment.reference_context_item = - TurnReferenceContextItem::Latest(Box::new(ctx.clone())); - } - } - } - RolloutItem::EventMsg(EventMsg::TurnStarted(event)) => { - // `TurnStarted` is the oldest boundary of the active reverse segment. - if active_segment.as_ref().is_some_and(|active_segment| { - turn_ids_are_compatible( - active_segment.turn_id.as_deref(), - Some(event.turn_id.as_str()), - ) - }) && let Some(active_segment) = active_segment.take() - { - finalize_active_segment( - active_segment, - &mut base_replacement_history, - &mut previous_turn_settings, - &mut reference_context_item, - &mut window_id, - &mut pending_rollback_turns, - ); - } - } - RolloutItem::ResponseItem(response_item) => { - let active_segment = - active_segment.get_or_insert_with(ActiveReplaySegment::default); - active_segment.counts_as_user_turn |= is_user_turn_boundary(response_item); - } - RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => {} - } - - if base_replacement_history.is_some() - && previous_turn_settings.is_some() - && !matches!(reference_context_item, TurnReferenceContextItem::NeverSet) - { - // At this point we have both eager resume metadata values and the replacement- - // history base for the surviving tail, so older rollout items cannot affect this - // result. - break; - } - } - - if let Some(active_segment) = active_segment.take() { - finalize_active_segment( - active_segment, - &mut base_replacement_history, - &mut previous_turn_settings, - &mut reference_context_item, - &mut window_id, - &mut pending_rollback_turns, - ); - } + let RolloutReplayMetadata { + base_replacement_history, + previous_turn_settings, + reference_context_item, + rollout_suffix, + window_id, + } = reconstruct_rollout_metadata(rollout_items); let fallback_window_id = u64::try_from( rollout_items @@ -302,12 +368,6 @@ impl Session { } } - let reference_context_item = match reference_context_item { - TurnReferenceContextItem::NeverSet | TurnReferenceContextItem::Cleared => None, - TurnReferenceContextItem::Latest(turn_reference_context_item) => { - Some(*turn_reference_context_item) - } - }; let reference_context_item = if saw_legacy_compaction_without_replacement_history { None } else { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 214489a18a..fbd5698d11 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4039,6 +4039,7 @@ fn turn_environments_for_tests( environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), environment: Arc::clone(environment), cwd: cwd.clone(), + workspace_roots: vec![cwd.clone()], shell: None, }], } @@ -6252,6 +6253,7 @@ async fn primary_environment_uses_first_turn_environment() { environment_id: "second".to_string(), environment: Arc::clone(&first_environment.environment), cwd: second_cwd.clone(), + workspace_roots: vec![second_cwd.clone()], shell: None, }); @@ -8079,6 +8081,58 @@ async fn turn_context_item_stores_split_file_system_sandbox_policy_when_differen ); } +#[tokio::test] +async fn update_runtime_workspace_persists_resume_and_fork_baseline() { + let (mut session, turn_context) = make_session_and_context().await; + let rollout_path = attach_thread_persistence(&mut session).await; + let updated_cwd = test_path_buf("/workspace/updated").abs(); + let updated_roots = vec![ + test_path_buf("/workspace").abs(), + test_path_buf("/external").abs(), + ]; + + let snapshot = session + .update_runtime_workspace( + &turn_context, + Some(updated_cwd.clone()), + updated_roots.clone(), + ) + .await + .expect("update runtime workspace"); + let runtime_workspace = turn_context.runtime_workspace.snapshot().await; + let expected_context_item = + turn_context.to_turn_context_item_with_runtime_workspace(&runtime_workspace); + + assert_eq!(snapshot.cwd, updated_cwd); + assert_eq!(snapshot.workspace_roots, updated_roots); + assert_eq!( + serde_json::to_value(session.reference_context_item().await) + .expect("serialize runtime workspace reference context item"), + serde_json::to_value(Some(expected_context_item.clone())) + .expect("serialize expected runtime workspace context item") + ); + + session.ensure_rollout_materialized().await; + session.flush_rollout().await.expect("rollout should flush"); + + let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path) + .await + .expect("read rollout history") + else { + panic!("expected resumed rollout history"); + }; + let persisted_context_item = resumed.history.iter().rev().find_map(|item| match item { + RolloutItem::TurnContext(ctx) => Some(ctx.clone()), + _ => None, + }); + assert_eq!( + serde_json::to_value(persisted_context_item) + .expect("serialize persisted runtime workspace context item"), + serde_json::to_value(Some(expected_context_item)) + .expect("serialize expected persisted runtime workspace context item") + ); +} + #[tokio::test] async fn record_context_updates_and_set_reference_context_item_injects_full_context_when_baseline_missing() { diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 0d3c9c033b..839474ee8b 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -22,6 +22,7 @@ use codex_model_provider::create_model_provider; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::models::AdditionalPermissionProfile as PermissionProfile; use codex_protocol::models::ContentItem; +use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::NetworkPermissions; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; @@ -30,6 +31,8 @@ use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsArgs; use codex_protocol::request_permissions::RequestPermissionsResponse; +use codex_protocol::request_permissions::WorkspaceMutationApprovalRequest; +use codex_protocol::request_permissions::WorkspaceMutationOperation; use core_test_support::PathExt; use core_test_support::TempDirExt; use core_test_support::codex_linux_sandbox_exe_or_skip; @@ -167,6 +170,100 @@ async fn request_permissions_routes_to_guardian_when_reviewer_is_enabled() { assert!(guardian_request.body_contains_text("need network")); } +#[tokio::test] +async fn workspace_mutation_permissions_approved_by_guardian_use_session_scope() { + let server = start_mock_server().await; + let _guardian_request_log = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-guardian"), + ev_assistant_message( + "msg-guardian", + &serde_json::json!({ + "risk_level": "low", + "user_authorization": "high", + "outcome": "allow", + "rationale": "The user requested the persistent workspace mutation.", + }) + .to_string(), + ), + ev_completed("resp-guardian"), + ]), + ) + .await; + + let (mut session, mut turn_context_raw) = make_session_and_context().await; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + turn_context_raw + .approval_policy + .set(AskForApproval::OnRequest) + .expect("test setup should allow updating approval policy"); + turn_context_raw + .features + .enable(Feature::GuardianApproval) + .expect("test setup should allow enabling guardian approvals"); + let mut config = (*turn_context_raw.config).clone(); + config.approvals_reviewer = ApprovalsReviewer::AutoReview; + config.model_provider.base_url = Some(format!("{}/v1", server.uri())); + let config = Arc::new(config); + let models_manager = models_manager_with_provider( + config.codex_home.to_path_buf(), + Arc::clone(&session.services.auth_manager), + config.model_provider.clone(), + ); + session.services.models_manager = models_manager; + turn_context_raw.config = Arc::clone(&config); + turn_context_raw.provider = create_model_provider( + config.model_provider.clone(), + turn_context_raw.auth_manager.clone(), + ); + #[allow(deprecated)] + let cwd = turn_context_raw.cwd.clone(); + let session = Arc::new(session); + let turn_context = Arc::new(turn_context_raw); + + let requested_permissions = RequestPermissionProfile { + file_system: Some(FileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![cwd.clone()]), + )), + ..RequestPermissionProfile::default() + }; + let response = tokio::time::timeout( + Duration::from_secs(45), + session.request_workspace_permissions_for_cwd( + &turn_context, + "perm-call-workspace-mutation".to_string(), + RequestPermissionsArgs { + reason: Some("persist workspace mutation".to_string()), + permissions: requested_permissions.clone(), + }, + cwd.clone(), + WorkspaceMutationApprovalRequest { + operation: WorkspaceMutationOperation::SetWorkingDirectory, + target: cwd.clone(), + resulting_workspace_roots: vec![cwd], + }, + CancellationToken::new(), + ), + ) + .await + .expect("workspace mutation permissions should not wait for a client approval"); + + assert_eq!( + response, + Some(RequestPermissionsResponse { + permissions: requested_permissions.clone(), + scope: PermissionGrantScope::Session, + strict_auto_review: false, + }) + ); + assert_eq!( + session.granted_session_permissions().await, + Some(requested_permissions.into()) + ); +} + #[tokio::test] async fn request_permissions_guardian_review_stops_when_cancelled() { let server = start_mock_server().await; diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index bfadeeb55a..86fa9b9ac3 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -19,6 +19,7 @@ use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy; use codex_sandboxing::policy_transforms::effective_network_sandbox_policy; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; +use tokio::sync::RwLock; #[derive(Clone, Debug)] pub(crate) struct TurnSkillsContext { @@ -40,6 +41,7 @@ pub(crate) struct TurnEnvironment { pub(crate) environment_id: String, pub(crate) environment: Arc, pub(crate) cwd: AbsolutePathBuf, + pub(crate) workspace_roots: Vec, pub(crate) shell: Option, } @@ -48,10 +50,39 @@ impl TurnEnvironment { TurnEnvironmentSelection { environment_id: self.environment_id.clone(), cwd: self.cwd.clone(), + workspace_roots: self.workspace_roots.clone(), } } } +#[derive(Clone, Debug)] +pub(crate) struct RuntimeWorkspaceSnapshot { + pub(crate) cwd: AbsolutePathBuf, + pub(crate) workspace_roots: Vec, + pub(crate) permission_profile: PermissionProfile, +} + +#[derive(Debug)] +pub(crate) struct RuntimeWorkspaceState { + snapshot: RwLock, +} + +impl RuntimeWorkspaceState { + fn new(snapshot: RuntimeWorkspaceSnapshot) -> Self { + Self { + snapshot: RwLock::new(snapshot), + } + } + + pub(crate) async fn snapshot(&self) -> RuntimeWorkspaceSnapshot { + self.snapshot.read().await.clone() + } + + pub(crate) async fn replace(&self, snapshot: RuntimeWorkspaceSnapshot) { + *self.snapshot.write().await = snapshot; + } +} + /// The context needed for a single turn of the thread. #[derive(Debug)] pub struct TurnContext { @@ -87,6 +118,7 @@ pub struct TurnContext { pub(crate) personality: Option, pub(crate) approval_policy: Constrained, pub(crate) permission_profile: PermissionProfile, + pub(crate) runtime_workspace: Arc, pub(crate) network: Option, pub(crate) windows_sandbox_level: WindowsSandboxLevel, pub(crate) shell_environment_policy: ShellEnvironmentPolicy, @@ -126,13 +158,28 @@ impl TurnContext { } pub(crate) fn sandbox_policy(&self) -> SandboxPolicy { - compatibility_sandbox_policy_for_permission_profile( + self.sandbox_policy_for_permission_profile( &self.permission_profile, #[allow(deprecated)] &self.cwd, ) } + fn sandbox_policy_for_permission_profile( + &self, + permission_profile: &PermissionProfile, + cwd: &AbsolutePathBuf, + ) -> SandboxPolicy { + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); + let network_sandbox_policy = permission_profile.network_sandbox_policy(); + compatibility_sandbox_policy_for_permission_profile( + permission_profile, + &file_system_sandbox_policy, + network_sandbox_policy, + cwd.as_path(), + ) + } + pub(crate) fn effective_reasoning_effort(&self) -> Option { if self.model_info.supports_reasoning_summaries { self.reasoning_effort @@ -256,6 +303,7 @@ impl TurnContext { personality: self.personality, approval_policy: self.approval_policy.clone(), permission_profile: self.permission_profile.clone(), + runtime_workspace: Arc::clone(&self.runtime_workspace), network: self.network.clone(), windows_sandbox_level: self.windows_sandbox_level, shell_environment_policy: self.shell_environment_policy.clone(), @@ -288,13 +336,14 @@ impl TurnContext { .map_or_else(|| self.cwd.clone(), |path| self.cwd.join(path)) } - pub(crate) fn file_system_sandbox_context( + pub(crate) fn file_system_sandbox_context_for_permission_profile( &self, + permission_profile: &PermissionProfile, additional_permissions: Option, cwd: &AbsolutePathBuf, ) -> FileSystemSandboxContext { let (base_file_system_sandbox_policy, base_network_sandbox_policy) = - self.permission_profile.to_runtime_permissions(); + permission_profile.to_runtime_permissions(); let file_system_sandbox_policy = effective_file_system_sandbox_policy( &base_file_system_sandbox_policy, additional_permissions.as_ref(), @@ -304,7 +353,7 @@ impl TurnContext { additional_permissions.as_ref(), ); let permissions = PermissionProfile::from_runtime_permissions_with_enforcement( - self.permission_profile.enforcement(), + permission_profile.enforcement(), &file_system_sandbox_policy, network_sandbox_policy, ); @@ -321,17 +370,29 @@ impl TurnContext { } fn non_legacy_file_system_sandbox_policy(&self) -> Option { + self.non_legacy_file_system_sandbox_policy_for_permission_profile( + &self.permission_profile, + #[allow(deprecated)] + &self.cwd, + ) + } + + fn non_legacy_file_system_sandbox_policy_for_permission_profile( + &self, + permission_profile: &PermissionProfile, + cwd: &AbsolutePathBuf, + ) -> Option { // Omit the derived split filesystem policy when it is equivalent to // the legacy sandbox policy. This keeps turn-context payloads stable // while both fields exist; once callers consume only the split policy, // this comparison and the legacy projection should go away. + let sandbox_policy = self.sandbox_policy_for_permission_profile(permission_profile, cwd); let legacy_file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( - &self.sandbox_policy(), - #[allow(deprecated)] - &self.cwd, + &sandbox_policy, + cwd.as_path(), ); - let file_system_sandbox_policy = self.file_system_sandbox_policy(); + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); (file_system_sandbox_policy != legacy_file_system_sandbox_policy) .then_some(file_system_sandbox_policy) } @@ -367,6 +428,27 @@ impl TurnContext { } } + pub(crate) fn to_turn_context_item_with_runtime_workspace( + &self, + runtime_workspace: &RuntimeWorkspaceSnapshot, + ) -> TurnContextItem { + let mut item = self.to_turn_context_item(); + item.cwd = runtime_workspace.cwd.to_path_buf(); + item.workspace_roots = (!runtime_workspace.workspace_roots.is_empty()) + .then_some(runtime_workspace.workspace_roots.clone()); + item.sandbox_policy = self.sandbox_policy_for_permission_profile( + &runtime_workspace.permission_profile, + &runtime_workspace.cwd, + ); + item.permission_profile = Some(runtime_workspace.permission_profile.clone()); + item.file_system_sandbox_policy = self + .non_legacy_file_system_sandbox_policy_for_permission_profile( + &runtime_workspace.permission_profile, + &runtime_workspace.cwd, + ); + item + } + fn turn_context_network_item(&self) -> Option { let network = self .config @@ -526,6 +608,11 @@ impl Session { let (current_date, timezone) = local_time_context(); let extension_data = Arc::new(codex_extension_api::ExtensionData::new(sub_id.clone())); extension_data.insert(HostLoadedSkills::new(Arc::clone(&skills_outcome))); + let runtime_workspace = Arc::new(RuntimeWorkspaceState::new(RuntimeWorkspaceSnapshot { + cwd: cwd.clone(), + workspace_roots: session_configuration.workspace_roots.clone(), + permission_profile: session_configuration.permission_profile(), + })); TurnContext { sub_id, trace_id: current_span_trace_id(), @@ -559,6 +646,7 @@ impl Session { personality: session_configuration.personality, approval_policy: session_configuration.approval_policy.clone(), permission_profile: session_configuration.permission_profile(), + runtime_workspace, network, windows_sandbox_level: session_configuration.windows_sandbox_level, shell_environment_policy: per_turn_config.permissions.shell_environment_policy.clone(), diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index c332622acb..bc34f92d45 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -13,6 +13,7 @@ use crate::session::CodexSpawnArgs; use crate::session::CodexSpawnOk; use crate::session::INITIAL_SUBMIT_ID; use crate::session::resolve_multi_agent_version; +use crate::session::runtime_workspace_baseline_from_initial_history; use crate::shell_snapshot::ShellSnapshot; use crate::tasks::InterruptedTurnHistoryMarker; use crate::tasks::interrupted_turn_history_marker; @@ -91,6 +92,32 @@ static FORCE_TEST_THREAD_MANAGER_BEHAVIOR: AtomicBool = AtomicBool::new(false); type CapturedOps = Vec<(ThreadId, Op)>; type SharedCapturedOps = Arc>; +#[derive(Clone, Copy, Debug, Default)] +pub struct RuntimeWorkspaceReplayOverrides { + pub preserve_cwd: bool, + pub preserve_workspace_roots: bool, +} + +fn hydrate_runtime_workspace_from_history( + config: &mut Config, + initial_history: &InitialHistory, + overrides: RuntimeWorkspaceReplayOverrides, +) { + let Some(runtime_workspace) = runtime_workspace_baseline_from_initial_history(initial_history) + else { + return; + }; + if !overrides.preserve_cwd { + config.cwd = runtime_workspace.cwd; + } + if !overrides.preserve_workspace_roots { + config.workspace_roots = runtime_workspace.workspace_roots.clone(); + config + .permissions + .set_workspace_roots(runtime_workspace.workspace_roots); + } +} + pub(crate) fn set_thread_manager_test_mode_for_tests(enabled: bool) { FORCE_TEST_THREAD_MANAGER_BEHAVIOR.store(enabled, Ordering::Relaxed); } @@ -709,6 +736,31 @@ impl ThreadManager { auth_manager: Arc, parent_trace: Option, ) -> CodexResult { + self.resume_thread_with_history_preserving_runtime_workspace_overrides( + config, + initial_history, + auth_manager, + persist_extended_history, + parent_trace, + RuntimeWorkspaceReplayOverrides::default(), + ) + .await + } + + pub async fn resume_thread_with_history_preserving_runtime_workspace_overrides( + &self, + mut config: Config, + initial_history: InitialHistory, + auth_manager: Arc, + persist_extended_history: bool, + parent_trace: Option, + runtime_workspace_overrides: RuntimeWorkspaceReplayOverrides, + ) -> CodexResult { + hydrate_runtime_workspace_from_history( + &mut config, + &initial_history, + runtime_workspace_overrides, + ); let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), &config.cwd, @@ -906,6 +958,32 @@ impl ThreadManager { thread_source: Option, parent_trace: Option, ) -> CodexResult + where + S: Into, + { + self.fork_thread_from_history_preserving_runtime_workspace_overrides( + snapshot, + config, + history, + thread_source, + persist_extended_history, + parent_trace, + RuntimeWorkspaceReplayOverrides::default(), + ) + .await + } + + #[allow(clippy::too_many_arguments)] + pub async fn fork_thread_from_history_preserving_runtime_workspace_overrides( + &self, + snapshot: S, + config: Config, + history: InitialHistory, + thread_source: Option, + persist_extended_history: bool, + parent_trace: Option, + runtime_workspace_overrides: RuntimeWorkspaceReplayOverrides, + ) -> CodexResult where S: Into, { @@ -915,17 +993,20 @@ impl ThreadManager { history, thread_source, parent_trace, + runtime_workspace_overrides, ) .await } + #[allow(clippy::too_many_arguments)] async fn fork_thread_with_initial_history( &self, snapshot: ForkSnapshot, - config: Config, + mut config: Config, history: InitialHistory, thread_source: Option, parent_trace: Option, + runtime_workspace_overrides: RuntimeWorkspaceReplayOverrides, ) -> CodexResult { // `forked_from_id()` describes this history's existing lineage. When // forking a resumed thread, the child copies the resumed thread itself. @@ -947,6 +1028,7 @@ impl ThreadManager { let interrupted_marker = InterruptedTurnHistoryMarker::from_config_and_version(&config, multi_agent_version); let history = fork_history_from_snapshot(snapshot, history, interrupted_marker); + hydrate_runtime_workspace_from_history(&mut config, &history, runtime_workspace_overrides); let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), &config.cwd, @@ -1230,7 +1312,7 @@ impl ThreadManagerState { options: ResumeThreadWithHistoryOptions, ) -> CodexResult { let ResumeThreadWithHistoryOptions { - config, + mut config, initial_history, agent_control, session_source, @@ -1238,6 +1320,11 @@ impl ThreadManagerState { inherited_shell_snapshot, inherited_exec_policy, } = options; + hydrate_runtime_workspace_from_history( + &mut config, + &initial_history, + RuntimeWorkspaceReplayOverrides::default(), + ); let environments = default_thread_environment_selections(self.environment_manager.as_ref(), &config.cwd); let thread_source = initial_history.get_resumed_thread_source(); diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index e76b43f16e..862882c3e5 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -177,6 +177,96 @@ fn fork_thread_accepts_legacy_usize_snapshot_argument() { let _: fn(&ThreadManager, Config, std::path::PathBuf) = assert_legacy_snapshot_callsite; } +#[tokio::test] +async fn hydrate_runtime_workspace_from_history_restores_persisted_baseline() { + let (_session, turn_context) = make_session_and_context().await; + let persisted_cwd = PathBuf::from("/workspace/updated").abs(); + let persisted_roots = vec![ + PathBuf::from("/workspace").abs(), + PathBuf::from("/external").abs(), + ]; + let mut context_item = turn_context.to_turn_context_item(); + context_item.cwd = persisted_cwd.to_path_buf(); + context_item.workspace_roots = Some(persisted_roots.clone()); + let history = InitialHistory::Forked(vec![ + RolloutItem::ResponseItem(user_msg("mutate workspace")), + RolloutItem::TurnContext(context_item), + ]); + let mut config = test_config().await; + + hydrate_runtime_workspace_from_history( + &mut config, + &history, + RuntimeWorkspaceReplayOverrides::default(), + ); + + assert_eq!(config.cwd, persisted_cwd); + assert_eq!(config.workspace_roots, persisted_roots); + assert_eq!(config.permissions.workspace_roots(), persisted_roots); +} + +#[tokio::test] +async fn hydrate_runtime_workspace_from_history_preserves_explicit_replacements() { + let (_session, turn_context) = make_session_and_context().await; + let mut context_item = turn_context.to_turn_context_item(); + context_item.cwd = PathBuf::from("/workspace/persisted").abs().to_path_buf(); + context_item.workspace_roots = Some(vec![PathBuf::from("/persisted-root").abs()]); + let history = InitialHistory::Forked(vec![ + RolloutItem::ResponseItem(user_msg("mutate workspace")), + RolloutItem::TurnContext(context_item), + ]); + let requested_cwd = PathBuf::from("/workspace/requested").abs(); + let requested_roots = vec![PathBuf::from("/requested-root").abs()]; + let mut config = test_config().await; + config.cwd = requested_cwd.clone(); + config.workspace_roots = requested_roots.clone(); + config + .permissions + .set_workspace_roots(requested_roots.clone()); + + hydrate_runtime_workspace_from_history( + &mut config, + &history, + RuntimeWorkspaceReplayOverrides { + preserve_cwd: true, + preserve_workspace_roots: true, + }, + ); + + assert_eq!(config.cwd, requested_cwd); + assert_eq!(config.workspace_roots, requested_roots); + assert_eq!(config.permissions.workspace_roots(), requested_roots); +} + +#[tokio::test] +async fn hydrate_runtime_workspace_from_history_preserves_cwd_without_retargeting_roots() { + let (_session, turn_context) = make_session_and_context().await; + let persisted_roots = vec![PathBuf::from("/persisted-root").abs()]; + let mut context_item = turn_context.to_turn_context_item(); + context_item.cwd = PathBuf::from("/workspace/persisted").abs().to_path_buf(); + context_item.workspace_roots = Some(persisted_roots.clone()); + let history = InitialHistory::Forked(vec![ + RolloutItem::ResponseItem(user_msg("mutate workspace")), + RolloutItem::TurnContext(context_item), + ]); + let requested_cwd = PathBuf::from("/workspace/requested").abs(); + let mut config = test_config().await; + config.cwd = requested_cwd.clone(); + + hydrate_runtime_workspace_from_history( + &mut config, + &history, + RuntimeWorkspaceReplayOverrides { + preserve_cwd: true, + preserve_workspace_roots: false, + }, + ); + + assert_eq!(config.cwd, requested_cwd); + assert_eq!(config.workspace_roots, persisted_roots); + assert_eq!(config.permissions.workspace_roots(), persisted_roots); +} + #[test] fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { let items = vec![ @@ -330,6 +420,7 @@ async fn start_thread_rejects_explicit_local_environment_when_default_provider_i environments: vec![TurnEnvironmentSelection { environment_id: "local".to_string(), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), }], thread_extension_init: Default::default(), }) @@ -495,6 +586,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { let environments = vec![TurnEnvironmentSelection { environment_id: "local".to_string(), cwd: selected_cwd.clone(), + workspace_roots: Vec::new(), }]; let default_cwd = config.cwd.clone(); let mut source_config = config.clone(); diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 70452b6657..c4072824a8 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -264,7 +264,7 @@ fn apply_patch_payload_command(payload: &ToolPayload) -> Option { async fn effective_patch_permissions( session: &Session, - turn: &TurnContext, + permission_profile: &codex_protocol::models::PermissionProfile, environment_id: &str, action: &ApplyPatchAction, cwd: &AbsolutePathBuf, @@ -284,7 +284,7 @@ async fn effective_patch_permissions( .await .as_ref(), ); - let base_file_system_sandbox_policy = turn.file_system_sandbox_policy(); + let base_file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); let file_system_sandbox_policy = effective_file_system_sandbox_policy( &base_file_system_sandbox_policy, granted_permissions.as_ref(), @@ -358,9 +358,18 @@ impl ApplyPatchHandler { "apply_patch is unavailable in this session".to_string(), )); }; - let cwd = turn_environment.cwd.clone(); + let runtime_workspace = turn.runtime_workspace.snapshot().await; + let cwd = if turn.environments.turn_environments.len() == 1 { + runtime_workspace.cwd.clone() + } else { + turn_environment.cwd.clone() + }; let fs = turn_environment.environment.get_filesystem(); - let sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None, &cwd); + let sandbox = turn.file_system_sandbox_context_for_permission_profile( + &runtime_workspace.permission_profile, + /*additional_permissions*/ None, + &cwd, + ); match codex_apply_patch::verify_apply_patch_args(args, &cwd, fs.as_ref(), Some(&sandbox)) .await { @@ -368,7 +377,7 @@ impl ApplyPatchHandler { let (file_paths, effective_additional_permissions, file_system_sandbox_policy) = effective_patch_permissions( session.as_ref(), - turn.as_ref(), + &runtime_workspace.permission_profile, &turn_environment.environment_id, &changes, &cwd, @@ -522,7 +531,12 @@ pub(crate) async fn intercept_apply_patch( call_id: &str, tool_name: &str, ) -> Result, FunctionCallError> { - let sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None, cwd); + let runtime_workspace = turn.runtime_workspace.snapshot().await; + let sandbox = turn.file_system_sandbox_context_for_permission_profile( + &runtime_workspace.permission_profile, + /*additional_permissions*/ None, + cwd, + ); match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd, fs, Some(&sandbox)) .await { @@ -530,7 +544,7 @@ pub(crate) async fn intercept_apply_patch( let (approval_keys, effective_additional_permissions, file_system_sandbox_policy) = effective_patch_permissions( session.as_ref(), - turn.as_ref(), + &runtime_workspace.permission_profile, &turn_environment.environment_id, &changes, cwd, diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index b978a2f577..c1fb07e894 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -78,6 +78,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result Result Result { let shell = session.user_shell(); let use_login_shell = Self::resolve_use_login_shell(params.login, allow_login_shell)?; let command = Self::base_command(shell.as_ref(), ¶ms.command, use_login_shell); - #[allow(deprecated)] - let cwd = turn_context.resolve_path(params.workdir.clone()); + let cwd = params + .workdir + .as_ref() + .map_or_else(|| cwd.clone(), |path| cwd.join(path)); Ok(ExecParams { command, @@ -167,11 +170,13 @@ impl ShellCommandHandler { ))); }; - #[allow(deprecated)] - let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?; + let runtime_workspace = turn.runtime_workspace.snapshot().await; + let cwd = resolve_workdir_base_path(&arguments, &runtime_workspace.cwd)?; let params: ShellCommandToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?; - #[allow(deprecated)] - let workdir = turn.resolve_path(params.workdir.clone()); + let workdir = params.workdir.as_ref().map_or_else( + || runtime_workspace.cwd.clone(), + |path| runtime_workspace.cwd.join(path), + ); maybe_emit_implicit_skill_invocation( session.as_ref(), turn.as_ref(), @@ -184,6 +189,7 @@ impl ShellCommandHandler { ¶ms, session.as_ref(), turn.as_ref(), + &runtime_workspace.cwd, session.thread_id, turn.config.permissions.allow_login_shell, )?; diff --git a/codex-rs/core/src/tools/handlers/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs index 792eb2bb1e..b8b50a5413 100644 --- a/codex-rs/core/src/tools/handlers/shell_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_tests.rs @@ -94,6 +94,11 @@ async fn shell_command_handler_to_exec_params_uses_session_shell_and_turn_contex &turn_context.shell_environment_policy, Some(session.thread_id), ); + let cwd = &turn_context + .environments + .primary() + .expect("primary environment") + .cwd; let params = ShellCommandToolCallParams { command, @@ -110,6 +115,7 @@ async fn shell_command_handler_to_exec_params_uses_session_shell_and_turn_contex ¶ms, &session, &turn_context, + cwd, session.thread_id, /*allow_login_shell*/ true, ) @@ -172,11 +178,17 @@ async fn shell_command_handler_defaults_to_non_login_when_disallowed() { prefix_rule: None, justification: None, }; + let cwd = &turn_context + .environments + .primary() + .expect("primary environment") + .cwd; let exec_params = ShellCommandHandler::to_exec_params( ¶ms, &session, &turn_context, + cwd, session.thread_id, /*allow_login_shell*/ false, ) diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index 7c914deb4f..b80f39443f 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -122,6 +122,7 @@ impl ExecCommandHandler { let manager: &UnifiedExecProcessManager = &session.services.unified_exec_manager; let context = UnifiedExecContext::new(session.clone(), turn.clone(), call_id.clone()); let environment_args: ExecCommandEnvironmentArgs = parse_arguments(&arguments)?; + let runtime_workspace = turn.runtime_workspace.snapshot().await; let Some(turn_environment) = resolve_tool_environment(turn.as_ref(), environment_args.environment_id.as_deref())? else { @@ -134,8 +135,20 @@ impl ExecCommandHandler { .as_deref() .filter(|workdir| !workdir.is_empty()) .map_or_else( - || turn_environment.cwd.clone(), - |workdir| turn_environment.cwd.join(workdir), + || { + if turn.environments.turn_environments.len() == 1 { + runtime_workspace.cwd.clone() + } else { + turn_environment.cwd.clone() + } + }, + |workdir| { + if turn.environments.turn_environments.len() == 1 { + runtime_workspace.cwd.join(workdir) + } else { + turn_environment.cwd.join(workdir) + } + }, ); let environment = Arc::clone(&turn_environment.environment); let fs = environment.get_filesystem(); diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 17708564bb..4e31f7a13a 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -143,9 +143,18 @@ impl ViewImageHandler { "view_image is unavailable in this session".to_string(), )); }; - let cwd = turn_environment.cwd.clone(); + let runtime_workspace = turn.runtime_workspace.snapshot().await; + let cwd = if turn.environments.turn_environments.len() == 1 { + runtime_workspace.cwd.clone() + } else { + turn_environment.cwd.clone() + }; let abs_path = cwd.join(path); - let sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None, &cwd); + let sandbox = turn.file_system_sandbox_context_for_permission_profile( + &runtime_workspace.permission_profile, + /*additional_permissions*/ None, + &cwd, + ); let fs = turn_environment.environment.get_filesystem(); let path_uri = PathUri::from_abs_path(&abs_path).map_err(|error| { FunctionCallError::RespondToModel(format!( @@ -389,6 +398,13 @@ mod tests { let image_path = image_cwd.join("image.png"); std::fs::write(image_path.as_path(), b"not a real image").expect("write test image"); turn.permission_profile = PermissionProfile::Disabled; + turn.runtime_workspace + .replace(crate::session::turn_context::RuntimeWorkspaceSnapshot { + cwd: image_cwd, + workspace_roots: Vec::new(), + permission_profile: PermissionProfile::Disabled, + }) + .await; let result = ViewImageHandler::default() .handle(ToolInvocation { diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index 0a741861fc..18d98a3c8e 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -19,6 +19,7 @@ fn test_turn_environment(environment_id: &str) -> crate::session::turn_context:: environment_id: environment_id.to_string(), environment: std::sync::Arc::new(codex_exec_server::Environment::default_for_tests()), cwd: std::env::temp_dir().abs(), + workspace_roots: vec![std::env::temp_dir().abs()], shell: None, } } diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 2f18425c2a..96535bee8c 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -353,6 +353,7 @@ async fn exec_command_routes_to_selected_remote_environment() -> Result<()> { let remote_selection = TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: remote_cwd.clone(), + workspace_roots: Vec::new(), }; let multi_env_output = exec_command_routing_output( &test, @@ -649,6 +650,7 @@ async fn apply_patch_freeform_routes_to_selected_remote_environment() -> Result< TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: remote_cwd.clone(), + workspace_roots: Vec::new(), }, ]), ) @@ -732,6 +734,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> { TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: remote_cwd.clone(), + workspace_roots: Vec::new(), }, ]; let local_patch = format!( @@ -930,6 +933,7 @@ async fn apply_patch_intercepted_exec_command_routes_to_selected_remote_environm TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: remote_cwd.clone(), + workspace_roots: Vec::new(), }, ]), ) diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 3790eb19b3..6a1b72745e 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -627,6 +627,7 @@ async fn view_image_routes_to_selected_remote_environment() -> anyhow::Result<() let remote_selection = TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: remote_cwd.clone(), + workspace_roots: Vec::new(), }; let call_id = "call-view-image-multi-env"; let response_mock = mount_sse_sequence( diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index ace096359c..c2c8531adb 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -5,6 +5,7 @@ use crate::parse_command::ParsedCommand; use crate::protocol::FileChange; use crate::protocol::ReviewDecision; use crate::request_permissions::RequestPermissionProfile; +use crate::request_permissions::WorkspaceMutationApprovalRequest; use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use serde::Deserialize; @@ -166,6 +167,9 @@ pub enum GuardianAssessmentAction { RequestPermissions { reason: Option, permissions: RequestPermissionProfile, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + workspace_mutation: Option, }, } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 059c5692ce..c1dca87b59 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -110,6 +110,8 @@ pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; pub struct TurnEnvironmentSelection { pub environment_id: String, pub cwd: AbsolutePathBuf, + #[serde(default)] + pub workspace_roots: Vec, } #[derive(Debug, Clone, PartialEq)] @@ -1925,6 +1927,8 @@ pub struct ThreadSettingsSnapshot { #[ts(optional)] pub active_permission_profile: Option, pub cwd: AbsolutePathBuf, + #[serde(default)] + pub workspace_roots: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub reasoning_effort: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/protocol/src/request_permissions.rs b/codex-rs/protocol/src/request_permissions.rs index 1752363ec5..e3e0e5fe30 100644 --- a/codex-rs/protocol/src/request_permissions.rs +++ b/codex-rs/protocol/src/request_permissions.rs @@ -71,6 +71,20 @@ pub struct RequestPermissionsResponse { pub strict_auto_review: bool, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceMutationOperation { + SetWorkingDirectory, + AddWorkspaceRoot, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct WorkspaceMutationApprovalRequest { + pub operation: WorkspaceMutationOperation, + pub target: AbsolutePathBuf, + pub resulting_workspace_roots: Vec, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] pub struct RequestPermissionsEvent { /// Responses API call id for the associated tool call, if available. @@ -96,4 +110,7 @@ pub struct RequestPermissionsEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub workspace_mutation: Option, }