Skip to content

The no-UAC ESP32 USB recovery, working: mpftp usb-restart and the task behind it - #33

Merged
bdbarnett merged 7 commits into
mainfrom
fix/issue-31
Sep 21, 2026
Merged

bdbarnett merged 7 commits into
mainfrom
fix/issue-31

Conversation

@bdbarnett

Copy link
Copy Markdown
Collaborator

Closes #31.

When an ESP32-S3 on native USB wedges during a flash, mpftp usb-restart --instance '<id>' now brings it back with nobody at the bench. --status says whether the recovery is really installed, and --list finds the instance ids. It needs one elevated install first; docs/agent-guide.md has the command to hand the owner.

The scheduled task this replaces had never worked. It piped to Restart-PnpDevice, which Windows PowerShell does not have, so it failed on every device and reported LastTaskResult 1, which looks the same from outside as a board that refused to come back.

What the hardware has shown, on a LilyGO T-Embed S3 on 2026-09-21: a wrong id sent through the task came back 3, the real id came back 0 with the COM port dropping and returning, and later that day the task made its first real rescue of a node stuck on Windows error 31.

It also hurt a board once, and that is fixed here. The first installed version fell back to Disable then Enable after pnputil answered 1167 for a board that had just gone away. The Disable stuck, the Enable failed, and the board came back with its USB node disabled until an administrator enabled it. The fallback now runs only where pnputil has no restart verb, it re-enables on the way out whatever happened, and the script enables a disabled node before it does anything else.

The task runs as SYSTEM, so it executes only what is installed under C:\Program Files\mpftp. The one thing an ordinary account supplies is an instance id in a request file, matched whole against an allow-list, never evaluated, and deleted on read. The request directory and the script both refuse symlinks, hard links and junctions.

Tests: cli/tests/test_esp_usb_restart.py, 39, green here. The refusals drive the real script through -DryRun. The restart itself runs for real over pretend devices — esp_usb_doubles.ps1 defines Get-PnpDevice, pnputil.exe and the PnpDevice cmdlets as functions and dot-sources the script under them. Every rule was watched going red against a copy of the script with that rule loosened; the counts are in the test file's docstrings.

Not done: the enable-a-disabled-node repair has not run on hardware. The installed copy only changes when the installer is run again.

…S3 flash was ever unattended

`mpftp-restart-esp-usb` ran, inline, as SYSTEM:

    Get-PnpDevice -PresentOnly
      | Where-Object { $_.InstanceId -like 'USB\VID_303A*' }
      | Restart-PnpDevice -Confirm:$false

Windows PowerShell's PnpDevice module ships Get-, Enable- and Disable-PnpDevice
and nothing else. So the pipeline raised CommandNotFoundException on every
device, the task reported LastTaskResult 1, and from outside that is
indistinguishable from a board that refused to come back. It never worked once.
It cost the 2026-09-17 pin-move run its S3 half and the 2026-09-21 live-audio
spike its auto-suspend measurement, and last night it cost the owner a trip out
of bed. Confirmed on the bench: Get-Command -Module PnpDevice returns exactly
those four names, and the registered action is still the text above.

The task now runs tools/windows/restart-esp-usb.ps1, which:

  - takes ONE device, by instance id, from a request file. The old VID_303A
    sweep would have bounced every Espressif board attached together -- on this
    bench that is a second board mid-demo;
  - matches that id whole against an allow-list for a VID_303A composite
    parent, never evaluates it, hands it to pnputil as an argument array, and
    deletes the request on read so a stale one is never replayed;
  - uses pnputil /restart-device, with Disable+Enable as the fallback;
  - writes a transcript, so LastTaskResult 1 can be told from a board that
    really did not come back;
  - exits 0 restarted, 2 no request, 3 request refused, 4 no such device,
    5 the restart failed -- a wrong id fails loudly rather than quietly.

The task runs as SYSTEM, so it must never execute anything an ordinary account
can write. install-restart-esp-usb-task.ps1 is the one elevated step: it puts
the script in C:\Program Files\mpftp with an explicit ACL, creates the
user-writable request directory beside it in ProgramData, registers the task,
and self-checks the registration. A later edit to the script costs another
elevated install; that is the correct price for the SYSTEM principal.

`mpftp usb-restart --status` reads the action the task is really registered
with, so an agent can tell "the recovery is not installed" from "the board did
not make it" before planning around it. --list finds instance ids instead of
hard-coding them.

Proved as far as an unprivileged session goes: 29 tests, including the script's
own refusals driven through -DryRun at medium integrity with the boards
untouched -- wrong vendor, an &MI_ child, three injection shapes, a wildcard, a
path, two devices in one request, an over-long request, empty, missing, and a
well-formed id for an absent device exiting 4 rather than 0. Both rules were
watched failing against deliberately loosened copies: the PowerShell regex
loosened to ^USB\VID_ turns five red (including accepting the other board's
UART), the one-line check loosened turns the two-device case red, and the same
loosening in Python turns six red.

NOT yet proved, and waiting on that one elevated install: the task reaching
LastTaskResult 0, COM12 disappearing and coming back, and a wrong instance id
failing through the task rather than through a direct run.
…is a link-following bug waiting to happen

