Plugin install: support symlinks per the cross-agent marketplace contract
Summary
codex plugins install (local marketplace path) silently drops symlinks from the plugin source tree when materializing the plugin into the install cache at ~/.codex/plugins/cache/<marketplace>/<plugin>/<version>/. Marketplace authors who use the standard pattern of symlinking a shared skill or shared script directory into a plugin folder end up with either an empty skills/ dir or a partially populated plugin tree — with no error, no warning, and no log line.
This makes it impossible to maintain a marketplace with a shared skills/ (or scripts/, references/, etc.) directory and lightweight "meta-plugins" that compose those shared pieces — which is one a common marketplace layout in the broader community.
Specific problem
The recursive copy used during plugin install only handles two file types and falls off the end for symlinks (codex-rs/core-plugins/src/store.rs, copy_dir_recursive):
fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), PluginStoreError> {
fs::create_dir_all(target)
.map_err(|err| PluginStoreError::io("failed to create plugin target directory", err))?;
for entry in fs::read_dir(source)
.map_err(|err| PluginStoreError::io("failed to read plugin source directory", err))?
{
let entry = entry
.map_err(|err| PluginStoreError::io("failed to read plugin source entry", err))?;
let source_path = entry.path();
let target_path = target.join(entry.file_name());
let file_type = entry
.file_type()
.map_err(|err| PluginStoreError::io("failed to inspect plugin source entry", err))?;
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))?;
}
}
entry.file_type() uses lstat semantics, so a symlink-to-directory returns is_symlink() == true, is_dir() == false, is_file() == false. The code has no third branch, so the entry is silently skipped — no error, no tracing event, no UI surface.
Concrete repro (a very common community pattern):
my-marketplace/
├── .agents/plugins/marketplace.json
├── skills/ # shared skill library at marketplace root
│ └── skillgroup/
│ └── skillX/
│ └── SKILL.md
└── plugins/
└── pluginX/
├── .codex-plugin/plugin.json
└── skills/
└── skillX -> ../../../skills/skillgroup/skillX # symlink
After codex plugins install pluginX@my-marketplace, the cache contains ~/.codex/plugins/cache/my-marketplace/pluginX/<version>/skills/ as an empty directory. The skill is unreachable. The skill discovery code is fine — it already follows symlinks at scope User/Repo/Admin (#8801) — the bug is upstream of discovery, in the install copy.
Proposed solution (high level)
Support symbolic links in plugin folders during install by implementing the three-way contract already specified by other agentic tools' plugin marketplaces:
Plugins reference → Share files within a marketplace with symlinks
| Where the symlink target resolves | Cache behavior |
| --- | --- |
| Inside the plugin's own directory | Preserve as a relative symlink (keeps resolving to the copied target at runtime) |
| Inside the same marketplace, outside the plugin | Dereference — copy the target's real content into the cache in its place |
| Outside the marketplace | Skip (security) |
| --plugin-dir / direct local install (no marketplace context) | Only same-plugin links are preserved; everything else is skipped |
Adopting this contract verbatim gives Codex three things at once:
- Unblocks the shared-resource marketplace layout that's a very common community pattern.
- Consistency across agentic CLIs. Plugin authors can ship the same marketplace layout to Codex and to other agentic tools with identical behavior. No "works elsewhere, silently empty in Codex" footgun. Reduces porting friction for the growing set of plugin/skill authors maintaining marketplaces that target multiple agents.
- Better security posture than blanket "follow all symlinks" — the third bucket explicitly refuses to pull arbitrary host files (e.g.,
/etc/...,~/.ssh/...) into the plugin cache, which is exactly the concern the existing tar-bundle path already enforces by rejecting all link entries incore-plugins/src/plugin_bundle_archive.rs.
Signal of need
This bug, or its near neighbors, has been independently rediscovered and reported by multiple users:
- #18863 — Plugin cache install silently drops symlinks from local plugin sources (open, labels:
bug,skills). The canonical bug report. Includes root-cause analysis pointing atcopy_dir_recursive, exactly the function quoted above, plus three regression tests already written and verified locally by the reporter. - #17952 — fix: preserve plugin cache symlinks (closed). Two maintainers both approved this PR on Apr 15, 2026. It was then auto-closed by the stale-bot on Apr 30 due to no updates — never merged. The PR implemented "preserve" (bucket 1 above) only; the contract proposed here generalizes it to all three buckets so it stays correct for shared-resource layouts too.
- #17066 — Marketplace local plugin path "./" cannot reference the repository root (open). Adjacent issue: users hit this when they try the workaround of placing the plugin at the marketplace root to avoid the symlink path altogether. The reporter's "current workaround A" relies on a
skillssymlink crossing the plugin boundary — which is exactly the case this proposal makes work natively.
Closes #18863. Supersedes (and generalizes) the approved patch in #17952. Removes the need for workaround A in #17066, though that issue tracks a separate marketplace-path-validation bug and should stay open on its own merits.
Proposed Solution Details
Required tasks
codex-rs/core-plugins/src/store.rs
- Replace
copy_dir_recursivewith a boundary-awarecopy_plugin_treethat takesplugin_rootandOption<marketplace_root>, walks the source withlstatsemantics (the existingentry.file_type()), and dispatches each symlink to one of three handlers per the contract above. - Add a small
SymlinkDispositionenum (PreserveRelative/Dereference/Skip) and aclassify_symlink_targethelper that canonicalizes the link and tests path containment against the two boundary roots. - Add a
recreate_relative_symlinkhelper that re-emits the originalread_linktext verbatim (so the cached link string is portable) and selectssymlink_dirvssymlink_fileon Windows. - Add a visited-set carried through the recursion for cycle protection on the dereference branch (mirrors what
core-skills/src/loader.rs::canonicalize_for_skill_identityalready does for the discovery walker). - Update the call site inside
PluginStore::install_plugin(and the staging helper that wrapscopy_dir_recursive) to thread the boundary roots through.
codex-rs/core-plugins/src/manager.rs(andcore-plugins/src/marketplace_add/install.rs)
- Plumb the marketplace root into the
PluginStore::install_plugincall. The marketplace path is already known at install time (it's the directoryclone_git_source/ local-path resolution produced). - For direct local-path installs that have no marketplace concept, pass
Noneso the routine falls back to the documented--plugin-dirrule (same-plugin preserve, everything else skip).
codex-rs/core-plugins/src/store.rs— tests (new module, or extendstore_tests.rs)
install_preserves_symlink_inside_plugin_dirinstall_dereferences_symlink_to_sibling_plugin_in_same_marketplaceinstall_dereferences_symlink_to_shared_dir_at_marketplace_root← the canonical "shared skills" layoutinstall_skips_symlink_targeting_path_outside_marketplaceinstall_for_direct_local_path_skips_symlinks_outside_plugin_dirinstall_preserves_relative_link_text_verbatim(usefs::read_linkafter install to assert)install_handles_symlink_cycle_within_marketplace_without_infinite_recursioninstall_skips_dangling_symlink_without_failing- Gate the symlink-creation portions on non-Windows where appropriate; on Windows, gate behind
std::os::windows::fs::symlink_*availability.
codex-rs/core-plugins/src/plugin_bundle_archive.rs
- Audit the existing
InvalidBundlerejection at theis_hard_link() || is_symlink()arm to confirm it remains the right policy for the tar path (it is — bundles are meant to be self-contained). No behavior change required, but worth a comment cross-referencing the new local-install contract so future contributors understand the asymmetry.
docs/plugins.md(or wherever the plugin authoring docs live incodex-rs's docs surface)
- Add a "Sharing files within a marketplace with symlinks" subsection mirroring the same wording authors targeting other agentic tools already follow.
Optional / deferrable
These can be split into follow-up PRs without blocking the core fix:
- (Optional, polish) When a symlink is skipped because it falls in bucket 3, emit a
tracing::warn!(not justdebug) once per install and surface a single aggregate hint in thecodex plugins installCLI output ("N symlink(s) outside the marketplace were skipped"). Quicker fix: justdebug!it for now. - (Optional) Treat a dangling symlink (read_link succeeds, canonicalize fails) as a hard
InvalidBundle-style error rather than a silent skip, parameterized by astrictflag on the install call. Quicker fix: silent skip with adebug!line, matching the default behavior on other agentic tools. - (Optional) Add a
paths.symlinksfield to the plugin manifest ({ "policy": "follow" | "preserve" | "reject" }) for plugins that want to opt into stricter behavior than the default. Not needed for the fix; only file as a follow-up if there's demand. - (Optional) If the maintainers prefer a minimal first cut to unblock users today, an acceptable interim implementation is bucket 2 only — dereference all symlinks in the plugin tree during install, with cycle protection — without yet distinguishing buckets 1 and 3. This restores the canonical shared-skills layout immediately and matches what the approved #17952 intent was, then a follow-up PR can layer the full three-way contract on top.
---
/cc @conrad-oai @dylan-hurd-oai @xl-openai