Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 12 additions & 21 deletions crates/tracedecay-agent-hosts/src/agents/claude/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -159,18 +161,7 @@ fn native_cache_content_drift_and_binary_relocation_require_refresh() {
.iter()
.map(|(relative, _)| (*relative).to_owned())
.collect::<Vec<_>>();
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,
Expand Down
91 changes: 80 additions & 11 deletions crates/tracedecay-agent-hosts/src/agents/codex/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions crates/tracedecay-agent-hosts/src/agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
59 changes: 59 additions & 0 deletions crates/tracedecay-agent-hosts/src/agents/native_cache_fixture.rs
Original file line number Diff line number Diff line change
@@ -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<Item = &'a str>,
) {
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()),
);
}
}
}
Loading