Windows: SkillsWatcher blocks fs::rename in replace_plugin_root_atomically

Open 💬 4 comments Opened May 21, 2026 by ishaan-mehta
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

What version of Codex CLI is running?

codex-cli 0.132.0

What subscription do you have?

ChatGPT Business

Which model were you using?

gpt-5.4 (not model-dependent)

What platform is your computer?

Microsoft Windows NT 10.0.26100.0 x64 (Windows 11 Enterprise 24H2). Windows only; fs::rename is not blocked by a watching process on Linux or macOS.

What terminal emulator and version are you using (if applicable)?

Windows Terminal (PowerShell 7).

Codex doctor report

{
  "codexVersion": "0.132.0",
  "checks": {
    "config.load": { "status": "ok" },
    "runtime.provenance": { "details": { "platform": "windows-x86_64", "version": "0.132.0" } }
  }
}

What issue are you seeing?

On Windows, in-session plugin cache updates can fail with Access is denied. (os error 5) while the originating codex session is alive, and the failure recurs across restart cycles.

The rename call that fails is in replace_plugin_root_atomically, specifically the fs::rename(target_root, &backup_root) that moves the existing cache plugin dir aside before staging the new version. The handle that blocks it belongs to SkillsWatcher, which constructs a FileWatcher wrapping a notify::RecommendedWatcher and keeps it open for the lifetime of the session.

On Windows, that watcher opens the watched directory via CreateFileW with dwDesiredAccess = FILE_LIST_DIRECTORY (access right) and dwShareMode = FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE (share-mode flags), then registers change notifications on that handle via ReadDirectoryChangesW.

FILE_SHARE_DELETE is what lets external DeleteFileW / RemoveDirectoryW calls (e.g. rd /s /q) succeed against the watched path while the watcher is alive. Windows does not extend that allowance to renames: MoveFileExW (which std::fs::rename calls on Windows when the source is a directory) returns ERROR_ACCESS_DENIED if any open handle exists on the path being renamed or on any directory beneath it, regardless of share mode. A SkillsWatcher handle rooted at target_root/<version>/skills/ is therefore enough to block fs::rename(target_root, ...) for the lifetime of the session.

A consequence worth flagging: the "close codex, let the next launch refresh the cache" recovery does not work. The app-server starts SkillsWatcher on launch and reattaches to the still-stale cache before any subsequent update attempt, so the same MoveFileExW failure recurs. The only recovery from outside the process is to shut down all codex processes, wipe the cache plugin dir, and start codex again; on that fresh start replace_plugin_root_atomically takes the else branch (no backup-swap needed) and installs cleanly.

What steps can reproduce the bug?

Two reproductions below. The first is a primitive-level repro that does not require running codex and isolates the rename failure to the MoveFileExW + ReadDirectoryChangesW interaction. The second is end-to-end through codex. Both run on codex-cli 0.132.0, Windows 11 24H2.

A. Primitive repro (no codex required)

.NET's FileSystemWatcher opens the directory with the same FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE mode that the notify crate uses, so it is a faithful stand-in. Move-Item in PowerShell has retry and copy-then-delete fallback paths that can mask the issue; [System.IO.Directory]::Move calls MoveFileExW directly, matching std::fs::rename on Windows.

$root  = "$env:TEMP\rename-watcher-repro"
$inner = "$root\plugin\1.0.0\skills"
$bk    = "$env:TEMP\rename-watcher-repro-backup"
New-Item -ItemType Directory -Path $inner -Force | Out-Null

$fsw = New-Object System.IO.FileSystemWatcher $inner
$fsw.IncludeSubdirectories = $true
$fsw.EnableRaisingEvents   = $true

try {
    [System.IO.Directory]::Move("$root\plugin", $bk)
    "OK: rename succeeded"
} catch {
    "FAIL: $($_.Exception.Message)"
}

$fsw.EnableRaisingEvents = $false; $fsw.Dispose()
Remove-Item $root,$bk -Recurse -Force -ErrorAction SilentlyContinue

