Remote access: a robot can belong to a Hugging Face account - #205
Open
pierre-rouanet wants to merge 5 commits into
Open
Remote access: a robot can belong to a Hugging Face account#205pierre-rouanet wants to merge 5 commits into
pierre-rouanet wants to merge 5 commits into
Conversation
`remote-webrtc.md` §7 has said since it was written that the remote path is a bridge to the signalling server already running on the robot, and that whether we adopt reachy-mini's Hugging Face Space and how a robot is bound to an account were out of scope "until local mode works". Local mode works. So this designs the two things that shape needs and does not have: a credential that names an account, and a service to present it to. The account is an OAuth **device** flow, not the redirect flow reachy-mini runs. That is not a preference — its flow points HF's callback at a URL on the robot, which costs a registered redirect URI per hostname, requires the authorising browser to resolve and reach the robot, and is why its mobile app carries a loopback HTTP bridge whose stated purpose is avoiding an HF-side config change. The device grant asks HF for a code, says "type M8HJ-FMGN at hf.co/oauth/device", and polls. A phone on cellular can do it. Established by probing the live services rather than by reading about them: HF advertises the device grant and the full round trip short of the user's click works today; the client must be public, so reachy-mini's confidential client id cannot be reused; the rendezvous Space is live and 401s without a token; and its wire is the gst envelopes over HTTP — SSE in, POST out — with per-hop ids, not the WebSocket protocol a LAN client speaks. That last one corrects §7 in this repository, which said the bridge "parses nothing" and is "a relay rather than a translator". The payload stays opaque; the envelope does not. Fixed there, with the two sides side by side in the new page. Four decisions are open and named with who can close them: the OAuth client id (one public app in the org, no secret — an admin's click), whether the token expires (one real authorization, then read the response — a click), which rendezvous, and where the remote client is served. The order of work puts the two slices that need no client first, because both are verifiable on their own. Assisted-by: Claude:claude-opus-5[1m]
pierre-rouanet
force-pushed
the
remote-access-design
branch
from
September 2, 2026 15:39
49d5e26 to
74661ba
Compare
Three calls — `account.login`, `account.status`, `account.logout` — reachable locally, over BLE and over a WebRTC datachannel. Nothing consumes the credential yet; that is the relay, and this is what it needs to exist. The flow is RFC 8628. `login` asks Hugging Face for a device code and answers with it; `updaterd` polls; a client comes back to `status`. That shape is the requirement rather than a convenience: a phone that opens a browser to show somebody the code backgrounds itself, and iOS then tears the GATT link down, so a login that reported success by holding a connection open would work from a laptop and fail from the device it is for. Hugging Face ships a first-party public device-code client, so this registers no OAuth app anywhere. Two things came out of running the flow against the real service, and both changed the design: A token lasts 30 days and its refresh token **rotates**. So the store is two strings plus an absolute expiry, `maintain` renews at a week left rather than on the last day, and the one window rotation leaves open — HF issued a new pair, the board lost power before the write — is named in `Store::save` and surfaces in `status`, because no write ordering can close it. The token also carries **every scope HF grants**: write-repos, manage-repos, jobs, read-billing. That client takes no `scope` parameter. A duck holding a credential that can push to its owner's repositories is worse than it needs to be for something whose job is proving an identity, and the fix is a public app in the org with `openid profile read-repos` — one constant here, one click there. Recorded as the thing to do before a duck ships rather than done, since the flow works today and a scope change is a re-login. `updaterd` owns it for `policy.*`'s reasons: the network stack, and a credential that also reaches a private Hub repo. `/etc/robot/hf-token`, root:robot, 0640 — not `robotd.toml`, where `configure --list`, the config editor and the "what changed on this robot" report would all print it. Written 0600 and relaxed after the group is set, rather than through `fsutil::write_atomic`, which does not set a mode: a token that lands 0644 and is chmodded a moment later is world-readable for that moment. Routing `account.login` to WebRTC is the decision worth reading. It is the only call on that transport whose effect outlives the session the way an account does — it converts having been on the wifi once into remote access that outlives being there. Permitted anyway, with the argument and its three mitigations in `mediad::route`: a robot already signed in refuses by name, the binding is readable by anybody without authorisation, and it is revocable from more places than the robot. That also means `mediad` needed `allow_users` in updater.toml, so two files now have to agree — hence `only_these_mutating_calls_are_reachable_over_webrtc`, the named list `btd` has had since BLE could apply an update. **Writing that list down found two methods nobody had noticed were broken.** `policy.install` and `policy.fetch` are routed to WebRTC and `mediad` was not in `allow_users`, so `updaterd` answered them PERMISSION_DENIED: the console could offer a Hub browser whose install button could not work. The line added here for the account fixes them too. Which is the argument for a named list over a counted one — it is where a transport's authority and a config file's grants are made to agree out loud. `API_VERSION` 21 → 22, additive. Assisted-by: Claude:claude-opus-5[1m]
An earlier draft of this page said the Space's repository was private and reverse-read its wire from the mini's client. That was a wrong-name 401 mistaken for a permissions error: the server is `pollen-robotics/reachy_mini_central`, we maintain it, and every number in §3 now comes off its `app.py`. What reading it changed: The lease is 30 s and keyed **only** on inbound `POST /send`, so a healthy SSE stream refreshes nothing. The welcome advertises a 10 s cadence and publishes no `lease_seconds`, which makes the middle rung of the mini relay's negotiation ladder unreachable here — not worth reproducing for a field nothing sends. The SSE side pings every 30 s of idle to keep the Space's proxy from dropping the connection, which is what a read timeout has to be sized against. The server gates concurrent sessions itself — `sessionRejected`, with the `activeApp` that holds it, plus a `sessionStateChanged` push to the owner's other devices. So a robot-side gate is belt-and-braces rather than a workaround, and it stays: two remote writers into one intent slot is §9's interleaving bug. `meta` is free-form to the protocol and not to the server, which is new: it reads `hardware_id` as a stable-identity key and evicts an older producer of the same user carrying the same value, ending its session. So a duck must put its SoC serial there — `producer.rs` already reads it — or a robot that reconnects with a fresh token is listed twice, and putting the *name* there would fork a robot's identity on rename. And one hazard that belongs to the flashing path: peers are keyed by **token**, so two robots sharing one take turns being reachable, with nothing that looks like a failure. Each duck runs its own device flow, so each has its own token — unless an image is cloned with `/etc/robot/hf-token` in it, which is what this project's image path does with everything else in `/etc/robot`. The console's SSE transport is settled by the same read: the query-string token form is deprecated server-side and going away, so the page is `fetch` plus SSE line-splitting rather than `EventSource`, first time rather than twice. Assisted-by: Claude:claude-opus-5[1m]
pierre-rouanet
force-pushed
the
remote-access-design
branch
from
September 2, 2026 15:42
74661ba to
267f44d
Compare
`account login` printed a code and a URL and left you to copy them. The reason was an invariant inherited from the mini's setup wizard — never open a browser by yourself — and applying it here was wrong: that rule is about a *phone*, where the browser replaces the only screen and backgrounds the app, so a code shown a moment earlier is gone before it is read. A terminal keeps it in the scrollback. So the rule is a property of the surface rather than a rule, and it gives three answers. `robotctl` opens nothing, because it runs on the robot and the robot has no display. `duckctl` opens, because it runs on your machine — where `duckctl open` already launches a browser, so this needs no new dependency and follows a pattern that exists. A phone app keeps the mini's rule for the mini's reason. It opens `verification_uri_complete`, which is worth the difference: Hugging Face sends no such field, so the robot synthesises the `?user_code=` form, and HF preserves that parameter across its own login redirect — so the code is filled in even in a browser that was not signed in yet. Nothing to type at all. Three things the ordering has to get right, and each is a line in the note: The code is printed **before** the browser opens, so a browser that will not launch leaves the instructions on screen instead of an error where they should have been. A failed open is a warning appended to the code, never a failure of the command — unlike `duckctl open`, where opening the browser *is* the command. `--no-open` skips it, and so does stderr not being a terminal: a script that opens a browser window on whoever runs it is a surprise rather than a convenience. Assisted-by: Claude:claude-opus-5[1m]
Coverage71.04% lines on this branch, against a floor of 72%. Per-file |
…han a test `duckctl account login` opened a URL with `?user_code=` appended, and both the design page and the field's own documentation said the code would arrive filled in. It does not. Hugging Face's device page ignores the parameter — it survives the login redirect and prefills nothing — which a browser showed within minutes of it shipping. The claim traces back to a line in `reachy_mini`'s setup notes saying `huggingface_hub` synthesises the `?user_code=` form. It does not: its `request_device_code` falls back to `verification_uri` unchanged, which is readable in four lines of the file this was otherwise copied from. What I checked instead was that the query parameter survived HF's login redirect, and that proves only that a redirect preserves a query string. So the fallback is now the plain URI, matching the hub. The field stays in the reply because it is RFC 8628's, so a server that starts sending a real one is used without a wire change — and the `urlencode` helper that existed only to build the invented URL goes with it. What `duckctl` opening the page buys is the navigation, not the typing. That is still worth having, and the ordering that matters is unchanged: the code is printed before the browser opens. Assisted-by: Claude:claude-opus-5[1m]
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.
Stacked on #191 (
policy-hub-design), so the base is that branch rather thanmain— GitHub retargets it tomainwhen 191 merges.A duck on a LAN needs no account. Reaching one from outside does, and this is that
half: a robot can belong to a Hugging Face account, signed in from a terminal, a
phone over Bluetooth, or the console page. Nothing consumes the credential yet —
the relay is next, and this is what it needed to exist.
remote-webrtc.md§7 has said since it was written that the remote path is a bridgeto the signalling server already on the robot, and that whether we adopt the
rendezvous Space and how a robot is bound to an account were out of scope "until
local mode works". Local mode works.
What a person can do now
The flow is RFC 8628, and the shape is the requirement
loginasks Hugging Face for a device code and answers with the code, not a token.updaterdpolls; a client comes back tostatus. That is not a convenience: a phonethat opens a browser to show somebody the code backgrounds itself, and iOS then tears
the GATT link down. A login that reported success by holding a connection open would
work from a laptop and fail from the device it is for.
Hugging Face ships a first-party public device-code client, which
reachy_mini'sown device flow uses, so this registers no OAuth app anywhere.
Three invariants inherited from the mini's setup wizard, none of them about Python:
lead with the code and never auto-open a browser (auto-switching to Safari hid the
code before users could read it); a transport drop mid-flow is expected, not an error;
and appearing on the rendezvous — not a stored token — is the only real success signal.
Two things running the flow against the real service changed
A token lasts 30 days and its refresh token rotates. So the store is two strings
plus an absolute expiry,
maintainrenews at a week left rather than on the last day,and the one window rotation leaves open — HF issued a new pair, the board lost power
before the write — is named in
Store::saveand surfaces instatus, because no writeordering can close it. A robot off for more than thirty days comes back needing a
login, which
token_expires_ingoing negative is how a client says.The token carries every scope HF grants:
write-repos,manage-repos,jobs,read-billing. That client takes noscopeparameter. A duck holding a credentialthat can push to its owner's repositories is worse than it needs to be for something
whose job is proving an identity — and a stolen board yields it. The fix is a public
device-code app in the org with
openid profile read-repos: one constant inaccount.rs, one click in HF. Not blocking — the flow works today and a scopechange is a re-login — but it should not ship without it. §2.4.
Where the credential lives
/etc/robot/hf-token,root:robot,0640.updaterdowns it forpolicy.*'sreasons — it is the daemon with a network stack,
robotctlmust not link one, and thesame credential is what reaches a private Hub repo.
mediadwill read it and must notown it: unprivileged,
ProtectHome=yes, and it is the process a remote peer talks to.Not in
robotd.toml, deliberately:configure --list, the config editor and the"what changed on this robot" report would all print it.
Written
0600and relaxed after the group is set, rather than throughfsutil::write_atomic, which sets no mode — a token that lands0644and is chmoddeda moment later is world-readable for that moment, which is invisible in testing and
permanent in whatever read it. A test asserts the landed file gives "others" nothing.
Routing it to WebRTC is the decision worth reading
account.loginis the first mutating call that transport carries, and the onlyone there whose effect outlives the session the way an account does: it converts having
been on the wifi once into remote access that outlives being there.
remote-webrtc.md§4 accepts that anyone on the network has the robot and its camera; it did not consider
anyone on the network having them from another continent next month.
Permitted anyway — the console is where somebody would sign a robot in, and the
alternative is ssh — with three properties that make it hold, argued in
mediad/src/route.rs:--forceto replace),so a LAN peer cannot silently take a robot from its owner;
account.statusnames the account from any transport with noauthorisation at all;
It also means
mediadneededallow_usersinupdater.toml, so two files now have toagree — hence
only_these_mutating_calls_are_reachable_over_webrtc, the named listbtdhas had since BLE could apply an update.
Writing that list down found two methods nobody had noticed were broken
policy.installandpolicy.fetchare routed to WebRTC on this branch, andmediadwasnot in
allow_users— soupdaterdanswered them PERMISSION_DENIED. The consolecould offer a Hub browser whose install button could not work. The
allow_userslineadded here for the account fixes them too, and they are now in the named list with a note
saying how they were found.
That is the argument for a named list over a counted one: it is where a transport's
authority and a config file's grants are made to agree out loud.
The rendezvous, and what reading its source settled
Decided:
pollen-robotics/reachy_mini_central, the Space the mini's fleet uses. Wemaintain it, so a duck-shaped need there is a pull request rather than a fork — and
the reverse is true too, which is worth knowing now that a second family of robots is
on it.
An earlier commit in this branch said its repository was private and reverse-read the
wire from the mini's client. That was a wrong-name 401 mistaken for a permissions
error. Reading
app.pycorrected five things:POST /send— a healthy SSE streamrefreshes nothing;
lease_seconds, so the middle rungof the mini relay's negotiation ladder is unreachable here;
a read timeout has to be sized against;
sessionRejected, with theactiveAppholding it), so a robot-side gate is belt-and-braces rather than aworkaround — and it stays, because two remote writers into one intent slot is §9's
interleaving bug;
metais free-form to the protocol and not to the server: it readshardware_idas a stable-identity key and evicts an older producer of the same user carrying the
same value. So a duck must put its SoC serial there —
producer.rsalready reads it— or a robot that reconnects with a fresh token is listed twice, and putting the
name there would fork a robot's identity on rename.
It also corrected
remote-webrtc.md§7, which said the bridge "parses nothing" and is"a relay rather than a translator". The payload stays opaque; the envelope does not —
the wire is HTTP (SSE in,
POSTout) with per-hop ids.One hazard for whoever owns the image path
Peers are keyed by token. Two robots sharing one take turns being reachable, and
neither looks broken. Each duck runs its own device flow, so each has its own token —
unless an image is cloned with
/etc/robot/hf-tokenin it, which is what thisproject's flashing path does with everything else in
/etc/robot. §3.7.Wire
API_VERSION21 → 22, additive — 21 went torobot.skillson the base branch whilethis was in flight, which the rebase settled.
account.login,account.status,account.logoutonupdaterd;loginandlogoutareis_mutating(so uid-authorised),statusis aread and stays ungated — which account a robot thinks it belongs to is the first thing
support asks.
Tested
Unit: the store's permissions and round trip, a corrupt credential reading as signed
out, the OAuth error classification (
slow_downtreated as failure would abandon alogin about to succeed), the device-code normalisation against exactly what
huggingface.co answered, and the refusals.
End to end over a real unix socket against a fake Hub that answers what HF answers:
the code arrives before anyone approves anything, a second client picks the flow
up (which is the property the whole shape exists for), the daemon polls through
authorization_pending, and what lands on disk is the pair rather than just the accesstoken. Plus both route tables' boundary tests.
The protocol itself was verified against the live Hugging Face endpoints.
Not run on a board. That is the gap.
What is next, and what is still open
The relay (§3): producer registration, the negotiated heartbeat, the split-brain poll
against
/api/robot-status. Verifiable with no client at all — the Space's dashboardcounts a producer.
Open: the scope narrowing above (§2.4, pre-ship), and where the remote console page is
served from (§5) — which is the decision that actually couples us to the service.