Skip to content

fix: install the managed plugin bin entry on Windows without a symlink - #2371

Open
gtrrz-victor wants to merge 8 commits into
mainfrom
fix-install-plugin-windows
Open

fix: install the managed plugin bin entry on Windows without a symlink#2371
gtrrz-victor wants to merge 8 commits into
mainfrom
fix-install-plugin-windows

Conversation

@gtrrz-victor

@gtrrz-victor gtrrz-victor commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

https://entire.io/gh/entireio/cli/trails/1304

Problem

On Windows, entire graph (on-demand plugin install, #2324) downloads, verifies and places entire-graph.exe under pkg\graph\ correctly, then links a 0-byte entry into bin\ that cannot be executed:

Failed to run plugin entire-graph.exe: fork/exec C:\...\entire\plugins\bin\entire-graph.exe: The filename, directory name, or volume label syntax is incorrect.

Root cause

Two defects in materializeManagedEntry (plugin_store.go) combined:

  1. os.Root.Symlink with an absolute target is unfollowable on Windows. Go 1.27 does not call CreateSymbolicLinkW; it sets the reparse point itself and stores the target verbatim, without the \??\ NT prefix. The kernel then rejects every follow (Stat, open, exec) with ERROR_INVALID_NAME. Go also re-enables SeCreateSymbolicLinkPrivilege around 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 compare Readlink output, which strips the prefix on read.
  2. The hardlink fallback could never run on any OS. os.Root.Link resolves 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

  • Windows never creates a symlink. The source is converted to a root-relative name (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.
  • Trade-off: a local-dev 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.
  • checkManagedPluginRunnable treats an unfollowable symlink and an empty file as reinstall-fixable, so entries left by earlier builds get the entire plugin install graph --force remedy instead of a raw fork/exec error.
  • Docs: external-commands.md step 5 and a CLAUDE.md Root Anchors bullet describing both os.Root pitfalls.

Review follow-ups (3daeaf6)

  • Tests that cover the Windows behaviour carry "Windows" in their names, since CI's test-windows job runs only -run '(Windows|MSYS)'.
  • plugin doctor flags every bin/ entry checkManagedPluginRunnable refuses, uses the same entire plugin install <name> --force remedy as the on-demand path, and also hashes a non-symlink bin/ entry against binary_sha256.

Tests

  • The materialize tests no longer skip on Windows and read through the entry (the assertion that would have caught this); on Windows they assert a hardlink for in-tree sources, a copy otherwise, and never a symlink.
  • New TestManagedTreeName, a Windows-only unfollowable-symlink case in TestCheckManagedPluginRunnable, and a hash/type check on the bin/ entry in TestInstallPluginFromRepo_EndToEnd.
  • withPluginDir now releases the memoized plugin-dir root, fixing pre-existing TempDir cleanup failures for every plugin test on Windows.

Verification

  • On the unfixed tree, TestInstallPluginFromRepo_EndToEnd already fails on Windows (elevated) with the exact error above; it passes with this change.
  • Manual: entire plugin install graph on windows/arm64 produces bin\entire-graph.exe as a 75 MB hardlink hashing to the manifest's binary_sha256; plugin doctor healthy; entire graph --help / version exit 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.Symlink absolute 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-runnable bin\entire-*.exe after remote install.

materializeManagedEntry now skips symlinks on Windows (Go’s os.Root.Symlink with absolute targets creates unfollowable reparse points) and uses managedTreeName so Root.Link hardlinks 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.

checkManagedPluginRunnable treats unfollowable symlinks and empty regular files as reinstall-fixable, steering users to entire plugin install <name> --force instead of opaque fork/exec errors.

Tests no longer skip Windows for materialize/install paths: they read through the managed entry, assert manifest hash match on remote install, and withPluginDir releases the memoized plugin root so temp cleanup works on Windows. Docs in external-commands.md and CLAUDE.md describe the os.Root Windows pitfalls.

Reviewed by Cursor Bugbot for commit bfd235b. Configure here.

`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
Copilot AI lite review requested due to automatic review settings September 11, 2026 09:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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 InstallPluginFromPath doc 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 replacing pkg/<name>/<binary>; InstallPluginFromPath later replaces the in-use bin entry directly. If the plugin is running, that rename can fail, after replaceBinary and SavePluginManifest have 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 a bin/ 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.

gtrrz-victor and others added 5 commits September 11, 2026 11:19
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
gtrrz-victor marked this pull request as ready for review September 11, 2026 13:04
@gtrrz-victor
gtrrz-victor requested a review from a team as a code owner September 11, 2026 13:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants