Files
qwen-code/docs/design/web-shell-device-registry.md
易良 699104d448 feat(serve): make paired Web Shell devices listable and revocable (#15000)
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.
2026-10-21 20:53:38 +08:00

7.3 KiB

Web Shell device registry

English | 简体中文

  • 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:

  • #webShell is a Set<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

  1. Make each paired device addressable by a stable id.
  2. Let the operator list paired devices without exposing their credentials.
  3. 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. persistDaemonToken and 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: 404 with { 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-shaped deviceId; 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 returns 404; 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 forgetRemoteConnection to revoke the device it stored, so deleting a saved connection also deletes its credential.
  • Consider expiry if a renewal flow is introduced.