Pairing minted a credential with no way to name or withdraw it: the store held an unindexed set of digests, the exchange response carried only the token, and the sole reset was a daemon restart. Key each device credential by an id the exchange now returns, list paired devices without credential material, and revoke one device behind the same bearer authorization as pairing issuance. This is the daemon half of "deleting a connection deletes its token" in #14947; the desktop catalog and the client-side wiring are separate decisions tracked there.
7.3 KiB
Web Shell device registry
- Status: implemented in this change
- Date: 2026-10-21
- Issue: #14947
- Reference commit:
a8616497861fd779a0a90d3d2986b5327211f03d
Problem
A daemon that serves the Web Shell on a LAN mints device credentials through
POST /web-shell/pairing + POST /web-shell/pairing/exchange. Those
credentials are accepted by CredentialStore.verify() on the primary listener,
so a paired browser or desktop client holds full daemon API authority.
Today that authority cannot be withdrawn per device:
#webShellis aSet<Buffer>of unsalted SHA-256 digests. There is no id, so a caller cannot name the device it wants to revoke.- The only management responses are the 128-device cap (
409 Paired device limit reached. Restart the daemon to reset pairing.) and a daemon restart, which clears every device at once. - The exchange response is
{ token }, so a client cannot record which device identity its credential belongs to.
The user-visible consequence is in #14947: deleting a saved connection cannot delete the credential that connection was using, and a lost or shared client cannot be un-paired without disconnecting everyone.
Current state
| piece | location |
|---|---|
| credential store | packages/cli/src/serve/local-control/credentials.ts |
| pairing routes | packages/cli/src/serve/routes/web-shell-pairing.ts |
| mount point | packages/cli/src/serve/server.ts — registerWebShellPairingRoutes runs before the global bearer middleware |
| authority model | verify() accepts the runtime token or any device token on the primary listener; device tokens are rejected on the local-control listener |
Goals
- Make each paired device addressable by a stable id.
- Let the operator list paired devices without exposing their credentials.
- Let the operator revoke one device, with the revocation immediately visible
to
verify().
Non-goals
- Changing which listener accepts a device credential.
- Adding expiry. A remembered credential is expected to keep working until it is revoked; expiry would need a renewal story that does not exist yet.
- Surviving a daemon restart. Persistence needs an answer to "what makes two daemon processes the same daemon" — a port-keyed registry would silently lose devices on an ephemeral port, and a machine-wide instance id would make two daemons accept each other's credentials. That remains an open question in #14947.
- Changing the client.
persistDaemonTokenand the connect flow are untouched.
Design
Data model
CredentialStore replaces #webShell: Set<Buffer> with
Map<string, { digest: Buffer; createdAt: number }> keyed by a device id.
The id is generated by the route with randomUUID() when the exchange succeeds;
the store never invents ids, so a caller cannot mint a credential it cannot
name.
MAX_WEB_SHELL_DEVICES stays 128 and keeps its current meaning.
verify() still compares every in-scope digest with timingSafeEqual even
after a match, so the timing of a successful check does not reveal which entry
matched.
HTTP surface
| route | authorization | behavior |
|---|---|---|
POST /web-shell/pairing |
bearer (existing) | unchanged |
POST /web-shell/pairing/exchange |
pairing secret (existing) | now returns { token, deviceId } |
GET /web-shell/devices |
bearer | { devices: [{ id, createdAt }] }, Cache-Control: no-store |
DELETE /web-shell/devices/:id |
bearer | { revoked: true }, or 404 { error: 'Unknown device' } |
The two new routes are registered by registerWebShellPairingRoutes, next to the
pairing routes they complete. That module mounts before the global bearer
middleware, so each route states its own bearerAuth(credentials) — the same
pattern as issuance. Cache-Control: no-store matches the existing pairing
responses.
Authority is unchanged rather than extended: a device token is already accepted
on POST /web-shell/pairing, so a paired client already holds operator-level
authority over pairing. Introducing an operator-only tier would be a separate
authorization change, not part of making devices addressable.
Errors
- Unknown device id:
404with{ error: 'Unknown device' }— a revoked device and a never-issued one are indistinguishable on purpose. - Cap reached: unchanged
409. Now that devices are revocable, the message's "restart the daemon" advice is the fallback rather than the only option; the text is left as-is to avoid changing an existing error contract.
Wire compatibility
deviceId is an additive field on the exchange response. Clients that only read
token are unaffected; packages/web-shell/client/config/pairing.ts ignores
unknown fields.
Constraints
packages/cli/src/serve/**is core infrastructure; this change stays inside it and adds no new dependency.- Device credentials remain memory-only, so the daemon-restart behavior in #14947 is unchanged by this design.
Risks
- Operator authority over devices. Because device tokens authenticate these
routes, a paired client can list and revoke other devices. This matches the
existing model (a paired client can already mint new invitations). Tightening
it needs a credential-kind signal in
bearerAuth, which is a wider change. - Id leakage. Device ids are not secrets, but they are stable identifiers returned to any authenticated client. They reveal nothing about the token.
Validation plan
packages/cli/src/serve/routes/web-shell-pairing.test.ts: exchange returns a UUID-shapeddeviceId; the listing contains the id and not the token; revoking one device stops its token from authenticating while the other device keeps working; a second revoke returns404; both routes reject unauthenticated requests.- Existing pairing, auth, and capabilities suites must stay green.
Acceptance criteria
- A successful exchange names its device.
- The operator can list paired devices without any token material.
- Revoking a device makes its credential fail verification immediately.
- The 128-device cap and listener scoping are unchanged.
Follow-ups
- Persist the registry across restarts once the daemon-identity question is answered (see #14947).
- Wire
forgetRemoteConnectionto revoke the device it stored, so deleting a saved connection also deletes its credential. - Consider expiry if a renewal flow is introduced.