codex - 💡(How to fix) Fix Plugin install: support symlinks per the cross-agent marketplace contract

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

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.

Error Message

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))?;
    }
}

Root Cause

  • (Optional, polish) When a symlink is skipped because it falls in bucket 3, emit a tracing::warn! (not just debug) once per install and surface a single aggregate hint in the codex plugins install CLI output ("N symlink(s) outside the marketplace were skipped"). Quicker fix: just debug! 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 a strict flag on the install call. Quicker fix: silent skip with a debug! line, matching the default behavior on other agentic tools.
  • (Optional) Add a paths.symlinks field 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.

Fix Action

Fix / Workaround

  • #18863 — Plugin cache install silently drops symlinks from local plugin sources (open, labels: bug, skills). The canonical bug report. Includes root-cause analysis pointing at copy_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 skills symlink 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.

  1. codex-rs/core-plugins/src/store.rs
    • Replace copy_dir_recursive with a boundary-aware copy_plugin_tree that takes plugin_root and Option<marketplace_root>, walks the source with lstat semantics (the existing entry.file_type()), and dispatches each symlink to one of three handlers per the contract above.
    • Add a small SymlinkDisposition enum (PreserveRelative / Dereference / Skip) and a classify_symlink_target helper that canonicalizes the link and tests path containment against the two boundary roots.
    • Add a recreate_relative_symlink helper that re-emits the original read_link text verbatim (so the cached link string is portable) and selects symlink_dir vs symlink_file on 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_identity already does for the discovery walker).
    • Update the call site inside PluginStore::install_plugin (and the staging helper that wraps copy_dir_recursive) to thread the boundary roots through.

Code Example

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))?;
        }
    }

---

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
RAW_BUFFERClick to expand / collapse

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 resolvesCache behavior
Inside the plugin's own directoryPreserve as a relative symlink (keeps resolving to the copied target at runtime)
Inside the same marketplace, outside the pluginDereference — copy the target's real content into the cache in its place
Outside the marketplaceSkip (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:

  1. Unblocks the shared-resource marketplace layout that's a very common community pattern.
  2. 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.
  3. 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 in core-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 at copy_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 skills symlink 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

  1. codex-rs/core-plugins/src/store.rs

    • Replace copy_dir_recursive with a boundary-aware copy_plugin_tree that takes plugin_root and Option<marketplace_root>, walks the source with lstat semantics (the existing entry.file_type()), and dispatches each symlink to one of three handlers per the contract above.
    • Add a small SymlinkDisposition enum (PreserveRelative / Dereference / Skip) and a classify_symlink_target helper that canonicalizes the link and tests path containment against the two boundary roots.
    • Add a recreate_relative_symlink helper that re-emits the original read_link text verbatim (so the cached link string is portable) and selects symlink_dir vs symlink_file on 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_identity already does for the discovery walker).
    • Update the call site inside PluginStore::install_plugin (and the staging helper that wraps copy_dir_recursive) to thread the boundary roots through.
  2. codex-rs/core-plugins/src/manager.rs (and core-plugins/src/marketplace_add/install.rs)

    • Plumb the marketplace root into the PluginStore::install_plugin call. The marketplace path is already known at install time (it's the directory clone_git_source / local-path resolution produced).
    • For direct local-path installs that have no marketplace concept, pass None so the routine falls back to the documented --plugin-dir rule (same-plugin preserve, everything else skip).
  3. codex-rs/core-plugins/src/store.rs — tests (new module, or extend store_tests.rs)

    • install_preserves_symlink_inside_plugin_dir
    • install_dereferences_symlink_to_sibling_plugin_in_same_marketplace
    • install_dereferences_symlink_to_shared_dir_at_marketplace_root ← the canonical "shared skills" layout
    • install_skips_symlink_targeting_path_outside_marketplace
    • install_for_direct_local_path_skips_symlinks_outside_plugin_dir
    • install_preserves_relative_link_text_verbatim (use fs::read_link after install to assert)
    • install_handles_symlink_cycle_within_marketplace_without_infinite_recursion
    • install_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.
  4. codex-rs/core-plugins/src/plugin_bundle_archive.rs

    • Audit the existing InvalidBundle rejection at the is_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.
  5. docs/plugins.md (or wherever the plugin authoring docs live in codex-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 just debug) once per install and surface a single aggregate hint in the codex plugins install CLI output ("N symlink(s) outside the marketplace were skipped"). Quicker fix: just debug! 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 a strict flag on the install call. Quicker fix: silent skip with a debug! line, matching the default behavior on other agentic tools.
  • (Optional) Add a paths.symlinks field 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

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

codex - 💡(How to fix) Fix Plugin install: support symlinks per the cross-agent marketplace contract