feat: abcd installs where the user can write, and the detector finds it (iss-171, A4) - #228
Merged
Conversation
The PATH detector recognised exactly one blessed target, /usr/local/bin/abcd, so a working ~/.local/bin/abcd — the single-user location the uv/pipx/rustup class of tools uses — reported symlink.missing while the detector was itself running as that very binary. Letting install "fix" it would have written a symlink to <plugin-root>/abcd without checking the target exists: a dangling link early on PATH shadows every working abcd behind it. Detection now scans PATH in order, resolves each hit through the same seam as iss-170 (resolvePath -> EvalSymlinks), and classifies it dev-shim / owned / foreign. An abcd-owned entry anywhere on PATH is the install, adopted where it stands. Two states that were invisible get named: an owned entry whose binary has gone is symlink.dangling, and an install directory absent from PATH is path.bin_dir_not_on_path — required but NOT resolvable, carrying the one-line export fix. abcd prints that line and never edits a shell profile. Install defaults to ~/.local/bin, created when absent. A system-wide directory is reachable only through an explicit --bin-dir, whose writability is probed before any write and whose failure is an error rather than a silent skip: abcd escalates nothing, so there is no privileged fallback to hide a refusal behind. Creating a symlink whose target does not exist is refused outright, with the reason carried on a new `notes` channel — a refusal that shows up only as a still-open gap reads as a silent failure. The two print sites outside the install receipt's own note seam render user-scope paths in tilde form, so the doctor gap text and the uninstall receipt carry the location without carrying the username. Both can be unified onto the receipt scrub's primitive once that lands. The README one-liner drops sudo and installs to ~/.local/bin, which is the breaking change: the documented install location moves. An existing /usr/local/bin/abcd keeps working — the detector adopts it. Resolves iss-171. Assisted-by: Claude:claude-opus-5[1m]
Review round on iss-171. Six correctness findings and three security ones, all about the same blind spot: the scan classified every `abcd` on PATH and then discarded everything it did not own, so the states it could see it did not say. The population the OLD one-liner created is the sharp case. It COPIED the binary to /usr/local/bin, and a copy is a regular file, so it classifies foreign, is never adopted, and the new entry lands behind it — status clean, install_mode pinned, and the stale copy still answering `abcd` forever. Any entry preceding abcd's own on PATH is now `symlink.shadowed`: required, diagnostic, naming the occupant and both remedies. abcd will not remove it, so it does not pretend it can. install_mode reports "pinned (shadowed on PATH)" rather than health, and dangling is computed for every entry, not only ours, so a foreign link pointing at nothing is described instead of invisible. The two required-but-unresolvable gaps (shadowed, bin-dir-not-on-PATH) now travel out of Install on the Notes slice. They were printed by nothing on a fresh install: excluded from Remaining, absent from InstallResult, and reachable only through `doctor` — which cannot be run by name on the machine where abcd is not yet on PATH. Install computes both against the target it actually wrote, which is the only place an explicit --bin-dir is known. The remaining refusals are made audible. A foreign occupant at the target bailed out recording nothing, so a --bin-dir run reported success having written nothing; it now names what is in the way. installPinnedSymlink's source check moves ahead of its owned-entry early return, so a dangling entry whose binary is gone states the reason instead of returning silently. Every note renders OS error text through the same home-path scrub as the rest of the seam. `ahoy uninstall` takes --bin-dir: an entry placed outside PATH by --bin-dir is invisible to a PATH scan, so uninstall would otherwise orphan it. The --bin-dir writability probe moves after the adoption gate, so a declined run no longer creates and removes a file in a directory the user never adopted. The CHANGELOG's mitigation sentence was false for exactly the population that needs it — it promised adoption to users whose binary is a copy — and now tells them what they will see and what to do. README and the command surface say the same. Watched red before, green after: seven new tests, one per reviewer scenario — shadowing foreign copy (detect and install), dangling foreign entry, foreign occupant at --bin-dir, fresh-install PATH note, dangling entry with a missing binary, and an off-PATH --bin-dir install described and then removed. Assisted-by: Claude:claude-opus-5[1m]
Textual: InstallResult gains both siblings' fields (Notes from this branch, OptionalSkipped from iss-167), same in the install result literal. Semantic, caught by the armed detectors: the receipt assertion now expects the tilde form iss-177's scrub renders for a user-scope write, and the README's install-section sentence describes the installer as a capability instead of instructing an invocation the reader's PATH state may not resolve — iss-207's every-occurrence rule holds over the union. Assisted-by: Claude:claude-fable-5
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.
Read only the A4 delta
This PR is branched off
fix/iss-205-plugin-root-ladder(A1), not offmain, so A1's two commits appear in the diff GitHub shows. A4's "adopt an existing owned install" logic depends on A1's commands-surface definition of what the command files look for, which is why the plan orders A1 first.Review exactly this:
Two commits are mine:
cfc90ea(the change) andb4f4207(the review round).What this is
A4 of the install-experience plan (
.abcd/development/plans/2026-08-11-install-experience.md), interview decision 5:ahoy installdefaults to~/.local/binand never escalates privileges. Resolves iss-171. Impact: breaking — the documented install location moves.The PATH detector recognised exactly one blessed target,
/usr/local/bin/abcd. A working~/.local/bin/abcd— the single-user location the uv/pipx/rustup class of tools uses — therefore reportedsymlink.missingwhile the detector was itself running as that very binary. Lettingahoy install"fix" that would have written a symlink to<plugin-root>/abcdwithout checking the target exists: a danglingabcdearly onPATHshadows every working one behind it.What changes
Detection scans PATH.
scanPathEntrieswalks$PATHin order, resolves eachabcdhit through the same seam as iss-170 (resolvePath→filepath.EvalSymlinks), and classifies it dev-shim / owned / foreign. An abcd-owned entry anywhere onPATHis the install.Two states that were invisible get named.
symlink.danglingpath.bin_dir_not_on_pathabcdstill cannot be run by name. Script-first: abcd printsexport PATH="$HOME/.local/bin:$PATH"and never edits a shell profile, so it is deliberately not resolvable by an apply stepInstall defaults to
~/.local/bin, created when absent, and adopts an abcd-owned entry already onPATHexactly where it stands rather than planting a second one. A system-wide directory is reachable only through an explicit--bin-dir, whose writability is probed before any write (resolveInstallTarget, walking to the nearest existing ancestor when the directory does not exist yet) and whose failure is an error, not a silent skip. There is no privileged fallback because abcd escalates nothing — nosudo, nodoas, no re-exec.Creating a symlink whose target does not exist is refused outright. The reason travels on a new
noteschannel onInstallResult(rendered by the CLI asnote: …): a refusal visible only as a still-open gap reads as a silent failure.README one-liner drops
sudo, installs to~/.local/bin, and ends with"$HOME/.local/bin/abcd" versionso it verifies even before the directory is onPATH. The prose states the export line.The iss-177 hand-off (from #225)
#225's reviewer found two absolute-path prints outside the receipt's
applyCtx.noteseam, harmless today only becausebinTargetdefaulted to/usr/local/bin. This change moves the default under$HOME, so both would have started printing the username. Both are fixed here:ahoy uninstall's printed target (internal/surface/cli/cli.gosymlinkNote) —UninstallReceipt.Symlink.Targetis now written in tilde form at the point the receipt is built, so the JSON envelope and the text line are both clean.internal/core/ahoy/detect.go) — every path a gap renders goes throughdisplayPath.displayPathis local tointernal/core/ahoy/fsutil.gobecausefsutil.RedactRootdoes not exist on this base. Once #225 merges, these two sites should be unified ontofsutil.RedactRoot— a follow-up, noted in the code comment. New receipt writes still go througha.note(), untouched, so #225's scrub covers them when both land.Two tests assert the hygiene directly:
TestUninstallReceiptCarriesNoAbsoluteHomePath(core) andTestAhoyUninstallPrintsNoAbsoluteHomePath(CLI), plusTestGapTextCarriesNoAbsoluteHomePathover every gap field.Red → green
Eleven tests written first and watched fail before the change (
internal/core/ahoy/bin_install_test.go,internal/surface/cli/ahoy_bin_install_test.go).Red, in the state before the fix:
(
TestInstallAdoptsOwnedInstallInPlacepassed vacuously in red — nothing was written anywhere near the adopted entry — and is meaningful only in green.)Green after:
ok internal/core/ahoy,ok internal/surface/cli, and the fullmake preflight.Review round (both reviews addressed, one commit:
b4f4207)Security APPROVE (3 findings) and correctness BLOCK (6 findings). All nine are fixed; each behavioural one has a test that reproduces the reviewer's scenario, watched fail before and pass after.
/usr/local/bin(a regular file — foreign, never adopted) got a new entry written behind it:status=clean,install_mode=pinned, stale copy still executing. Dangling was computed only for owned entries.symlink.shadowedgap (required, diagnostic) naming the occupant and both remedies;install_modereportspinned (shadowed on PATH);danglingnow computed for every entry, so a foreign link to nothing is described. Security's independent version of this seam is the same fix.--bin-dir); README andcommands/ahoy.mdsay the same.stepSymlink's foreign bail-out recorded nothing, andsymlink.foreignis advisory so it is filtered fromRemaining— a--bin-dirrun reported success having written nothing.a.refuse(...)names the occupant and what it is before returning.path.bin_dir_not_on_pathis required-but-not-resolvable, so it is excluded fromRemaining, absent fromInstallResult, and printed only bydoctor— unreachable by name on the machine where abcd is not onPATH. Nothing printed it on a fresh install.noteReachability()carries it (and the shadow) out onNotes, computed against the target actually written.clearDanglingEntry's "the refusal below states why" was unreachable:installPinnedSymlink's owned-entry early return preceded the source check, so dangling + missing binary was silent.--bin-diroffPATHwas written but never described, anduninstallcould not reach it.ahoy uninstall --bin-dir <dir>reaches an entry aPATHscan cannot see (a derived-only design would have to invent persisted machine state; the flag is the honest seam, and the install note says so).err.Error(), leaking$HOMEinto textcommands/ahoy.mdtells the agent to relay.errText/displayTextscrub every note site; asserted byTestInstallFreshPathGapIsCarriedOnNotes.--bin-dirwrite probe ran before the adoption prompt, so a declined run had already created and removed a dotfile there.Red → green for the round (seven new tests, all failing first against
cfc90ea):Nothing the reviewers explicitly cleared was reworked: EEXIST-atomic symlink creation, guarded PATH scanning, no execution of PATH contents, no escalation, permissions/umask, export-line injection resistance, uninstall reach limits, the README checksum flow,
displayPathat the two A6 hand-off sites, the loud unwritable--bin-dirfailure, and the TOCTOU refusal paths all stand as reviewed.Gates
make preflightgreen in this worktree, with branch + SHA asserted immediately before and after each run:feat/iss-171-no-sudo-install@cfc90ea6d908713f3a6984219f0c5a039741fd57(first cut) and @b4f420798b296621dab927efafaaec862a72aaa5(after the review round).gofmt -l .empty.go generate ./internal/surface/clire-rendereddocs/reference/cli/commands.mdand.abcd/development/release/surface.jsonfor the new--bin-dirflag (both are drift-gated by tests).Record
iss-171moved open → resolved in this PR,--impact breaking.[Unreleased], citing iss-171. The maintainer resolves cross-PR CHANGELOG conflicts — several items in this run touch[Unreleased].04-surfaces/01-ahoy.md(detection step 8, apply step 9, uninstall semantics) and02-constraints/04-naming.md.commands/ahoy.mddocuments--bin-dir, the notes channel, and the instruction never to suggestsudo.Not in scope
The install receipt's own
Writeslist still carries absolute paths — that is iss-177's seam and #225 owns it.TestInstallDefaultsToUserLocalBinsays so in a comment rather than asserting a scrub this PR does not perform.Assisted-by: Claude:claude-opus-5[1m]