GitHub Docs

Device Pairing

Pairing is the flow that bootstraps a mobile, watch, IDE plugin, or second-shell client onto a VibeCody daemon. Every cross-client deployment starts here.

This page covers the bearer-token pairing flow surfaced by the daemon’s /pair endpoint and the watch-specific P-256 ECDSA flow used by Apple Watch and Wear OS companions.


Two pairing surfaces

VibeCody uses two distinct pairing models depending on the client:

Surface Used by Crypto Endpoint
Bearer token VibeMobile, IDE plugins, second-shell clients 128-bit token from OS CSPRNG /pair
P-256 ECDSA VibeCodyWatch (watchOS), VibeCodyWear (Wear OS) secp256r1 device-key challenge/response /mobile/pairing/* and /watch/*

The two are independent — a watch goes through ECDSA pairing because Apple’s Secure Enclave only supports P-256, while a phone or IDE plugin goes through bearer-token pairing because it’s faster and doesn’t need on-device key generation.


Bearer-token flow (mobile / IDE / second shell)

  1. Daemon advertises the URL. Hit GET /pair and the daemon returns:

     {
       "url": "http://192.168.1.42:7878/pair?token=<32-hex-chars>",
       "token": "<32-hex-chars>",
       "instructions": "Open this URL in your device's browser to pair with this VibeCLI instance."
     }
    

    The host advertised in the URL is taken from your Host: header. Hit the daemon at http://192.168.1.42:7878/pair from your phone and you get a LAN-reachable URL back, not a useless http://localhost:... that only works on the daemon machine.

  2. Client sends the token in the Authorization: Bearer <token> header on every subsequent request.

  3. Daemon validates by comparing against the configured token list. The vibe-collab and IDE plugin clients use exactly this header.

Token security

  • 128 bits of entropy sourced from the OS CSPRNG (rand::rng()ThreadRng, internally seeded from getrandom(2)).
  • Hex-encoded as 32 lowercase ASCII characters.
  • Bearer credential — anyone in possession of the token can connect. Tokens are NOT bound to a device id; if you need device-bound auth, use the watch P-256 flow.

The previous implementation used RandomState (HashMap DoS resistance), which is not cryptographically secure — the seed was roughly hash(unix_nanos) ^ hash(pid), predictable to anyone who could probe the pairing endpoint with timing. This was fixed. Tokens generated by current builds are statistically indistinguishable from random; see the regression test pairing::tests::back_to_back_tokens_diverge_quickly.


P-256 ECDSA flow (watch)

Watch pairing uses a four-step flow that proves the watch holds a private key the daemon can verify:

  1. Watch fetches GET /watch/pairing-info — daemon returns a nonce and machine_id.
  2. Watch displays a QR code containing the nonce, the daemon’s URL, and the pairing endpoint.
  3. Phone companion (or watch directly, on Wear OS) scans the QR, generates a fresh P-256 keypair in the Secure Enclave / Hardware-Backed KeyStore, and signs the nonce with the private key. The public key + signature are POSTed to /watch/devices.
  4. Daemon verifies the signature with the public key, registers the device, and responds with a device_id. From here on, every watch request is signed (in headers) and verified.

Why P-256, not Ed25519

Apple’s Secure Enclave only supports P-256 ECDSA (secp256r1). Ed25519 keys cannot live in the Enclave. Since the entire security story for watch pairing rests on the private key never leaving the secure hardware, P-256 is the only option. Wear OS (Hardware-Backed KeyStore) supports both algorithms but pins to P-256 for cross-platform parity.

Never reintroduce Ed25519 for device keys. The codebase has comments and review checklists guarding this; it’s a recurring trap because Ed25519 is faster and more popular in modern systems generally.


URL-only / Bearer-only / QR — three entry points

The pairing flow supports three entry mechanisms; clients pick whichever works for their environment:

Entry When to use
URL-only The user types the URL directly into the client. Works everywhere — emulators with no camera, desktop browsers, web demos. The PRIMARY path.
URL + Bearer pre-fill A trusted environment (e.g. a Tauri desktop app on the same machine) reads the daemon’s stored token directly and sends it to the client without manual entry.
QR code Convenience for phone users who don’t want to type a 30+ char URL. Falls back to URL-only if the camera is unavailable.

QR is never the only path. Emulators (Android Studio, iOS Simulator) have no camera, so a QR-only flow would be a P0 regression. Every shipping client must support URL entry.


/health declaration

features.pairing:

{
  "available": true,
  "transport": "daemon-http",
  "endpoint": "/pair",
  "token_bits": 128,
  "rng": "os-csprng"
}

The rng field is the audit trail — it should always be os-csprng. If it ever reads hash-state or anything else, treat that as a security regression and refuse to pair.


Observability

Pairing operations emit structured tracing events under vibecody::pairing:

RUST_LOG=vibecody::pairing=info vibecli serve

Events:

INFO vibecody::pairing: pairing.url.generated
  host=192.168.1.42 port=7878 token_len=32

Tokens are NEVER logged — only their length. That’s a hard rule: log statements must reject any field name containing the literal substring token if it carries a value other than length.


Watch-specific endpoints

For the watch ECDSA flow, the daemon exposes:

Route Purpose
GET /watch/pairing-info Returns the current nonce + machine_id for the QR payload
POST /watch/devices Register a new device (public key + signed nonce)
GET /watch/devices List all registered watches (revoked + active)
POST /watch/devices/:id/revoke Revoke a device — its signature is no longer accepted
GET /watch/devices/:id/verify One-shot signature verification (used by middleware)

The Tauri-side WatchManagementPanel is a thin wrapper around these — it lists registered watches, surfaces revoked devices in a separate section, and offers a Revoke button per row. Revocation is server-side; the device itself doesn’t know it was revoked until its next request fails.


Mobile-specific endpoints

Mobile pairing has a slightly richer flow with a 6-digit PIN displayed on the daemon side:

Route Purpose
POST /mobile/pairing Mobile creates a pairing intent — daemon returns an id + PIN
POST /mobile/pairing/:id/verify Mobile sends the PIN; daemon issues a bearer token
POST /mobile/pairing/:id/accept Daemon-side accept (e.g. user clicks “Allow” in VibeUI)
POST /mobile/pairing/:id/reject Daemon-side reject

The PIN is a usability layer — it doesn’t add security beyond the bearer token, since the token is what’s actually checked on every request. It exists so the user explicitly types digits to confirm “yes this is my device” rather than silently accepting any inbound pairing request.


Cross-client matrix

Client Pairing flow Crypto
VibeCLI / VibeUI / VibeApp n/a — runs on the daemon machine n/a
VibeMobile /mobile/pairing/* with 6-digit PIN bearer token
VibeCodyWatch / VibeCodyWear /watch/devices + Secure Enclave / HW KeyStore P-256 ECDSA
VS Code / JetBrains / Neovim /pair with bearer token bearer token
Agent SDK /pair with bearer token bearer token
vibe-collab /pair with bearer token bearer token

Whatever path a client uses, the daemon enforces the auth on every subsequent request via the Authorization header (or the watch signature header). Mobile / IDE clients store the bearer token in their platform-secure store (Keychain on iOS, Keystore on Android, OS credential locker on desktop).


Troubleshooting

“Cannot connect — pairing URL is http://localhost:...

You hit the /pair endpoint from the daemon machine itself. The advertised URL is built from the Host: header — when you hit it from localhost, you get localhost back, which doesn’t reach external devices. Hit the daemon over the LAN address (e.g. http://192.168.1.42:7878/pair) from another device or use the daemon’s startup banner URL.

“Token validation failed”

The token in the URL doesn’t match what the daemon has stored. Either the daemon was restarted (tokens are regenerated on every boot today — persisted tokens are a future feature) or the URL was scraped between generation and use. Hit /pair again to rotate.

Watch pairing fails with “signature verification failed”

The watch generated a key in the Secure Enclave but the signature doesn’t validate. Most often this is a clock skew — the nonce embeds a timestamp, and the verifier checks it’s within ±60 seconds. Fix the watch clock (which usually means fixing the paired phone’s clock since watches sync from there).

“kernel.unprivileged_userns_clone: 0” on Linux during pairing

Unrelated — that’s a sandbox issue, not a pairing one. See docs/sandbox.

QR code scans but the URL won’t open in the browser

Either the URL contains localhost (see above) or your phone is on a different network than the daemon. Check the daemon’s startup banner — it logs the actual reachable URL.


  • Source:
    • vibecli/vibecli-cli/src/pairing.rs — bearer-token generation
    • vibecli/vibecli-cli/src/serve.rs/pair, /mobile/pairing/*, /watch/devices/*
    • vibeui/src/components/RemoteControlPanel.tsx — the host-side server UI
    • vibeui/src/components/WatchManagementPanel.tsx — registered watches + revocation
  • Connectivity: docs/connectivity — mDNS / Tailscale / ngrok / phone-relay races
  • Watch integration: docs/watch-integration — companion-app architecture