fix: non-elevated self-update + honest daemon stop/kill (privilege UX)#544
Merged
Conversation
…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>
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.
What
Two fixes to the self-update / daemon privilege UX, developed and Windows-validated across this branch.
Daemon stop/kill (fix #1)
uffs --daemon killno 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. Newdaemon_ctl::is_pid_alive.Non-elevated self-update (fix #2)
uffs --updatemirrors 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):winget upgradenon-elevated; not gated on daemon-stop —winget::quiescestops an in-package daemon cooperatively (IPC shutdown, no elevation)commands::elevationgate (traitElevatablePlan); update consumes it, migrating uninstall onto it is a tracked follow-up.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-msvcclippy + rustdoc clean; full pre-push gate green.🤖 Generated with Claude Code