diff --git a/codex-rs/external-agent-migration/src/memory_import.rs b/codex-rs/external-agent-migration/src/memory_import.rs index 92c38aa..f58ac29 100644 --- a/codex-rs/external-agent-migration/src/memory_import.rs +++ b/codex-rs/external-agent-migration/src/memory_import.rs @@ -50,6 +50,12 @@ struct ProjectScope<'a> { cwd: &'a Path, } +#[derive(Debug, PartialEq, Eq)] +struct PreparedMemoryFile { + relative_path: PathBuf, + content: Vec, +} + pub(super) async fn import( codex_home: &Path, external_agent_home: &Path, @@ -284,21 +290,45 @@ fn replace_project_resources( project_cwd: &Path, memory_files: &[&ExternalMemoryFile], ) -> io::Result<()> { + replace_project_resources_with_preflight( + codex_home, + project_key, + project_cwd, + memory_files, + |_, _, _| Ok(None), + ) + .map(|_| ()) +} + +fn replace_project_resources_with_preflight( + codex_home: &Path, + project_key: &str, + project_cwd: &Path, + memory_files: &[&ExternalMemoryFile], + preflight: impl FnOnce(&str, &Path, &[PreparedMemoryFile]) -> io::Result>, +) -> io::Result> { let source_files = memory_files .iter() .map(|memory_file| { - fs::read(&memory_file.source_path) - .map(|content| (memory_file.relative_path.clone(), content)) + fs::read(&memory_file.source_path).map(|content| PreparedMemoryFile { + relative_path: memory_file.relative_path.clone(), + content, + }) }) .collect::>>()?; let scope_content = project_scope_content(project_cwd)?; + // A verifier is deliberately called after every source byte is prepared but + // before the previous imported project is removed. This keeps ordinary + // Markdown imports unchanged while providing a fail-closed integration seam. + let conformance_digest = preflight(project_key, project_cwd, &source_files)?; + remove_project_resources(codex_home, project_key)?; let target_root = resources_root(codex_home).join(project_key); fs::create_dir_all(&target_root)?; fs::write(target_root.join(PROJECT_SCOPE_FILE), scope_content)?; - for (relative_path, content) in source_files { - let target_path = target_root.join(relative_path); + for source_file in source_files { + let target_path = target_root.join(source_file.relative_path); let target_parent = target_path.parent().ok_or_else(|| { invalid_data_error(format!( "memory target path has no parent: {}", @@ -306,9 +336,9 @@ fn replace_project_resources( )) })?; fs::create_dir_all(target_parent)?; - fs::write(target_path, content)?; + fs::write(target_path, source_file.content)?; } - Ok(()) + Ok(conformance_digest) } fn remove_project_resources(codex_home: &Path, project_key: &str) -> io::Result { diff --git a/codex-rs/external-agent-migration/src/memory_import_tests.rs b/codex-rs/external-agent-migration/src/memory_import_tests.rs index 9bf57f3..7d9729c 100644 --- a/codex-rs/external-agent-migration/src/memory_import_tests.rs +++ b/codex-rs/external-agent-migration/src/memory_import_tests.rs @@ -1,7 +1,37 @@ use super::*; use pretty_assertions::assert_eq; +use std::process::Command; use tempfile::TempDir; +fn verify_canonical_fixture(fixture: &str) -> io::Result { + let repository_root = std::env::var_os("CMV_REPOSITORY_ROOT") + .map(PathBuf::from) + .ok_or_else(|| invalid_data_error("CMV_REPOSITORY_ROOT is required"))?; + let python = std::env::var_os("CMV_PYTHON").unwrap_or_else(|| "python3".into()); + let output = Command::new(python) + .arg("-B") + .arg(repository_root.join("skills/verify-canonical-memory/scripts/cmverify.py")) + .arg("verify") + .arg(repository_root.join("fixtures").join(fixture)) + .output()?; + let result: serde_json::Value = serde_json::from_slice(&output.stdout) + .map_err(|_| invalid_data_error("cmverify did not emit strict JSON"))?; + if output.status.success() + && result.get("status").and_then(|value| value.as_str()) == Some("VERIFIED") + { + return result + .pointer("/digests/conformance_sha256") + .and_then(|value| value.as_str()) + .map(str::to_string) + .ok_or_else(|| invalid_data_error("verified result omitted conformance digest")); + } + let code = result + .pointer("/errors/0/code") + .and_then(|value| value.as_str()) + .unwrap_or("CMV_INTERNAL_FAILURE"); + Err(invalid_data_error(code)) +} + fn write_project_session(project_root: &Path, project_cwd: &Path) { fs::create_dir_all(project_cwd).expect("create project cwd"); fs::write( @@ -333,3 +363,154 @@ fn removes_an_existing_unscoped_target_when_cwd_is_unavailable() { ); assert!(!target_root.exists()); } + +#[test] +fn preflight_failure_preserves_previous_imported_project() { + let root = TempDir::new().expect("create tempdir"); + let codex_home = root.path().join(".codex"); + let project_cwd = root.path().join("project-a-cwd"); + fs::create_dir_all(&project_cwd).expect("create project cwd"); + + let target_root = resources_root(&codex_home).join("project-a"); + fs::create_dir_all(&target_root).expect("create previous imported project"); + fs::write(target_root.join("MEMORY.md"), b"previous verified memory") + .expect("write previous memory"); + fs::write( + target_root.join(PROJECT_SCOPE_FILE), + project_scope_content(&project_cwd).expect("serialize project scope"), + ) + .expect("write previous scope"); + + let source_path = root.path().join("candidate-MEMORY.md"); + fs::write(&source_path, b"candidate memory").expect("write candidate memory"); + let memory_file = ExternalMemoryFile { + project_key: "project-a".to_string(), + project_cwd: Some(project_cwd.clone()), + source_path, + relative_path: PathBuf::from("MEMORY.md"), + }; + + let error = replace_project_resources_with_preflight( + &codex_home, + "project-a", + &project_cwd, + &[&memory_file], + |project_key, checked_cwd, prepared_files| { + assert_eq!(project_key, "project-a"); + assert_eq!(checked_cwd, project_cwd); + assert_eq!( + prepared_files, + &[PreparedMemoryFile { + relative_path: PathBuf::from("MEMORY.md"), + content: b"candidate memory".to_vec(), + }] + ); + Err(invalid_data_error("CMV_SOURCE_HASH_MISMATCH")) + }, + ) + .expect_err("reject invalid candidate before replacement"); + + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert_eq!(error.to_string(), "CMV_SOURCE_HASH_MISMATCH"); + assert_eq!( + fs::read(target_root.join("MEMORY.md")).expect("read preserved memory"), + b"previous verified memory" + ); + assert_eq!( + fs::read(target_root.join(PROJECT_SCOPE_FILE)).expect("read preserved scope"), + project_scope_content(&project_cwd).expect("serialize expected scope") + ); +} + +#[test] +fn canonical_verifier_accepts_valid_bundle_with_stable_digest() { + let root = TempDir::new().expect("create tempdir"); + let codex_home = root.path().join(".codex"); + let project_cwd = root.path().join("project-a-cwd"); + fs::create_dir_all(&project_cwd).expect("create project cwd"); + let source_path = root.path().join("candidate-MEMORY.md"); + fs::write(&source_path, b"candidate memory").expect("write candidate memory"); + let memory_file = ExternalMemoryFile { + project_key: "project-a".to_string(), + project_cwd: Some(project_cwd.clone()), + source_path, + relative_path: PathBuf::from("MEMORY.md"), + }; + let expected_digest = verify_canonical_fixture("valid/basic").expect("verify valid bundle"); + + let actual_digest = replace_project_resources_with_preflight( + &codex_home, + "project-a", + &project_cwd, + &[&memory_file], + |_, _, _| verify_canonical_fixture("valid/basic").map(Some), + ) + .expect("import verified candidate"); + + assert_eq!(actual_digest, Some(expected_digest)); + assert_eq!( + fs::read(resources_root(&codex_home).join("project-a/MEMORY.md")) + .expect("read imported candidate"), + b"candidate memory" + ); +} + +#[test] +fn canonical_verifier_rejections_preserve_previous_import() { + let root = TempDir::new().expect("create tempdir"); + let codex_home = root.path().join(".codex"); + let project_cwd = root.path().join("project-a-cwd"); + fs::create_dir_all(&project_cwd).expect("create project cwd"); + let target_root = resources_root(&codex_home).join("project-a"); + fs::create_dir_all(&target_root).expect("create previous imported project"); + let source_path = root.path().join("candidate-MEMORY.md"); + fs::write(&source_path, b"candidate memory").expect("write candidate memory"); + let memory_file = ExternalMemoryFile { + project_key: "project-a".to_string(), + project_cwd: Some(project_cwd.clone()), + source_path, + relative_path: PathBuf::from("MEMORY.md"), + }; + let cases = [ + ("invalid/source-tampered", "CMV_SOURCE_HASH_MISMATCH"), + ("invalid/ambiguous-heads", "CMV_CURRENT_HEAD_AMBIGUOUS"), + ( + "invalid/approval-true-expired", + "CMV_APPROVAL_ELIGIBILITY_VIOLATION", + ), + ( + "invalid/approval-true-unexpired", + "CMV_APPROVAL_ELIGIBILITY_VIOLATION", + ), + ]; + + for (fixture, expected_code) in cases { + fs::write(target_root.join("MEMORY.md"), b"previous verified memory") + .expect("restore previous memory"); + fs::write( + target_root.join(PROJECT_SCOPE_FILE), + project_scope_content(&project_cwd).expect("serialize project scope"), + ) + .expect("restore previous scope"); + + let error = replace_project_resources_with_preflight( + &codex_home, + "project-a", + &project_cwd, + &[&memory_file], + |_, _, _| verify_canonical_fixture(fixture).map(Some), + ) + .expect_err("reject invalid candidate before replacement"); + + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert_eq!(error.to_string(), expected_code); + assert_eq!( + fs::read(target_root.join("MEMORY.md")).expect("read preserved memory"), + b"previous verified memory" + ); + assert_eq!( + fs::read(target_root.join(PROJECT_SCOPE_FILE)).expect("read preserved scope"), + project_scope_content(&project_cwd).expect("serialize expected scope") + ); + } +}