Skip to content

feat(windows): add scoped elevated USB PnP recovery helper #1148

Description

@zackees

Outcome

Provide an explicit, one-shot Windows recovery path for a known unhealthy USB device without ever elevating, killing, replacing, or duplicating the long-lived fbuild daemon.

User-facing policy:

  • fbuild deploy --admin: permit one UAC prompt if and only if deployment identifies a supported recoverable USB PnP state.
  • fbuild deploy --no-admin: never prompt; print exact manual recovery instructions.
  • no flag: diagnose and recommend rerunning with --admin; do not surprise the user with UAC.
  • the flags conflict.
  • CI/non-interactive sessions never prompt, even when --admin was passed; fail with a clear diagnostic.

This issue is optional recovery layered on the correctness fixes in #1146 and #1147. A device must never become selectable merely because recovery was attempted; it must pass a fresh health and openability check afterward.

Locked architecture

The daemon remains normal privilege

Do not:

  • terminate and relaunch the daemon elevated;
  • run a second privileged daemon on another port;
  • add privilege level to daemon cache identity;
  • let an elevated process host HTTP/WebSocket endpoints;
  • write build/dependency/zccache caches as administrator.

The existing normal daemon continues to own project state, serial leases, caches, and deployment coordination. A normal fbuild invocation during recovery sees the same normal daemon; the helper is too short-lived to arbitrate with it.

Reuse the shipped fbuild executable as the helper

Implement a hidden internal helper subcommand in fbuild-cli, launched by the normal CLI through Windows ShellExecuteExW with verb runas.

Do not add a workspace crate. Prefer no additional binary: reusing fbuild.exe avoids changes to setup.py, ci/publish.py, native-build staging, checksums, attestations, and wheel scripts.

The elevated process must enter helper mode before daemon discovery/startup. It performs exactly one allowlisted PnP operation, writes a small result, and exits. It must not:

  • connect to or spawn fbuild-daemon;
  • build, flash, monitor, or open project files;
  • initialize package/build caches;
  • accept arbitrary commands, executable paths, shell fragments, or registry edits;
  • invoke cmd.exe or PowerShell.

Structured normal/elevated handoff

Add a typed recovery payload to the daemon/CLI operation response (exact type location may vary):

struct UsbRecoveryRequest {
    operation_id: String,
    instance_id: String,
    parent_instance_id: Option<String>,
    expected_vid: u16,
    expected_pid: u16,
    expected_serial: Option<String>,
    problem_code: Option<u32>,
    flash_completed: bool,
}

Required control flow:

  1. The normal daemon discovers the unhealthy endpoint using fix(serial): preserve Windows PnP health through port probing #1146 and returns a structured request; do not scrape human-readable error text.
  2. The normal CLI checks policy:
    • --no-admin or no flag: render manual guidance;
    • --admin + interactive Windows session: launch its own executable in hidden helper mode with UAC;
    • CI/non-interactive/non-Windows: do not launch.
  3. The elevated helper re-queries the current PnP tree and validates every identity field before acting.
  4. The helper performs one bounded recovery ladder and exits.
  5. The normal CLI/daemon performs a fresh fix(serial): preserve Windows PnP health through port probing #1146 scan and openability probe.
  6. A post-flash recovery must continue through a recovery-only path; it must not rebuild or flash the firmware again.
  7. A preflight recovery may retry the original deploy at most once, with an internal ?recovery already attempted? guard.
  8. If re-probe still reports phantom/problem/unopenable, stop and print BOOTSEL/replug guidance.

A small POST /api/deploy/recover-port-style normal-daemon endpoint is acceptable for step 6. It must acquire the normal device lease, rescan/reopen, and resume an explicitly requested monitor, but it must never build or flash. Keep any normal-daemon recovery context bounded by operation ID and short expiry.

Privileged operation ladder

Put Windows PnP logic in the existing fbuild-serial Windows module so the CLI helper is thin. Use existing Windows bindings/FFI where possible; adding a dependency requires the repository's normal justification.

For the exact validated target:

  1. Locate the devnode by canonical instance ID, allowing a phantom lookup.
  2. Re-query VID/PID, serial, class, presence, problem code, and parent. Abort on any mismatch.
  3. For a phantom child, request CM_Reenumerate_DevNode on its nearest verified live parent. Re-enumerating the parent is allowed; disabling/restarting the parent hub is not.
  4. Poll for a bounded interval and re-query.
  5. If the exact child is now present but still has a problem, restart only that child. Prefer direct SetupAPI/Config Manager calls; an exact-argument pnputil.exe /restart-device <instance-id> fallback is acceptable on supported Windows versions. Never route through a shell.
  6. Poll again and return structured before/after health.