Observed on this machine:

FAIL: Exception calling "Move" with "2" argument(s):
"Access to the path 'C:\Users\...\rename-watcher-repro\plugin' is denied."

With the same setup but no FileSystemWatcher attached, the same Directory.Move succeeds. The watcher's open handle on a grandchild of the rename source is enough to deny it. That matches exactly what replace_plugin_root_atomically does while SkillsWatcher is alive.

B. End-to-end repro through codex

Set up a marketplace under $HOME, install with a skills/ dir present, then bump the version while a session is live.

$mp     = "$env:USERPROFILE\repro-mp"
$plugin = "$mp\repro-test-plugin"
New-Item -ItemType Directory -Path "$env:USERPROFILE\.claude-plugin","$plugin\.claude-plugin","$plugin\skills\demo" -Force | Out-Null

@'
{ "name": "repro-mp", "owner": { "name": "repro" },
  "plugins": [ { "name": "repro-test-plugin", "source": "../repro-mp/repro-test-plugin" } ] }
'@ | Set-Content -Encoding UTF8 "$env:USERPROFILE\.claude-plugin\marketplace.json"

@'
{ "name": "repro-test-plugin", "version": "1.0.0" }
'@ | Set-Content -Encoding UTF8 "$plugin\.claude-plugin\plugin.json"

"# Demo`n`nNoop." | Set-Content "$plugin\skills\demo\SKILL.md"

codex plugin add 'repro-test-plugin@repro-mp'

# Start codex interactively in another window and leave it open.

@'
{ "name": "repro-test-plugin", "version": "1.1.0" }
'@ | Set-Content -Encoding UTF8 "$plugin\.claude-plugin\plugin.json"

# In a separate shell, force a refresh.
codex --version
# Expected: cache updates to 1.1.0.
# Actual: refresh fails internally with
#   "failed to move existing plugin root aside: Access is denied. (os error 5)"
# Cache stays at 1.0.0.

# Close the interactive codex window, then re-run `codex --version`.
# Refresh now succeeds because no handle is held.

# Cleanup.
codex plugin remove 'repro-test-plugin@repro-mp'
Remove-Item "$env:USERPROFILE\.claude-plugin","$mp" -Recurse -Force
Remove-Item "$env:USERPROFILE\.codex\plugins\cache\repro-mp" -Recurse -Force -ErrorAction SilentlyContinue

procmon filtered on Operation = SetRenameInformationFile, Path contains \.codex\plugins\cache\, Result = ACCESS DENIED shows the failing call originating from the codex process while the same process has a CreateFile on the corresponding skills\ dir with Desired Access: Read Data/List Directory, Synchronize and Share Mode: Read, Write, Delete.

What is the expected behavior?

Codex should be able to refresh the plugin cache to a new plugin version on Windows, including while a session is running.

Additional information

None.

View original on GitHub ↗

4 Comments

github-actions[bot] contributor · 3 months ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #22462
  • #22962

Powered by Codex Action

ishaan-mehta · 3 months ago

Not a duplicate of the above

zemeng5208 · 16 days ago

I maintain WinBridge Recovery for Windows Codex Desktop local plugin/cache recovery: https://github.com/zemeng5208/winbridge-recovery

This is useful evidence for a broader Windows rule the project follows: an Access denied during plugin-root replacement should not automatically be treated as an ACL problem. A live directory watcher/loaded child can make a Windows rename fail even when ordinary read/write/delete permissions look correct.

WinBridge currently focuses on the official bundled Browser/Chrome/Computer Use state rather than acting as a general third-party plugin manager, but v3.1.1 already uses verified staging, pre-replacement lock checks, atomic swaps and rollback instead of repeatedly mutating an active cache root in place. If the target cannot be replaced safely, the intended behavior is to keep/restore the last usable state rather than leave a partially installed plugin tree.

Precise lock-owner attribution is still an area I am improving. The tool currently handles known Codex/Chrome helper processes and can refuse an unsafe replacement, but I would not claim it specifically coordinates with every SkillsWatcher instance yet. This issue is a good reason to make that diagnosis more explicit rather than responding to every os error 5 with ownership/ACL changes.

