fix: install the managed plugin bin entry on Windows without a symlink - #2371
Open
gtrrz-victor wants to merge 8 commits into
Open
fix: install the managed plugin bin entry on Windows without a symlink#2371gtrrz-victor wants to merge 8 commits into
gtrrz-victor wants to merge 8 commits into
Conversation
`entire graph` downloaded, verified and placed entire-graph.exe under pkg/graph/ correctly, then linked a 0-byte entry into bin/ that Windows refused to execute: "The filename, directory name, or volume label syntax is incorrect." Two defects in materializeManagedEntry combined. os.Root.Symlink on Windows stores an absolute target verbatim, without the `\??\` prefix that CreateSymbolicLinkW adds, so the link is created (Go re-enables the symlink privilege itself, so an elevated shell gets no error) and then cannot be followed. And os.Root.Link resolves both operands inside the root, so the hardlink fallback, handed an absolute source, failed as a path escape on every platform and never ran once. Windows now skips the symlink entirely; the source is converted to a root-relative name (managedTreeName) so the hardlink works for every remote install, and the copy remains the fallback for local-dev sources outside the tree. Unix keeps symlink-first. checkManagedPluginRunnable treats an unfollowable symlink and an empty file as reinstall-fixable, so entries left by earlier builds get the `plugin install graph --force` remedy instead of a raw fork/exec error. The materialize tests no longer skip on Windows and read THROUGH the entry, which is the assertion that would have caught this; withPluginDir releases the memoized plugin-dir root so TempDir cleanup succeeds on Windows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Entire-Checkpoint: 01M27XP9BBGJ2F8D4TKZX3TM33
Contributor
There was a problem hiding this comment.
🔵 Needs a closer look
The Windows hardlinked entry upgrade path can remain stale, and related documentation comments need updates.
Pull request overview
Fixes Windows managed-plugin installation by replacing broken symlinks with hardlinks or copies and improving repair diagnostics.
Changes:
- Uses Windows hardlink-or-copy materialization and retains Unix symlink-first behavior.
- Detects broken symlinks and empty entries with reinstall guidance.
- Expands cross-platform tests and updates documentation.
File summaries
| File | Summary |
|---|---|
docs/architecture/external-commands.md |
Documents installation behavior; remaining Windows symlink/copy statements need correction. |
cmd/entire/cli/plugin_store.go |
Adds platform-aware materialization. A Windows upgrade may leave an old hardlinked bin entry if replacement fails while running; the API comment also needs updating. |
cmd/entire/cli/plugin_store_test.go |
Adds cross-platform materialization and cleanup coverage. |
cmd/entire/cli/plugin_on_demand.go |
Improves diagnostics for broken or empty plugin entries. |
cmd/entire/cli/plugin_on_demand_test.go |
Adds diagnostic test coverage. |
cmd/entire/cli/plugin_install_remote_test.go |
Verifies installed entry type and binary hash. |
CLAUDE.md |
Documents os.Root linking pitfalls. |
Review details
Suppressed comments (3)
cmd/entire/cli/plugin_store.go:520
- The changed behavior also makes the
InstallPluginFromPathdoc comment above (line 376) inaccurate: it still says the source is symlinked into the managed directory, but Windows now uses a hardlink for in-tree sources and copies out-of-tree sources. Please update that API comment so callers do not rely on symlink semantics on Windows.
// materializeManagedEntry creates dest as a reference to src, falling back
// through symlink → hardlink → copy in that order on Unix, and hardlink → copy
// on Windows.
cmd/entire/cli/plugin_store.go:560
- On Windows this hardlinks
bin/<plugin>to the package binary, but the upgrade path only has a rename-aside retry for replacingpkg/<name>/<binary>;InstallPluginFromPathlater replaces the in-usebinentry directly. If the plugin is running, that rename can fail, afterreplaceBinaryandSavePluginManifesthave already installed the new package, leaving the old hardlinked entry to run while the manifest reports the new version. Please handle the bin replacement with the same Windows rename-aside strategy (and clean up the old link), or avoid this hardlink shape for upgradeable binaries.
if srcName, ok := managedTreeName(root, src); ok {
if err := root.Link(srcName, destName); err == nil {
return nil
docs/architecture/external-commands.md:62
- This changes the Windows materialization policy for every install, but the same document still says local-path installs are
Symlink/copy(line 52) and that abin/copy only occurs on Windows without Developer Mode (line 172). Windows now never creates symlinks, so those statements are false even with Developer Mode and can mislead users about local development and doctor coverage. Please update the remaining plugin-install/doctor text to describe the hardlink-or-copy behavior.
5. The binary lands in `pkg/<name>/` next to a `manifest.yml` — written atomically, and *before* the `bin/` link — recording provenance (repo URL, tag, asset, asset SHA-256, **binary SHA-256**, verification state, pin state, dependency list), and is linked into `bin/` through the same fallback ladder as local installs: symlink → hardlink → copy on Unix, hardlink → copy on Windows. Windows never gets a symlink because `os.Root.Symlink` there stores an absolute target without the `\??\` prefix Windows needs to follow it, and Go re-enables the symlink privilege around the call, so an elevated shell got a 0-byte link that `exec` rejected with "The filename, directory name, or volume label syntax is incorrect" (`materializeManagedEntry` carries the details). The hardlink only applies to a source inside the managed tree — every remote install — since `os.Root.Link` takes root-relative names; a local-dev source outside the tree is copied. The dispatcher never changes.
- Files reviewed: 7/7 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Move the one platform difference in materializeManagedEntry into
plugin_store_unix.go / plugin_store_windows.go (the pattern
plugin_signal_{unix,windows}.go already uses) instead of a runtime.GOOS
branch, and cut the comments down to what a reader needs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Entire-Checkpoint: 01M27Z0H76E26R31QSD9VE02VX
- Name the Windows-relevant tests with "Windows" so CI's test-windows job (-run '(Windows|MSYS)') runs the materialize, managedTreeName, unfollowable-symlink and bin-entry hash assertions; split the last two into their own tests. - plugin doctor now reports every bin/ entry checkManagedPluginRunnable refuses and names the same remedy the on-demand path does (entire plugin install <name> --force), and its integrity check also hashes a non-symlink bin/ entry against binary_sha256. - Drop the redundant fs.ValidPath after filepath.Rel+IsLocal, name the runnable-case type once, and fix stale symlink wording in InstallPluginFromPath's doc comment and external-commands.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRxAEJ1ivPFqqks62Zgeib Entire-Checkpoint: 01M281YXZJN09KAM5R6NBT2WZD
TestRunPluginDoctor_ChecksBinEntry drives the two doctor branches added for the review: an entry that cannot be run (with the on-demand path's remedy) and a non-symlink bin/ entry whose bytes drifted from an intact pkg/. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRxAEJ1ivPFqqks62Zgeib Entire-Checkpoint: 01M282RP9GYHHVJG7ZNSYZHTDQ
- Dispatch checks a managed entry before exec: LookPath accepts a 0-byte executable, so an empty bin/ entry used to fail with "exec format error" instead of the reinstall remedy the on-demand path prints. - Doctor's integrity check settles the bin/ entry with os.SameFile first (hardlink or symlink to pkg/: no second read), hashes only a genuinely separate file, and reports a differing one as "not the installed release binary" — a local `plugin install <path> --force` over a release produces that shape deliberately, so the remedy says to keep it if intended. - Doctor names the link target again for an unrunnable symlink entry. - The Windows unfollowable-symlink test fails rather than skips on CI when the runner cannot create the link. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRxAEJ1ivPFqqks62Zgeib Entire-Checkpoint: 01M285VPVAV5S88SKNKBEDKWH1
The bin/ entry report added for a local `plugin install <path> --force` over a release made `plugin doctor` exit 1 for a state whose remedy is "keep it". PluginDoctorIssue gains Note; the command prints notes with a `note:` prefix, counts only faults for its exit status, and still says "All plugins healthy." when notes are all it found. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRxAEJ1ivPFqqks62Zgeib Entire-Checkpoint: 01M286MT4Y16PRP0TY2H2HF2X5
gtrrz-victor
marked this pull request as ready for review
September 11, 2026 13:04
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
https://entire.io/gh/entireio/cli/trails/1304
Problem
On Windows,
entire graph(on-demand plugin install, #2324) downloads, verifies and placesentire-graph.exeunderpkg\graph\correctly, then links a 0-byte entry intobin\that cannot be executed:Root cause
Two defects in
materializeManagedEntry(plugin_store.go) combined:os.Root.Symlinkwith an absolute target is unfollowable on Windows. Go 1.27 does not callCreateSymbolicLinkW; it sets the reparse point itself and stores the target verbatim, without the\??\NT prefix. The kernel then rejects every follow (Stat, open,exec) withERROR_INVALID_NAME. Go also re-enablesSeCreateSymbolicLinkPrivilegearound the call, so in an elevated shell the link is created rather than refused. A Windows file symlink lists as 0 bytes, which is the "empty binary". Go's own tests miss this: they compareReadlinkoutput, which strips the prefix on read.os.Root.Linkresolves both operands inside the root, and the code passed an absolute source, which is rejected as a path escape.So the ladder was: symlink (broken when it "works", error when unprivileged) → link (always fails) → copy. Unprivileged Windows users got a working copy, elevated ones got the broken link. Reproduced with a standalone program before changing anything.
Fix
managedTreeName) so the hardlink actually works for every remote install (pkg/<name>/…); copy remains the fallback for local-dev sources outside the tree. Unix keeps symlink-first.entire plugin install <path>on Windows now yields a copy, so rebuilding the source is no longer reflected in the managed entry there (it still is on Unix). The previous behaviour was a broken link or an error, so nothing working is lost.checkManagedPluginRunnabletreats an unfollowable symlink and an empty file as reinstall-fixable, so entries left by earlier builds get theentire plugin install graph --forceremedy instead of a rawfork/execerror.external-commands.mdstep 5 and a CLAUDE.md Root Anchors bullet describing bothos.Rootpitfalls.Review follow-ups (3daeaf6)
-run '(Windows|MSYS)'.plugin doctorflags every bin/ entrycheckManagedPluginRunnablerefuses, uses the sameentire plugin install <name> --forceremedy as the on-demand path, and also hashes a non-symlink bin/ entry againstbinary_sha256.Tests
TestManagedTreeName, a Windows-only unfollowable-symlink case inTestCheckManagedPluginRunnable, and a hash/type check on thebin/entry inTestInstallPluginFromRepo_EndToEnd.withPluginDirnow releases the memoized plugin-dir root, fixing pre-existingTempDircleanup failures for every plugin test on Windows.Verification
TestInstallPluginFromRepo_EndToEndalready fails on Windows (elevated) with the exact error above; it passes with this change.entire plugin install graphon windows/arm64 producesbin\entire-graph.exeas a 75 MB hardlink hashing to the manifest'sbinary_sha256;plugin doctorhealthy;entire graph --help/versionexit 0.go vet, plugin tests, and golangci-lint on the package pass (remaining lint findings are pre-existing Windows-only files).Worth filing upstream at golang/go:
Root.Symlinkabsolute targets on Windows are written without\??\.🤖 Generated with Claude Code
Note
Medium Risk
Changes how plugin binaries are linked into the managed
bin/directory on Windows and tightens pre-exec validation; incorrect logic could break installs or block legitimate local symlinks on Unix.Overview
Fixes Windows on-demand plugin installs (e.g.
entire graph) that left a 0-byte, non-runnablebin\entire-*.exeafter remote install.materializeManagedEntrynow skips symlinks on Windows (Go’sos.Root.Symlinkwith absolute targets creates unfollowable reparse points) and usesmanagedTreeNamesoRoot.Linkhardlinks only with root-relative names—fixing a hardlink fallback that never worked when passed an absolute path. Unix still prefers symlink → hardlink → copy; Windows uses hardlink → copy for in-tree sources and copy for local-dev paths outside the tree.checkManagedPluginRunnabletreats unfollowable symlinks and empty regular files as reinstall-fixable, steering users toentire plugin install <name> --forceinstead of opaquefork/execerrors.Tests no longer skip Windows for materialize/install paths: they read through the managed entry, assert manifest hash match on remote install, and
withPluginDirreleases the memoized plugin root so temp cleanup works on Windows. Docs inexternal-commands.mdandCLAUDE.mddescribe theos.RootWindows pitfalls.Reviewed by Cursor Bugbot for commit bfd235b. Configure here.