diff --git a/codex-rs/core/src/config/constraint.rs b/codex-rs/core/src/config/constraint.rs index 23b6c57c74..d095794e4f 100644 --- a/codex-rs/core/src/config/constraint.rs +++ b/codex-rs/core/src/config/constraint.rs @@ -92,6 +92,29 @@ impl Constrained { } } + pub fn allow_only(only_value: T) -> Self + where + T: Clone + fmt::Debug + PartialEq + 'static, + { + let allowed_value = only_value.clone(); + Self { + value: only_value, + validator: Arc::new(move |candidate| { + if candidate == &allowed_value { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "", + candidate: format!("{candidate:?}"), + allowed: format!("[{allowed_value:?}]"), + requirement_source: RequirementSource::Unknown, + }) + } + }), + normalizer: None, + } + } + /// Allow any value of T, using T's Default as the initial value. pub fn allow_any_from_default() -> Self where @@ -176,6 +199,20 @@ mod tests { assert_eq!(constrained.value(), 0); } + #[test] + fn constrained_allow_only_rejects_different_values() { + let mut constrained = Constrained::allow_only(5); + constrained + .set(5) + .expect("allowed value should be accepted"); + + let err = constrained + .set(6) + .expect_err("different value should be rejected"); + assert_eq!(err, invalid_value("6", "[5]")); + assert_eq!(constrained.value(), 5); + } + #[test] fn constrained_normalizer_applies_on_init_and_set() -> anyhow::Result<()> { let mut constrained = Constrained::normalized(-1, |value| value.max(0))?; diff --git a/codex-rs/core/src/memories/startup/dispatch.rs b/codex-rs/core/src/memories/startup/dispatch.rs index f75d88dd4c..c0360bd25c 100644 --- a/codex-rs/core/src/memories/startup/dispatch.rs +++ b/codex-rs/core/src/memories/startup/dispatch.rs @@ -1,9 +1,13 @@ use crate::codex::Session; use crate::config::Config; +use crate::config::Constrained; use crate::memories::memory_root; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::user_input::UserInput; +use codex_utils_absolute_path::AbsolutePathBuf; use std::sync::Arc; use tracing::debug; use tracing::info; @@ -64,6 +68,42 @@ pub(super) async fn run_global_memory_consolidation( } }; + let root = memory_root(&config.codex_home); + let consolidation_config = { + let mut consolidation_config = config.as_ref().clone(); + consolidation_config.cwd = root.clone(); + consolidation_config.approval_policy = Constrained::allow_only(AskForApproval::Never); + let mut writable_roots = Vec::new(); + match AbsolutePathBuf::from_absolute_path(consolidation_config.codex_home.clone()) { + Ok(codex_home) => writable_roots.push(codex_home), + Err(err) => warn!( + "memory phase-2 consolidation could not add codex_home writable root {}: {err}", + consolidation_config.codex_home.display() + ), + } + let consolidation_sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + if let Err(err) = consolidation_config + .sandbox_policy + .set(consolidation_sandbox_policy) + { + warn!("memory phase-2 consolidation sandbox policy was rejected by constraints: {err}"); + let _ = state_db + .mark_global_phase2_job_failed( + &ownership_token, + "consolidation sandbox policy was rejected by constraints", + PHASE_TWO_JOB_RETRY_DELAY_SECONDS, + ) + .await; + return false; + } + consolidation_config + }; + let latest_memories = match state_db .list_stage1_outputs_for_global(MAX_RAW_MEMORIES_FOR_GLOBAL) .await @@ -81,7 +121,6 @@ pub(super) async fn run_global_memory_consolidation( return false; } }; - let root = memory_root(&config.codex_home); let completion_watermark = completion_watermark(claimed_watermark, &latest_memories); if let Err(err) = sync_rollout_summaries_from_memories(&root, &latest_memories).await { warn!("failed syncing local memory artifacts for global consolidation: {err}"); @@ -119,8 +158,6 @@ pub(super) async fn run_global_memory_consolidation( text: prompt, text_elements: vec![], }]; - let mut consolidation_config = config.as_ref().clone(); - consolidation_config.cwd = root.clone(); let source = SessionSource::SubAgent(SubAgentSource::Other( MEMORY_CONSOLIDATION_SUBAGENT_LABEL.to_string(), )); @@ -173,7 +210,9 @@ mod tests { use crate::memories::rollout_summaries_dir; use chrono::Utc; use codex_protocol::ThreadId; + use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Op; + use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_state::Phase2JobClaimOutcome; use codex_state::Stage1Output; @@ -336,6 +375,27 @@ mod tests { let user_input_ops = harness.user_input_ops_count(); assert_eq!(user_input_ops, 1); + let thread_ids = harness.manager.list_thread_ids().await; + assert_eq!(thread_ids.len(), 1); + let subagent = harness + .manager + .get_thread(thread_ids[0]) + .await + .expect("get consolidation thread"); + let config_snapshot = subagent.config_snapshot().await; + assert_eq!(config_snapshot.approval_policy, AskForApproval::Never); + assert_eq!(config_snapshot.cwd, memory_root(&harness.config.codex_home)); + match config_snapshot.sandbox_policy { + SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + assert!( + writable_roots + .iter() + .any(|root| root.as_path() == harness.config.codex_home.as_path()), + "consolidation subagent should have codex_home as writable root" + ); + } + other => panic!("unexpected sandbox policy: {other:?}"), + } harness.shutdown_threads().await; }