WinBridge does not take ownership of WindowsApps or broadly kill node.exe processes.

MylesMCook · 15 hours ago

Windows marketplace upgrade replaces an unchanged live plugin cache and fails with OS error 5

Summary

On Codex CLI 0.149.0, upgrading a Git-backed marketplace force-refreshes each
configured plugin cache. If a plugin's version and contents are unchanged, the
store still takes the same-version replacement path and renames the whole live
plugin root to a backup. On Windows, an active executable or watcher below that
root can block the rename with Access is denied. (os error 5).

This was reproduced with fabric-mcp-server 1.5.0. The current marketplace
source and installed cache contained the same 16 files with identical SHA-256
hashes, while nine processes were executing the installed cache binary.

Environment

  • Codex CLI: 0.149.0
  • OS: Windows 11 Pro 10.0.26200, x64
  • Marketplace: Git-backed, non-curated
  • Plugin: fabric-mcp-server 1.5.0, installed and enabled
  • First observed: 2026-08-27
  • Reproduction rate: 2/2 marketplace upgrade attempts

Reproduction

  1. Install and enable a Windows plugin that launches an executable from its

versioned Codex cache directory.

  1. Keep the plugin running.
  2. Advance the marketplace repository without changing that plugin's version

or files.

  1. Run codex plugin marketplace upgrade <marketplace>.
  2. Observe the aggregate failure during the unchanged plugin's cache refresh.
Failed to upgrade marketplace `<marketplace>`: failed to refresh plugin cache
for fabric-mcp-server@<marketplace>: failed to back up plugin cache entry:
Access is denied. (os error 5)
Error: 1 upgrade failure(s) occurred.

The marketplace checkout advances despite the aggregate failure, so source and
installed-cache state must be checked separately.

Root cause

The current marketplace-upgrade path force-reinstalls configured non-curated
plugins after updating the marketplace checkout. In
replace_plugin_root_atomically, a new version is activated additively, but an
existing same-version entry still uses the whole-root backup sequence:

rename(target_root, backup_root)
rename(staged_root, target_root)

That first rename is unnecessary when the staged version tree is byte-for-byte
identical to the installed version tree. It is also the exact operation blocked
by open descendant handles on Windows. The behavior is consistent with
openai/codex#23903. It also intersects openai/codex#21138: changed source files
at the same manifest version must still replace stale cache contents.

Proposed fix

After staging, validating, and migrating the plugin, compare the staged version
tree with the installed version tree. If they are identical, return success
before renaming the live root. If any path, type, size, or file content differs,
retain the existing same-version replacement behavior. New-version installs
remain additive.

Validated implementation:

Verification

  • cargo check -p codex-core-plugins --tests: passed.
  • Focused just test regressions: 2/2 passed on Windows.
  • An identical reinstall preserves the existing plugin root.
  • An identical reinstall succeeds while an installed file is open.
  • just fix -p codex-core-plugins: passed.
  • just fmt: passed.
  • Full codex-core-plugins suite: 406/407 passed. The one failure,

managed_bundled_source_is_bound_to_its_expected_name, is an unrelated
existing Windows device-path policy test outside the changed store code.

  • Fabric MCP npm run check:offline: passed 94 Rust tests, 4 package-contract

tests, all 11 agent scenarios, inventory audits, and native smoke.

  • Installed Fabric MCP readback: enabled, version 1.5.0, and all 16 source and

cache files have matching SHA-256 hashes.

Expected behavior

An unchanged same-version plugin refresh is an idempotent success and does not
rename its live cache root. A changed same-version plugin still refreshes so
stale contents are not retained. A new version still installs additively.

Temporary recovery

Until Codex ships a fix, stop only processes whose executable path is exactly
inside the affected plugin cache, retry the upgrade, and verify the installed
version and contents. Do not broadly terminate processes by executable name.
This is a recovery procedure, not a safe general update design.