Plugin cache install silently drops symlinks from local plugin sources
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/pipremain 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.
- 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
- Install that plugin through a local marketplace source.
- Inspect the installed cache entry under:
~/.codex/plugins/cache/<marketplace-name>/<plugin-name>/<version-or-local>/
- 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::Localis resolved as a local path.materialize_marketplace_plugin_source()returns local plugin paths directly.- The cache write is performed by
PluginStoreincodex-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 incopy_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_dirvssymlink_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
3 Comments
i'm still running into this issue
Independent Linux reproduction, with a broader distribution impact than a missing individual symlink.
We have a TypeScript/Bun marketplace plugin whose package.json declares runtime dependencies, including marked. The source is a Bun workspace, so a normal source install either has workspace-managed links or no plugin-local node_modules tree. Codex copies the plugin into its versioned cache but does not install those declared runtime dependencies. Installing them manually with bun install --production inside the cached plugin makes the hook work immediately.
That repair is not durable. During a later Codex plugin refresh in the same running session, Codex replaces the same versioned cache tree from marketplace source. The production node_modules we installed in the cache disappears, and every PreToolUse invocation then fails with:
Cannot find package 'marked' from '<cache>/model.ts'
hook exit: 1
We reproduced this repeatedly:
This leaves scripting-language plugin authors without a reliable dependency strategy:
Bundling or vendoring every dependency as ordinary files is currently the only durable option we can see. That is a serious limitation for shipping CLI tools and hooks written in TypeScript, JavaScript, Python, or other dependency-managed scripting languages.
Expected behavior should provide at least one supported durable contract: preserve safe internal symlinks, install declared production dependencies as part of the atomic plugin transaction, or support a built artifact whose dependency closure is materialized before the cache becomes active. A refresh must preserve/recreate that closure instead of silently replacing it with an unrunnable source copy.
Related cache-refresh reports: #21138, #23902, #31383. The direct-development request in #40457 would improve iteration, but installed plugin distribution still needs a dependency-complete cache contract.
A further consequence: compiling the scripting-language plugin into a native executable is not an adequate workaround either.
A native Forge artifact would need variants for at least Linux, macOS, and Windows across x64 and ARM64. The Codex plugin contract currently provides no manifest-level OS/architecture artifact selection, no compatibility rejection before activation, and no established plugin bin exposure convention. Related reports make those gaps explicit:
That is exactly why tools such as this are sensibly shipped in a scripting language: one portable source artifact, a supported interpreter/runtime, and dependencies resolved by the package manager on the actual execution host.
The present plugin lifecycle removes that viable path by failing to materialize or preserve dependencies, while also lacking the platform-selection contract required for a native-binary alternative.
Bundling dependencies into one portable JavaScript file that still runs under Bun may avoid node_modules, but that is a workaround, not a dependency model. It also only works for dependencies that can be bundled without platform-native components.
For installed command-backed plugins to be generally shippable, Codex needs at least one complete contract:
Without one of those, a plugin with nontrivial runtime dependencies cannot be distributed reliably across supported Codex environments.