591 lines
19 KiB
Rust
591 lines
19 KiB
Rust
//! Runs the real `sync.sh` against a throwaway `$HOME`, with a stub `claude`
|
|||
|
|
//! on `PATH` that records its arguments. Skipped when `jq` or `tar` is missing.
|
||
|
|
|
||
|
|
use std::fs;
|
||
|
|
use std::path::{Path, PathBuf};
|
||
|
|
use std::process::Command;
|
||
|
|
|
||
|
|
use serde_json::{json, Value};
|
||
|
|
|
||
|
|
use super::sync::SYNC_SCRIPT;
|
||
|
|
use crate::models::marketplace::SyncReport;
|
||
|
|
|
||
|
|
const C1: &str = "1111111111111111111111111111111111111111";
|
||
|
|
const C2: &str = "2222222222222222222222222222222222222222";
|
||
|
|
const SLUG: &str = "team-tools-m1aaaaaa";
|
||
|
|
|
||
|
|
fn have(tool: &str) -> bool {
|
||
|
|
Command::new(tool)
|
||
|
|
.arg("--version")
|
||
|
|
.output()
|
||
|
|
.map(|o| o.status.success())
|
||
|
|
.unwrap_or(false)
|
||
|
|
}
|
||
|
|
|
||
|
|
struct Env {
|
||
|
|
_root: tempfile::TempDir,
|
||
|
|
home: PathBuf,
|
||
|
|
incoming: PathBuf,
|
||
|
|
stub_dir: PathBuf,
|
||
|
|
log: PathBuf,
|
||
|
|
script: PathBuf,
|
||
|
|
lock: PathBuf,
|
||
|
|
}
|
||
|
|
|
||
|
|
fn env() -> Option<Env> {
|
||
|
|
if !have("jq") || !have("tar") {
|
||
|
|
eprintln!("skipping: jq or tar is not installed");
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
let root = tempfile::tempdir().unwrap();
|
||
|
|
let home = root.path().join("home");
|
||
|
|
let incoming = root.path().join("incoming");
|
||
|
|
let stub_dir = root.path().join("bin");
|
||
|
|
for d in [&home, &incoming, &stub_dir] {
|
||
|
|
fs::create_dir_all(d).unwrap();
|
||
|
|
}
|
||
|
|
let log = root.path().join("claude.log");
|
||
|
|
let stub = stub_dir.join("claude");
|
||
|
|
fs::write(
|
||
|
|
&stub,
|
||
|
|
"#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$CLAUDE_LOG\"\nexit 0\n",
|
||
|
|
)
|
||
|
|
.unwrap();
|
||
|
|
#[cfg(unix)]
|
||
|
|
{
|
||
|
|
use std::os::unix::fs::PermissionsExt;
|
||
|
|
fs::set_permissions(&stub, fs::Permissions::from_mode(0o755)).unwrap();
|
||
|
|
}
|
||
|
|
let script = root.path().join("sync.sh");
|
||
|
|
fs::write(&script, SYNC_SCRIPT).unwrap();
|
||
|
|
let lock = root.path().join("lock");
|
||
|
|
Some(Env {
|
||
|
|
home,
|
||
|
|
incoming,
|
||
|
|
stub_dir,
|
||
|
|
log,
|
||
|
|
script,
|
||
|
|
lock,
|
||
|
|
_root: root,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Write `payload.tar` into the incoming dir: `files` plus `manifest.json`.
|
||
|
|
fn payload(env: &Env, files: &[(&str, &str, bool)], manifest: Value) {
|
||
|
|
let mut b = tar::Builder::new(Vec::new());
|
||
|
|
let mut add = |path: &str, data: &[u8], exec: bool| {
|
||
|
|
let mut h = tar::Header::new_gnu();
|
||
|
|
h.set_size(data.len() as u64);
|
||
|
|
h.set_mode(if exec { 0o755 } else { 0o644 });
|
||
|
|
h.set_entry_type(tar::EntryType::Regular);
|
||
|
|
b.append_data(&mut h, path, data).unwrap();
|
||
|
|
};
|
||
|
|
for (path, text, exec) in files {
|
||
|
|
add(path, text.as_bytes(), *exec);
|
||
|
|
}
|
||
|
|
add("manifest.json", manifest.to_string().as_bytes(), false);
|
||
|
|
fs::write(env.incoming.join("payload.tar"), b.into_inner().unwrap()).unwrap();
|
||
|
|
}
|
||
|
|
|
||
|
|
fn run(env: &Env) -> SyncReport {
|
||
|
|
run_with(env, "sh")
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Run the script under a specific shell (`sh` is dash on Ubuntu).
|
||
|
|
fn run_with(env: &Env, shell: &str) -> SyncReport {
|
||
|
|
let out = Command::new(shell)
|
||
|
|
.arg(&env.script)
|
||
|
|
.env_clear()
|
||
|
|
.env("HOME", &env.home)
|
||
|
|
.env(
|
||
|
|
"PATH",
|
||
|
|
format!("{}:/usr/local/bin:/usr/bin:/bin", env.stub_dir.display()),
|
||
|
|
)
|
||
|
|
.env("MARKETPLACE_INCOMING", &env.incoming)
|
||
|
|
.env("MARKETPLACE_LOCK", &env.lock)
|
||
|
|
.env("CLAUDE_LOG", &env.log)
|
||
|
|
.output()
|
||
|
|
.unwrap();
|
||
|
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||
|
|
assert!(
|
||
|
|
out.status.success(),
|
||
|
|
"script failed: {}",
|
||
|
|
String::from_utf8_lossy(&out.stderr)
|
||
|
|
);
|
||
|
|
let last = stdout
|
||
|
|
.lines()
|
||
|
|
.rev()
|
||
|
|
.find(|l| !l.trim().is_empty())
|
||
|
|
.expect("a report line");
|
||
|
|
serde_json::from_str(last).unwrap_or_else(|e| panic!("bad report {last:?}: {e}"))
|
||
|
|
}
|
||
|
|
|
||
|
|
fn claude_log(env: &Env) -> Vec<String> {
|
||
|
|
fs::read_to_string(&env.log)
|
||
|
|
.unwrap_or_default()
|
||
|
|
.lines()
|
||
|
|
.map(str::to_string)
|
||
|
|
.collect()
|
||
|
|
}
|
||
|
|
|
||
|
|
fn read_json(p: &Path) -> Value {
|
||
|
|
serde_json::from_str(&fs::read_to_string(p).unwrap()).unwrap()
|
||
|
|
}
|
||
|
|
|
||
|
|
fn hook_settings() -> Value {
|
||
|
|
json!({ "Stop": [{ "hooks": [{ "type": "command",
|
||
|
|
"command": "/home/claude/.claude/triple-c/hooks/notify-on-stop/notify.sh" }] }] })
|
||
|
|
}
|
||
|
|
|
||
|
|
fn all_kinds(commit: &str) -> (Vec<(&'static str, &'static str, bool)>, Value) {
|
||
|
|
(
|
||
|
|
vec![
|
||
|
|
("agents/code-reviewer.md", "agent body\n", false),
|
||
|
|
("skills/example-skill/SKILL.md", "skill body\n", false),
|
||
|
|
("commands/example-command.md", "command body\n", false),
|
||
|
|
("hooks/notify-on-stop/hook.json", "{}", false),
|
||
|
|
(
|
||
|
|
"hooks/notify-on-stop/notify.sh",
|
||
|
|
"#!/bin/sh\necho hi\n",
|
||
|
|
true,
|
||
|
|
),
|
||
|
|
],
|
||
|
|
json!({ "version": 1, "plugin_marketplaces": [], "items": [
|
||
|
|
{ "kind": "agent", "key": "code-reviewer", "marketplace": "m1", "commit": commit, "file": "agents/code-reviewer.md" },
|
||
|
|
{ "kind": "skill", "key": "example-skill", "marketplace": "m1", "commit": commit, "dir": "skills/example-skill" },
|
||
|
|
{ "kind": "command", "key": "example-command", "marketplace": "m1", "commit": commit, "file": "commands/example-command.md" },
|
||
|
|
{ "kind": "hook", "key": "notify-on-stop", "marketplace": "m1", "commit": commit, "dir": "hooks/notify-on-stop", "settings": hook_settings() }
|
||
|
|
]}),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn empty_manifest() -> Value {
|
||
|
|
json!({ "version": 1, "items": [], "plugin_marketplaces": [] })
|
||
|
|
}
|
||
|
|
|
||
|
|
fn sorted(mut v: Vec<String>) -> Vec<String> {
|
||
|
|
v.sort();
|
||
|
|
v
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn sync_installs_all_kinds() {
|
||
|
|
let Some(env) = env() else { return };
|
||
|
|
let (files, manifest) = all_kinds(C1);
|
||
|
|
payload(&env, &files, manifest);
|
||
|
|
|
||
|
|
let r = run(&env);
|
||
|
|
|
||
|
|
assert_eq!(r.errors, Vec::<String>::new());
|
||
|
|
assert_eq!(
|
||
|
|
sorted(r.installed),
|
||
|
|
vec![
|
||
|
|
"agent:code-reviewer",
|
||
|
|
"command:example-command",
|
||
|
|
"hook:notify-on-stop",
|
||
|
|
"skill:example-skill"
|
||
|
|
]
|
||
|
|
);
|
||
|
|
let claude = env.home.join(".claude");
|
||
|
|
assert_eq!(
|
||
|
|
fs::read_to_string(claude.join("agents/code-reviewer.md")).unwrap(),
|
||
|
|
"agent body\n"
|
||
|
|
);
|
||
|
|
assert!(claude.join("skills/example-skill/SKILL.md").is_file());
|
||
|
|
assert!(claude.join("commands/example-command.md").is_file());
|
||
|
|
let script = claude.join("triple-c/hooks/notify-on-stop/notify.sh");
|
||
|
|
#[cfg(unix)]
|
||
|
|
{
|
||
|
|
use std::os::unix::fs::PermissionsExt;
|
||
|
|
assert_ne!(
|
||
|
|
fs::metadata(&script).unwrap().permissions().mode() & 0o111,
|
||
|
|
0,
|
||
|
|
"hook script must stay executable"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
assert_eq!(
|
||
|
|
read_json(&claude.join("settings.json"))["hooks"],
|
||
|
|
hook_settings()
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
!env.incoming.join("payload.tar").exists(),
|
||
|
|
"payload is consumed"
|
||
|
|
);
|
||
|
|
|
||
|
|
// A second identical run is a no-op in the report.
|
||
|
|
payload(&env, &all_kinds(C1).0, all_kinds(C1).1);
|
||
|
|
let again = run(&env);
|
||
|
|
assert!(
|
||
|
|
again.installed.is_empty() && again.updated.is_empty() && again.removed.is_empty(),
|
||
|
|
"{again:?}"
|
||
|
|
);
|
||
|
|
assert_eq!(
|
||
|
|
read_json(&claude.join("settings.json"))["hooks"]["Stop"]
|
||
|
|
.as_array()
|
||
|
|
.unwrap()
|
||
|
|
.len(),
|
||
|
|
1
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn sync_updates_report_changed_commits() {
|
||
|
|
let Some(env) = env() else { return };
|
||
|
|
let (files, manifest) = all_kinds(C1);
|
||
|
|
payload(&env, &files, manifest);
|
||
|
|
run(&env);
|
||
|
|
let (files, manifest) = all_kinds(C2);
|
||
|
|
payload(&env, &files, manifest);
|
||
|
|
|
||
|
|
let r = run(&env);
|
||
|
|
|
||
|
|
assert_eq!(r.updated.len(), 4, "{r:?}");
|
||
|
|
assert!(r.installed.is_empty());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn sync_removes_deselected() {
|
||
|
|
let Some(env) = env() else { return };
|
||
|
|
let (files, manifest) = all_kinds(C1);
|
||
|
|
payload(&env, &files, manifest);
|
||
|
|
run(&env);
|
||
|
|
payload(&env, &[], empty_manifest());
|
||
|
|
|
||
|
|
let r = run(&env);
|
||
|
|
|
||
|
|
assert_eq!(
|
||
|
|
sorted(r.removed),
|
||
|
|
vec![
|
||
|
|
"agent:code-reviewer",
|
||
|
|
"command:example-command",
|
||
|
|
"hook:notify-on-stop",
|
||
|
|
"skill:example-skill"
|
||
|
|
]
|
||
|
|
);
|
||
|
|
let claude = env.home.join(".claude");
|
||
|
|
assert!(!claude.join("agents/code-reviewer.md").exists());
|
||
|
|
assert!(!claude.join("skills/example-skill").exists());
|
||
|
|
assert!(!claude.join("commands/example-command.md").exists());
|
||
|
|
assert!(!claude.join("triple-c/hooks/notify-on-stop").exists());
|
||
|
|
assert_eq!(read_json(&claude.join("settings.json")).get("hooks"), None);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn sync_skips_user_owned_agent() {
|
||
|
|
let Some(env) = env() else { return };
|
||
|
|
let mine = env.home.join(".claude/agents/code-reviewer.md");
|
||
|
|
fs::create_dir_all(mine.parent().unwrap()).unwrap();
|
||
|
|
fs::write(&mine, "mine\n").unwrap();
|
||
|
|
let (files, manifest) = all_kinds(C1);
|
||
|
|
payload(&env, &files, manifest);
|
||
|
|
|
||
|
|
let r = run(&env);
|
||
|
|
|
||
|
|
assert_eq!(r.skipped.len(), 1, "{r:?}");
|
||
|
|
assert_eq!(r.skipped[0].item, "agent:code-reviewer");
|
||
|
|
assert!(
|
||
|
|
r.skipped[0]
|
||
|
|
.reason
|
||
|
|
.contains("was not installed by Triple-C"),
|
||
|
|
"{}",
|
||
|
|
r.skipped[0].reason
|
||
|
|
);
|
||
|
|
assert_eq!(fs::read_to_string(&mine).unwrap(), "mine\n");
|
||
|
|
|
||
|
|
// Deselecting everything must not delete the user's own file either.
|
||
|
|
payload(&env, &[], empty_manifest());
|
||
|
|
let r = run(&env);
|
||
|
|
assert!(!r.removed.contains(&"agent:code-reviewer".to_string()));
|
||
|
|
assert_eq!(fs::read_to_string(&mine).unwrap(), "mine\n");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn sync_preserves_user_hooks() {
|
||
|
|
let Some(env) = env() else { return };
|
||
|
|
let settings_path = env.home.join(".claude/settings.json");
|
||
|
|
fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
|
||
|
|
let original = json!({
|
||
|
|
"model": "opus",
|
||
|
|
"hooks": {
|
||
|
|
"Stop": [{ "hooks": [{ "type": "command", "command": "echo mine" }] }],
|
||
|
|
"PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo pre" }] }]
|
||
|
|
}
|
||
|
|
});
|
||
|
|
fs::write(
|
||
|
|
&settings_path,
|
||
|
|
serde_json::to_string_pretty(&original).unwrap(),
|
||
|
|
)
|
||
|
|
.unwrap();
|
||
|
|
|
||
|
|
let (files, manifest) = all_kinds(C1);
|
||
|
|
payload(&env, &files, manifest);
|
||
|
|
run(&env);
|
||
|
|
let merged = read_json(&settings_path);
|
||
|
|
assert_eq!(merged["model"], "opus");
|
||
|
|
assert_eq!(
|
||
|
|
merged["hooks"]["PreToolUse"],
|
||
|
|
original["hooks"]["PreToolUse"]
|
||
|
|
);
|
||
|
|
assert_eq!(
|
||
|
|
merged["hooks"]["Stop"][0], original["hooks"]["Stop"][0],
|
||
|
|
"user hook stays first"
|
||
|
|
);
|
||
|
|
assert_eq!(merged["hooks"]["Stop"][1], hook_settings()["Stop"][0]);
|
||
|
|
|
||
|
|
// An update with a changed hook entry replaces only ours.
|
||
|
|
let (files, mut manifest) = all_kinds(C2);
|
||
|
|
manifest["items"][3]["settings"]["Stop"][0]["hooks"][0]["timeout"] = json!(5);
|
||
|
|
payload(&env, &files, manifest);
|
||
|
|
run(&env);
|
||
|
|
let updated = read_json(&settings_path);
|
||
|
|
assert_eq!(updated["hooks"]["Stop"].as_array().unwrap().len(), 2);
|
||
|
|
assert_eq!(updated["hooks"]["Stop"][0], original["hooks"]["Stop"][0]);
|
||
|
|
assert_eq!(updated["hooks"]["Stop"][1]["hooks"][0]["timeout"], 5);
|
||
|
|
|
||
|
|
// Uninstalling everything restores the user's settings exactly.
|
||
|
|
payload(&env, &[], empty_manifest());
|
||
|
|
run(&env);
|
||
|
|
assert_eq!(read_json(&settings_path), original);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn sync_plugin_calls() {
|
||
|
|
let Some(env) = env() else { return };
|
||
|
|
let files = [
|
||
|
|
(
|
||
|
|
"plugins/team-tools-m1aaaaaa/.claude-plugin/marketplace.json",
|
||
|
|
r#"{"name":"triple-c-team-tools-m1aaaaaa","owner":{"name":"Triple-C"},"plugins":[{"name":"example-plugin","source":"./example-plugin"}]}"#,
|
||
|
|
false,
|
||
|
|
),
|
||
|
|
(
|
||
|
|
"plugins/team-tools-m1aaaaaa/example-plugin/.claude-plugin/plugin.json",
|
||
|
|
r#"{"name":"example-plugin"}"#,
|
||
|
|
false,
|
||
|
|
),
|
||
|
|
];
|
||
|
|
let manifest = |commit: &str| {
|
||
|
|
json!({ "version": 1,
|
||
|
|
"items": [{ "kind": "plugin", "key": "example-plugin", "marketplace": "m1", "commit": commit, "slug": SLUG }],
|
||
|
|
"plugin_marketplaces": [{ "slug": SLUG, "dir": format!("plugins/{SLUG}"), "plugins": ["example-plugin"] }] })
|
||
|
|
};
|
||
|
|
let tree = env.home.join(".claude/triple-c/plugins").join(SLUG);
|
||
|
|
|
||
|
|
payload(&env, &files, manifest(C1));
|
||
|
|
let r = run(&env);
|
||
|
|
assert_eq!(r.installed, vec!["plugin:example-plugin"]);
|
||
|
|
assert_eq!(
|
||
|
|
claude_log(&env),
|
||
|
|
vec![
|
||
|
|
format!("plugin marketplace add {}", tree.display()),
|
||
|
|
format!("plugin install example-plugin@triple-c-{SLUG}"),
|
||
|
|
]
|
||
|
|
);
|
||
|
|
assert!(tree.join(".claude-plugin/marketplace.json").is_file());
|
||
|
|
|
||
|
|
// Same commit again: catalog refreshed, nothing reinstalled.
|
||
|
|
fs::remove_file(&env.log).unwrap();
|
||
|
|
payload(&env, &files, manifest(C1));
|
||
|
|
run(&env);
|
||
|
|
assert_eq!(
|
||
|
|
claude_log(&env),
|
||
|
|
vec![format!("plugin marketplace update triple-c-{SLUG}")]
|
||
|
|
);
|
||
|
|
|
||
|
|
// New commit: uninstall + install.
|
||
|
|
fs::remove_file(&env.log).unwrap();
|
||
|
|
payload(&env, &files, manifest(C2));
|
||
|
|
let r = run(&env);
|
||
|
|
assert_eq!(r.updated, vec!["plugin:example-plugin"]);
|
||
|
|
assert_eq!(
|
||
|
|
claude_log(&env),
|
||
|
|
vec![
|
||
|
|
format!("plugin marketplace update triple-c-{SLUG}"),
|
||
|
|
format!("plugin uninstall example-plugin@triple-c-{SLUG}"),
|
||
|
|
format!("plugin install example-plugin@triple-c-{SLUG}"),
|
||
|
|
]
|
||
|
|
);
|
||
|
|
|
||
|
|
// Deselected: uninstall, drop the registration and the tree.
|
||
|
|
fs::remove_file(&env.log).unwrap();
|
||
|
|
payload(&env, &[], empty_manifest());
|
||
|
|
let r = run(&env);
|
||
|
|
assert_eq!(r.removed, vec!["plugin:example-plugin"]);
|
||
|
|
assert_eq!(
|
||
|
|
claude_log(&env),
|
||
|
|
vec![
|
||
|
|
format!("plugin uninstall example-plugin@triple-c-{SLUG}"),
|
||
|
|
format!("plugin marketplace remove triple-c-{SLUG}"),
|
||
|
|
]
|
||
|
|
);
|
||
|
|
assert!(!tree.exists());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn sync_rejects_bad_keys() {
|
||
|
|
let Some(env) = env() else { return };
|
||
|
|
payload(
|
||
|
|
&env,
|
||
|
|
&[("agents/x.md", "x", false)],
|
||
|
|
json!({ "version": 1, "plugin_marketplaces": [], "items": [
|
||
|
|
{ "kind": "agent", "key": "../../evil", "marketplace": "m1", "commit": C1 },
|
||
|
|
{ "kind": "agent", "key": "-rf", "marketplace": "m1", "commit": C1 },
|
||
|
|
{ "kind": "agent", "key": "ok", "marketplace": "m1", "commit": "not-a-sha" }
|
||
|
|
]}),
|
||
|
|
);
|
||
|
|
let r = run(&env);
|
||
|
|
assert_eq!(r.skipped.len(), 3, "{r:?}");
|
||
|
|
assert!(r.installed.is_empty());
|
||
|
|
assert!(!env.home.join("evil.md").exists());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn a_missing_payload_is_reported_not_fatal() {
|
||
|
|
let Some(env) = env() else { return };
|
||
|
|
let r = run(&env);
|
||
|
|
assert_eq!(r.errors, vec!["no payload was uploaded"]);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn sync_keeps_settings_json_private() {
|
||
|
|
// Pre-flight N11: the entrypoint keeps settings.json at 0600; a hook
|
||
|
|
// merge must not leave it world-readable.
|
||
|
|
let Some(env) = env() else { return };
|
||
|
|
let settings_path = env.home.join(".claude/settings.json");
|
||
|
|
let (files, manifest) = all_kinds(C1);
|
||
|
|
payload(&env, &files, manifest);
|
||
|
|
run(&env);
|
||
|
|
#[cfg(unix)]
|
||
|
|
{
|
||
|
|
use std::os::unix::fs::PermissionsExt;
|
||
|
|
let mode = |p: &Path| fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||
|
|
assert_eq!(mode(&settings_path), 0o600, "created by the merge");
|
||
|
|
|
||
|
|
fs::set_permissions(&settings_path, fs::Permissions::from_mode(0o644)).unwrap();
|
||
|
|
payload(&env, &[], empty_manifest());
|
||
|
|
run(&env);
|
||
|
|
assert_eq!(
|
||
|
|
mode(&settings_path),
|
||
|
|
0o600,
|
||
|
|
"rewritten by the uninstall merge"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn sync_runs_under_bash_and_dash() {
|
||
|
|
let Some(env) = env() else { return };
|
||
|
|
for shell in ["bash", "dash"] {
|
||
|
|
if !Path::new("/bin").join(shell).exists() && !have(shell) {
|
||
|
|
eprintln!("skipping {shell}: not installed");
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
let (files, manifest) = all_kinds(C1);
|
||
|
|
payload(&env, &files, manifest);
|
||
|
|
let r = run_with(&env, shell);
|
||
|
|
assert_eq!(r.errors, Vec::<String>::new(), "{shell}");
|
||
|
|
assert_eq!(r.installed.len(), 4, "{shell}: {r:?}");
|
||
|
|
payload(&env, &[], empty_manifest());
|
||
|
|
let r = run_with(&env, shell);
|
||
|
|
assert_eq!(r.removed.len(), 4, "{shell}: {r:?}");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn sync_never_interpolates_hostile_keys() {
|
||
|
|
let Some(env) = env() else { return };
|
||
|
|
let canary = env.home.join("pwned");
|
||
|
|
let evil = format!("a$(touch {})", canary.display());
|
||
|
|
let evil_tab = "a\tb";
|
||
|
|
let evil_nl = "ok\nagent\tgood";
|
||
|
|
let evil_slug = format!("s;touch {}", canary.display());
|
||
|
|
payload(
|
||
|
|
&env,
|
||
|
|
&[("agents/a.md", "x", false)],
|
||
|
|
json!({ "version": 1,
|
||
|
|
"plugin_marketplaces": [{ "slug": evil_slug, "dir": "plugins/x", "plugins": [] }],
|
||
|
|
"items": [
|
||
|
|
{ "kind": "agent", "key": evil, "marketplace": "m1", "commit": C1 },
|
||
|
|
{ "kind": "agent", "key": evil_tab, "marketplace": "m1", "commit": C1 },
|
||
|
|
{ "kind": "agent", "key": evil_nl, "marketplace": "m1", "commit": C1 },
|
||
|
|
{ "kind": "agent", "key": "a".repeat(65), "marketplace": "m1", "commit": C1 },
|
||
|
|
{ "kind": "agent$(id)", "key": "a", "marketplace": "m1", "commit": C1 },
|
||
|
|
{ "kind": "plugin", "key": "p", "marketplace": "m1", "commit": C1, "slug": evil_slug }
|
||
|
|
]}),
|
||
|
|
);
|
||
|
|
let r = run(&env);
|
||
|
|
assert!(r.installed.is_empty(), "{r:?}");
|
||
|
|
assert_eq!(r.skipped.len(), 5, "{r:?}");
|
||
|
|
assert!(!canary.exists(), "a manifest value was executed");
|
||
|
|
assert!(claude_log(&env).is_empty(), "{:?}", claude_log(&env));
|
||
|
|
assert!(!env.home.join(".claude/agents").exists());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn sync_rejects_a_payload_with_symlinks() {
|
||
|
|
let Some(env) = env() else { return };
|
||
|
|
let outside = env.home.join("outside");
|
||
|
|
fs::create_dir_all(&outside).unwrap();
|
||
|
|
let mut b = tar::Builder::new(Vec::new());
|
||
|
|
let mut h = tar::Header::new_gnu();
|
||
|
|
h.set_entry_type(tar::EntryType::Symlink);
|
||
|
|
h.set_size(0);
|
||
|
|
h.set_mode(0o777);
|
||
|
|
b.append_link(&mut h, "skills/example-skill", &outside)
|
||
|
|
.unwrap();
|
||
|
|
let manifest = json!({ "version": 1, "plugin_marketplaces": [], "items": [
|
||
|
|
{ "kind": "skill", "key": "example-skill", "marketplace": "m1", "commit": C1, "dir": "skills/example-skill" }
|
||
|
|
]})
|
||
|
|
.to_string();
|
||
|
|
let mut h = tar::Header::new_gnu();
|
||
|
|
h.set_size(manifest.len() as u64);
|
||
|
|
h.set_mode(0o644);
|
||
|
|
b.append_data(&mut h, "manifest.json", manifest.as_bytes())
|
||
|
|
.unwrap();
|
||
|
|
fs::write(env.incoming.join("payload.tar"), b.into_inner().unwrap()).unwrap();
|
||
|
|
|
||
|
|
let r = run(&env);
|
||
|
|
|
||
|
|
assert!(r.installed.is_empty(), "{r:?}");
|
||
|
|
assert_eq!(r.errors.len(), 1, "{r:?}");
|
||
|
|
assert!(r.errors[0].contains("symbolic link"), "{r:?}");
|
||
|
|
assert!(!env.home.join(".claude/skills/example-skill").exists());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn sync_skips_user_owned_skill_and_command() {
|
||
|
|
let Some(env) = env() else { return };
|
||
|
|
let claude = env.home.join(".claude");
|
||
|
|
let skill = claude.join("skills/example-skill/SKILL.md");
|
||
|
|
let command = claude.join("commands/example-command.md");
|
||
|
|
for p in [&skill, &command] {
|
||
|
|
fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||
|
|
fs::write(p, "mine\n").unwrap();
|
||
|
|
}
|
||
|
|
let (files, manifest) = all_kinds(C1);
|
||
|
|
payload(&env, &files, manifest);
|
||
|
|
|
||
|
|
let r = run(&env);
|
||
|
|
|
||
|
|
let skipped = sorted(r.skipped.iter().map(|s| s.item.clone()).collect());
|
||
|
|
assert_eq!(
|
||
|
|
skipped,
|
||
|
|
vec!["command:example-command", "skill:example-skill"],
|
||
|
|
"{r:?}"
|
||
|
|
);
|
||
|
|
assert_eq!(
|
||
|
|
sorted(r.installed),
|
||
|
|
vec!["agent:code-reviewer", "hook:notify-on-stop"]
|
||
|
|
);
|
||
|
|
assert_eq!(fs::read_to_string(&skill).unwrap(), "mine\n");
|
||
|
|
assert_eq!(fs::read_to_string(&command).unwrap(), "mine\n");
|
||
|
|
|
||
|
|
payload(&env, &[], empty_manifest());
|
||
|
|
let r = run(&env);
|
||
|
|
assert_eq!(
|
||
|
|
sorted(r.removed),
|
||
|
|
vec!["agent:code-reviewer", "hook:notify-on-stop"]
|
||
|
|
);
|
||
|
|
assert_eq!(fs::read_to_string(&skill).unwrap(), "mine\n");
|
||
|
|
assert_eq!(fs::read_to_string(&command).unwrap(), "mine\n");
|
||
|
|
}
|