diff --git a/codex-rs/core/src/plugins/store.rs b/codex-rs/core/src/plugins/store.rs index 4316f18002..f7d4466eea 100644 --- a/codex-rs/core/src/plugins/store.rs +++ b/codex-rs/core/src/plugins/store.rs @@ -8,6 +8,10 @@ use serde::Deserialize; use serde_json::Value as JsonValue; use std::fs; use std::io; +#[cfg(unix)] +use std::os::unix::fs as unix_fs; +#[cfg(windows)] +use std::os::windows::fs as windows_fs; use std::path::Path; use std::path::PathBuf; @@ -348,21 +352,58 @@ fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), PluginStoreErr entry.map_err(|err| PluginStoreError::io("failed to enumerate plugin source", err))?; let source_path = entry.path(); let target_path = target.join(entry.file_name()); - let file_type = entry - .file_type() + let metadata = fs::symlink_metadata(&source_path) .map_err(|err| PluginStoreError::io("failed to inspect plugin source entry", err))?; + let file_type = metadata.file_type(); if file_type.is_dir() { copy_dir_recursive(&source_path, &target_path)?; } else if file_type.is_file() { fs::copy(&source_path, &target_path) .map_err(|err| PluginStoreError::io("failed to copy plugin file", err))?; + } else if file_type.is_symlink() { + copy_symlink(&source_path, &target_path)?; } } Ok(()) } +#[cfg(unix)] +fn copy_symlink(source: &Path, target: &Path) -> Result<(), PluginStoreError> { + let link_target = fs::read_link(source) + .map_err(|err| PluginStoreError::io("failed to read plugin symlink", err))?; + unix_fs::symlink(link_target, target) + .map_err(|err| PluginStoreError::io("failed to copy plugin symlink", err)) +} + +#[cfg(windows)] +fn copy_symlink(source: &Path, target: &Path) -> Result<(), PluginStoreError> { + let link_target = fs::read_link(source) + .map_err(|err| PluginStoreError::io("failed to read plugin symlink", err))?; + let resolved_target = if link_target.is_absolute() { + link_target.clone() + } else { + source + .parent() + .map(|parent| parent.join(&link_target)) + .unwrap_or_else(|| link_target.clone()) + }; + let result = if resolved_target.is_dir() { + windows_fs::symlink_dir(link_target, target) + } else { + windows_fs::symlink_file(link_target, target) + }; + result.map_err(|err| PluginStoreError::io("failed to copy plugin symlink", err)) +} + +#[cfg(not(any(unix, windows)))] +fn copy_symlink(_source: &Path, _target: &Path) -> Result<(), PluginStoreError> { + Err(PluginStoreError::Invalid( + "plugin symlinks are not supported on this platform".to_string(), + )) +} + #[cfg(test)] #[path = "store_tests.rs"] mod tests; diff --git a/codex-rs/core/src/plugins/store_tests.rs b/codex-rs/core/src/plugins/store_tests.rs index cb23443e80..f5171c4432 100644 --- a/codex-rs/core/src/plugins/store_tests.rs +++ b/codex-rs/core/src/plugins/store_tests.rs @@ -59,6 +59,45 @@ fn install_copies_plugin_into_default_marketplace() { assert!(installed_path.join("skills/SKILL.md").is_file()); } +#[cfg(unix)] +#[test] +fn install_preserves_plugin_symlinks() { + let tmp = tempdir().unwrap(); + write_plugin(tmp.path(), "sample-plugin", "sample-plugin"); + let source_plugin = tmp.path().join("sample-plugin"); + fs::create_dir_all(source_plugin.join("bundle/Versions/A")).unwrap(); + fs::write( + source_plugin.join("bundle/Versions/A/payload.txt"), + "payload", + ) + .unwrap(); + std::os::unix::fs::symlink("A", source_plugin.join("bundle/Versions/Current")).unwrap(); + std::os::unix::fs::symlink( + "Versions/Current/payload.txt", + source_plugin.join("bundle/payload.txt"), + ) + .unwrap(); + let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); + + PluginStore::new(tmp.path().to_path_buf()) + .install(AbsolutePathBuf::try_from(source_plugin).unwrap(), plugin_id) + .unwrap(); + + let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local"); + assert_eq!( + fs::read_link(installed_path.join("bundle/Versions/Current")).unwrap(), + PathBuf::from("A"), + ); + assert_eq!( + fs::read_link(installed_path.join("bundle/payload.txt")).unwrap(), + PathBuf::from("Versions/Current/payload.txt"), + ); + assert_eq!( + fs::read_to_string(installed_path.join("bundle/payload.txt")).unwrap(), + "payload", + ); +} + #[test] fn install_uses_manifest_name_for_destination_and_key() { let tmp = tempdir().unwrap();