Skip to content

feat: abcd installs where the user can write, and the detector finds it (iss-171, A4) - #228

Merged
REPPL merged 3 commits into
mainfrom
feat/iss-171-no-sudo-install
Aug 12, 2026
Merged

feat: abcd installs where the user can write, and the detector finds it (iss-171, A4)#228
REPPL merged 3 commits into
mainfrom
feat/iss-171-no-sudo-install

Conversation

@REPPL

@REPPL REPPL commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Read only the A4 delta

This PR is branched off fix/iss-205-plugin-root-ladder (A1), not off main, 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:

git diff fix/iss-205-plugin-root-ladder...feat/iss-171-no-sudo-install

Two commits are mine: cfc90ea (the change) and b4f4207 (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 install defaults to ~/.local/bin and 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 reported symlink.missing while the detector was itself running as that very binary. Letting ahoy install "fix" that would have written a symlink to <plugin-root>/abcd without checking the target exists: a dangling abcd early on PATH shadows every working one behind it.

What changes

Detection scans PATH. scanPathEntries walks $PATH in order, resolves each abcd hit through the same seam as iss-170 (resolvePathfilepath.EvalSymlinks), and classifies it dev-shim / owned / foreign. An abcd-owned entry anywhere on PATH is the install.

Two states that were invisible get named.

gap required resolvable why
symlink.dangling yes yes an owned entry whose binary has gone shadows every later PATH entry; install repairs it in place
path.bin_dir_not_on_path yes no the entry exists and abcd still cannot be run by name. Script-first: abcd prints export PATH="$HOME/.local/bin:$PATH" and never edits a shell profile, so it is deliberately not resolvable by an apply step

Install defaults to ~/.local/bin, created when absent, and adopts an abcd-owned entry already on PATH exactly 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 — no sudo, no doas, no re-exec.

Creating a symlink whose target does not exist is refused outright. The reason travels on a new notes channel on InstallResult (rendered by the CLI as note: …): 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" version so it verifies even before the directory is on PATH. 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.note seam, harmless today only because binTarget defaulted to /usr/local/bin. This change moves the default under $HOME, so both would have started printing the username. Both are fixed here:

  1. ahoy uninstall's printed target (internal/surface/cli/cli.go symlinkNote) — UninstallReceipt.Symlink.Target is now written in tilde form at the point the receipt is built, so the JSON envelope and the text line are both clean.
  2. Doctor gap text embedding the bin target (internal/core/ahoy/detect.go) — every path a gap renders goes through displayPath.

displayPath is local to internal/core/ahoy/fsutil.go because fsutil.RedactRoot does not exist on this base. Once #225 merges, these two sites should be unified onto fsutil.RedactRoot — a follow-up, noted in the code comment. New receipt writes still go through a.note(), untouched, so #225's scrub covers them when both land.

Two tests assert the hygiene directly: TestUninstallReceiptCarriesNoAbsoluteHomePath (core) and TestAhoyUninstallPrintsNoAbsoluteHomePath (CLI), plus TestGapTextCarriesNoAbsoluteHomePath over 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:

--- FAIL: TestDetectAdoptsOwnedInstallOnPath — a working ~/.local/bin/abcd reported symlink.missing; install_mode = "", want pinned
--- FAIL: TestDetectAdoptsOwnedInstallAnywhereOnPath — owned install on PATH reported symlink.missing
--- FAIL: TestDetectDanglingOwnedSymlinkIsItsOwnGap — no symlink.dangling gap
--- FAIL: TestDetectBinDirNotOnPathIsALoudGap — no path.bin_dir_not_on_path gap
--- FAIL: TestGapTextCarriesNoAbsoluteHomePath — no gap named the location in tilde form
--- FAIL: TestInstallDefaultsToUserLocalBin — install did not create ~/.local/bin/abcd
--- FAIL: TestInstallRefusesToCreateDanglingSymlink — the refusal was silent; notes = []
--- FAIL: TestInstallBinDirUnwritableFailsLoudly — an unwritable --bin-dir was accepted silently
--- FAIL: TestInstallBinDirWritableInstallsThere — --bin-dir install created nothing
--- FAIL: TestUninstallReceiptCarriesNoAbsoluteHomePath — {Target:/usr/local/bin/abcd Removed:false Note:absent}
--- FAIL: TestAhoyInstallBinDirFlagIsWired — unknown flag: --bin-dir
--- FAIL: TestAhoyUninstallPrintsNoAbsoluteHomePath — did not name the removed entry in tilde form

(TestInstallAdoptsOwnedInstallInPlace passed 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 full make 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.

# severity finding fix
1 BLOCKER Foreign PATH entries were classified and then discarded, so a pre-A4 user whose binary was copied to /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. New symlink.shadowed gap (required, diagnostic) naming the occupant and both remedies; install_mode reports pinned (shadowed on PATH); dangling now computed for every entry, so a foreign link to nothing is described. Security's independent version of this seam is the same fix.
2 MAJOR The CHANGELOG's mitigation sentence was false for exactly that population — it promised adoption to users whose binary is a copy. Rewritten to say what they will see and what to do (delete the stale copy, or install ahead with --bin-dir); README and commands/ahoy.md say the same.
3 MAJOR stepSymlink's foreign bail-out recorded nothing, and symlink.foreign is advisory so it is filtered from Remaining — a --bin-dir run reported success having written nothing. a.refuse(...) names the occupant and what it is before returning.
4 MAJOR path.bin_dir_not_on_path is required-but-not-resolvable, so it is excluded from Remaining, absent from InstallResult, and printed only by doctor — unreachable by name on the machine where abcd is not on PATH. Nothing printed it on a fresh install. noteReachability() carries it (and the shadow) out on Notes, computed against the target actually written.
5 MINOR 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. The source check moves ahead of the early return; the comment now describes what happens.
6 MINOR An explicit --bin-dir off PATH was written but never described, and uninstall could not reach it. Install describes the directory it actually wrote; ahoy uninstall --bin-dir <dir> reaches an entry a PATH scan 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).
7 MINOR (sec) Refusal notes appended raw err.Error(), leaking $HOME into text commands/ahoy.md tells the agent to relay. New errText/displayText scrub every note site; asserted by TestInstallFreshPathGapIsCarriedOnNotes.
9 NIT (sec) The --bin-dir write probe ran before the adoption prompt, so a declined run had already created and removed a dotfile there. Target resolution moves after the adoption gate.

Red → green for the round (seven new tests, all failing first against cfc90ea):

--- FAIL: TestDetectShadowedByForeignCopyOnPath — no symlink.shadowed gap
--- FAIL: TestInstallBehindForeignCopyReportsShadowing — notes = []
--- FAIL: TestDetectDanglingForeignEntryIsDescribed — a dangling foreign entry produced no gap
--- FAIL: TestInstallForeignOccupantAtBinDirIsRefusedLoudly — refused in silence; notes = []
--- FAIL: TestInstallFreshPathGapIsCarriedOnNotes — install did not carry the PATH fix; notes = []
--- FAIL: TestInstallDanglingEntryWithMissingBinaryRefusesLoudly — silent; notes = []
--- FAIL: TestInstallBinDirOffPathIsDescribedAndRemovable — notes = []; uninstall {Removed:false Note:absent}

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, displayPath at the two A6 hand-off sites, the loud unwritable --bin-dir failure, and the TOCTOU refusal paths all stand as reviewed.

Gates

  • make preflight green 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/cli re-rendered docs/reference/cli/commands.md and .abcd/development/release/surface.json for the new --bin-dir flag (both are drift-gated by tests).

Record

  • Ledger: iss-171 moved open → resolved in this PR, --impact breaking.
  • CHANGELOG: a Breaking entry at the top of [Unreleased], citing iss-171. The maintainer resolves cross-PR CHANGELOG conflicts — several items in this run touch [Unreleased].
  • Brief updated where it named the blessed location: 04-surfaces/01-ahoy.md (detection step 8, apply step 9, uninstall semantics) and 02-constraints/04-naming.md.
  • commands/ahoy.md documents --bin-dir, the notes channel, and the instruction never to suggest sudo.

Not in scope

The install receipt's own Writes list still carries absolute paths — that is iss-177's seam and #225 owns it. TestInstallDefaultsToUserLocalBin says so in a comment rather than asserting a scrub this PR does not perform.

Assisted-by: Claude:claude-opus-5[1m]

REPPL added 3 commits August 12, 2026 12:19
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
@REPPL
REPPL enabled auto-merge August 12, 2026 14:37
@REPPL
REPPL merged commit 58cc554 into main Aug 12, 2026
13 checks passed
@REPPL
REPPL deleted the feat/iss-171-no-sudo-install branch August 12, 2026 14:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant