The no-UAC ESP32 USB recovery, working: mpftp usb-restart and the task behind it - #33
Merged
Merged
Conversation
…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.
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.
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.--statussays whether the recovery is really installed, and--listfinds the instance ids. It needs one elevated install first;docs/agent-guide.mdhas 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 reportedLastTaskResult 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
pnputilanswered 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 wherepnputilhas 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.ps1definesGet-PnpDevice,pnputil.exeand 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.