Allowed operations are only ?re-enumerate verified parent? and ?restart verified target child.? Not allowed:

  • install/update/remove drivers;
  • remove a devnode;
  • disable or restart a hub/root controller;
  • scan or restart all USB devices;
  • change power policy or registry values;
  • operate on an instance ID that no longer matches the expected board identity.

CM_Reenumerate_DevNode requires elevated privilege on current Windows, which is why this code must stay in the one-shot helper.

Result transport

Use a generated nonce plus a create-new result path or a user-scoped named pipe. The response must contain only:

  • operation ID/nonce;
  • validated instance ID;
  • operation attempted;
  • before/after health and problem code;
  • success/error code.

The helper must revalidate device identity, so tampering with an unprivileged request cannot expand the allowlist. Delete temporary request/result files after the normal CLI consumes them. Do not persist privileged state in fbuild caches.

Implementation map

  • crates/fbuild-cli/src/cli/args.rs
    • add conflicting deploy flags --admin and --no-admin;
    • add a hidden internal helper subcommand;
    • ensure helper dispatch happens before daemon acquisition.
  • crates/fbuild-cli/src/cli/deploy.rs
    • apply the recovery policy to structured daemon responses;
    • own UAC launch and retry/recover-only orchestration.
  • crates/fbuild-cli/src/daemon_client/types.rs
    • carry the typed recovery request/result; no string parsing.
  • crates/fbuild-daemon/src/models.rs
    • deserialize recovery policy with a backward-compatible default;
    • serialize the structured recovery request.
  • crates/fbuild-daemon/src/handlers/operations/deploy.rs
    • emit the request only for an exact unhealthy target;
    • expose recovery-only re-probe/monitor continuation without reflashing.
  • crates/fbuild-serial/src/ports.rs or a sibling Windows module
    • identity revalidation and allowlisted PnP operations.
  • crates/fbuild-cli/src/daemon_client.rs
    • no change that would spawn an elevated daemon.

If implementation chooses a separate helper binary despite the locked preference, stop and obtain maintainer approval first. It would also require local source-install staging in setup.py, release staging in .github/workflows/template_native_build.yml, wheel assembly through ci/publish.py, checksums/attestation, archive contents, and install tests. A helper that works only from a cargo checkout is not acceptable.

Failure behavior

Every failure must preserve the original deployment facts:

  • ?flash not attempted? vs ?flash completed?;
  • exact unhealthy instance and problem code;
  • UAC cancelled vs helper failed vs re-probe failed;
  • no stale deploy port;
  • manual BOOTSEL/replug/direct-port instructions.

Cancelling UAC is a normal recoverable outcome, not a daemon failure. It must not leave leases, temporary files, or a pending privileged process.

Automated tests

Abstract both UAC launch and PnP calls so CI uses fakes. Required tests:

  • --admin and --no-admin conflict;
  • no flag does not prompt;
  • --no-admin never launches helper;
  • --admin launches once only for a typed recoverable response;
  • CI/non-interactive --admin refuses without launching;
  • UAC cancellation is reported and cleaned up;
  • helper dispatch does not call daemon discovery;
  • mismatched VID/PID, serial, class, instance ID, or parent is rejected;
  • arbitrary operation strings/commands are impossible or rejected;
  • phantom target uses parent re-enumeration but never parent disable/restart;
  • present-problem target restart is scoped to the child;
  • helper success is followed by fresh health + open probe;
  • helper success with unhealthy re-probe still fails closed;
  • preflight retry happens at most once;
  • post-flash recovery-only continuation never invokes build/deploy;
  • concurrent normal fbuild calls keep using the normal daemon;
  • request/result files are create-new, nonce-matched, and removed.

Run:

soldr cargo test -p fbuild-serial
soldr cargo test -p fbuild-cli
soldr cargo test -p fbuild-daemon
soldr cargo clippy -p fbuild-serial -p fbuild-cli -p fbuild-daemon --all-targets -- -D warnings

