feat(core): persist runtime workspace state

This commit is contained in:
Felipe Coury
2026-06-12 00:18:43 -07:00
parent bf667c7003
commit cb72ac4e89
27 changed files with 963 additions and 192 deletions
@@ -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<String>,
pub permissions: RequestPermissionProfile,
pub workspace_mutation: Option<WorkspaceMutationApprovalRequest>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -623,6 +625,7 @@ pub enum GuardianApprovalReviewAction {
RequestPermissions {
reason: Option<String>,
permissions: RequestPermissionProfile,
workspace_mutation: Option<WorkspaceMutationApprovalRequest>,
},
}
@@ -679,9 +682,11 @@ impl From<CoreGuardianAssessmentAction> 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<GuardianApprovalReviewAction> for CoreGuardianAssessmentAction {
GuardianApprovalReviewAction::RequestPermissions {
reason,
permissions,
workspace_mutation,
} => Self::RequestPermissions {
reason,
permissions: permissions.into(),
workspace_mutation: workspace_mutation.map(Into::into),
},
}
}
@@ -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<CoreNetworkPolicyAmendment> 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<String>,
pub permissions: RequestPermissionProfile,
pub workspace_mutation: Option<WorkspaceMutationApprovalRequest>,
}
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<AbsolutePathBuf>,
}
impl From<CoreWorkspaceMutationApprovalRequest> for WorkspaceMutationApprovalRequest {
fn from(value: CoreWorkspaceMutationApprovalRequest) -> Self {
Self {
operation: value.operation.into(),
target: value.target,
resulting_workspace_roots: value.resulting_workspace_roots,
}
}
}
impl From<WorkspaceMutationApprovalRequest> 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!(
+24 -7
View File
@@ -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;
@@ -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,
)
@@ -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
@@ -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<String>,
permissions: RequestPermissionProfile,
workspace_mutation: Option<WorkspaceMutationApprovalRequest>,
},
}
@@ -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<Value> {
@@ -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(),
},
}
}
+2 -1
View File
@@ -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,
+119 -1
View File
@@ -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<AbsolutePathBuf>,
workspace_roots: Vec<AbsolutePathBuf>,
) -> ConstraintResult<ThreadConfigSnapshot> {
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<RequestPermissionsResponse> {
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<Self>,
turn_context: &Arc<TurnContext>,
call_id: String,
args: RequestPermissionsArgs,
cwd: AbsolutePathBuf,
workspace_mutation: WorkspaceMutationApprovalRequest,
cancellation_token: CancellationToken,
) -> Option<RequestPermissionsResponse> {
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<Self>,
turn_context: &Arc<TurnContext>,
call_id: String,
args: RequestPermissionsArgs,
environment: TurnEnvironmentSelection,
workspace_mutation: Option<WorkspaceMutationApprovalRequest>,
cancellation_token: CancellationToken,
) -> Option<RequestPermissionsResponse> {
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! {
+1
View File
@@ -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(),
@@ -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<PreviousTurnSettings>,
reference_context_item: Option<TurnContextItem>,
rollout_suffix: &'a [RolloutItem],
window_id: Option<u64>,
}
#[derive(Debug)]
pub(crate) struct RuntimeWorkspaceBaseline {
pub(crate) cwd: AbsolutePathBuf,
pub(crate) workspace_roots: Vec<AbsolutePathBuf>,
}
#[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<ActiveReplaySegment<'_>> = 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<RuntimeWorkspaceBaseline> {
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<ActiveReplaySegment<'_>> = 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 {
+54
View File
@@ -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()
{
@@ -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;
+96 -8
View File
@@ -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<Environment>,
pub(crate) cwd: AbsolutePathBuf,
pub(crate) workspace_roots: Vec<AbsolutePathBuf>,
pub(crate) shell: Option<shell::Shell>,
}
@@ -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<AbsolutePathBuf>,
pub(crate) permission_profile: PermissionProfile,
}
#[derive(Debug)]
pub(crate) struct RuntimeWorkspaceState {
snapshot: RwLock<RuntimeWorkspaceSnapshot>,
}
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<Personality>,
pub(crate) approval_policy: Constrained<AskForApproval>,
pub(crate) permission_profile: PermissionProfile,
pub(crate) runtime_workspace: Arc<RuntimeWorkspaceState>,
pub(crate) network: Option<NetworkProxy>,
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<ReasoningEffortConfig> {
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<AdditionalPermissionProfile>,
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<FileSystemSandboxPolicy> {
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<FileSystemSandboxPolicy> {
// 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<TurnContextNetworkItem> {
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(),
+89 -2
View File
@@ -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<std::sync::Mutex<CapturedOps>>;
#[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<AuthManager>,
parent_trace: Option<W3cTraceContext>,
) -> CodexResult<NewThread> {
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<AuthManager>,
persist_extended_history: bool,
parent_trace: Option<W3cTraceContext>,
runtime_workspace_overrides: RuntimeWorkspaceReplayOverrides,
) -> CodexResult<NewThread> {
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<ThreadSource>,
parent_trace: Option<W3cTraceContext>,
) -> CodexResult<NewThread>
where
S: Into<ForkSnapshot>,
{
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<S>(
&self,
snapshot: S,
config: Config,
history: InitialHistory,
thread_source: Option<ThreadSource>,
persist_extended_history: bool,
parent_trace: Option<W3cTraceContext>,
runtime_workspace_overrides: RuntimeWorkspaceReplayOverrides,
) -> CodexResult<NewThread>
where
S: Into<ForkSnapshot>,
{
@@ -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<ThreadSource>,
parent_trace: Option<W3cTraceContext>,
runtime_workspace_overrides: RuntimeWorkspaceReplayOverrides,
) -> CodexResult<NewThread> {
// `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<NewThread> {
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();
+92
View File
@@ -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();
@@ -264,7 +264,7 @@ fn apply_patch_payload_command(payload: &ToolPayload) -> Option<String> {
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<Option<FunctionToolOutput>, 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,
+2 -1
View File
@@ -78,6 +78,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result<FunctionToolOutput, Func
"shell is unavailable in this session".to_string(),
));
};
let runtime_workspace = turn.runtime_workspace.snapshot().await;
let fs = turn_environment.environment.get_filesystem();
let explicit_env_overrides = turn.shell_environment_policy.r#set.clone();
@@ -166,7 +167,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result<FunctionToolOutput, Func
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &exec_params.command,
approval_policy: turn.approval_policy.value(),
permission_profile: turn.permission_profile(),
permission_profile: runtime_workspace.permission_profile.clone(),
windows_sandbox_level: turn.windows_sandbox_level,
sandbox_permissions: if effective_additional_permissions.permissions_preapproved {
codex_protocol::models::SandboxPermissions::UseDefault
@@ -86,14 +86,17 @@ impl ShellCommandHandler {
params: &ShellCommandToolCallParams,
session: &crate::session::session::Session,
turn_context: &TurnContext,
cwd: &codex_utils_absolute_path::AbsolutePathBuf,
thread_id: ThreadId,
allow_login_shell: bool,
) -> Result<ExecParams, FunctionCallError> {
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(), &params.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 {
&params,
session.as_ref(),
turn.as_ref(),
&runtime_workspace.cwd,
session.thread_id,
turn.config.permissions.allow_login_shell,
)?;
@@ -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
&params,
&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(
&params,
&session,
&turn_context,
cwd,
session.thread_id,
/*allow_login_shell*/ false,
)
@@ -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();
+18 -2
View File
@@ -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 {
@@ -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,
}
}
+4
View File
@@ -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(),
},
]),
)
+1
View File
@@ -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(
+4
View File
@@ -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<String>,
permissions: RequestPermissionProfile,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
workspace_mutation: Option<WorkspaceMutationApprovalRequest>,
},
}
+4
View File
@@ -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<AbsolutePathBuf>,
}
#[derive(Debug, Clone, PartialEq)]
@@ -1925,6 +1927,8 @@ pub struct ThreadSettingsSnapshot {
#[ts(optional)]
pub active_permission_profile: Option<ActivePermissionProfile>,
pub cwd: AbsolutePathBuf,
#[serde(default)]
pub workspace_roots: Vec<AbsolutePathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<ReasoningEffortConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -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<AbsolutePathBuf>,
}
#[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<AbsolutePathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub workspace_mutation: Option<WorkspaceMutationApprovalRequest>,
}