The task runs as SYSTEM and both reads and deletes
C:\ProgramData\mpftp\restart-esp-usb.target, inside a directory ordinary
accounts write to. The first cut granted Users Modify on that directory, which
includes DELETE on the directory itself -- so a user could remove it and
recreate it as a junction pointing anywhere, and SYSTEM's Remove-Item would
follow. Closing both halves.

The directory. Users now get exactly CreateFiles + ReadAndExecute on the folder
itself, and Modify on files within it (ObjectInherit, InheritOnly). No Delete
on the folder, no CreateDirectories, no WriteAttributes, no ChangePermissions.
Ownership goes to Administrators, because an owner keeps the implicit right to
rewrite the permissions that constrain it. An admin-only .keep file keeps the
directory non-empty, and a non-empty directory cannot be converted into a
reparse point. The installer refuses to install onto an existing reparse point,
re-applies the ACL if the directory is already there, self-checks that .keep
landed, and prints the resulting Users ACEs so the claim is checkable.

Verified against a scratch directory with the shipped helpers, as an ordinary
user: drop a request allowed, rewrite it allowed, delete it allowed; create a
subdirectory refused, delete .keep refused, delete the directory refused, rmdir
it refused. (Measured while still owning the directory, so the DACL is doing
the work -- an owner's implicit rights are READ_CONTROL and WRITE_DAC, not data
access.)

The script. Before reading, and again before deleting, it refuses a request
that is a symlink, a hard link, a directory, or sits beneath a reparse point --
exit 3, nothing read, nothing deleted, and the contents are NOT echoed, because
the log is world-readable and with Developer Mode on the link could point at
something only SYSTEM can read. Both conditions are load-bearing: measured
here, all three link types are creatable unprivileged, and a hard link carries
no ReparsePoint attribute (only LinkType says so) while a WSL symlink carries
the attribute with a blank LinkType. Either check alone misses one. The
re-check before the delete narrows but does not close the check-to-syscall gap;
doing that needs FILE_FLAG_OPEN_REPARSE_POINT, which PowerShell cannot open.

33 tests, four new ones covering a symlinked request, a hard-linked request, a
request inside a junction, and the absence of a content leak. Each was watched
failing against a planted fault: link checks neutered turns all four red, the
regex loosened to ^USB\VID_ turns five red, the one-line check loosened turns
one red, echoing contents on a link refusal turns two red.

Also fixes the harness itself, which was quietly wrong: it used
os.path.dirname on a Windows path under Linux Python, which returns "", so the
override used for planted-fault runs pointed every request at the current
directory. The earlier fault runs resolved by accident. ntpath.dirname now, and
the numbers above were re-measured after the fix.

What this does not defend against: malware already running as the desktop
account on a machine where that account is an administrator has other ways up.
This is about mpftp not adding one. Said as much in the guide.

The owner's one command is unchanged.
Under 'powershell.exe -File' -- which is how the scheduled task runs this --
$PSScriptRoot is still empty while the param block's defaults are evaluated
(Windows PowerShell 5.1). The -LogPath default was 'Join-Path $PSScriptRoot
...', so the first real install ran, threw before its first log line, left the
request unconsumed and exited 1: LastTaskResult 1, no transcript, the board
untouched -- indistinguishable from the broken task it replaced.

Every test passed -LogPath explicitly, and so did the installer's self-check,
so the production invocation was the one form nothing exercised. The default
is resolved in the script body now, the task is registered with -LogPath as
well, the self-check runs the script the way the task does, and two tests run
it with -File and no -LogPath (they fail against the old default: 2 of 35).
The Disable+Enable fallback ran when pnputil /restart-device said 'the device
is not connected' (exit 1167) -- a board that had gone into ROM download mode.
Disable took; Enable failed with 'Generic failure'; and when the board came
back it was CM_PROB_DISABLED: enumerated, no COM port, and a later
/restart-device 'succeeded' on it without changing anything. It took an
administrator to enable it by hand (2026-09-21, a LilyGO T-Embed S3).

The fallback is for a Windows with no /restart-device verb, so it only runs
there now; if it disables and cannot enable, it enables again on the way out;
and every run first enables a node it finds disabled.
… and the guide says what the hardware has shown

The never-leave-a-node-disabled fix went in with no test. PowerShell resolves
a function before a cmdlet or an executable, so esp_usb_doubles.ps1 defines
Get-PnpDevice, pnputil.exe and the PnpDevice cmdlets, records each call, and
dot-sources the real script. Four scenarios; each of the three rules was
watched going red against a copy with one line changed.

The guide's 'not yet proved on hardware' paragraph is replaced by what has
been: the two proofs through the task, the first real rescue, what the task
cannot do, and how the first installed version left a node disabled.
main has been red on lint since 79a3ee3; these five are the whole of it.
@bdbarnett
bdbarnett merged commit 151fb9d into main Sep 21, 2026
3 checks passed
@bdbarnett
bdbarnett deleted the fix/issue-31 branch September 21, 2026 20:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

restart-esp-usb: the scheduled task runs a cmdlet that does not exist, so every ESP32-S3 flash needs a human

1 participant