Plugin cache install silently drops symlinks from local plugin sources

Open 💬 3 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 ↗

3 Comments

sriram-vudayagiri-glean · 15 days ago

i'm still running into this issue

DuaneNielsen · 17 hours ago

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:

  1. codex plugin add installs Forge 0.1.1.
  2. bun install --production in plugins/cache/wild-west/forge/0.1.1 installs real, non-symlinked dependencies.
  3. Direct and live hook invocations exit 0.
  4. A subsequent Codex plugin refresh replaces the cache.
  5. node_modules/marked is absent again.
  6. Direct invocation of the same active hook exits 1.

This leaves scripting-language plugin authors without a reliable dependency strategy:

  • workspace/package-manager links are silently omitted by the cache copier, as this issue reports;
  • dependencies are not installed from package.json during plugin installation;
  • installing dependencies into the cache afterward is undone by later cache refresh;
  • a version bump changes which cache is selected but does not solve dependency materialization;
  • hooks may begin failing during an already-running session.

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.

DuaneNielsen · 17 hours ago

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:

  • #40461 requests even the basic behavior of exposing an installed plugin bin directory on the session PATH, and identifies platform-specific binary selection and multi-architecture packaging as future work.
  • #38574 shows the loader starting a macOS-only plugin component on Windows and Linux because plugin manifests cannot declare supported platforms or capabilities.
  • #33391 requests reliable executor OS and architecture information; shell and cwd are not sufficient, particularly when UI, app-server, and executor may run on different hosts.

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:

  1. scripting-language packages: atomically install and preserve declared production dependencies for the active platform; or
  2. native packages: support OS/architecture-qualified artifacts, compatibility gating, and executable discovery; or
  3. allow a plugin-provided install/build step whose resulting immutable artifact becomes the active cache and is not later replaced by a source-only refresh.

Without one of those, a plugin with nontrivial runtime dependencies cannot be distributed reliably across supported Codex environments.