mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
feat(core): persist runtime workspace state
This commit is contained in:
@@ -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!(
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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! {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(), ¶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,
|
||||
)?;
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user