Deferred integration validation (performed under #1147)

With a deliberately known phantom/problem RP2040 record:

  • normal fbuild deploy diagnoses and recommends --admin without UAC;
  • fbuild deploy --no-admin gives manual recovery and never prompts;
  • fbuild deploy --admin produces one UAC prompt;
  • Task Manager/process diagnostics show no elevated fbuild-daemon.exe;
  • helper exits after the one operation;
  • the normal daemon PID/endpoint/cache identity remains unchanged;
  • a simultaneous normal diagnostic command remains functional;
  • after helper success, only a freshly healthy/openable CDC endpoint is returned;
  • if Windows cannot revive it, the command falls back to BOOTSEL/replug instructions without claiming a port.

Acceptance criteria

  • The daemon is never elevated, bounced, or duplicated for recovery.
  • --admin means ?permit one-shot USB recovery,? not ?run fbuild as administrator.?
  • Default and --no-admin behavior are non-elevating and deterministic.
  • The helper is the existing shipped fbuild.exe in hidden allowlisted mode, or an explicitly approved fully packaged alternative.
  • Identity is revalidated in the elevated process before every operation.
  • No arbitrary command, broad USB reset, driver mutation, or hub restart is possible.
  • Post-flash recovery does not reflash.
  • Normal daemon leases/caches remain owned by the normal user.
  • Automated policy/security/concurrency tests pass without UAC in CI.
  • Deterministic tests cover UAC accept/cancel outcomes; live UAC evidence is recorded under fix(rp2040): reject phantom CDC ports and give recoverable BOOTSEL guidance #1147 after the RP2040 integration exists.

Non-goals

  • This is not a general administrator mode.
  • This is not a driver installer or USB repair suite.
  • This does not replace physical BOOTSEL/replug recovery.
  • This does not make a known-unhealthy port deployable without a successful fresh re-probe.
  • macOS/Linux parity is tracked separately in test(usb): validate port scanning parity on macOS and Linux #1091.

Related issues

Implementation handoff: foundation boundary and security checklist

This child supplies the generic, testable Windows recovery capability after #1146 has merged. It does not make RP2040 deployment invoke recovery; #1147 is the sole integration and live-hardware child. Therefore, #1148 can be closed with deterministic policy/identity/PnP fakes and helper-process tests. The final UAC accept/cancel run belongs to #1147's end-to-end hardware evidence once an RP2040 deployment emits a typed request.

Implement in this sequence:

  1. Define typed request/result and an explicit recovery policy enum (default, allow-admin, deny-admin); no parsing of diagnostic strings.
  2. Add the hidden helper dispatch before any daemon discovery in helper mode. The normal CLI may contact the existing normal daemon first to obtain a typed request; the elevated helper itself must not.
  3. Put identity revalidation and the two allowlisted Config Manager operations behind a narrow Windows trait/function boundary with fake implementations for tests.
  4. Add the normal-CLI UAC launcher behind an injected launcher interface; tests prove no UAC on default/no-admin/CI/cancel paths.
  5. Return to the normal process for the fresh enumeration/openability gate. The helper result is advisory, never a port result.

Mandatory security mechanics

  • The normal process creates a nonce-bound request/result rendezvous under a user-private directory using create-new semantics. Reject pre-existing paths, nonce/operation-ID mismatch, reparse points, and overlong/unexpected fields; remove both artifacts on every terminal path.
  • Launch only the current executable with a fixed hidden helper verb/argument shape through ShellExecuteExW runas; no shell, command-line fragments, caller-supplied executable, arbitrary instance ID, or arbitrary operation enum.
  • The helper re-queries the canonical instance and checks VID, PID, serial when supplied, class, parent, and current health before doing anything. A request-file identity match alone is insufficient.
  • Permit only re-enumerating the nearest verified live parent and restarting the verified child. Never disable/restart parent hubs/controllers, install/remove drivers, mutate registry/power policy, or enumerate-reset all USB devices.
  • Bound helper execution and every poll. Record before/after health and error code only; do not return a COM port or persist elevated cache/daemon state.

Deterministic proof required before merge

Tests must assert helper dispatch bypasses daemon acquisition; default/no-admin/CI never call the launcher; --admin launches once at most; cancellation cleans artifacts; all identity mismatches are rejected before the PnP backend; only the exact two allowlisted operations are representable; and a successful helper result still requires a later non-elevated healthy/openable re-probe. If packaging/argument routing cannot make the existing shipped executable enter helper mode before daemon startup, stop and request maintainer direction rather than adding a second binary.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions