Why VS Code can't install extensions to a remote when your local ones are read-only
The misleading EntryWriteLocked error, what actually triggers it over VS Code Remote, and the one-line workaround.
If your local VS Code extensions directory is read-only (common with Nix-managed setups) and you work over VS Code Remote (Remote-SSH, WSL, Dev Containers, Codespaces), installing an extension onto the remote fails with a misleading error:
Unable to write file '/home/you/.vscode-server/extensions/.<uuid>/package.json'
(EntryWriteLocked (FileSystemError): Error: EACCES: permission denied,
open '/home/you/.vscode-server/extensions/.<uuid>/package.json')
at updateManifestMetadata → extractUserExtension → extractVSIX
TL;DR. A read-only local extensions directory (common with Nix) gives you reproducibility and some hardening, and
it breaks installing extensions onto a remote. The ones that fail are the ones you already have installed
locally: VS Code reuses the local copy by zipping it, preserving the 0444 mode bits, so the files it unpacks
on the server are read-only and the install can’t rewrite them (EntryWriteLocked / EACCES). This is a known
VS Code bug (#151543) with no merged fix. Install remote extensions with code --install-extension
from the integrated terminal instead.
Verified against VS Code 1.124.2, June 2026. The bug is still open (#151543); check it before assuming this is current.
Background: how VS Code installs extensions on a remote
VS Code Remote splits the editor in two. The window runs on your local machine. A second copy, the VS Code
Server, runs on the remote and hosts the extensions that need to be there. Each extension declares where it
runs: UI extensions (themes, keymaps) stay local; workspace extensions (language servers, linters,
formatters) run on the remote, in ~/.vscode-server/extensions.
How an extension reaches the remote depends on whether you already have it installed locally. The bug is in that branch.
When the local extensions directory is read-only
The trigger is a read-only local extensions directory. The most common source is managing extensions
declaratively with Nix and home-manager (programs.vscode.profiles.default.extensions), which makes
~/.vscode/extensions a read-only tree of /nix/store symlinks. A read-only --extensions-dir or an
immutable editor image produces the same result.
This is usually deliberate, for two reasons:
- Reproducibility. The editor is rebuilt from a config file, with no drift and no unrequested auto-updates.
- Hardening. VS Code’s extension security model is weak: extensions are ordinary Node code with broad filesystem access. A read-only extensions directory prevents an installed extension from rewriting itself or its neighbours on disk. It does not prevent a malicious extension from writing elsewhere, but it removes one foothold.
VS Code assumes this directory is writable. That assumption is the bug.
The mechanism
EntryWriteLocked means the file is not writable
Despite the name, this is a permission failure, not a lock. From diskFileSystemProvider.ts,
after a write returns EACCES:
const { stat } = await SymlinkSupport.stat(this.toFilePath(resource));
// 0o200 = writable by owner
if (!(stat.mode & 0o200)) {
writeError = createFileSystemProviderError(
error,
FileSystemProviderErrorCode.FileWriteLocked,
);
}
FileWriteLocked prints as EntryWriteLocked. The write returned EACCES, and the stat showed no owner-write
bit. The package.json being written was mode 0444.
The file is 0444 because it came from your read-only copy
When you install an extension you already have locally, VS Code can reuse that local copy instead of
downloading a fresh one. It zips your local files and sends the VSIX to the remote
(extensionsWorkbenchService.installInServer):
// local = your read-only 0444 copy
const vsix = await this.extensionManagementService.zip(local);
return await server.extensionManagementService.install(vsix);
zip() copies each source file’s mode into the VSIX entry, and extraction restores it
(modeFromEntry in zip.ts). Nothing in between makes the files writable:
// modeFromEntry(entry) derives the file mode from the zip entry. 0o100644
// is only a fallback for when the entry carries no stored mode; it then
// masks attr down and returns it — your entry stores 0444, so it's 0o444.
const attr = entry.externalFileAttributes >> 16 || 33188;
// the caller writes the file with exactly that mode (0o444):
const mode = modeFromEntry(entry);
istream = createWriteStream(targetFileName, { mode });
A 0444 file in your local copy becomes a 0444 entry in the VSIX, then a 0444 file on the remote. The next
install step, updateManifestMetadata, opens that package.json to add an __metadata block and gets
EACCES.
A snapshot of the staging directory during a failing install shows every file read-only:
.<uuid>/ 755
.<uuid>/package.json 444
.<uuid>/readme.md 444
The same extension installed from the command line extracts at 0644.
Why some extensions still work
The failure requires VS Code to reuse your local read-only copy. When the install goes through a fresh marketplace download instead, the files are 0644 and it succeeds:
| extension you install | have it locally? | path taken | result |
|---|---|---|---|
| already installed | yes (read-only) | zips and ships the 0444 copy | fails (remote) |
| not installed | no | fresh download from the marketplace | works |
| a UI-kind extension | n/a | installs into the local read-only dir | fails (local) |
The server log shows which path ran. Marketplace installs carry options such as pinned and
clientTargetPlatform; the failing path is bare ({"isBuiltin":false,"isApplicationScoped":false}), which
is what installing from a VSIX file logs. The marketplace download is writable, so the 0444 came from your
local copy, not the publisher. zipinfo confirms it:
$ zipinfo even-better-toml.vsix extension/package.json
-rw-r--r-- ... extension/package.json
What it is not
Plausible explanations, all wrong:
- The remote’s permissions.
~/.vscode-server/extensionsis writable; installing the same extension from the command line into it succeeds. - umask. The server runs umask 0022, but the 0444 is an explicit
modepassed on create, which umask can only narrow. - Concurrency. Several parallel installs succeed; the
EntryWriteLockedname is misleading. - A platform-specific extension. The deciding factor is whether the extension is installed locally, not its architecture. Platform-specific extensions can avoid the bug because a platform mismatch forces a fresh download instead of reusing the local copy.
For UI-kind extensions the error appears on the local machine instead of the remote, so the path is the local
one (/Users/... on macOS, /home/... on Linux):
mkdir '/Users/you/.vscode/extensions/.<uuid>' EACCES. Reading only the remote server logs, you never see
it.
A known, unfixed VS Code bug
This is microsoft/vscode#151543: “read-only --extensions-dir (common in Nix deployments)… copies
preserving 0444… modify package.json fails.” Status: open, Backlog, labelled team-low-hanging. The one fix
attempt, PR #242256, added the missing write bits when the VSIX is created:
// OR-in user+group write
mode: stat.mode | 0o220,
It was closed without merging (“still under discussion… closing this PR until then”), so it will not resolve on its own.
The same failure is filed on the Nix side as home-manager#7188: extensions installed through
home-manager’s profiles fail on the remote with this exact error, while the FHS build (vscode-fhs)
installs them. The difference is the read-only store. vscode-fhs keeps a writable extensions directory, so
the copied files come out writable.
Solutions
| approach | reliable? | keeps the read-only directory? | cost |
|---|---|---|---|
reactive chmod watcher (inotify/poll) | no, races the metadata write | yes | a background service, and unreliable |
bindfs presenting the remote server dir as writable | yes | yes | a maintained FUSE mount |
CAP_DAC_OVERRIDE on the server’s node | yes | yes | broad capability, re-apply per server version |
| make the local extensions directory writable | yes, removes the 0444 source | no | gives up reproducibility and hardening |
code --install-extension from the integrated terminal | yes, forces a fresh download | yes | one line per extension |
The reactive watcher is unreliable. The chmod must land between each file’s creation and the metadata write, and it loses that race for any extension with more than a few files: a small one installs, a larger one does not. Making the directory writable works but gives up the reproducibility and hardening that motivated the read-only setup.
The fix: code –install-extension
Install remote extensions from the integrated terminal connected to the remote:
code --install-extension rust-lang.rust-analyzer \
--install-extension tamasfe.even-better-toml \
--install-extension jnoortheen.nix-ide
This only works in the remote integrated terminal, where code is the remote command-line tool: it talks to
the running server and installs each extension with a fresh download to the writable remote directory, so the
reuse path is never taken. Run the same command in a local terminal and code is your local binary, which
installs the extension locally instead. Installed extensions persist in ~/.vscode-server/extensions on the
remote, so this is a one-time action, not something you repeat per connection.
Avoid the GUI “Install in code --install-extension lines in a script the box runs at setup (a postCreate hook, or a small unit in its
config).
References
- microsoft/vscode#151543: read-only
--extensions-dirbreaks remote extension installation - microsoft/vscode#242256: the abandoned fix (
stat.mode | 0o220) - nix-community/home-manager#7188: the same error reported for home-manager
profiles - home-manager
programs.vscode.mutableExtensionsDir: the option that makes the local directory writable (mutually exclusive withprofiles) - Source, pinned to VS Code commit
6928394(1.124.2):FileWriteLocked·zip()·modeFromEntry·installInServer·extractUserExtension·updateManifestMetadata