diff --git a/crates/tracedecay-agent-hosts/src/agents/claude/tests.rs b/crates/tracedecay-agent-hosts/src/agents/claude/tests.rs index ba7d200b50..0a6f9e0b76 100644 --- a/crates/tracedecay-agent-hosts/src/agents/claude/tests.rs +++ b/crates/tracedecay-agent-hosts/src/agents/claude/tests.rs @@ -19,15 +19,17 @@ fn deploy_rendered_bundle(home: &Path, tracedecay_bin: &str) -> PathBuf { deploy_dir } -fn copy_rendered_bundle_to_native_cache(home: &Path, tracedecay_bin: &str) { - let source = plugin_deploy_dir(home); - let cache = claude_current_cached_plugin_root(home); - for (relative, _) in rendered_plugin_files(tracedecay_bin, &ServeArgs::default()).unwrap() { - let target = cache.join(relative); - std::fs::create_dir_all(target.parent().unwrap()).unwrap(); - let contents = std::fs::read(source.join(relative)).unwrap(); - std::fs::write(target, contents).unwrap(); - } +fn copy_rendered_bundle_to_native_cache( + home: &Path, + tracedecay_bin: &str, +) -> Vec<(&'static str, String)> { + let rendered = rendered_plugin_files(tracedecay_bin, &ServeArgs::default()).unwrap(); + super::super::native_cache_fixture::materialize_cached_bundle( + &plugin_deploy_dir(home), + &claude_current_cached_plugin_root(home), + rendered.iter().map(|(relative, _)| *relative), + ); + rendered } fn write_native_activation(home: &Path, tracedecay_bin: &str) { @@ -159,18 +161,7 @@ fn native_cache_content_drift_and_binary_relocation_require_refresh() { .iter() .map(|(relative, _)| (*relative).to_owned()) .collect::>(); - for (relative, expected) in rendered { - assert_eq!( - std::fs::read_to_string(source.join(relative)).unwrap(), - expected, - "relocated marketplace bytes differ at {relative}" - ); - assert_eq!( - std::fs::read_to_string(cache.join(relative)).unwrap(), - expected, - "refreshed native cache bytes differ at {relative}" - ); - } + super::super::native_cache_fixture::assert_rendered_bundle_bytes(&source, &cache, &rendered); for root in [&source, &cache] { let unexpected = unexpected_bundle_entrypoints( root, diff --git a/crates/tracedecay-agent-hosts/src/agents/codex/tests.rs b/crates/tracedecay-agent-hosts/src/agents/codex/tests.rs index 57a8a22305..a8d4df9372 100644 --- a/crates/tracedecay-agent-hosts/src/agents/codex/tests.rs +++ b/crates/tracedecay-agent-hosts/src/agents/codex/tests.rs @@ -762,16 +762,17 @@ fn install_codex_personal_bootstrap(home: &Path, tracedecay_bin: &str) -> Result Ok(install_dir) } -fn copy_rendered_bundle_to_native_cache(home: &Path, tracedecay_bin: &str) { - let source = codex_plugin_install_dir(home); - let cache = codex_plugin_current_cached_install_dir(home); - for (relative, _) in - rendered_global_plugin_files(tracedecay_bin, &ServeArgs::default()).unwrap() - { - let target = cache.join(relative); - std::fs::create_dir_all(target.parent().unwrap()).unwrap(); - std::fs::copy(source.join(relative), target).unwrap(); - } +fn copy_rendered_bundle_to_native_cache( + home: &Path, + tracedecay_bin: &str, +) -> Vec<(&'static str, String)> { + let rendered = rendered_global_plugin_files(tracedecay_bin, &ServeArgs::default()).unwrap(); + super::super::native_cache_fixture::materialize_cached_bundle( + &codex_plugin_install_dir(home), + &codex_plugin_current_cached_install_dir(home), + rendered.iter().map(|(relative, _)| *relative), + ); + rendered } fn write_exact_native_activation(home: &Path, tracedecay_bin: &str) { @@ -1048,7 +1049,12 @@ fn native_cache_content_drift_and_binary_relocation_require_refresh() { "binary relocation must require refresh: {}", native_activation_diagnostics(profile, home.path(), new_bin), ); - copy_rendered_bundle_to_native_cache(home.path(), new_bin); + let rendered = copy_rendered_bundle_to_native_cache(home.path(), new_bin); + super::super::native_cache_fixture::assert_rendered_bundle_bytes( + &codex_plugin_install_dir(home.path()), + &codex_plugin_current_cached_install_dir(home.path()), + &rendered, + ); assert!( codex_plugin_is_natively_active( profile, @@ -1061,6 +1067,69 @@ fn native_cache_content_drift_and_binary_relocation_require_refresh() { ); } +#[test] +fn native_cache_same_size_unchanged_mtime_content_drift_deactivates() { + let home = tempfile::tempdir().unwrap(); + let profile = &tracedecay_runtime_core::config::ProfileRoot::under_home(home.path()); + write_exact_native_activation(home.path(), TEST_BIN); + assert!( + codex_plugin_is_natively_active( + profile, + home.path(), + Some((TEST_BIN, &ServeArgs::default())) + ) + .unwrap(), + "initial native activation: {}", + native_activation_diagnostics(profile, home.path(), TEST_BIN), + ); + + let rendered = rendered_global_plugin_files(TEST_BIN, &ServeArgs::default()).unwrap(); + let relative = rendered + .iter() + .find(|(relative, _)| relative.starts_with("skills/")) + .map(|(relative, _)| *relative) + .expect("the Codex bundle ships at least one skill file"); + let cached = codex_plugin_current_cached_install_dir(home.path()).join(relative); + let original = std::fs::read(&cached).unwrap(); + let modified = std::fs::metadata(&cached).unwrap().modified().unwrap(); + let mut drifted = original.clone(); + let letter = drifted + .iter() + .rposition(u8::is_ascii_alphabetic) + .expect("a rendered skill file contains letters"); + drifted[letter] = if drifted[letter] == b'a' { b'b' } else { b'a' }; + assert_eq!( + drifted.len(), + original.len(), + "the drift must keep {relative} the same size" + ); + assert_ne!(drifted, original); + + std::fs::write(&cached, &drifted).unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(&cached) + .unwrap() + .set_modified(modified) + .unwrap(); + assert_eq!( + std::fs::metadata(&cached).unwrap().modified().unwrap(), + modified, + "the drift must keep {relative}'s mtime" + ); + + assert!( + !codex_plugin_is_natively_active( + profile, + home.path(), + Some((TEST_BIN, &ServeArgs::default())) + ) + .unwrap(), + "a same-size byte change with an unchanged mtime must deactivate: {}", + native_activation_diagnostics(profile, home.path(), TEST_BIN), + ); +} + #[test] fn redeploy_preserves_foreign_discovery_and_support_bytes() { let home = tempfile::tempdir().unwrap(); diff --git a/crates/tracedecay-agent-hosts/src/agents/mod.rs b/crates/tracedecay-agent-hosts/src/agents/mod.rs index 28e807c12e..a831884907 100644 --- a/crates/tracedecay-agent-hosts/src/agents/mod.rs +++ b/crates/tracedecay-agent-hosts/src/agents/mod.rs @@ -28,6 +28,9 @@ pub mod kilo; pub mod kimi; pub mod kiro; mod mcp_registration; +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod native_cache_fixture; pub mod opencode; pub mod pi; pub mod plugin_bundle; diff --git a/crates/tracedecay-agent-hosts/src/agents/native_cache_fixture.rs b/crates/tracedecay-agent-hosts/src/agents/native_cache_fixture.rs new file mode 100644 index 0000000000..b2de81ea9b --- /dev/null +++ b/crates/tracedecay-agent-hosts/src/agents/native_cache_fixture.rs @@ -0,0 +1,59 @@ +//! Byte-level fixture for a host's native plugin cache. +//! +//! Claude Code and Codex each copy the rendered plugin source into their own +//! versioned cache, and TraceDecay's activation probes decide whether the host +//! loads the plugin by comparing those cached bytes against the rendered +//! bundle. Fixtures have to reproduce the host's copy as a plain byte copy. + +use std::path::Path; + +use tracedecay_domain::canonical_text::sha256_hex; + +/// Materialize the rendered `relatives` from `source` into the host's native +/// `cache` root. +/// +/// Deliberately reads and writes bytes instead of [`std::fs::copy`]. On Linux +/// `fs::copy` takes the kernel copy-offload path (`copy_file_range` / +/// `sendfile`), and the Linux CI filesystem has served a same-length cache file +/// whose bytes were not the source's for a bundle the installer had just +/// republished; macOS copies through `fcopyfile`, so the same fixture passed +/// locally. The activation probes compare content digests, so only a byte copy +/// is a faithful stand-in for the host's own copy. +pub(crate) fn materialize_cached_bundle<'a>( + source: &Path, + cache: &Path, + relatives: impl IntoIterator, +) { + for relative in relatives { + let target = cache.join(relative); + std::fs::create_dir_all(target.parent().unwrap()).unwrap(); + let contents = std::fs::read(source.join(relative)).unwrap(); + std::fs::write(&target, &contents).unwrap(); + } +} + +/// Assert `source` and `cache` both hold exactly the bytes `rendered` declares. +/// +/// A host-cache refresh is a content contract: the fixture must not leave a +/// same-length file with different bytes behind, and a divergence has to name +/// the file and both digests instead of only failing the activation probe. +pub(crate) fn assert_rendered_bundle_bytes( + source: &Path, + cache: &Path, + rendered: &[(&str, String)], +) { + for (relative, expected) in rendered { + for (label, root) in [("source", source), ("cache", cache)] { + let observed = std::fs::read(root.join(relative)).unwrap(); + assert!( + observed == expected.as_bytes(), + "{label}/{relative}: refreshed bundle is not byte-exact; \ + actual(len={} sha256={}), expected(len={} sha256={})", + observed.len(), + sha256_hex(&observed), + expected.len(), + sha256_hex(expected.as_bytes()), + ); + } + } +}