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()); } #[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 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::( &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::( &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_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 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: only ASCII letters, digits, `_`, and `-` are allowed 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: only ASCII letters, digits, `_`, and `-` are allowed" ); } #[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`" ); }