Skip to content

fix: non-elevated self-update + honest daemon stop/kill (privilege UX)#544

Merged
githubrobbi merged 7 commits into
mainfrom
fix/self-update-privilege-ux
Jul 8, 2026
Merged

fix: non-elevated self-update + honest daemon stop/kill (privilege UX)#544
githubrobbi merged 7 commits into
mainfrom
fix/self-update-privilege-ux

Conversation

@githubrobbi

Copy link
Copy Markdown
Collaborator

What

Two fixes to the self-update / daemon privilege UX, developed and Windows-validated across this branch.

Daemon stop/kill (fix #1)

uffs --daemon kill no longer does a half job (remove the PID file but leave the process running). It now: stale-PID pre-check → clean up; kill succeeds → remove PID/socket; can't kill (needs elevation) → keep the PID file and explain why. New daemon_ctl::is_pid_alive.

Non-elevated self-update (fix #2)

uffs --update mirrors the uninstall flow: build a per-root plan, run a shared elevation gate up front, prune to the doable roots, then reuse the existing acquire/quiesce/apply/winget execute — instead of the old 20 s daemon-stop-timeout → rollback.

Per-root elevation model (commands::update::plan):

  • already-current root → skip (and its daemon is never stopped)
  • unmanaged → replace in place; needs elevation if the dir isn't writable or a component running here can't be stopped without elevation (reuses fix Bump the cargo group across 1 directory with 2 updates #1's gate)
  • winget userwinget upgrade non-elevated; not gated on daemon-stop — winget::quiesce stops an in-package daemon cooperatively (IPC shutdown, no elevation)
  • winget machine → needs elevation
  • New shared commands::elevation gate (trait ElevatablePlan); update consumes it, migrating uninstall onto it is a tracked follow-up.
  • After a winget upgrade, if the daemon can't restart non-elevated (no broker), print a ⚠ note pointing at uffs-broker --install.

Validation

Windows-tested all three daemon situations (no daemon / daemon in ~\bin / daemon in the winget root) updating from a non-elevated shell. 147 uffs-cli tests (11 new for the plan/elevation model); native + x86_64-pc-windows-msvc clippy + rustdoc clean; full pre-push gate green.

🤖 Generated with Claude Code

githubrobbi and others added 7 commits July 7, 2026 20:49
…IPC limit (#543)

* docs(readme): add YouTube demos playlist link

Link the full uncut CLI/MCP/TUI demo reels above the inline GIFs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(readme): soften Everything-CLI export claim to the tested ~2 GB IPC limit

Replace the absolute 'Everything's CLI cannot/can't run this' with the
factual, scoped claim ('hit a ~2 GB IPC limit at this scale') in both the
blog-link paragraph and the benchmark highlights — matching the softened
blog post so the exact claim most likely to be scrutinised is consistent
across README and blog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
`uffs --update` hit a privilege catch-22: an elevated shell can't run
`winget upgrade` (winget refuses a user package elevated), while a
non-elevated shell can't stop an elevated daemon — so quiesce shelled
`daemon stop`, ignored the gated failure, waited 20s, and rolled the
whole update back on an install the caller could never have updated.

Mirror the uninstall flow instead: build a per-root plan, run a shared
elevation gate up front (continue-without / abort), then feed the
journaled execute a report PRUNED to just the doable roots so
quiesce/apply/winget never touch a dropped root.

- New shared `commands::elevation` gate (trait `ElevatablePlan`):
  surface admin-only work once, before any mutation. Update consumes it;
  the working uninstall flow is deliberately left on its own gate for now
  (migrating it is a safe follow-up — no risk to a flow that works).
- New `commands::update::plan` — per-root classification + elevation
  model: unmanaged→writable-probe, winget-user→must-run-non-elevated
  (the winget inversion, surfaced as a delegation note, not the gate),
  winget-machine→needs elevation, dev-build/unknown→skip. + unit tests.
  Also holds the plan render/prune helpers (keeps update/mod.rs under the
  800-LOC ceiling).
- `run_automatic_update`: gate → split winget-when-elevated → prune
  report → reuse the existing acquire/quiesce/apply/winget execute.

Compile-verified native + x86_64-pc-windows-msvc; 144 uffs-cli tests
green. Windows runtime behavior pending user validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Windows testing exposed the real gap: a non-elevated `uffs --update` with an
elevated daemon running from `C:\Users\rnio\bin` still hit the 20s
stop-timeout → rollback. The plan judged `~\bin` "doable" purely from
dir-writability, so it never saw that the elevated daemon there must be
stopped first (which needs Administrator) — the gate stayed silent,
`apply` shelled `daemon stop`, hit the gate prose, and the helper's own
quiesce blindly waited 20s before rolling back.

Two rules the plan was missing:

1. **Stop cost is the root's cost** — `classify_root` now checks whether a
   component running from the root can be stopped without elevation, reusing
   fix #1's exact gate (`mutating_management_needs_elevation`, now
   `pub(crate)`): broker → always needs elevation; daemon/mcp → needs it when
   the daemon is elevated with no serving broker. If so, the root is
   elevation-required and the gate surfaces it up front.
2. **Already current** — a root whose every binary is at the target version
   is a pure skip, so an already-current `~\bin` hosting an elevated daemon is
   never dragged in (and its daemon never stopped). `UpdatePlan::build` now
   takes `latest` for the per-root version compare.

Also: `run_automatic_update` skips `apply` entirely when the pruned plan has
no unmanaged root, so no daemon is ever stopped for work that isn't there.

+2 unit tests covering the exact scenario (elevated daemon in an
already-current root → skip; out-of-date root with an unstoppable daemon →
forces elevation). 146 uffs-cli tests green; native + windows-msvc clippy +
rustdoc clean. Windows runtime behavior pending re-validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the daemon-stop fix: when the gate/prune leaves only winget
roots, `acquire` (the uffs-update helper's binary download for hand-placed
roots) errors with "no unmanaged binaries to acquire" and aborts the whole
update. Gate `acquire` on `has_unmanaged` exactly like `apply` — the winget
root is upgraded by `winget::run_upgrade` (which shells `winget upgrade`
directly and needs neither acquire nor apply).

146 uffs-cli tests green; native + windows-msvc clippy + rustdoc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The winget-root corner exposed a dead-end: a daemon running FROM a
winget-user root got the root flagged elevation-required, so a non-elevated
`uffs --update` printed "re-run as Administrator" — which winget can't honor
(it refuses a user package elevated). Neither continue-without nor re-run
updated the root.

Root cause: I applied the daemon-stop-elevation rule to winget roots too.
But `winget::quiesce` stops a daemon inside the package *cooperatively* —
`connect_raw().shutdown()`, an IPC shutdown the daemon honors regardless of
its own elevation (same-user pipe), NOT the policy-gated CLI `daemon stop`.
So a winget root never needs elevation for a running component; it only needs
winget's own privilege level (user = non-elevated, machine = elevated).

Restrict `root_stop_needs_elevation` to unmanaged roots (where the uffs-update
helper does use the gated `daemon stop`, so an elevated daemon there really
does need Administrator). A winget-user root with a daemon now classifies as
(WingetUpgrade, non-elevated) → no gate → quiesce stops the daemon → winget
upgrades non-elevated.

+1 unit test (winget-user root hosting a daemon is not gated). 147 uffs-cli
tests green; native + windows-msvc clippy + rustdoc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
After a winget upgrade, `resume` stops+restarts a daemon that lived in the
package. When that daemon needs elevation to read the MFT and there is no
broker, the non-elevated restart silently fails and the daemon is left down —
confusing after a "✓ updated" message.

Check whether the daemon actually came back (`daemon_ctl` PID file + live
process) and, if not, print the one-time fix instead of a silent stop:

  ⚠ The index daemon was stopped for the update and couldn't restart
    non-elevated (no broker). Run `uffs-broker --install` for zero-UAC
    restarts, or start it from an elevated terminal.

147 uffs-cli tests green; native + windows-msvc clippy + rustdoc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@githubrobbi githubrobbi added this pull request to the merge queue Jul 8, 2026
Merged via the queue into main with commit 9ae2e63 Jul 8, 2026
27 checks passed
@githubrobbi githubrobbi deleted the fix/self-update-privilege-ux branch July 8, 2026 19:26
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