mod support;
use shepherd_cli::shepherd::run::RunStatus;
use std::{
fs,
io::Write,
path::{Path, PathBuf},
process::{Command, Output, Stdio},
};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
struct Fixture {
base: PathBuf,
root: PathBuf,
installed: PathBuf,
descriptor: PathBuf,
harness: &'static str,
}
impl Fixture {
fn new(harness: &'static str) -> Self {
let base =
std::env::temp_dir().join(format!("shepherd-root-skill-{}", uuid::Uuid::now_v7()));
fs::create_dir(&base).expect("fixture directory");
let base = fs::canonicalize(base).expect("canonical fixture");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&base, fs::Permissions::from_mode(0o700)).expect("private fixture");
}
let root = base.join("workspace");
fs::create_dir(&root).expect("workspace");
assert!(
Command::new("git")
.args(["init", "--quiet"])
.current_dir(&root)
.status()
.expect("fixture Git")
.success()
);
let fixture = Self {
installed: base.join("installed"),
descriptor: base.join("native.json"),
base,
root,
harness,
};
fixture.success(&["init", "--confirm"], None);
fixture.success(
&[
"run",
"init",
"v999",
"--branch",
"v9.9.9",
"--base",
"main",
"--version",
"9.9.9",
],
None,
);
fixture.success(
&[
"compile",
"--target",
harness,
"--out",
fixture.installed.to_str().expect("installed path"),
],
None,
);
let binary = fs::canonicalize(env!("CARGO_BIN_EXE_shepherd")).expect("native binary");
let manifest = fixture.installed.join(".shepherd-generated.json");
write_json(
&fixture.descriptor,
&json!({"schema": "shepherd.native-transport/2", "binary": binary,
"project_root": fixture.root, "installed_package_root": fixture.installed, "installed_manifest": manifest,
"candidate_sha256": digest(&fs::read(&binary).expect("binary bytes")),
"installed_manifest_sha256": digest(&fs::read(&manifest).expect("compiler manifest")), "env": {}}),
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&fixture.descriptor, fs::Permissions::from_mode(0o600))
.expect("private native descriptor");
}
fixture.success(&["dispatch", "bind-root"], Some(&json!({"schema": "shepherd.dispatch-request/1", "run": "v999", "harness": harness,
"session_id": "actual-root-session", "role_carrier": "shepherd:shepherd", "mode": "planning", "lease_ms": 600_000})));
fixture
}
fn invoke(&self, args: &[&str], input: Option<&Value>) -> Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_shepherd"));
command
.args(args)
.current_dir(&self.root)
.env_clear()
.env("PATH", std::env::var_os("PATH").expect("PATH"))
.env("HOME", self.base.join("home"))
.env("SHEPHERD_HOME", self.base.join("home/.shepherd"))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if self.descriptor.exists() {
command.env("SHEPHERD_NATIVE_DESCRIPTOR", &self.descriptor);
}
let mut child = command.spawn().expect("native command");
if let Some(input) = input {
child
.stdin
.take()
.expect("stdin")
.write_all(&serde_json::to_vec(input).expect("request JSON"))
.expect("request input");
}
child.wait_with_output().expect("native result")
}
fn success(&self, args: &[&str], input: Option<&Value>) -> Output {
let output = self.invoke(args, input);
assert!(
output.status.success(),
"{args:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
output
}
fn request(&self) -> Value {
json!({"schema": "shepherd.skill-use-request/1", "run": "v999", "harness": self.harness, "session_id": "actual-root-session", "skill": "verification"})
}
fn bind_request(&self, lease_ms: u64) -> Value {
json!({"schema": "shepherd.dispatch-request/1", "run": "v999", "harness": self.harness,
"session_id": "actual-root-session", "role_carrier": "shepherd:shepherd", "mode": "planning", "lease_ms": lease_ms})
}
fn prepare_request(&self) -> Value {
let mut input = self.request();
input["stage"] = json!("completion");
input["lease_ms"] = json!(120_000);
input
}
fn prepare(&self) -> Output {
self.invoke(
&["dispatch", "skill-use-prepare"],
Some(&self.prepare_request()),
)
}
fn profile(&self, action: &str) -> Output {
let mut input = json!({"schema": "shepherd.profile-request/1", "run": "v999", "harness": self.harness, "session_id": "actual-root-session"});
if action == "profile-enter" {
input["lease_ms"] = json!(300_000);
}
self.invoke(&["dispatch", action], Some(&input))
}
fn accept_seed(&self) {
for name in ["mesh.md", "seed.md"] {
fs::copy(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/seed-contract/valid")
.join(name),
self.root.join(".shepherd/runs/v999").join(name),
)
.expect("canonical seed fixture");
}
self.success(&["seed", "verify", ".shepherd/runs/v999/seed.md"], None);
self.success(
&[
"run",
"set",
"v999",
"--seed",
".shepherd/runs/v999/seed.md",
],
None,
);
}
fn root_challenges(&self) -> Vec<PathBuf> {
fs::read_dir(self.root.join(".shepherd/runs/v999/dispatch"))
.expect("dispatch namespace")
.map(|entry| entry.expect("entry").path())
.filter(|path| {
path.file_name()
.expect("filename")
.to_string_lossy()
.starts_with(".root-skill-use.")
})
.collect()
}
fn binding_paths(&self) -> [PathBuf; 2] {
[
self.root
.join(".shepherd/runs/v999/dispatch/.root-session.actual-root-session.json"),
self.root
.join(".shepherd/runs/.root-session.actual-root-session.json"),
]
}
fn mutate_root_binding(&self, mutate: impl Fn(&mut Value)) {
for path in self.binding_paths() {
let mut value: Value =
serde_json::from_slice(&fs::read(&path).expect("binding fixture"))
.expect("binding JSON");
mutate(&mut value);
write_json(&path, &value);
}
}
}
impl Drop for Fixture {
fn drop(&mut self) {
support::remove_dir_all(&self.base);
}
}
fn digest(bytes: &[u8]) -> String {
Sha256::digest(bytes)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
fn write_json(path: &Path, value: &Value) {
fs::write(path, serde_json::to_vec(value).expect("JSON")).expect("write fixture");
}
fn response(output: &Output) -> Value {
serde_json::from_slice(&output.stdout).expect("native response JSON")
}
#[test]
fn root_verification_uses_real_binding_without_a_child_on_all_carriers() {
for harness in ["claude", "codex", "pi"] {
let fixture = Fixture::new(harness);
let prepared = fixture.prepare();
assert!(
prepared.status.success(),
"root {harness} verification was denied: {}",
String::from_utf8_lossy(&prepared.stderr)
);
let challenge = response(&prepared);
assert!(challenge["dispatch_id"].is_null());
assert_eq!(challenge["role"], "shepherd");
assert_eq!(challenge["startup_skill"], "shepherd");
assert_eq!(
challenge["root_authority"]["binding"]["session_id"],
"actual-root-session"
);
assert!(challenge["root_authority"]["profile_lease"].is_null());
assert_eq!(fixture.root_challenges().len(), 1);
assert!(
!fixture.prepare().status.success(),
"same-authority challenge cannot be prepared twice"
);
fixture.success(&["dispatch", "skill-use-attest"], Some(&fixture.request()));
let verified = fixture.success(&["dispatch", "skill-use-verify"], Some(&fixture.request()));
assert_eq!(response(&verified)["state"], "attested");
assert!(
!fixture
.invoke(&["dispatch", "skill-use-attest"], Some(&fixture.request()))
.status
.success()
);
assert!(
shepherd_cli::DispatchStore::new(fixture.root.join(".shepherd/runs"))
.list_for_run(&shepherd_cli::shepherd::dispatch::RunId::new("v999").expect("run"))
.expect("native child inventory")
.is_empty(),
"root loading must never invent a child record"
);
}
}
#[test]
fn planter_verification_exit_then_root_verification_have_distinct_native_custody() {
let fixture = Fixture::new("codex");
let entered = fixture.profile("profile-enter");
assert!(
entered.status.success(),
"{}",
String::from_utf8_lossy(&entered.stderr)
);
assert!(
!fixture.prepare().status.success(),
"unactivated Planter must not fall back to root authority"
);
let activated = fixture.profile("profile-activate");
assert!(
activated.status.success(),
"{}",
String::from_utf8_lossy(&activated.stderr)
);
let prepared = fixture.prepare();
assert!(
prepared.status.success(),
"active Planter verification was denied: {}",
String::from_utf8_lossy(&prepared.stderr)
);
let planter = response(&prepared);
assert_eq!(planter["role"], "planter");
assert_eq!(
planter["root_authority"]["profile_lease"]["state"],
"active"
);
assert!(
planter["installed_carrier_path"]
.as_str()
.unwrap()
.ends_with("/shepherd.codex.toml"),
"Planter must use the actual shared compiler carrier, not a fake child file"
);
fixture.success(&["dispatch", "skill-use-attest"], Some(&fixture.request()));
let old_path = fixture
.root_challenges()
.pop()
.expect("Planter challenge path");
let old_bytes = fs::read(&old_path).expect("Planter proof bytes");
fixture.accept_seed();
let exited = fixture.profile("profile-exit");
assert!(
exited.status.success(),
"{}",
String::from_utf8_lossy(&exited.stderr)
);
assert!(
!fixture
.invoke(&["dispatch", "skill-use-verify"], Some(&fixture.request()))
.status
.success(),
"old Planter proof must not become a root proof"
);
let prepared = fixture.prepare();
assert!(
prepared.status.success(),
"post-Planter root needs fresh authority: {}",
String::from_utf8_lossy(&prepared.stderr)
);
let root = response(&prepared);
assert_eq!(root["role"], "shepherd");
assert_eq!(root["root_authority"]["profile_lease"]["state"], "exited");
assert_ne!(root["nonce_sha256"], planter["nonce_sha256"]);
assert_eq!(fixture.root_challenges().len(), 2);
assert_eq!(
fs::read(old_path).expect("retained Planter proof"),
old_bytes
);
fixture.success(&["dispatch", "skill-use-attest"], Some(&fixture.request()));
fixture.success(&["dispatch", "skill-use-verify"], Some(&fixture.request()));
}
#[test]
fn root_skill_use_rejects_unknown_mixed_and_unbound_requests_without_receipts() {
let fixture = Fixture::new("codex");
for (field, value) in [
("session_id", json!("unbound-root")),
("harness", json!("pi")),
("run", json!("v998")),
("dispatch_id", json!("actual-root-session")),
("root_authority", json!({"binding": {"role": "shepherd"}})),
("authority_sha256", json!("a".repeat(64))),
("role", json!("planter")),
("skill", json!("debugging")),
("skill", json!("../verification")),
("stage", json!("during-work")),
("lease_ms", json!(700_000)),
] {
let mut request = fixture.prepare_request();
request[field] = value;
let output = fixture.invoke(&["dispatch", "skill-use-prepare"], Some(&request));
assert!(
!output.status.success(),
"forged request accepted: {request}"
);
assert!(fixture.root_challenges().is_empty());
}
assert!(
!fixture
.invoke(&["dispatch", "skill-use-verify"], Some(&fixture.request()))
.status
.success()
);
}
#[test]
fn root_skill_use_rechecks_binding_and_installed_bytes_before_attest_and_use() {
let fixture = Fixture::new("claude");
fixture.success(
&["dispatch", "skill-use-prepare"],
Some(&fixture.prepare_request()),
);
assert!(
!fixture
.invoke(&["dispatch", "skill-use-verify"], Some(&fixture.request()))
.status
.success(),
"pending receipt is not proof"
);
let skill = fixture.installed.join("skills/verification/SKILL.md");
let original = fs::read(&skill).unwrap();
let mut tampered = original.clone();
tampered.extend_from_slice(b"\nchanged\n");
fs::write(&skill, tampered).unwrap();
assert!(
!fixture
.invoke(&["dispatch", "skill-use-attest"], Some(&fixture.request()))
.status
.success()
);
let receipt = fixture.root_challenges().pop().unwrap();
assert_eq!(
serde_json::from_slice::<Value>(&fs::read(&receipt).unwrap()).unwrap()["state"],
"pending"
);
fs::write(&skill, &original).unwrap();
fixture.success(&["dispatch", "skill-use-attest"], Some(&fixture.request()));
fixture.success(&["dispatch", "skill-use-verify"], Some(&fixture.request()));
fs::write(&skill, b"different installed verification").unwrap();
assert!(
!fixture
.invoke(&["dispatch", "skill-use-verify"], Some(&fixture.request()))
.status
.success()
);
fs::write(&skill, original).unwrap();
fixture.mutate_root_binding(|binding| {
binding["bound_at"] = json!(binding["bound_at"].as_i64().unwrap() + 1);
});
assert!(
!fixture
.invoke(&["dispatch", "skill-use-verify"], Some(&fixture.request()))
.status
.success(),
"root transition cannot reuse old proof"
);
assert!(
!fixture
.invoke(&["dispatch", "skill-use-attest"], Some(&fixture.request()))
.status
.success(),
"root transition cannot attest old challenge"
);
assert_eq!(fixture.root_challenges().len(), 1);
assert_eq!(
serde_json::from_slice::<Value>(&fs::read(receipt).unwrap()).unwrap()["state"],
"attested"
);
}
#[test]
fn root_profile_cycle_and_revocation_never_revive_old_challenges() {
let fixture = Fixture::new("pi");
fixture.success(
&["dispatch", "skill-use-prepare"],
Some(&fixture.prepare_request()),
);
let initial = fixture.root_challenges().pop().unwrap();
let before = fs::read(&initial).unwrap();
assert!(fixture.profile("profile-enter").status.success());
assert!(
!fixture
.invoke(&["dispatch", "skill-use-attest"], Some(&fixture.request()))
.status
.success()
);
assert!(fixture.profile("profile-activate").status.success());
fixture.success(
&["dispatch", "skill-use-prepare"],
Some(&fixture.prepare_request()),
);
fixture.success(&["dispatch", "skill-use-attest"], Some(&fixture.request()));
assert!(fixture.profile("profile-revoke").status.success());
assert!(
!fixture
.invoke(&["dispatch", "skill-use-verify"], Some(&fixture.request()))
.status
.success(),
"revoked Planter proof cannot become Shepherd proof"
);
assert!(
!fixture
.invoke(&["dispatch", "skill-use-attest"], Some(&fixture.request()))
.status
.success(),
"prior bare-root challenge remains stale after profile cycle"
);
let fresh = fixture.success(
&["dispatch", "skill-use-prepare"],
Some(&fixture.prepare_request()),
);
assert_eq!(
response(&fresh)["root_authority"]["profile_lease"]["state"],
"revoked"
);
assert_eq!(fixture.root_challenges().len(), 3);
assert_eq!(fs::read(initial).unwrap(), before);
fixture.success(&["dispatch", "skill-use-attest"], Some(&fixture.request()));
fixture.success(&["dispatch", "skill-use-verify"], Some(&fixture.request()));
}
#[test]
fn legacy_root_without_filesystem_field_is_not_rewritten_by_skill_loading() {
let fixture = Fixture::new("codex");
fixture.mutate_root_binding(|binding| {
binding
.as_object_mut()
.unwrap()
.remove("project_filesystem_id");
});
let paths = fixture.binding_paths();
let before = paths.map(|path| fs::read(path).unwrap());
fixture.success(
&["dispatch", "skill-use-prepare"],
Some(&fixture.prepare_request()),
);
fixture.success(&["dispatch", "skill-use-attest"], Some(&fixture.request()));
fixture.success(&["dispatch", "skill-use-verify"], Some(&fixture.request()));
for (path, bytes) in fixture.binding_paths().into_iter().zip(before) {
assert_eq!(fs::read(path).unwrap(), bytes);
}
assert!(
!fixture.profile("profile-enter").status.success(),
"unbound legacy filesystem cannot confer a Planter write lease"
);
}
#[test]
fn root_skill_use_store_checks_live_authority_and_immutable_candidate() {
use shepherd_cli::{
DispatchStore,
shepherd::dispatch::{LoadedSkillAttestation, SkillUseChallenge},
};
let fixture = Fixture::new("codex");
let output = fixture.success(
&["dispatch", "skill-use-prepare"],
Some(&fixture.prepare_request()),
);
let expected: SkillUseChallenge = serde_json::from_slice(&output.stdout).unwrap();
let store = DispatchStore::new(fixture.root.join(".shepherd/runs"));
let mut forged = expected.clone();
forged
.attest(
&LoadedSkillAttestation::from_challenge(&expected, expected.prepared_at + 1),
expected.prepared_at + 1,
)
.unwrap();
forged.candidate_sha256 = [77; 32];
assert!(
store.replace_skill_use(&expected, &forged).is_err(),
"candidate identity is immutable"
);
let receipt = fixture.root_challenges().pop().unwrap();
let original = fs::read(&receipt).unwrap();
assert!(fixture.profile("profile-enter").status.success());
assert!(
store
.load_root_skill_use(
&expected.run,
expected.root_authority.as_ref().unwrap(),
"verification"
)
.is_err(),
"the store must check live profile state under its lock, not only filename hashes"
);
let mut replacement = expected.clone();
let loaded = LoadedSkillAttestation::from_challenge(&expected, expected.prepared_at + 1);
replacement
.attest(&loaded, expected.prepared_at + 1)
.unwrap();
assert!(
store.replace_skill_use(&expected, &replacement).is_err(),
"concurrent profile transition cannot publish an old attestation"
);
assert_eq!(fs::read(receipt).unwrap(), original);
}
#[test]
fn skill_use_store_cannot_reopen_a_consumed_nonce() {
use shepherd_cli::{
DispatchStore,
shepherd::{
Harness, RunState,
dispatch::{
LoadedSkillAttestation, ProjectId, Role, RootSessionBinding, RunId, SessionId,
SkillUseChallenge, SkillUsePrepare, SkillUseRootAuthority, SkillUseStage,
},
},
};
struct Root(PathBuf);
impl Drop for Root {
fn drop(&mut self) {
support::remove_dir_all(&self.0);
}
}
let root =
Root(std::env::temp_dir().join(format!("shepherd-skill-store-{}", uuid::Uuid::now_v7())));
fs::create_dir(&root.0).unwrap();
let runs = fs::canonicalize(&root.0).unwrap().join("runs");
let state: RunState =
serde_json::from_value(json!({"run": "v999", "status": "executing"})).unwrap();
state.store(&runs.join("v999/run.json")).unwrap();
let store = DispatchStore::new(runs);
let binding = RootSessionBinding {
schema: "shepherd.root-session/1".into(),
project_id: ProjectId::new("018f47ce-72d7-7f64-9eb1-2f651d521c2a").unwrap(),
run: RunId::new("v999").unwrap(),
harness: Harness::Codex,
session_id: SessionId::new("root-session").unwrap(),
role: Role::Shepherd,
mode: shepherd_cli::shepherd::dispatch::RootMode::Execution,
project_filesystem_id: None,
bound_at: 1,
expires_at: 1_000,
};
store.publish_root_binding_for_run(&binding).unwrap();
store.activate_current_root_binding(&binding).unwrap();
let pending = SkillUseChallenge::prepare(SkillUsePrepare {
project_id: binding.project_id.clone(),
run: binding.run.clone(),
dispatch_id: None,
root_authority: Some(SkillUseRootAuthority {
binding: binding.clone(),
profile_lease: None,
}),
session_id: binding.session_id.clone(),
target: Harness::Codex,
role: Role::Shepherd,
startup_skill: "shepherd".into(),
skill: "verification".into(),
stage: SkillUseStage::Completion,
installed_carrier_path: "/installed/shepherd.codex.toml".into(),
candidate_sha256: [1; 32],
carrier_sha256: [2; 32],
compiler_tree_sha256: [3; 32],
skill_bundle_sha256: [4; 32],
nonce_sha256: [5; 32],
prepared_at: 10,
expires_at: 100,
})
.unwrap();
store.publish_skill_use(&pending).unwrap();
let mut attested = pending.clone();
attested
.attest(&LoadedSkillAttestation::from_challenge(&pending, 20), 20)
.unwrap();
let mut forged = attested.clone();
forged.candidate_sha256 = [77; 32];
assert!(
store.replace_skill_use(&pending, &forged).is_err(),
"candidate digest must be immutable even on a legal state edge"
);
store.replace_skill_use(&pending, &attested).unwrap();
assert!(
store.replace_skill_use(&attested, &pending).is_err(),
"consumed Native nonce must never be reopened"
);
assert_eq!(
store
.load_root_skill_use(
&binding.run,
pending.root_authority.as_ref().unwrap(),
"verification"
)
.unwrap(),
attested
);
}
#[test]
fn legacy_root_rebootstrap_preserves_none_and_exact_unix_identity() {
let fixture = Fixture::new("codex");
#[cfg(unix)]
let identities = {
use std::os::unix::fs::MetadataExt;
let metadata = fs::metadata(&fixture.root).unwrap();
vec![
None,
Some(format!("unix:{:x}:{:x}", metadata.dev(), metadata.ino())),
]
};
#[cfg(not(unix))]
let identities: Vec<Option<String>> = vec![None];
for identity in identities {
fixture.mutate_root_binding(|binding| match &identity {
Some(identity) => {
binding["project_filesystem_id"] = json!(identity);
}
None => {
binding
.as_object_mut()
.unwrap()
.remove("project_filesystem_id");
}
});
let before = fixture.binding_paths().map(|path| fs::read(path).unwrap());
let result = fixture.invoke(&["dispatch", "bind-root"], Some(&fixture.bind_request(1)));
assert!(
result.status.success(),
"legitimate legacy binding rejected: {}",
String::from_utf8_lossy(&result.stderr)
);
for (path, bytes) in fixture.binding_paths().into_iter().zip(before) {
assert_eq!(fs::read(path).unwrap(), bytes);
}
fixture.success(
&["dispatch", "skill-use-prepare"],
Some(&fixture.prepare_request()),
);
fixture.success(&["dispatch", "skill-use-attest"], Some(&fixture.request()));
}
}
#[test]
fn expired_exact_root_rebootstrap_renews_lease_without_reviving_old_proofs() {
let fixture = Fixture::new("codex");
fixture.success(
&["dispatch", "skill-use-prepare"],
Some(&fixture.prepare_request()),
);
let old = fixture.root_challenges().pop().unwrap();
let old_bytes = fs::read(&old).unwrap();
fixture.mutate_root_binding(|binding| {
binding["bound_at"] = json!(1);
binding["expires_at"] = json!(2);
});
assert!(
!fixture.prepare().status.success(),
"expired root must not authorize verification"
);
let renewed = fixture.success(
&["dispatch", "bind-root"],
Some(&fixture.bind_request(600_000)),
);
let renewed = response(&renewed);
assert!(
renewed["bound_at"].as_i64().unwrap() > 2,
"explicit rebootstrap must renew, not report success with the expired binding"
);
assert!(renewed["expires_at"].as_i64().unwrap() > renewed["bound_at"].as_i64().unwrap());
let paths = fixture.binding_paths();
assert_eq!(
fs::read(&paths[0]).unwrap(),
fs::read(&paths[1]).unwrap(),
"successful renewal must leave exact root and current index"
);
assert!(
!fixture
.invoke(&["dispatch", "skill-use-attest"], Some(&fixture.request()))
.status
.success(),
"old proof remains invalid after renewal"
);
fixture.success(
&["dispatch", "skill-use-prepare"],
Some(&fixture.prepare_request()),
);
fixture.success(&["dispatch", "skill-use-attest"], Some(&fixture.request()));
fixture.success(&["dispatch", "skill-use-verify"], Some(&fixture.request()));
assert_eq!(fixture.root_challenges().len(), 2);
assert_eq!(fs::read(old).unwrap(), old_bytes);
}
#[test]
fn active_planter_root_renewal_preserves_profile_and_requires_fresh_proof() {
let fixture = Fixture::new("codex");
assert!(fixture.profile("profile-enter").status.success());
assert!(fixture.profile("profile-activate").status.success());
let prepared = fixture.success(
&["dispatch", "skill-use-prepare"],
Some(&fixture.prepare_request()),
);
let old = response(&prepared);
assert_eq!(old["role"], "planter");
let old_path = fixture.root_challenges().pop().unwrap();
let old_bytes = fs::read(&old_path).unwrap();
let profile_path = fixture
.root
.join(".shepherd/runs/v999/dispatch/.profile.actual-root-session.json");
let profile_before = fs::read(&profile_path).unwrap();
let renewed = fixture.success(
&["dispatch", "bind-root"],
Some(&fixture.bind_request(900_000)),
);
let renewed = response(&renewed);
assert!(
renewed["bound_at"].as_i64().unwrap()
> old["root_authority"]["binding"]["bound_at"]
.as_i64()
.unwrap()
);
assert_eq!(
fs::read(&profile_path).unwrap(),
profile_before,
"root renewal cannot extend or rewrite the active Planter lease"
);
assert!(
!fixture
.invoke(&["dispatch", "skill-use-attest"], Some(&fixture.request()))
.status
.success(),
"old Planter challenge cannot survive root renewal"
);
let fresh = response(&fixture.success(
&["dispatch", "skill-use-prepare"],
Some(&fixture.prepare_request()),
));
assert_eq!(fresh["role"], "planter");
assert_eq!(fresh["root_authority"]["binding"], renewed);
assert_eq!(
fresh["root_authority"]["profile_lease"],
old["root_authority"]["profile_lease"]
);
assert_ne!(fresh["nonce_sha256"], old["nonce_sha256"]);
fixture.success(&["dispatch", "skill-use-attest"], Some(&fixture.request()));
fixture.success(&["dispatch", "skill-use-verify"], Some(&fixture.request()));
assert_eq!(fs::read(old_path).unwrap(), old_bytes);
assert_eq!(fixture.root_challenges().len(), 2);
}
#[test]
fn legacy_root_rebootstrap_denies_missing_index_descriptor_and_other_identity() {
let fixture = Fixture::new("codex");
fixture.mutate_root_binding(|binding| {
binding
.as_object_mut()
.unwrap()
.remove("project_filesystem_id");
});
let original = fixture.binding_paths().map(|path| fs::read(path).unwrap());
for (field, value) in [
("harness", json!("pi")),
("role_carrier", json!("shepherd:planter")),
("lease_ms", json!(0)),
("lease_ms", json!(86_400_001)),
] {
let mut request = fixture.bind_request(600_000);
request[field] = value;
assert!(
!fixture
.invoke(&["dispatch", "bind-root"], Some(&request))
.status
.success()
);
}
let descriptor = fixture.base.join("descriptor-retained.json");
fs::rename(&fixture.descriptor, &descriptor).unwrap();
assert!(
!fixture
.invoke(
&["dispatch", "bind-root"],
Some(&fixture.bind_request(600_000))
)
.status
.success()
);
fs::rename(descriptor, &fixture.descriptor).unwrap();
let index = fixture.binding_paths()[1].clone();
let moved = fixture.base.join("index-retained.json");
fs::rename(&index, &moved).unwrap();
assert!(
!fixture
.invoke(
&["dispatch", "bind-root"],
Some(&fixture.bind_request(600_000))
)
.status
.success()
);
assert!(
!index.exists(),
"missing current index cannot be reconstructed from a legacy root file"
);
fs::rename(moved, index).unwrap();
for (path, bytes) in fixture.binding_paths().into_iter().zip(original) {
assert_eq!(fs::read(path).unwrap(), bytes);
}
fixture.mutate_root_binding(|binding| {
binding["project_filesystem_id"] = json!("unix:0:0");
});
assert!(
!fixture
.invoke(
&["dispatch", "bind-root"],
Some(&fixture.bind_request(600_000))
)
.status
.success()
);
}
fn crash_root_renewal(
fixture: &Fixture,
fault: shepherd_cli::RootRenewalFault,
) -> (PathBuf, Value) {
use shepherd_cli::{DispatchStore, shepherd::dispatch::RootSessionBinding};
let paths = fixture.binding_paths();
let expected: RootSessionBinding =
serde_json::from_slice(&fs::read(&paths[0]).unwrap()).unwrap();
let mut replacement = expected.clone();
replacement.bound_at += 1;
replacement.expires_at += 60_000;
let store =
DispatchStore::new(fixture.root.join(".shepherd/runs")).with_root_renewal_fault(fault);
let error = store
.renew_root_binding(&expected, &replacement, None)
.expect_err("injected crash");
assert!(
error.to_string().contains("injected root renewal crash"),
"{error}"
);
let path = fixture
.root
.join(".shepherd/runs/.root-renewal.actual-root-session.json");
let intent = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
(path, intent)
}
#[test]
fn root_renewal_recovers_each_durable_crash_seam_only_via_exact_native_bootstrap() {
use shepherd_cli::{
DispatchStore, RootRenewalFault,
shepherd::dispatch::{RunId, SessionId},
};
for fault in [
RootRenewalFault::AfterIntentPublish,
RootRenewalFault::AfterIntentCommit,
RootRenewalFault::AfterRunBindingCommit,
RootRenewalFault::AfterIndexCommit,
] {
let fixture = Fixture::new("codex");
let (intent_path, _) = crash_root_renewal(&fixture, fault);
let before = fixture.binding_paths().map(|path| fs::read(path).unwrap());
let intent_before = fs::read(&intent_path).unwrap();
let store = DispatchStore::new(fixture.root.join(".shepherd/runs"));
let run = RunId::new("v999").unwrap();
let session = SessionId::new("actual-root-session").unwrap();
assert!(store.load_root_binding_for_run(&run, &session).is_err());
assert!(store.load_current_root_binding(&session).is_err());
let read = fixture.success(
&["hook", "--harness", "codex"],
Some(&json!({
"hook_event_name": "PreToolUse", "session_id": "actual-root-session",
"tool_name": "Bash", "tool_input": {"command": "git status --short"},
})),
);
assert_eq!(
response(&read)["hookSpecificOutput"]["permissionDecision"],
"deny"
);
for (path, bytes) in fixture.binding_paths().into_iter().zip(before) {
assert_eq!(fs::read(path).unwrap(), bytes);
}
assert_eq!(
fs::read(&intent_path).unwrap(),
intent_before,
"ordinary reads must never reconcile a root"
);
let mut foreign = fixture.bind_request(600_000);
foreign["harness"] = json!("pi");
assert!(
!fixture
.invoke(&["dispatch", "bind-root"], Some(&foreign))
.status
.success()
);
assert_eq!(fs::read(&intent_path).unwrap(), intent_before);
let bootstrap = fixture.success(&["hook", "--harness", "codex"], Some(&json!({
"hook_event_name": "PreToolUse", "session_id": "actual-root-session",
"tool_name": "Bash", "tool_input": {"command": "shepherd dispatch bind-root --run v999 --mode planning --confirm"},
})));
assert_ne!(
response(&bootstrap)["hookSpecificOutput"]["permissionDecision"],
"deny",
"{}",
String::from_utf8_lossy(&bootstrap.stdout)
);
assert!(
!intent_path.exists(),
"committed root journal must be cleared"
);
assert_eq!(
store.load_current_root_binding(&session).unwrap(),
store.load_root_binding_for_run(&run, &session).unwrap()
);
fixture.success(
&["dispatch", "skill-use-prepare"],
Some(&fixture.prepare_request()),
);
fixture.success(&["dispatch", "skill-use-attest"], Some(&fixture.request()));
}
}
#[test]
fn root_renewal_recovery_rejects_corrupt_intent_missing_index_and_impossible_order() {
use shepherd_cli::RootRenewalFault;
let fixture = Fixture::new("codex");
let (intent_path, intent) = crash_root_renewal(&fixture, RootRenewalFault::AfterIntentCommit);
let before = fixture.binding_paths().map(|path| fs::read(path).unwrap());
for mutate in [
|value: &mut Value| value["schema"] = json!("shepherd.root-renewal/999"),
|value: &mut Value| value["replacement"]["session_id"] = json!("foreign-session"),
|value: &mut Value| value["replacement"]["harness"] = json!("pi"),
|value: &mut Value| value["root_path"] = json!("../foreign"),
|value: &mut Value| {
value["replacement"]["expires_at"] = value["expected"]["expires_at"].clone()
},
] {
let mut corrupt = intent.clone();
mutate(&mut corrupt);
write_json(&intent_path, &corrupt);
assert!(
!fixture
.invoke(
&["dispatch", "bind-root"],
Some(&fixture.bind_request(600_000))
)
.status
.success()
);
assert_eq!(
serde_json::from_slice::<Value>(&fs::read(&intent_path).unwrap()).unwrap(),
corrupt
);
for (path, bytes) in fixture.binding_paths().into_iter().zip(&before) {
assert_eq!(&fs::read(path).unwrap(), bytes);
}
}
write_json(&intent_path, &intent);
let index = fixture.binding_paths()[1].clone();
write_json(&index, &intent["replacement"]);
assert!(
!fixture
.invoke(
&["dispatch", "bind-root"],
Some(&fixture.bind_request(600_000))
)
.status
.success(),
"old run/new index is not a reachable commit state"
);
fs::write(&index, &before[1]).unwrap();
let held_index = fixture.base.join("held-index.json");
fs::rename(&index, &held_index).unwrap();
assert!(
!fixture
.invoke(
&["dispatch", "bind-root"],
Some(&fixture.bind_request(600_000))
)
.status
.success()
);
assert!(!index.exists());
fs::rename(held_index, &index).unwrap();
fixture.success(
&["dispatch", "bind-root"],
Some(&fixture.bind_request(600_000)),
);
assert!(!intent_path.exists());
}
#[test]
fn root_renewal_recovery_rejects_profile_drift_without_overwriting_it() {
use shepherd_cli::{
DispatchStore, RootRenewalFault,
shepherd::{
Harness,
dispatch::{Profile, ProfileAttachmentExpectation, ProfileLease, RootSessionBinding},
},
};
let fixture = Fixture::new("codex");
let (intent_path, intent) =
crash_root_renewal(&fixture, RootRenewalFault::AfterRunBindingCommit);
let expected: RootSessionBinding = serde_json::from_value(intent["expected"].clone()).unwrap();
let attachment = ProfileAttachmentExpectation::new(
Harness::Codex,
Profile::Planter,
"planting",
[1; 32],
[2; 32],
[3; 32],
)
.unwrap();
let profile = ProfileLease::enter(
&expected,
attachment,
expected.bound_at + 1,
expected.expires_at - 1,
&RunStatus::Planted.into(),
false,
)
.unwrap();
let store = DispatchStore::new(fixture.root.join(".shepherd/runs"));
store.publish_profile_lease(&profile).unwrap();
let before = fixture.binding_paths().map(|path| fs::read(path).unwrap());
let intent_before = fs::read(&intent_path).unwrap();
assert!(
!fixture
.invoke(
&["dispatch", "bind-root"],
Some(&fixture.bind_request(600_000))
)
.status
.success()
);
assert_eq!(fs::read(intent_path).unwrap(), intent_before);
assert_eq!(
store
.load_profile_lease(&expected.run, &expected.session_id)
.unwrap(),
profile
);
for (path, bytes) in fixture.binding_paths().into_iter().zip(before) {
assert_eq!(fs::read(path).unwrap(), bytes);
}
}