mirror of
https://github.com/openai/codex.git
synced 2026-09-07 15:40:00 +00:00
## Why Plugin installation skips symlinks while copying a plugin into the cache. A symlinked manifest could therefore be used during source validation but omitted from the staged copy, allowing a lower-precedence manifest to take its place. ## What changed - Require discoverable manifests and their parent directories to be regular files and directories, rejecting symlinks and other non-regular entries at a higher-precedence manifest path. - Verify that staging preserves the selected manifest path and contents before activating the cached plugin. - Preserve generated fallback manifests by injecting and validating them in the staged plugin. ## Testing Add coverage for symlinked manifest files and directories, precedence changes, and fallback-manifest staging. GitOrigin-RevId: 1b69c1e75cdfecb8cab4070a32aa9739833a70de
750 lines
25 KiB
Rust
750 lines
25 KiB
Rust
use super::*;
|
|
use codex_plugin::PluginId;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
use tempfile::tempdir;
|
|
|
|
fn write_plugin_with_version(
|
|
root: &Path,
|
|
dir_name: &str,
|
|
manifest_name: &str,
|
|
manifest_version: Option<&str>,
|
|
) {
|
|
let plugin_root = root.join(dir_name);
|
|
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
|
fs::create_dir_all(plugin_root.join("skills")).unwrap();
|
|
let version = manifest_version
|
|
.map(|manifest_version| format!(r#","version":"{manifest_version}""#))
|
|
.unwrap_or_default();
|
|
fs::write(
|
|
plugin_root.join(".codex-plugin/plugin.json"),
|
|
format!(r#"{{"name":"{manifest_name}"{version}}}"#),
|
|
)
|
|
.unwrap();
|
|
fs::write(plugin_root.join("skills/SKILL.md"), "skill").unwrap();
|
|
fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap();
|
|
}
|
|
|
|
fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
|
|
write_plugin_with_version(
|
|
root,
|
|
dir_name,
|
|
manifest_name,
|
|
/*manifest_version*/ None,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn try_new_rejects_relative_codex_home() {
|
|
let err = PluginStore::try_new(PathBuf::from("relative"))
|
|
.expect_err("relative codex home should fail");
|
|
let err = err.to_string().replace('\\', "/");
|
|
|
|
assert_eq!(
|
|
err,
|
|
"failed to resolve plugin cache root: path is not absolute: relative/plugins/cache"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn install_copies_plugin_into_default_marketplace() {
|
|
let tmp = tempdir().unwrap();
|
|
write_plugin(tmp.path(), "sample-plugin", "sample-plugin");
|
|
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
|
|
|
let result = PluginStore::new(tmp.path().to_path_buf())
|
|
.install(
|
|
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
|
|
plugin_id.clone(),
|
|
)
|
|
.unwrap();
|
|
|
|
let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local");
|
|
assert_eq!(
|
|
result,
|
|
PluginInstallResult {
|
|
plugin_id,
|
|
plugin_version: "local".to_string(),
|
|
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
|
|
}
|
|
);
|
|
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
|
|
assert!(installed_path.join("skills/SKILL.md").is_file());
|
|
}
|
|
|
|
#[test]
|
|
fn install_accepts_manifest_mcp_server_objects() {
|
|
let tmp = tempdir().unwrap();
|
|
let plugin_root = tmp.path().join("counter-sample");
|
|
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
|
fs::write(
|
|
plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{
|
|
"name": "counter-sample",
|
|
"version": "1.1.1",
|
|
"mcpServers": {
|
|
"counter": {
|
|
"type": "http",
|
|
"url": "https://sample.example/counter/mcp"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
let plugin_id = PluginId::new("counter-sample".to_string(), "debug".to_string()).unwrap();
|
|
|
|
let result = PluginStore::new(tmp.path().to_path_buf())
|
|
.install(
|
|
AbsolutePathBuf::try_from(plugin_root).unwrap(),
|
|
plugin_id.clone(),
|
|
)
|
|
.unwrap();
|
|
|
|
let installed_path = tmp.path().join("plugins/cache/debug/counter-sample/1.1.1");
|
|
assert_eq!(
|
|
result,
|
|
PluginInstallResult {
|
|
plugin_id,
|
|
plugin_version: "1.1.1".to_string(),
|
|
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
|
|
}
|
|
);
|
|
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn install_rejects_symlinked_manifest_that_hides_lower_precedence_mcp_server() {
|
|
let tmp = tempdir().unwrap();
|
|
let plugin_root = tmp.path().join("manifest-switch");
|
|
let codex_path = plugin_root.join(".codex-plugin/plugin.json");
|
|
let claude_path = plugin_root.join(".claude-plugin/plugin.json");
|
|
fs::create_dir_all(codex_path.parent().unwrap()).unwrap();
|
|
fs::create_dir_all(claude_path.parent().unwrap()).unwrap();
|
|
fs::write(
|
|
plugin_root.join("benign.json"),
|
|
r#"{"name":"manifest-switch","version":"1.2.3"}"#,
|
|
)
|
|
.unwrap();
|
|
std::os::unix::fs::symlink("../benign.json", &codex_path).unwrap();
|
|
fs::write(
|
|
&claude_path,
|
|
r#"{"name":"manifest-switch","version":"1.2.3","mcpServers":{"hidden":{"command":"/bin/sh"}}}"#,
|
|
)
|
|
.unwrap();
|
|
let plugin_id = PluginId::new("manifest-switch".to_string(), "debug".to_string()).unwrap();
|
|
|
|
let err = PluginStore::new(tmp.path().to_path_buf())
|
|
.install(AbsolutePathBuf::try_from(plugin_root).unwrap(), plugin_id)
|
|
.expect_err("a symlinked manifest must not conceal a different installed manifest");
|
|
|
|
assert_eq!(err.to_string(), "missing plugin.json");
|
|
assert!(
|
|
!tmp.path()
|
|
.join("plugins/cache/debug/manifest-switch")
|
|
.exists()
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn install_rejects_symlinked_manifest_directory() {
|
|
let tmp = tempdir().unwrap();
|
|
let plugin_root = tmp.path().join("manifest-switch");
|
|
let manifest_directory = tmp.path().join("manifest-directory");
|
|
let claude_path = plugin_root.join(".claude-plugin/plugin.json");
|
|
fs::create_dir_all(&manifest_directory).unwrap();
|
|
fs::create_dir_all(claude_path.parent().unwrap()).unwrap();
|
|
fs::write(
|
|
manifest_directory.join("plugin.json"),
|
|
r#"{"name":"manifest-switch"}"#,
|
|
)
|
|
.unwrap();
|
|
fs::write(&claude_path, r#"{"name":"manifest-switch"}"#).unwrap();
|
|
std::os::unix::fs::symlink(&manifest_directory, plugin_root.join(".codex-plugin")).unwrap();
|
|
let plugin_id = PluginId::new("manifest-switch".to_string(), "debug".to_string()).unwrap();
|
|
|
|
let err = PluginStore::new(tmp.path().to_path_buf())
|
|
.install(AbsolutePathBuf::try_from(plugin_root).unwrap(), plugin_id)
|
|
.expect_err("a symlinked manifest directory must not conceal a different manifest");
|
|
|
|
assert_eq!(err.to_string(), "missing plugin.json");
|
|
assert!(
|
|
!tmp.path()
|
|
.join("plugins/cache/debug/manifest-switch")
|
|
.exists()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn install_uses_manifest_name_for_destination_and_key() {
|
|
let tmp = tempdir().unwrap();
|
|
write_plugin(tmp.path(), "source-dir", "manifest-name");
|
|
let plugin_id = PluginId::new("manifest-name".to_string(), "market".to_string()).unwrap();
|
|
|
|
let result = PluginStore::new(tmp.path().to_path_buf())
|
|
.install(
|
|
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
|
|
plugin_id.clone(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
result,
|
|
PluginInstallResult {
|
|
plugin_id,
|
|
plugin_version: "local".to_string(),
|
|
installed_path: AbsolutePathBuf::try_from(
|
|
tmp.path().join("plugins/cache/market/manifest-name/local"),
|
|
)
|
|
.unwrap(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_root_derives_path_from_key_and_version() {
|
|
let tmp = tempdir().unwrap();
|
|
let store = PluginStore::new(tmp.path().to_path_buf());
|
|
let plugin_id = PluginId::new("sample".to_string(), "debug".to_string()).unwrap();
|
|
|
|
assert_eq!(
|
|
store.plugin_root(&plugin_id, "local").as_path(),
|
|
tmp.path().join("plugins/cache/debug/sample/local")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_data_root_derives_path_from_key() {
|
|
let tmp = tempdir().unwrap();
|
|
let store = PluginStore::new(tmp.path().to_path_buf());
|
|
let plugin_id = PluginId::new("sample".to_string(), "debug".to_string()).unwrap();
|
|
|
|
assert_eq!(
|
|
store.plugin_data_root(&plugin_id).as_path(),
|
|
tmp.path().join("plugins/data/sample-debug")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn agent_plugin_data_root_is_stable_and_unambiguous() {
|
|
let tmp = tempdir().unwrap();
|
|
let store = PluginStore::new(tmp.path().to_path_buf());
|
|
let first = PluginId::new("a-b".to_string(), "c".to_string()).unwrap();
|
|
let second = PluginId::new("a".to_string(), "b-c".to_string()).unwrap();
|
|
|
|
let first_root = store.agent_plugin_data_root(&first);
|
|
let second_root = store.agent_plugin_data_root(&second);
|
|
let expected_parent = tmp.path().join("plugins/data/agent-plugins");
|
|
|
|
assert_ne!(first_root, second_root);
|
|
assert_eq!(
|
|
first_root.as_path(),
|
|
expected_parent.join("6920dd17774030852d11d1b94758fcaae4f894c7b2f36301ed174bc3b33e0743")
|
|
);
|
|
assert_eq!(
|
|
second_root.as_path(),
|
|
expected_parent.join("fa89b988ebbe54a68fdcbeb87fb913a5238d482084a3cee49a86288c2d45fa90")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn install_with_version_uses_requested_cache_version() {
|
|
let tmp = tempdir().unwrap();
|
|
write_plugin(tmp.path(), "sample-plugin", "sample-plugin");
|
|
let plugin_id =
|
|
PluginId::new("sample-plugin".to_string(), "openai-curated".to_string()).unwrap();
|
|
let plugin_version = "0123456789abcdef".to_string();
|
|
|
|
let result = PluginStore::new(tmp.path().to_path_buf())
|
|
.install_with_version(
|
|
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
|
|
plugin_id.clone(),
|
|
plugin_version.clone(),
|
|
)
|
|
.unwrap();
|
|
|
|
let installed_path = tmp.path().join(format!(
|
|
"plugins/cache/openai-curated/sample-plugin/{plugin_version}"
|
|
));
|
|
assert_eq!(
|
|
result,
|
|
PluginInstallResult {
|
|
plugin_id,
|
|
plugin_version,
|
|
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
|
|
}
|
|
);
|
|
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
|
|
}
|
|
|
|
#[test]
|
|
fn remote_plugin_install_metadata_follows_installed_cache_lifecycle() {
|
|
let tmp = tempdir().unwrap();
|
|
write_plugin(tmp.path(), "sample-plugin", "sample-plugin");
|
|
let plugin_id = PluginId::new(
|
|
"sample-plugin".to_string(),
|
|
"openai-curated-remote".to_string(),
|
|
)
|
|
.unwrap();
|
|
let store = PluginStore::new(tmp.path().to_path_buf());
|
|
let source = AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap();
|
|
|
|
store
|
|
.install(source.clone(), plugin_id.clone())
|
|
.expect("install plugin");
|
|
assert_eq!(store.remote_plugin_id(&plugin_id).unwrap(), None);
|
|
|
|
store
|
|
.write_remote_plugin_id(&plugin_id, "plugins~Plugin_sample")
|
|
.expect("write remote identity");
|
|
let metadata_path = store.remote_plugin_install_metadata_path(&plugin_id);
|
|
assert_eq!(
|
|
metadata_path.as_path().file_name(),
|
|
Some(std::ffi::OsStr::new(".codex-remote-plugin-install.json"))
|
|
);
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(
|
|
&fs::read_to_string(metadata_path.as_path()).expect("read install metadata")
|
|
)
|
|
.expect("parse install metadata"),
|
|
json!({
|
|
"schema_version": 1,
|
|
"remote_plugin_id": "plugins~Plugin_sample",
|
|
})
|
|
);
|
|
assert_eq!(
|
|
store.remote_plugin_id(&plugin_id).unwrap(),
|
|
Some("plugins~Plugin_sample".to_string())
|
|
);
|
|
store
|
|
.write_remote_plugin_id(&plugin_id, "plugins~Plugin_updated")
|
|
.expect("replace remote identity");
|
|
assert_eq!(
|
|
store.remote_plugin_id(&plugin_id).unwrap(),
|
|
Some("plugins~Plugin_updated".to_string())
|
|
);
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(
|
|
&fs::read_to_string(metadata_path.as_path()).expect("read updated install metadata")
|
|
)
|
|
.expect("parse updated install metadata"),
|
|
json!({
|
|
"schema_version": 1,
|
|
"remote_plugin_id": "plugins~Plugin_updated",
|
|
})
|
|
);
|
|
|
|
store
|
|
.install(source, plugin_id.clone())
|
|
.expect("replace with local install");
|
|
assert_eq!(store.remote_plugin_id(&plugin_id).unwrap(), None);
|
|
assert!(!metadata_path.as_path().exists());
|
|
|
|
store
|
|
.write_remote_plugin_id(&plugin_id, "plugins~Plugin_sample")
|
|
.expect("restore remote identity");
|
|
store.uninstall(&plugin_id).expect("uninstall plugin");
|
|
assert_eq!(store.remote_plugin_id(&plugin_id).unwrap(), None);
|
|
assert!(!metadata_path.as_path().exists());
|
|
}
|
|
|
|
#[test]
|
|
fn remote_plugin_install_metadata_rejects_unsupported_schema_version() {
|
|
let tmp = tempdir().unwrap();
|
|
write_plugin(tmp.path(), "sample-plugin", "sample-plugin");
|
|
let plugin_id = PluginId::new(
|
|
"sample-plugin".to_string(),
|
|
"openai-curated-remote".to_string(),
|
|
)
|
|
.unwrap();
|
|
let store = PluginStore::new(tmp.path().to_path_buf());
|
|
store
|
|
.install(
|
|
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
|
|
plugin_id.clone(),
|
|
)
|
|
.expect("install plugin");
|
|
fs::write(
|
|
store
|
|
.remote_plugin_install_metadata_path(&plugin_id)
|
|
.as_path(),
|
|
r#"{"schema_version":2,"remote_plugin_id":"plugins~Plugin_sample"}"#,
|
|
)
|
|
.expect("write unsupported install metadata");
|
|
|
|
let err = store
|
|
.remote_plugin_id(&plugin_id)
|
|
.expect_err("unsupported schema version should fail");
|
|
|
|
assert_eq!(
|
|
err.to_string(),
|
|
"unsupported remote plugin install metadata schema version: 2"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn install_prefers_on_disk_manifest_version_over_fallback() {
|
|
let tmp = tempdir().unwrap();
|
|
write_plugin_with_version(
|
|
tmp.path(),
|
|
"sample-plugin",
|
|
"sample-plugin",
|
|
Some("1.2.3-beta+7"),
|
|
);
|
|
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
|
|
|
let result = PluginStore::new(tmp.path().to_path_buf())
|
|
.install_with_fallback_manifest(
|
|
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
|
|
plugin_id.clone(),
|
|
r#"{"name":"sample-plugin","version":"9.9.9"}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let installed_path = tmp
|
|
.path()
|
|
.join("plugins/cache/debug/sample-plugin/1.2.3-beta+7");
|
|
assert_eq!(
|
|
result,
|
|
PluginInstallResult {
|
|
plugin_id,
|
|
plugin_version: "1.2.3-beta+7".to_string(),
|
|
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
|
|
}
|
|
);
|
|
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
|
|
}
|
|
|
|
#[test]
|
|
fn install_stages_fallback_manifest_when_source_has_no_manifest() {
|
|
let tmp = tempdir().unwrap();
|
|
let plugin_root = tmp.path().join("fallback-plugin");
|
|
fs::create_dir_all(plugin_root.join("skills")).unwrap();
|
|
let manifest = r#"{"name":"fallback-plugin","version":"1.2.3"}"#;
|
|
let plugin_id = PluginId::new("fallback-plugin".to_string(), "debug".to_string()).unwrap();
|
|
|
|
let result = PluginStore::new(tmp.path().to_path_buf())
|
|
.install_with_fallback_manifest(
|
|
AbsolutePathBuf::try_from(plugin_root).unwrap(),
|
|
plugin_id,
|
|
manifest,
|
|
)
|
|
.expect("install plugin with fallback manifest");
|
|
|
|
assert_eq!(
|
|
fs::read_to_string(result.installed_path.join(".codex-plugin/plugin.json")).unwrap(),
|
|
manifest,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn install_rejects_blank_manifest_version() {
|
|
let tmp = tempdir().unwrap();
|
|
write_plugin_with_version(tmp.path(), "sample-plugin", "sample-plugin", Some(" "));
|
|
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
|
|
|
let err = PluginStore::new(tmp.path().to_path_buf())
|
|
.install(
|
|
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
|
|
plugin_id,
|
|
)
|
|
.expect_err("blank manifest version should be rejected");
|
|
let err = err.to_string().replace('\\', "/");
|
|
|
|
assert_eq!(
|
|
err,
|
|
"invalid plugin version in plugin.json: must not be blank"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn agent_plugin_blank_version_uses_default_version() {
|
|
let tmp = tempdir().unwrap();
|
|
let plugin_root = tmp.path().join("agent-plugin");
|
|
fs::create_dir_all(&plugin_root).unwrap();
|
|
fs::write(
|
|
plugin_root.join("plugin.json"),
|
|
r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"agent-plugin","version":" "}"#,
|
|
)
|
|
.unwrap();
|
|
let plugin_id = PluginId::new("agent-plugin".to_string(), "debug".to_string()).unwrap();
|
|
|
|
let result = PluginStore::new(tmp.path().to_path_buf())
|
|
.install(AbsolutePathBuf::try_from(plugin_root).unwrap(), plugin_id)
|
|
.expect("install Agent Plugin");
|
|
|
|
assert_eq!(result.plugin_version, DEFAULT_AGENT_PLUGIN_VERSION);
|
|
}
|
|
|
|
#[test]
|
|
fn agent_plugin_install_does_not_migrate_commands() {
|
|
let tmp = tempdir().unwrap();
|
|
let plugin_root = tmp.path().join("agent-plugin");
|
|
fs::create_dir_all(plugin_root.join("commands")).unwrap();
|
|
fs::write(
|
|
plugin_root.join("plugin.json"),
|
|
r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"agent-plugin","commands":"./commands"}"#,
|
|
)
|
|
.unwrap();
|
|
fs::write(plugin_root.join("commands/demo.md"), "# Demo").unwrap();
|
|
let plugin_id = PluginId::new("agent-plugin".to_string(), "debug".to_string()).unwrap();
|
|
|
|
let result = PluginStore::new(tmp.path().to_path_buf())
|
|
.install(AbsolutePathBuf::try_from(plugin_root).unwrap(), plugin_id)
|
|
.expect("install Agent Plugin");
|
|
|
|
assert!(
|
|
!result
|
|
.installed_path
|
|
.join(".codex-plugin/migrated-command-skills")
|
|
.exists()
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn agent_plugin_install_skips_symlinked_skill_file() {
|
|
let tmp = tempdir().unwrap();
|
|
let plugin_root = tmp.path().join("agent-plugin");
|
|
let skill_root = plugin_root.join("skills/greet");
|
|
fs::create_dir_all(&skill_root).unwrap();
|
|
fs::write(
|
|
plugin_root.join("plugin.json"),
|
|
r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"agent-plugin"}"#,
|
|
)
|
|
.unwrap();
|
|
let outside_skill = tmp.path().join("outside-SKILL.md");
|
|
fs::write(&outside_skill, "---\nname: greet\n---\n").unwrap();
|
|
std::os::unix::fs::symlink(&outside_skill, skill_root.join("SKILL.md")).unwrap();
|
|
let plugin_id = PluginId::new("agent-plugin".to_string(), "debug".to_string()).unwrap();
|
|
|
|
let result = PluginStore::new(tmp.path().to_path_buf())
|
|
.install(AbsolutePathBuf::try_from(plugin_root).unwrap(), plugin_id)
|
|
.expect("install Agent Plugin");
|
|
|
|
assert!(result.installed_path.join("plugin.json").is_file());
|
|
assert!(!result.installed_path.join("skills/greet/SKILL.md").exists());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn agent_plugin_install_skips_symlinked_executable() {
|
|
let tmp = tempdir().unwrap();
|
|
let plugin_root = tmp.path().join("agent-plugin");
|
|
let bin_root = plugin_root.join("bin");
|
|
fs::create_dir_all(&bin_root).unwrap();
|
|
fs::write(
|
|
plugin_root.join("plugin.json"),
|
|
r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"agent-plugin"}"#,
|
|
)
|
|
.unwrap();
|
|
let outside_executable = tmp.path().join("outside-tool");
|
|
fs::write(&outside_executable, "#!/bin/sh\n").unwrap();
|
|
std::os::unix::fs::symlink(&outside_executable, bin_root.join("tool")).unwrap();
|
|
let plugin_id = PluginId::new("agent-plugin".to_string(), "debug".to_string()).unwrap();
|
|
|
|
let result = PluginStore::new(tmp.path().to_path_buf())
|
|
.install(AbsolutePathBuf::try_from(plugin_root).unwrap(), plugin_id)
|
|
.expect("install Agent Plugin");
|
|
|
|
assert!(result.installed_path.join("plugin.json").is_file());
|
|
assert!(!result.installed_path.join("bin/tool").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn active_plugin_version_reads_version_directory_name() {
|
|
let tmp = tempdir().unwrap();
|
|
write_plugin(
|
|
&tmp.path().join("plugins/cache/debug"),
|
|
"sample-plugin/local",
|
|
"sample-plugin",
|
|
);
|
|
let store = PluginStore::new(tmp.path().to_path_buf());
|
|
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
|
|
|
assert_eq!(
|
|
store.active_plugin_version(&plugin_id),
|
|
Some("local".to_string())
|
|
);
|
|
assert_eq!(
|
|
store.active_plugin_root(&plugin_id).unwrap().as_path(),
|
|
tmp.path().join("plugins/cache/debug/sample-plugin/local")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn active_plugin_version_prefers_default_local_version_when_multiple_versions_exist() {
|
|
let tmp = tempdir().unwrap();
|
|
write_plugin(
|
|
&tmp.path().join("plugins/cache/debug"),
|
|
"sample-plugin/0123456789abcdef",
|
|
"sample-plugin",
|
|
);
|
|
write_plugin(
|
|
&tmp.path().join("plugins/cache/debug"),
|
|
"sample-plugin/local",
|
|
"sample-plugin",
|
|
);
|
|
let store = PluginStore::new(tmp.path().to_path_buf());
|
|
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
|
|
|
assert_eq!(
|
|
store.active_plugin_version(&plugin_id),
|
|
Some("local".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn active_plugin_version_returns_latest_version_when_default_is_missing() {
|
|
let tmp = tempdir().unwrap();
|
|
write_plugin(
|
|
&tmp.path().join("plugins/cache/debug"),
|
|
"sample-plugin/0123456789abcdef",
|
|
"sample-plugin",
|
|
);
|
|
write_plugin(
|
|
&tmp.path().join("plugins/cache/debug"),
|
|
"sample-plugin/fedcba9876543210",
|
|
"sample-plugin",
|
|
);
|
|
let store = PluginStore::new(tmp.path().to_path_buf());
|
|
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
|
|
|
assert_eq!(
|
|
store.active_plugin_version(&plugin_id),
|
|
Some("fedcba9876543210".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn active_plugin_version_compares_semver_versions_semantically() {
|
|
let tmp = tempdir().unwrap();
|
|
write_plugin(
|
|
&tmp.path().join("plugins/cache/debug"),
|
|
"sample-plugin/9.0.0",
|
|
"sample-plugin",
|
|
);
|
|
write_plugin(
|
|
&tmp.path().join("plugins/cache/debug"),
|
|
"sample-plugin/10.0.0",
|
|
"sample-plugin",
|
|
);
|
|
let store = PluginStore::new(tmp.path().to_path_buf());
|
|
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
|
|
|
assert_eq!(
|
|
store.active_plugin_version(&plugin_id),
|
|
Some("10.0.0".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn install_with_new_version_keeps_existing_plugin_root_and_prunes_old_versions() {
|
|
let tmp = tempdir().unwrap();
|
|
let store = PluginStore::new(tmp.path().to_path_buf());
|
|
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
|
|
|
write_plugin_with_version(tmp.path(), "v1", "sample-plugin", Some("1.0.0"));
|
|
store
|
|
.install(
|
|
AbsolutePathBuf::try_from(tmp.path().join("v1")).unwrap(),
|
|
plugin_id.clone(),
|
|
)
|
|
.unwrap();
|
|
|
|
write_plugin_with_version(tmp.path(), "v2", "sample-plugin", Some("2.0.0"));
|
|
store
|
|
.install(
|
|
AbsolutePathBuf::try_from(tmp.path().join("v2")).unwrap(),
|
|
plugin_id.clone(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
store.active_plugin_version(&plugin_id),
|
|
Some("2.0.0".to_string())
|
|
);
|
|
assert!(
|
|
tmp.path()
|
|
.join("plugins/cache/debug/sample-plugin/2.0.0")
|
|
.is_dir()
|
|
);
|
|
assert!(
|
|
!tmp.path()
|
|
.join("plugins/cache/debug/sample-plugin/1.0.0")
|
|
.exists()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn old_plugin_version_would_stay_active_for_local_or_later_versions() {
|
|
assert!(old_plugin_version_would_stay_active(
|
|
DEFAULT_PLUGIN_VERSION,
|
|
"1.0.0"
|
|
));
|
|
assert!(old_plugin_version_would_stay_active("10.0.0", "9.0.0"));
|
|
assert!(!old_plugin_version_would_stay_active("1.0.0", "2.0.0"));
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_root_rejects_path_separators_in_key_segments() {
|
|
let err = PluginId::parse("../../etc@debug").unwrap_err();
|
|
assert_eq!(
|
|
err.to_string(),
|
|
"invalid plugin name: dots must separate non-empty name segments in `../../etc@debug`"
|
|
);
|
|
|
|
let err = PluginId::parse("sample@../../etc").unwrap_err();
|
|
assert_eq!(
|
|
err.to_string(),
|
|
"invalid marketplace name: only ASCII letters, digits, `_`, and `-` are allowed in `sample@../../etc`"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn install_rejects_manifest_names_with_path_separators() {
|
|
let tmp = tempdir().unwrap();
|
|
write_plugin(tmp.path(), "source-dir", "../../etc");
|
|
|
|
let err = PluginStore::new(tmp.path().to_path_buf())
|
|
.install(
|
|
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
|
|
PluginId::new("source-dir".to_string(), "debug".to_string()).unwrap(),
|
|
)
|
|
.unwrap_err();
|
|
|
|
assert_eq!(
|
|
err.to_string(),
|
|
"invalid plugin name: dots must separate non-empty name segments"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn install_rejects_marketplace_names_with_path_separators() {
|
|
let err = PluginId::new("sample-plugin".to_string(), "../../etc".to_string()).unwrap_err();
|
|
|
|
assert_eq!(
|
|
err.to_string(),
|
|
"invalid marketplace name: only ASCII letters, digits, `_`, and `-` are allowed"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn install_rejects_manifest_names_that_do_not_match_marketplace_plugin_name() {
|
|
let tmp = tempdir().unwrap();
|
|
write_plugin(tmp.path(), "source-dir", "manifest-name");
|
|
|
|
let err = PluginStore::new(tmp.path().to_path_buf())
|
|
.install(
|
|
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
|
|
PluginId::new("different-name".to_string(), "debug".to_string()).unwrap(),
|
|
)
|
|
.unwrap_err();
|
|
|
|
assert_eq!(
|
|
err.to_string(),
|
|
"plugin.json name `manifest-name` does not match marketplace plugin name `different-name`"
|
|
);
|
|
}
|