Skip to content

✨ feat: sudo-free install, uninstall, and self-upgrade - #76

Merged
yeazelm merged 4 commits into
mainfrom
matt/pcc-1279-sudo-free-install-uninstall-upgrade
Aug 25, 2026
Merged

yeazelm merged 4 commits into
mainfrom
matt/pcc-1279-sudo-free-install-uninstall-upgrade

Conversation

@yeazelm

@yeazelm yeazelm commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

🤖

Summary

  • Install moves to ~/.local/bin: plain user file I/O end to end, PATH wired via a sentinel-marked rc block; re-running the installer migrates an old root-owned install (declining is fine and still succeeds).
  • tapesctl uninstall takes back everything the installer wrote — binary, rc block, local state — sequenced so every failure leaves a tool that can retry; harness-side plugin registrations are deliberately left to plugin uninstall.
  • tapesctl upgrade downloads, checksum-verifies, probes, and atomically swaps the running binary; every failure mode converges on "the binary you had still works". --version pins (downgrades included), --nightly always fetches.
  • Concurrent upgrades are serialized by an exclusive flock held for the pipeline's lifetime — an adversarial-audit repro showed two overlapping runs could otherwise commit a partial download over the live binary with a false success. The loser now refuses before any network I/O; uninstall sweeps the lock and staging residue.
  • The rc-file rewrite now classifies files none/ok/malformed on both the shell and Rust sides — an orphaned begin marker is refused rather than eating the rest of the file (a hazard inherited from paper's installer, fixed here).

Test plan

  • dagger call lint + dagger call test (CI-identical) green locally
  • End-to-end self-upgrade suite: real compiled binary replaces itself against a localhost bucket, abort paths assert installed bytes unchanged
  • contracts/freshness/pins checks green
  • Manual: fresh curl | bash install on a clean machine
  • Manual: migration from an actual root-owned /usr/local/bin install

Fixes PCC-1279

@linear-code

linear-code Bot commented Aug 24, 2026

Copy link
Copy Markdown

PCC-1279

@greptile-apps

greptile-apps Bot commented Aug 24, 2026

Copy link
Copy Markdown

Greptile Summary

The PR moves installation into a user-owned directory and adds self-upgrade and uninstall workflows.

  • Installs into ~/.local/bin and manages a sentinel-marked shell PATH block.
  • Adds checksum-verified, serialized, atomic self-upgrades with staged-binary probing.
  • Adds uninstall cleanup for the binary, installer-managed shell configuration, and local state.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
crates/tapesctl/src/upgrade/artifact.rs Implements artifact staging, digest verification, bounded process-group probing, upgrade locking, and atomic replacement; the previously reported timeout issues are addressed.
crates/tapesctl/src/upgrade/mod.rs Orchestrates the serialized download, verification, probe, version-validation, and atomic-commit pipeline.
crates/tapesctl/src/uninstall.rs Adds ordered removal of local state, installer-managed rc blocks, and the running binary while preserving retryability.
crates/tapesctl/src/rc_block.rs Adds guarded removal of installer sentinel blocks while refusing malformed marker layouts.
crates/tapesctl/src/install_layout.rs Centralizes paths and writability checks used by upgrade and uninstall operations.
install.sh Moves default installation to a user-owned directory, manages PATH configuration, and supports migration from the legacy location.
crates/tapesctl/tests/self_upgrade.rs Exercises the real self-replacement pipeline and verifies that abort paths preserve the installed binary.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    I[Installed tapesctl] --> L[Acquire exclusive upgrade lock]
    L --> R[Resolve target release]
    R --> D[Download staging artifact]
    D --> H[Verify published SHA-256]
    H --> X[Mark staged file executable]
    X --> P[Run bounded version probe]
    P --> V[Validate reported version]
    V --> A[Atomically rename over installed binary]
    H -->|failure| K[Keep existing binary]
    P -->|failure or timeout| K
    V -->|failure| K
Loading

Reviews (4): Last reviewed commit: "📚 docs: the install layout, upgrade, an..." | Re-trigger Greptile

Comment thread crates/tapesctl/src/upgrade/artifact.rs Outdated
@yeazelm
yeazelm force-pushed the matt/pcc-1279-sudo-free-install-uninstall-upgrade branch from ef5eda7 to cf17cc4 Compare August 24, 2026 21:17
Comment thread crates/tapesctl/src/upgrade/artifact.rs Outdated
@yeazelm
yeazelm force-pushed the matt/pcc-1279-sudo-free-install-uninstall-upgrade branch from cf17cc4 to f243694 Compare August 24, 2026 21:38
Comment thread crates/tapesctl/src/upgrade/artifact.rs
The install location moves to ~/.local/bin, and with it the last routine
sudo leaves the tool. A fresh install is now ordinary user file I/O end
to end: mkdir, download, install. What /usr/local/bin bought — presence
on every default PATH — is replaced by a guarded export written into a
sentinel-marked block in the user's rc file: guarded on PATH membership
so nested shells don't stack duplicates, guarded on the binary existing
so a block that outlives an uninstall is silent, and idempotent across
re-installs because the block is rewritten, never duplicated. Without
the export an install into ~/.local/bin is unreachable, so this is not
optional polish; it is what makes the install an install.

The marker text names tapesctl while keeping the shared glyph. Both this
script and the Rust remover that follows compare whole lines, so a
paperctl block in the same rc file is neither matched nor disturbed —
one dotfile can carry both without either installer touching the other's
block.

Existing users migrate by re-running the installer. When it finds the
old root-owned binary it says plainly that this is the last time
tapesctl will ever ask for sudo, and removes it — leaving it would fork
behavior by context forever, with scripts, CI, and GUI-launched tools
silently running the stale binary that default PATHs still find first.
Declining is fine: the new install still lands, the manual removal
command is printed, and the install exits successfully. The
same-directory guard compares canonicalized paths, so a trailing slash
or a symlinked spelling of the install dir cannot turn the migration
into a delete of the binary this very run installed.

A read-only rc file — nix, home-manager, a dotfiles manager — must not
fail the install: the binary is already on disk, it just is not on PATH
yet. That case prints the line to add by hand, the way Homebrew does,
and keeps going.

The rc rewrite classifies a file the same three ways the Rust remover
does — no block, well-formed, malformed — and refuses to rewrite a
malformed one: dropping everything below an orphaned begin marker is
worse than leaving the block. Classification is whole-line and
byte-exact, so a marker differing by a trailing space or CRLF reads as
malformed rather than slipping past a substring guard the stripper
cannot match.

An absent HOME with an explicit TAPESCTL_INSTALL_DIR degrades to
printed PATH instructions — the binary is installed; only the rc
convenience is impossible. Absent both is refused before anything is
downloaded.

The script's whole executable body runs through a main() invoked on
the last line. bash executes a piped script incrementally as bytes
arrive; without the wrapper, a transfer dying between the old binary's
rm and the new one's install left no tapesctl at all. A truncated
stream now fails to parse and executes nothing.
The installer now writes into two places the user did not choose by
hand: a directory on their PATH and a sentinel block in their shell rc
file. Neither should be something only a hand-edit can take back, so
this is the command that takes it back.

An install layout is derived, never assumed: canonicalize the running
executable (so a PATH invocation, an absolute one, and one through a
symlink all resolve to the same place), take its directory, and derive
the binary and staging paths from that. A writability probe on the
directory is the gate every mutating operation checks first — directory
write permission is what unlink and rename actually require on Unix, and
file permissions are irrelevant to both. The probe creates a file rather
than reading mode bits, so ACLs and read-only mounts are respected too.
When the directory is not user-writable — the old root-owned layout —
the binary refuses with the installer command and the manual rm. It
never escalates.

rc files get a surgical primitive: remove exactly the lines between the
sentinel markers, byte-preserving everything outside them, and refuse to
rewrite at all when a begin marker has no matching end — dropping
everything after an orphaned marker is worse than leaving the block.
Working on bytes rather than str keeps a latin-1 comment or a stray byte
elsewhere in the file undamaged. The markers are the contract that makes
touching user dotfiles defensible, so the Rust constants are pinned
against the installer by a test that reads it: marker drift is a red
build, not a block nothing can ever remove again. Whole-line comparison
is also what lets a paperctl block share the file untouched.

Uninstall runs state, then the rc block, then its own binary, then
exits. Destruction is sequenced so every failure leaves a tool that can
retry — the self-unlink is dead last, and it is safe because the inode
outlives the running process. Each step warns and continues; a read-only
rc file is a warning line, not an abort. Harness-side plugin
registrations are deliberately left alone: those live in a config file
the harness owns, and `plugin uninstall` is the command that speaks that
contract, so the report names it rather than reaching into it.

A prompt that reaches EOF counts as a decline. An uninstall that
proceeds because nobody was there to say no is the one outcome the
prompt exists to prevent.

State removal is contained to the cache path this crate derives for
itself. An overridden TAPESCTL_CACHE_DIR names a directory the user
chose — routinely one holding more than this cache — so it is reported
and left in place rather than handed to remove_dir_all.

An executable path that will not resolve degrades rather than aborts:
the rest of the uninstall proceeds and the report names the manual
step. The prompt writes to stderr and enumerates every path it is
about to remove, two of which are recursive deletes.

The unwritable-directory tests probe their own precondition and skip
when mode bits cannot construct it — root writes through 0o555, and
containerized CI runs as root.

Upgrade residue goes with the binary: the staging file an interrupted
upgrade can leave behind and the pipeline lock file are removed
best-effort — their sweep otherwise runs only on the next upgrade, and
after an uninstall there is not one.
tapesctl upgrade checks the published
version, reports "already up to date" with a clean exit when there is
nothing to do, and otherwise downloads, verifies, and atomically
replaces this binary, printing old → new. --version pins an exact
release (older included — a bad release needs an escape hatch) and
--nightly always fetches, because nightly builds are deliberately
outside version ordering.

The core is a pipeline whose failure modes all converge on "the binary
you had still works." The new artifact is downloaded to a dotfile staged
in the install directory itself — same directory means same filesystem,
which is what makes the final rename atomic; a staging file in /tmp
would cross devices and degrade to copy-plus-delete, reopening exactly
the corruption window this exists to close. The dotted name keeps a
crashed run from leaving a PATH-visible half-binary, and any stale
staging file is swept at the start of the next attempt, so repeated
failures converge instead of accumulating.

Nothing touches the live binary until the staged bytes have earned it,
in a fixed order: the published sha256 is fetched and compared first —
before the staged file is made executable, before it is run. Executing
an unverified download would make the updater an arbitrary-code-
execution primitive against itself, so the sanity probe runs strictly
after the digest passes; it catches what a correct digest cannot, a
faithfully published wrong-arch artifact. The probe's answer is then
checked against the resolved version, which catches what neither can: a
prefix serving the wrong build. Only then does rename swap it over the
running executable — safe on Unix because the current process keeps its
inode.

A missing .sha256 aborts rather than degrading to an unverified
install. Checksum mismatch, truncated download, failed probe, unwritable
directory: each is a typed error that aborts with the original untouched
and the staging file cleaned. The unwritable case names the installer —
an unmigrated root-owned install cannot self-upgrade by design, and the
message routes it to the script that migrates it, before a byte is
downloaded that it could never apply.

Unlike paperctl's, the pipeline ends at the swap: tapesctl runs no
daemon and installs no symlink, so there is nothing to re-point or
bounce. A capture already in flight keeps running on its old inode.

The comparison has one wrinkle paperctl does not: a tapesctl build
stamps its commit as semver build metadata, so `nightly` arrives as
`nightly+3f2a1b9` and never matches a bare sentinel. The name is split
from its metadata before that check, rather than leaving the answer to
depend on whether the version parser happens to reject the whole string.

main now prints the source chain rather than the outermost message
alone. Every error here is a typed wrapper around the one beneath it and
the outermost is deliberately the least specific — "upgrade failed" is a
category, while the cause a user acts on lives one or two links down.
Printing only the top discarded exactly the half that says what to do.

A hidden env override for the download base URL exists so the end-to-end
test can point the real compiled binary at a localhost bucket and watch
it replace itself on disk — including the abort paths, where the
assertion is that the installed bytes are identical afterwards.

The staging file is created create_new with mode 0600: no truncate of
an existing path, no symlink follow, and no executable window before
the digest passes. That is what makes the stale-staging sweep
load-bearing rather than incidental — disabling it fails a test. The
probe is time-bounded with captured output capped, because it is the
one step that executes code the bucket supplied. The swap inherits the
mode of the binary being replaced, execute bits forced on, so a
deliberately private install stays private across upgrades. Redirects
are pinned to https, the download carries a size ceiling in place of an
overall timeout, and a sidecar behind any non-success status is treated
as the absent checksum it is.

The unwritable-directory test probes its own precondition and skips
when mode bits cannot construct it — root writes through 0o555, and
containerized CI runs as root.

A probe that outlives its timeout is killed, not abandoned — and
killed as a process group, not a single pid: the probe leads its own
group, so descendants a forking artifact leaves behind go with it.
The child handle is still held when the timeout fires, which is what
makes the group id trustworthy: an unreaped child's pid cannot be
recycled. Output is read to a cap and the rest drained, so a chatty
artifact can neither exhaust the updater's memory nor deadlock the
probe on a full pipe.

One pipeline at a time: an exclusive flock on a lock file in the
install directory serializes concurrent upgrades — the kernel drops it
however the process ends. Two runs sharing one staging path could
otherwise unlink each other's verified bytes and commit a partial
download as the live binary while printing success.

The probe is a sanity check, not a sandbox. Its containment of the
staged binary is best-effort by design: an adversarial bucket able to
serve hostile verified bytes defeats the pipeline at the swap, not at
the probe — the security boundary is the digest gate.
The install section described a `/usr/local/bin` install that no longer
happens, and the reference had no entry for either new command. Both are
now documented where a user looks: the README for the tour,
docs/commands.md for the reference, docs/introduction.md for the first
five minutes.

The `version` entry was also stale in a way that now actively
contradicts `upgrade`. It said the number is not a release identifier
because the crate version has never been bumped — true when it was
written, false since the build began stamping the release tag, and
directly at odds with a command whose "already up to date" answer is
that number compared against the bucket's. It now describes what the
three build kinds actually report and why the commit rides along as
build metadata that comparison ignores.

The exit-code and error-family entries said every runtime error is one
line. They are a line plus a `caused by:` line per underlying cause now,
and the note says to read the chain from the bottom, because the
outermost message is deliberately the least specific.

The uninstall entry names what is deliberately not deleted: an
overridden TAPESCTL_CACHE_DIR is reported and left in place rather
than recursively deleted, because the variable names a directory the
user chose. The checksum error carries the status it saw, since any
non-success answer — not only a 404 — counts as an absent sidecar.
@yeazelm
yeazelm force-pushed the matt/pcc-1279-sudo-free-install-uninstall-upgrade branch from f243694 to 9172a0f Compare August 24, 2026 23:25
@yeazelm
yeazelm merged commit bb1808d into main Aug 25, 2026
21 checks passed
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