fix(cli): marketplace add fails instead of registering an unfindable cache (#233) - #260
Open
spxrogers wants to merge 6 commits into
Open
fix(cli): marketplace add fails instead of registering an unfindable cache (#233)#260spxrogers wants to merge 6 commits into
spxrogers wants to merge 6 commits into
Conversation
…cache `addMarketplaceSource` fetches into a URL-derived slug directory and re-slots the cache under the marketplace's declared name — the name it writes into marketplaces/<name>.toml and the state record, and the only name any later lookup derives the cache directory from. Both arms of that re-slot dropped their errors, so a failed move still produced a success line and a registration whose cache nothing could find; the next plugin install reported `marketplace "x" not found in cache; run: agentsync marketplace add <url>` — advice that repeats the identical failure. The move is now a named helper that returns every failure, and the add stops before writing the TOML or the state entry, discarding the fetched tree so nothing is half-registered and no unregistered copy is left under the slug for a bare-id `plugin add` to find. The helper also replaces an existing destination (via the package's swapDir) rather than failing on it: os.Rename onto a non-empty directory fails with ENOTEMPTY, which meant EVERY re-add of a marketplace whose declared name differs from its slug silently kept the stale cache, orphaned the freshly fetched tree under the slug, and moved head_sha on anyway. Tests: the helper's four arms, a failed add registering nothing and leaving no cache behind, two end-to-end adds — first add slotted under the registered name, re-add refreshing it with no orphan left behind — and `import claude:plugin` warning and skipping a marketplace whose re-slot fails instead of registering a phantom. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4VNyoCuGXx7pYxNfLVbFG
Round 1 (four lenses on 2ec7bd7): two lenses found the same defect and three converged on the error text. - On a case-insensitive filesystem (macOS, Windows) a slug and a declared name that differ only in case name ONE directory, so the remove-then- rename deleted the freshly fetched tree and then failed, where the old plain rename had succeeded as a case-only rename. reslotMarketplaceCache is now rename-first: os.Rename does the whole job when nothing is at the destination and, on such filesystems, for a case-only alias of the same directory; it reports EEXIST for a different existing directory and fails without touching the destination otherwise. Only EEXIST goes on to the replace, and never when the destination turns out to be the source itself. A missing source therefore no longer costs a marketplace the cache it already has. - The add's error no longer claims nothing changed: a cache already under that name may be gone in the one destructive window left (a rename that fails after the destination was removed), and the message says so. The helper's doc and the call-site comment describe the new shape; the comment's "distinct sibling directories whenever they differ" claim, false on case-folding filesystems, is gone. - Tests: two table arms pin the missing-source and same-directory cases; TestMarketplaceAdd_SameDeclaredNameReplacesTheEarlierCache pins the one intentional behaviour change the CHANGELOG describes; the import test moves beside the other import tests. The CHANGELOG scopes the head_sha clause to git sources. Four mutations each fail exactly their target test: dropping the same- file guard, replacing first, removing the EEXIST gate, and replacing with a plain rename. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4VNyoCuGXx7pYxNfLVbFG
…233) Round-1 follow-up on #260. The replace step ran only when the failed rename reported EEXIST. That is Linux's answer for an occupied destination; Go's os.Rename on Windows has no directory pre-check and MoveFileEx onto an existing directory reports access denied, which maps to ErrPermission — so a re-add on Windows would have failed cleanly instead of refreshing the cache. The decision is now made by stat: after a failed rename, replace only when both the source and the destination exist and are different files; if either stat fails the failure is genuine and the destination is left as it was; the same file under two spellings is already in place. Same tests; the guard and gate mutations each fail exactly their arm. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4VNyoCuGXx7pYxNfLVbFG
Round 2 (four lenses on 99288e7): three lenses CLEAN with NITs, the adversarial lens with two findings about symlinks at the destination. - The gate decides with Lstat, not Stat. A symlink standing at the declared-name path is a link to unlink, not the tree under another name: with Stat, a planted link pointing at the source read as "already in place", the tree stayed under the slug behind the link, and a later `marketplace remove` would unlink the link alone and leave the slug tree as an orphan a bare-id `plugin add` could pick up. - Two table arms pin the symlink cases: replacing a symlinked destination unlinks the link and never touches its target (a resolve-then-remove would delete data outside the cache root with every other test green), and a link pointing at the source is replaced rather than mistaken for the tree. The plain missing-source arm is folded into the one that also checks the destination was left alone; the identical-paths arm is named for what it pins ("keeps the tree when the destination already is the fetched tree") rather than a second spelling Linux cannot produce. - Wording: Go refuses any directory destination before the syscall, so "a rename onto a non-empty directory" and ENOTEMPTY become "an existing directory" in the helper doc, both test files and the CHANGELOG; the add's advice is "fix the cause and re-run" (nothing is "above" on one line, least of all under import's prefix); the failure-path discard is marked best-effort; the CHANGELOG bullet is reflowed. Two mutations each fail exactly their arm: Stat instead of Lstat fails the link-to-source arm; resolving the destination's symlink before the replace fails the link-elsewhere arm. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4VNyoCuGXx7pYxNfLVbFG
swapDir, shared by the marketplace re-slot and the plugin-upgrade cache swap, removed the destination before renaming the source in, so an I/O failure between the two left the marketplace with no cache under either name — the pattern internal/marketplace's extractSubdir documents as a retired bug. It now renames the old tree aside, renames the new one into place, discards the aside only then, and rolls back when the second rename fails; the aside's name holds "..", which no sanitized cache key can, and a leftover from an interrupted swap is cleared first. The poll engine's "cache (old) and TOML (old) stay consistent" comment is true now, and the add's error no longer warns that a cache may be gone. reslotMarketplaceCache refuses a symlink standing at the source: no fetcher leaves one, and moving it would install a link (dangling, when it points at the destination) as the registered cache. The helper doc and the test header no longer say a rename onto an existing directory "always" fails — Go lets a case-only alias of the same directory through. Tests: TestSwapDir (replace, empty destination, rollback on a missing source, leftover aside cleared; no aside survives any arm); the stale-replace arm asserts no aside survives; a symlink-at-source arm. Break-verified: dropping the aside's final removal fails the stale-replace arm, two TestSwapDir arms and both re-add e2e tests; dropping the rollback fails exactly the missing-source TestSwapDir arm; dropping the leading clear fails exactly the leftover-aside arm; reverting swapDir to remove-then-rename fails the missing-source and leftover-aside arms; disabling the symlink-at-source guard fails exactly its arm. CHANGELOG notes the non-destructive replace. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4VNyoCuGXx7pYxNfLVbFG
Round 3's symlink-at-source guard sat after the plain rename, which succeeds into an empty slot — the first-add path — and installed the link as the registered cache; the refusal now comes before anything moves, and an arm with an empty destination pins it. swapDir reported nothing when the rename that puts the old tree back failed too, leaving the only copy at the aside with an error naming neither; it now reports both failures and where the old tree remains, as extractSubdir does. The aside is defined once, in the marketplace package (CacheAsideSuffix, IsCacheAside), and every cache-root scan — searchAllMarketplaces, buildMarketplaceIndex and the direct scan in resolveInstalledEntry — skips it: an interrupted replace must not surface a stale copy as a marketplace of its own, nor let a removed plugin outlive its removal under the live marketplace's name. The three doc sites that claimed an unconditional restore say what holds. Tests: a cross-device forcing arm (source on /dev/shm; skips where it is not a separate, writable filesystem) makes the rename-in fail with a SURVIVING source and pins that both trees are kept — the one witness a swap that checks the source first and then removes the destination does not pass; TestSearchAllMarketplaces_SkipsCacheAsides; TestBuildMarketplaceIndex_SkipsCacheAsides (index and direct scan). Break-verified: deleting the guard fails both symlink-at-source arms; the round-3 placement fails exactly the empty-destination arm; the check-then-remove swap fails exactly the cross-device arm; each scan's skip and IsCacheAside fail exactly their tests. A failed restore is not forcible here (root; same parent), so that branch is pinned by review. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4VNyoCuGXx7pYxNfLVbFG
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.
Summary
Closes #233. Seventh PR in the #226 code-quality series (after #240, #244, #247, #249, #252, #253).
marketplace addre-slots the freshly fetched cache from the URL-derived slug directory to the marketplace's declared name — the name it records inmarketplaces/<name>.tomlandtargets.json, and the only name any later lookup derives the cache directory from. Both failure arms of that move were discarded while the registration proceeded, so a failed move produced✅ added marketplace …plus a registration whose cache nothing could find, and the nextplugin add <id>@<name>reportedmarketplace "<name>" not found in cache; run: agentsync marketplace add <url>— advice that repeats the same failure.The move is now
reslotMarketplaceCache. A symlink at the source is refused before anything moves (no fetcher leaves one, and the plain rename would install it into an empty slot as the registered cache). Then it is a plain rename first, which is the whole job when nothing is at the destination and, on case-insensitive filesystems, for a case-only alias of the same directory. When a different entry stands at the destination while the fetched tree is still at the source (decided byLstat, because Linux reports an exists error for an occupied destination and Windows reports access denied, and because a symlink at the destination is a link to unlink rather than the tree under another name), the destination is replaced with the package'sswapDir, so a re-add refreshes the cache instead of silently keeping the stale one: a rename onto a different existing directory fails, which is why every re-add of a marketplace whose declared name differs from its slug (typically everygithub:owner/reposource) kept the stale cache, orphaned the fresh tree under the slug, and, for a git source, advancedhead_shaanyway — a plugin published between two adds stayed invisible. That second symptom was found while planning and reproduced at the base commit; the CHANGELOG bullet describes both.swapDiritself — shared with the cache swapplugin upgradeperforms — now renames the old tree aside, renames the new one into place, and discards the aside only then; if the new tree cannot be moved in, the old one is put back, and if even that fails the error says where the old tree remains (the shapeextractSubdirininternal/marketplacedocuments as the fix for a cache it once destroyed). The aside is defined once (marketplace.CacheAsideSuffix,<dir>..old, a name no sanitized cache key can be), and every cache-root scan skips it (marketplace.IsCacheAside), so an interrupted replace never surfaces a stale copy as a marketplace of its own. A move that fails leaves the destination as it was, fails the add before the TOML or the state entry is written, and discards the fetched tree; the error says nothing was written and to fix the cause and re-run.import <agent>:pluginregisters marketplaces through the same function and now warns and skips instead of registering a phantom. Success-path behaviour is unchanged: a two-binary transcript comparison of a firstmarketplace addis byte-identical.Review provenance. The execution spec was reviewed before execution by a fresh reviewer who replicated both symptoms at the base commit and found three issues, all folded in: the failure path left a plugin-installable orphan under the slug (now discarded); the error wording misdescribed a re-add; two rows of the break-verify table understated which tests a mutation fails. Two pre-existing findings from that review are filed separately: #258 (a test helper leaks an unterminated prompt onto the test binary's stderr) and #259 (the issue's "related consolidation" plus orphan-cache cleanup, the slug-collision hazard, and — from this loop — the stale
.golangci.ymlcomment onplugin_poll.go's exclusion, which this PR does not touch).Review loop. Round 1 (four lenses on
2ec7bd7; closed by241a74dand99288e7): two lenses independently found that on a case-insensitive filesystem (macOS, Windows) a slug and a declared name differing only in case name one directory, so the remove-then-rename deleted the fresh fetch and then failed, where the base's plain rename had succeeded — the helper is now rename-first and never replaces a destination that is the source itself. Three lenses found the error text claimed nothing had changed while a rename failing after the destination's removal could leave an already-registered marketplace cache-less; a missing source no longer removes an existing destination at all. The same-name replacement the CHANGELOG describes is pinned byTestMarketplaceAdd_SameDeclaredNameReplacesTheEarlierCache; the import test moved beside its peers; thehead_shaclause is scoped to git sources. A follow-up commit replaced an exists-errno gate with a stat-based one after checking Go's Windows rename path (MoveFileExonto an existing directory reports access denied, not exists), so a Windows re-add refreshes rather than failing. Round 2 (on99288e7; three lenses CLEAN; closed by00d4487): the adversarial lens showed a symlink planted at the declared-name path pointing at the source read as "already in place" underStat, leaving the tree under the slug behind a link thatmarketplace removewould unlink alone — the gate usesLstat, and two table arms pin that a symlinked destination is unlinked without touching its target and is replaced even when it points at the source. Round 3 (on00d4487; correctness, API-design and test-rigor lenses CLEAN; closed by23e8398): the adversarial lens found that the remaining destructive window —swapDirremoved the destination before renaming the source in, so an I/O failure between the two left the marketplace with no cache under either name, which the error text admitted — is exactly the patternextractSubdirdocuments as a retired bug;swapDirbecame rename-aside with rollback, the error text dropped its "may be gone" clause, andTestSwapDirpins the replace, the rollback, and the clearing of a leftover aside. Three lenses converged on "always fails" being overstated for a rename (a case-only alias of the same directory goes through); overruled with a citation was the adversarial claim that the alias never reaches the kernel (os/file_unix.goletsnewname != oldname && SameFilethrough; the correctness lens confirmed it, and the adversarial lens conceded it in round 4). Round 4 (on23e8398; test-rigor CLEAN; closed byb768f49): the adversarial lens found round 3's symlink-at-source guard was dead on the common path — it sat after the plain rename, which succeeds into an empty slot and installs the link (the test-rigor lens measured the same; the correctness lens had reasoned only about the post-rename ordering) — the guard now runs before anything moves, and an empty-destination arm pins it. Three lenses converged onswapDirswallowing its rollback error (a double failure left the only copy at the aside, unnamed), and on a surviving aside being enumerated by the cache-root scans as a marketplace of its own; the rollback failure is now reported with the aside's path, and the aside is defined once ininternal/marketplaceand skipped bysearchAllMarketplaces,buildMarketplaceIndexand the direct scan inresolveInstalledEntry. The test-rigor lens showed the closed window had no mutation-proof witness (a swap that checks the source first and then removes the destination passed the whole package); a cross-device forcing arm now kills that mutant. The"..old"literal became a constant. Declined with reason: a caller-levelplugin upgradeswap-failure test (no injection seam; the unit test pins the contract and the TOML rollback has its own test); pinning the tolerance on the rename-aside step (not forcible as root); and from earlier rounds, dropping theMkdirAllfailure arm, a build-tagged Windows test, an end-to-end failure-then-success test, and inlining thefrom != tocarve-out. Deferred to #259: the stale.golangci.ymlcomment.Type of change
Test plan
New tests, no existing test changed:
TestReslotMarketplaceCache(nine arms: the move; a stale cache from an earlier add is replaced, not merged, and no aside survives; a failure to prepare the cache root, forced by a regular-file parent; a missing source with a populated destination leaves that destination alone; a symlink at the source is refused with the destination and the link left as they were, both with a populated destination and with an empty one; identical paths, the Linux stand-in for a case-only alias, keep the tree in place; a symlink at the destination is replaced without touching its target; a symlink at the destination pointing at the source is replaced rather than mistaken for the tree).TestSwapDir(four arms: an old tree is replaced; an empty destination is filled; a missing source leaves the old tree in place after the rollback; a leftover aside from an interrupted swap is cleared first — and no aside survives any arm) andTestSwapDir_KeepsBothTreesWhenTheRenameInFails(the source on/dev/shm, another filesystem, so the rename in fails with a surviving source; the old tree is put back, the source is left where it was, no aside survives; skips where/dev/shmis missing, unwritable, or the same filesystem as the temp dir — it ran here).TestSearchAllMarketplaces_SkipsCacheAsidesandTestBuildMarketplaceIndex_SkipsCacheAsides(an aside carrying a marketplace.json is neither offered to a bare-idplugin addnor indexed, by the index or by the direct scan).TestAddMarketplaceSource_ReslotFailureRegistersNothing(a 300-character declared name forcesENAMETOOLONG; asserts the error names the move, and thatmarketplaces/,targets.jsonand the cache root are all untouched).TestMarketplaceAdd_CacheIsSlottedUnderTheRegisteredName,TestMarketplaceAdd_ReAddRefreshesTheCache(re-adds a fixture after publishing a new plugin and asserts the cache carries it, with no orphan under the slug), andTestMarketplaceAdd_SameDeclaredNameReplacesTheEarlierCache(two sources declaring one name; the cache under it holds the second source's tree).TestImportPlugin_SkipsMarketplaceWhoseReslotFails(import claude:pluginexits 0, prints the skip warning naming the move, registers nothing).Break-verifies (literal
go testfail sets, each mutation asserted to land exactly once and to compile, files restored from acpbackup and sha-checked):TestAddMarketplaceSource_ReslotFailureRegistersNothing,TestMarketplaceAdd_ReAddRefreshesTheCache,TestImportPlugin_SkipsMarketplaceWhoseReslotFails.MkdirAll's error → exactly the cache-root arm.TestMarketplaceAdd_ReAddRefreshesTheCacheandTestMarketplaceAdd_SameDeclaredNameReplacesTheEarlierCache.Statinstead ofLstatin the gate → exactly the link-to-source arm; resolving the destination's symlink before the replace → both symlink arms.TestSwapDirarms, and both re-add e2e tests (the aside shows up in the cache root); dropping the rollback → exactly the missing-sourceTestSwapDirarm; dropping the leading clear → exactly the leftover-aside arm; revertingswapDirto remove-then-rename → the missing-source and leftover-aside arms.TestSwapDir_KeepsBothTreesWhenTheRenameInFails; dropping the skip insearchAllMarketplaces→ exactly its test;IsCacheAsidealways false → both skip tests; dropping the skip in either marketplace scan alone → exactly the index test. A failed restore insideswapDiris not forcible here (root; the aside shares the destination's parent), so that branch is pinned by review, not by a test.TestMarketplaceAdd_CacheIsSlottedUnderTheRegisteredNamepasses under every mutation, by design: it is the success-path guard.Post-conditions on
internal/cli/marketplace.go://nolint:forbidigo3 → 3 (the old rename's went; one arrived on the rename-first and the failure-pathos.RemoveAllcarries the other, replacing the two the base had on the block);.golangci.ymluntouched (plugin_poll.gokeeps its whole-file exclusion).Gates on the head:
go build,go vet,gofmt -lempty, gofumpt +go mod tidyrewrote nothing,AGENTSYNC_TEST_IN_CONTAINER=1 go test ./...(29 packages ok, zero-jsonfailures),-race ./internal/cli/...ok,-tags=e2eok,-tags=bddok,GOOS=windows go build ./...ok,GOTOOLCHAIN=go1.26.2 golangci-lint@v2.12.2 run ./...→ 0 issues.just test-releaseis green (the release bar) —justis not installable in this session; every layer the recipe orchestrates was run directly as listed above, and CI's hermetictest-releasejob runs on the PR.just lintis clean — run directly with the pinned toolchain;go.mod/go.sumuntouched.Checklist
fix(secrets): …).internal/secrets,internal/capture, or anysource.Write*path, I've re-read the secret-handling invariants inCLAUDE.md/SECURITY.mdand not weakened them. — Not applicable: the change is confined to the marketplace and plugin fetch caches under.state/cache; no dest→source path is involved.CHANGELOG.mdunder[Unreleased] / Fixed;docs/components.mdlists the newcache_aside.goand its two exports underinternal/marketplace. Nodocs/or website sentence describes the cache layout, the slug-versus-declared-name distinction, the upgrade cache swap, ormarketplace add's failure modes (checked), so nothing else changes.🤖 Generated with Claude Code
https://claude.ai/code/session_01M4VNyoCuGXx7pYxNfLVbFG