use std::path::{Path, PathBuf};
use std::process::Command;
fn checkout() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.unwrap()
}
fn context(temp: &Path, next: &str) -> PathBuf {
let path = temp.join(format!("context-{next}.json"));
let value = serde_json::json!({
"task": "fixture task", "objective": "deliver one reviewed artifact",
"artifact_root": "/tmp/artifact-root", "status": "completed",
"changed_paths_or_commit": "report.csv", "verification": "schema check: exit 0",
"review": "Auditor: pass", "evidence_location": "review.md",
"remaining_work": "none", "next_action": next,
});
std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
path
}
fn handoff(host: &str, root: &Path, context: &Path, output: Option<&Path>) -> std::process::Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_shepherd"));
command
.args(["handoff", "--host", host, "--plugin-root"])
.arg(root)
.arg("--context")
.arg(context);
if let Some(output) = output {
command.arg("--output").arg(output);
}
command.output().unwrap()
}
#[test]
fn installed_templates_render_identical_handoff_without_writes() {
let root = checkout();
let temp = tempfile::tempdir().unwrap();
let input = context(temp.path(), "return reviewed result");
let before = std::fs::read(&input).unwrap();
let mut outputs = Vec::new();
for (host, plugin) in [
("claude", root.clone()),
("codex", root.join("plugins/shepherd")),
("claude", root.join("packages/harness-claude")),
("codex", root.join("packages/harness-codex")),
("pi", root.join("packages/harness-pi")),
] {
let result = handoff(host, &plugin, &input, None);
assert!(
result.status.success(),
"{host}: {}",
String::from_utf8_lossy(&result.stderr)
);
let body = String::from_utf8(result.stdout).unwrap();
assert!(body.contains("fixture task") && body.contains("report.csv"));
assert!(!body.contains("{{") && !body.contains("<completed"));
outputs.push(body);
}
assert!(outputs.windows(2).all(|pair| pair[0] == pair[1]));
assert_eq!(std::fs::read(&input).unwrap(), before);
assert_eq!(std::fs::read_dir(temp.path()).unwrap().count(), 1);
}
#[test]
fn supplied_non_git_continuation_facts_survive_every_carrier() {
let root = checkout();
let temp = tempfile::tempdir().expect("create handoff fixture directory");
let input = context(temp.path(), "return CSV to continuing root");
let mut value: serde_json::Value =
serde_json::from_slice(&std::fs::read(&input).expect("read context fixture"))
.expect("parse context fixture");
value["controller"] =
serde_json::json!({"kind": "continuing root", "session": "project-root-17"});
value["owner"] = serde_json::json!({"kind": "delegated thread", "session": "thread-4"});
value["scope"] = serde_json::json!("produce one reviewed CSV");
value["owned_paths"] = serde_json::json!(["reports/report.csv", "reports/evidence.json"]);
value["forbidden_paths"] = serde_json::json!(["source/**", "credentials/**"]);
value["fingerprints"] = serde_json::json!({
"input": {"source.csv": "sha256:input-abc"},
"output": {"reports/report.csv": "sha256:output-def"},
});
value["result_route"] =
serde_json::json!({"address": "project-root-17", "method": "native message"});
value["location_mapping"] = serde_json::json!({
"source": "/source/artifacts", "destination": "/remote/artifacts",
});
value["activation"] =
serde_json::json!("bridge received by thread-4; host capabilities recheck pending");
value["review_episode"] = serde_json::json!({
"episode": "review-7", "round": 1, "remaining_rounds": 1,
"reviewers": ["critic-2", "auditor-3"], "findings": ["M05"],
});
std::fs::write(
&input,
serde_json::to_vec(&value).expect("serialize context fixture"),
)
.expect("write supplied continuation facts");
let before = std::fs::read(&input).expect("read supplied context");
let mut outputs = Vec::new();
for (host, plugin) in [
("claude", root.clone()),
("codex", root.join("plugins/shepherd")),
("claude", root.join("packages/harness-claude")),
("codex", root.join("packages/harness-codex")),
("pi", root.join("packages/harness-pi")),
] {
let result = handoff(host, &plugin, &input, None);
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
let body = String::from_utf8(result.stdout).expect("rendered handoff is UTF-8");
for expected in [
"Controller: {\"kind\": \"continuing root\", \"session\": \"project-root-17\"}",
"Owner: {\"kind\": \"delegated thread\", \"session\": \"thread-4\"}",
"Scope: produce one reviewed CSV",
"Owned paths: [\"reports/report.csv\", \"reports/evidence.json\"]",
"Forbidden paths: [\"source/**\", \"credentials/**\"]",
"Fingerprints: {\"input\": {\"source.csv\": \"sha256:input-abc\"}, \"output\": {\"reports/report.csv\": \"sha256:output-def\"}}",
"Result route: {\"address\": \"project-root-17\", \"method\": \"native message\"}",
"Location mapping: {\"destination\": \"/remote/artifacts\", \"source\": \"/source/artifacts\"}",
"Activation and received context: bridge received by thread-4; host capabilities recheck pending",
"Review episode: {\"episode\": \"review-7\", \"findings\": [\"M05\"], \"remaining_rounds\": 1, \"reviewers\": [\"critic-2\", \"auditor-3\"], \"round\": 1}",
] {
assert!(
body.lines().any(|line| line == expected),
"{host}: missing {expected}"
);
}
for git_label in ["Branch: ", "Base: ", "Candidate: ", "Custody: "] {
assert!(!body.lines().any(|line| line.starts_with(git_label)));
}
assert!(body.contains("Carry the same review episode"));
outputs.push(body);
}
assert!(outputs.windows(2).all(|pair| pair[0] == pair[1]));
assert_eq!(std::fs::read(&input).expect("reread context"), before);
assert_eq!(
std::fs::read_dir(temp.path())
.expect("list fixture files")
.count(),
1
);
}
#[test]
fn relocated_git_source_and_destination_facts_remain_distinct() {
let root = checkout();
let temp = tempfile::tempdir().expect("create relocation fixture directory");
let input = context(temp.path(), "report destination checks before writing");
let mut value: serde_json::Value =
serde_json::from_slice(&std::fs::read(&input).expect("read context fixture"))
.expect("parse context fixture");
value["artifact_root"] = serde_json::json!("/cloud/clone");
value["branch"] = serde_json::json!("refs/heads/thread-work");
value["base"] = serde_json::json!("refs/heads/release");
value["candidate"] = serde_json::json!("1111111111111111111111111111111111111111");
value["controller"] = serde_json::json!("continuing project root, not thread-work");
value["owner"] = serde_json::json!(
"thread-work owns reports/**; prior writer stop requested, confirmation unknown"
);
value["result_route"] = serde_json::json!("native message to project-root-17");
value["custody"] =
serde_json::json!("source pinned before launch; destination identity checks pending");
value["location_mapping"] = serde_json::json!({
"source": {"repository": "https://example.invalid/acme/service.git", "artifact_root": "/local/service", "common_dir": "/local/service/.git"},
"destination": {"artifact_root": "/cloud/clone", "common_dir": "/cloud/clone/.git", "identity": "unverified"},
"integration_target": "refs/heads/release",
});
std::fs::write(
&input,
serde_json::to_vec(&value).expect("serialize relocation context"),
)
.expect("write relocation context");
let result = handoff("codex", &root.join("packages/harness-codex"), &input, None);
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
let body = String::from_utf8(result.stdout).expect("rendered handoff is UTF-8");
for expected in [
"Artifact root: /cloud/clone",
"Branch: refs/heads/thread-work",
"Base: refs/heads/release",
"Candidate: 1111111111111111111111111111111111111111",
"Controller: continuing project root, not thread-work",
"Owner: thread-work owns reports/**; prior writer stop requested, confirmation unknown",
"Result route: native message to project-root-17",
"Custody: source pinned before launch; destination identity checks pending",
] {
assert!(
body.lines().any(|line| line == expected),
"missing {expected}"
);
}
let mapping = body
.lines()
.find(|line| line.starts_with("Location mapping: "))
.expect("rendered source/destination mapping");
for expected in [
"\"source\": {\"artifact_root\": \"/local/service\", \"common_dir\": \"/local/service/.git\", \"repository\": \"https://example.invalid/acme/service.git\"}",
"\"destination\": {\"artifact_root\": \"/cloud/clone\", \"common_dir\": \"/cloud/clone/.git\", \"identity\": \"unverified\"}",
"\"integration_target\": \"refs/heads/release\"",
] {
assert!(
mapping.contains(expected),
"relocation fact lost: {expected}"
);
}
assert!(body.contains("live worktree/common directory"));
}
#[test]
fn continuation_instructions_follow_supplied_git_and_review_state() {
let root = checkout();
let temp = tempfile::tempdir().unwrap();
let input = context(temp.path(), "return report");
let mut value: serde_json::Value =
serde_json::from_slice(&std::fs::read(&input).unwrap()).unwrap();
let mut absent_body = None;
for supplied in [None, Some(false), Some(true)] {
if supplied == Some(true) {
value["custody"] = serde_json::json!("verified Git tuple");
value["review_episode"] = serde_json::json!("episode 7, one round remaining");
} else if supplied == Some(false) {
value["custody"] = serde_json::Value::Null;
value["review_episode"] = serde_json::Value::Null;
for key in [
"branch",
"base",
"candidate",
"date",
"rollback",
"activation",
"reconciliation",
"controller",
"owner",
"scope",
"owned_paths",
"forbidden_paths",
"fingerprints",
"result_route",
"location_mapping",
] {
value[key] = serde_json::Value::Null;
}
}
std::fs::write(&input, serde_json::to_vec(&value).unwrap()).unwrap();
let result = handoff("claude", &root, &input, None);
assert!(result.status.success());
let body = String::from_utf8(result.stdout).unwrap();
assert_eq!(
body.contains("live worktree/common directory"),
supplied == Some(true)
);
assert_eq!(
body.contains("Carry the same review episode"),
supplied == Some(true)
);
assert!(!body.contains(": None"));
assert!(body.contains("artifact root"));
for label in [
"Controller: ",
"Owner: ",
"Scope: ",
"Owned paths: ",
"Forbidden paths: ",
"Fingerprints: ",
"Result route: ",
"Location mapping: ",
] {
assert!(!body.lines().any(|line| line.starts_with(label)));
}
if supplied.is_none() {
absent_body = Some(body.clone());
} else if supplied == Some(false) {
assert_eq!(Some(&body), absent_body.as_ref());
}
if supplied == Some(true) {
assert!(
body.contains("verified Git tuple")
&& body.contains("episode 7, one round remaining")
);
}
}
}
#[test]
#[cfg(unix)]
fn installed_template_cannot_escape_plugin_root() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let plugin = temp.path().join("plugin");
std::fs::create_dir_all(plugin.join("templates")).unwrap();
std::fs::write(
plugin.join("package.json"),
serde_json::json!({"name":"@pzzld-org/pi-shepherd","version":env!("CARGO_PKG_VERSION")})
.to_string(),
)
.unwrap();
let outside = temp.path().join("outside.md");
std::fs::write(&outside, "{{ task }}").unwrap();
symlink(&outside, plugin.join("templates/handoff.md")).unwrap();
let input = context(temp.path(), "safe action");
let output = temp.path().join("result.md");
let result = handoff("pi", &plugin, &input, Some(&output));
assert!(!result.status.success());
assert!(String::from_utf8_lossy(&result.stderr).contains("escapes plugin root"));
assert!(!output.exists());
}
#[test]
fn malformed_or_missing_context_fails_before_output() {
let root = checkout();
let temp = tempfile::tempdir().unwrap();
let input = temp.path().join("context.json");
let output = temp.path().join("handoff.md");
std::fs::write(&input, "not json").unwrap();
assert!(
!handoff("claude", &root, &input, Some(&output))
.status
.success()
);
assert!(!output.exists());
std::fs::write(&input, r#"{"task":"only a task"}"#).unwrap();
assert!(
!handoff("claude", &root, &input, Some(&output))
.status
.success()
);
assert!(!output.exists());
}
#[test]
fn explicit_output_is_no_clobber_and_identical_retry_is_noop() {
let root = checkout();
let temp = tempfile::tempdir().unwrap();
let first = context(temp.path(), "first action");
let second = context(temp.path(), "second action");
let output = temp.path().join("handoff.md");
assert!(
handoff("claude", &root, &first, Some(&output))
.status
.success()
);
let bytes = std::fs::read(&output).unwrap();
let modified = std::fs::metadata(&output).unwrap().modified().unwrap();
assert!(
handoff("claude", &root, &first, Some(&output))
.status
.success()
);
assert_eq!(
std::fs::metadata(&output).unwrap().modified().unwrap(),
modified
);
assert!(
!handoff("claude", &root, &second, Some(&output))
.status
.success()
);
assert_eq!(std::fs::read(&output).unwrap(), bytes);
}
#[test]
fn racing_different_outputs_leave_one_complete_artifact() {
let root = checkout();
let temp = tempfile::tempdir().unwrap();
let first = context(temp.path(), "first action");
let second = context(temp.path(), "second action");
let output = temp.path().join("handoff.md");
let expected_first = handoff("pi", &root.join("packages/harness-pi"), &first, None).stdout;
let expected_second = handoff("pi", &root.join("packages/harness-pi"), &second, None).stdout;
let mut a = Command::new(env!("CARGO_BIN_EXE_shepherd"));
a.args(["handoff", "--host", "pi", "--plugin-root"])
.arg(root.join("packages/harness-pi"))
.arg("--context")
.arg(&first)
.arg("--output")
.arg(&output);
let mut b = Command::new(env!("CARGO_BIN_EXE_shepherd"));
b.args(["handoff", "--host", "pi", "--plugin-root"])
.arg(root.join("packages/harness-pi"))
.arg("--context")
.arg(&second)
.arg("--output")
.arg(&output);
let mut child_a = a.spawn().unwrap();
let mut child_b = b.spawn().unwrap();
let status_a = child_a.wait().unwrap();
let status_b = child_b.wait().unwrap();
assert_ne!(status_a.success(), status_b.success());
let actual = std::fs::read(&output).unwrap();
assert!(actual == expected_first || actual == expected_second);
}