Windows: SkillsWatcher blocks fs::rename in replace_plugin_root_atomically
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.
4 Comments
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
Not a duplicate of the above
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 deniedduring 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
SkillsWatcherinstance yet. This issue is a good reason to make that diagnosis more explicit rather than responding to everyos error 5with ownership/ACL changes.WinBridge does not take ownership of WindowsApps or broadly kill
node.exeprocesses.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 eachconfigured 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-server1.5.0. The current marketplacesource and installed cache contained the same 16 files with identical SHA-256
hashes, while nine processes were executing the installed cache binary.
Environment
0.149.010.0.26200, x64fabric-mcp-server1.5.0, installed and enabledReproduction
versioned Codex cache directory.
or files.
codex plugin marketplace upgrade <marketplace>.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 anexisting same-version entry still uses the whole-root backup sequence:
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.just testregressions: 2/2 passed on Windows.just fix -p codex-core-plugins: passed.just fmt: passed.codex-core-pluginssuite: 406/407 passed. The one failure,managed_bundled_source_is_bound_to_its_expected_name, is an unrelatedexisting Windows device-path policy test outside the changed store code.
npm run check:offline: passed 94 Rust tests, 4 package-contracttests, all 11 agent scenarios, inventory audits, and native smoke.
1.5.0, and all 16 source andcache 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.