Plugin cache install silently drops symlinks from local plugin sources

Open 💬 0 comments Opened Apr 21, 2026 by kkkzbh

What issue are you seeing?

Summary

Local plugin installation into the Codex plugin cache silently drops symlinks.

This breaks plugin source trees that contain valid symlinked entries. In my case, a local plugin with a Python virtual environment was copied into:

~/.codex/plugins/cache/local-personal/linux-computer-use/...

but the cached .venv was missing .venv/bin/python, because that entry is a symlink in a normal Python venv. The cached tree still contained regular files such as pip, so the result looked like a partially copied venv rather than a clean missing-runtime error.

This does not appear to be caused by Linux archive extraction or unpacking. The affected marketplace source is a local plugin source, and the Codex source path for local plugins does not go through tar, zip, or archive materialization.

Observed failure:

missing venv .../.venv/bin/python

Local evidence from the cached plugin tree:

  • source plugin tree contains symlinks
  • cached plugin tree contains zero corresponding symlinks
  • .venv/bin/python* symlinks are missing from the cache
  • regular files such as .venv/bin/pip remain in the cache
  • cached venv metadata/entrypoints can still refer to the original source plugin path, which suggests a partially copied runtime rather than a rebuilt one

The direct source-level cause appears to be codex-rs/core-plugins/src/store.rs: copy_dir_recursive() handles only directories and regular files and has no is_symlink() branch, so symlinks are silently skipped.

What steps can reproduce the bug?

A minimal repro is a local marketplace plugin whose source tree contains a symlink.

  1. Create or use a local plugin source with a symlinked file:
mkdir -p /tmp/codex-local-plugin/.codex-plugin
printf '{"name":"sample-plugin","version":"local","description":"sample"}\n' \
  > /tmp/codex-local-plugin/.codex-plugin/plugin.json

echo "hello" > /tmp/codex-local-plugin/target.txt
ln -s target.txt /tmp/codex-local-plugin/target-link.txt
  1. Install that plugin through a local marketplace source.
  1. Inspect the installed cache entry under:
~/.codex/plugins/cache/<marketplace-name>/<plugin-name>/<version-or-local>/
  1. Check whether the symlink was preserved:
test -L ~/.codex/plugins/cache/<marketplace-name>/<plugin-name>/<version-or-local>/target-link.txt

Actual result: the symlink is missing.

Expected result: target-link.txt should still be a symlink pointing to target.txt.

For local marketplace plugins, this is not an archive extraction issue:

  • MarketplacePluginSource::Local is resolved as a local path.
  • materialize_marketplace_plugin_source() returns local plugin paths directly.
  • The cache write is performed by PluginStore in codex-rs/core-plugins/src/store.rs.

What is the expected behavior?

Plugin cache installation should preserve symlinked files and symlinked directories from the materialized plugin source tree.

If Codex intentionally does not support some filesystem entry type, the install should fail with an explicit error rather than silently omitting entries.

Additional information

I searched existing issues for symlink plugin cache, plugin cache, local plugin symlink, and copy_dir_recursive, and did not find a duplicate for this specific cache-copy behavior.

The relevant code appears to be:

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

copy_dir_recursive() currently handles only directories and regular files:

if file_type.is_dir() {
    ...
} else if file_type.is_file() {
    fs::copy(...)?;
}

There is already a symlink-preserving copy implementation elsewhere in the repository:

codex-rs/exec-server/src/local_file_system.rs

That implementation reads the link target and recreates the symlink, which seems like the expected behavior for plugin cache materialization as well.

A possible fix would be to:

  • add an is_symlink() branch in copy_dir_recursive()
  • use fs::read_link() and recreate the symlink in the staged cache directory
  • on Unix, use std::os::unix::fs::symlink
  • on Windows, choose symlink_dir vs symlink_file
  • return an explicit error for unsupported entry types instead of silently skipping them

I validated this approach locally with regression coverage for:

  • preserving a symlinked file during plugin cache install
  • preserving a symlinked directory during plugin cache install
  • preserving symlinks through the local marketplace PluginsManager::install_plugin() path

These passed locally:

cargo test --manifest-path codex-rs/Cargo.toml -p codex-core-plugins
cargo test --manifest-path codex-rs/Cargo.toml -p codex-core install_plugin_supports_git_subdir_marketplace_sources
cargo test --manifest-path codex-rs/Cargo.toml -p codex-core install_plugin_preserves_symlinks_from_local_marketplace_sources

View original on GitHub ↗