0204 — A self-hosted Mac pairs the App Store app directly
Status: accepted (VUH-1463, 2026-09-30)
Date: 2026-09-30
Context
The App Store build bakes one control plane, https://api.clankie.bot, and
refused any pairing link without the gateway's encrypted fragment. A
self-hosted Mac could therefore pair the phone only by turning on remote
access, which requires a Clankie account. Direct pairing existed only in source
builds (CLANKIE_HOST). ADR 0151 made the gateway the doorway; VUH-1452 then
let a gateway-paired device learn explicit direct endpoints (directRoute,
clankie gateway direct) and fall back to them.
Two constraints shape the answer:
- App Store ATS.
NSAllowsLocalNetworkingpermits plain HTTP to.local, single-label names and IP literals. Anything else needs HTTPS, and the store build carries noNSAllowsArbitraryLoadsand no exception domains.ts.netis on the Public Suffix List, so no store-safe exception covers a tailnet name over HTTP. - The service binds loopback only. A phone on the LAN cannot reach
127.0.0.1:4310, and exposing the whole service would widen what any LAN peer can call.
Decision
The route follows where Clankie's body lives, not whether the user pays. A self-hosted Mac pairs directly, with no account. Remote access through the gateway is optional for self-hosters; whether it is free is still open. Managed users, including BYOK and bring-your-own-subscription, always go through the gateway because their body runs in our cloud.
-
One link carries every route.
clankie pairmints one single-use offer. When the Mac has a direct route (relay.controlPlaneUrlandrelay.url, fromclankie gateway direct), the link gains&direct=<control origin>. When remote access is on, the gateway's encrypted fragment stays last, as before. The wire reportsgateway: trueanddirect, andclankie pairprints which routes the QR carries. It warns when the App Store app cannot reach a direct origin. -
The gateway stays the default. The app redeems through the gateway first. It uses the direct origin only when the gateway cannot answer (network failure or 5xx), or when the link carries no gateway. A pairing that completes directly is a direct session with no gateway credential. It stores the host's
directRouteand uses its relay, never the gateway relay the host also publishes. App Review, which reaches the Mac only through the gateway, sees no change. -
clankie pairkeeps working when the doorway cannot carry an offer. If the doorway is signed out or unreachable and a direct route exists, the service mints a direct-only offer instead of refusing withpublic_gateway_unavailable. Review offers (ADR 0154) never carry a private address and still refuse, because App Review cannot use one. -
Transport is decided by one shared rule.
directOriginTransportin@clankie/protocol(node-free) classifies an origin:https: any name.local: plain HTTP to.local, a single-label name, or a private, link-local or loopback IP literal.blocked: everything else, including tailnet names and Tailscale's100.64/10IPs over HTTP.
The server uses it to warn; the app uses it to explain an ATS refusal ("Clankie needs a secure address") instead of calling the Mac unreachable. Tailscale users serve HTTPS with
tailscale serve --https. -
An opt-in device doorway, not a wider bind.
CLANKIE_DEVICE_HOST(andCLANKIE_DEVICE_PORT, default 4311) opens a second listener. It serves only the device routes the public gateway already carries to the Mac:/v1/pairing/*,/v1/devices/*,/v1/model-keys*and/v1/accounts*. Everything else, including operator, webhook, gateway-envelope and hosted routes and/health, answers 404. A LAN peer can call nothing the internet cannot already call through the gateway. The relay keeps its ownCLANKIE_RELAY_HOST. -
The store build declares its local network use.
app.jsonsetsNSLocalNetworkUsageDescriptionto Clankie's own sentence, which expo-dev-launcher leaves in Release.ios-appstore.shfails an archive that allows arbitrary loads, lacksNSAllowsLocalNetworking, or lacks that string.
LAN: CLANKIE_DEVICE_HOST=0.0.0.0 CLANKIE_RELAY_HOST=0.0.0.0 clankie restart captain (which restarts the relay too), then clankie gateway direct --control-plane-url http://<mac>.local:4311 --relay-url http://<mac>.local:4321. Tailscale:
tailscale serve --https=<port> in front of both, with the HTTPS origins as the
direct route.
Consequences
-
A self-hoster pairs the App Store app over the LAN or tailnet with no account. The offer secret stays the single-use capability. Device bearer authorization, expiry, revocation and grant checks are unchanged.
-
The Mac cannot know which of its addresses the phone can reach, so a direct route is still configured explicitly, never inferred (as in VUH-1452). With none configured and remote access off, the QR pairs only a source build, and
clankie pairsays so. -
The doorway binds are environment settings. The launcher passes the caller's environment to services, but nothing persists them yet. Durable settings belong to the remote-access redesign (VUH-1464).
-
A directly paired device has no gateway credential. It gets no gateway push (ADR 0159), sends no gateway diagnostics, and cannot fall back to the gateway when it leaves the LAN. The reverse of VUH-1452's fallback is future work.
-
A scanned direct link is trusted the way a gateway link already is: the person scanning chooses the host. The access review names the host before any grant.
-
Amended 2026-10-06: a native client on the same Mac (the macOS app's On this Mac code) pairs, refreshes and restores over loopback, so the service answers it with its own loopback control and relay addresses instead of the configured route. That route is for other devices and is often plain HTTP, which macOS App Transport Security refuses. The listener's socket decides this, never a header; the gateway connector marks what it forwards so gateway traffic keeps the configured route.
-
Amended 2026-10-11 (VUH-2140): the native Mac app obtains its first connection automatically through the same user's private companion issuer, without a code or
clankie pair --local-companionpreparation. A 0600 Unix socket in the owner's 0700 companion directory admitsPOST /v1/pairing/local/handoff; it uses the service's configured owner identity to mint and atomically write a normal single-use offer. The app validates private discovery and its kernel-reported peer UID and sends no operator bearer. The private owner provenance is captured before request-body middleware clones the HTTP request. TCP, LAN and gateway listeners expose no such route or provenance. Same-user processes share this account boundary. Lost or invalid private-local sessions can request fresh authorization once; revocation asks once to reconnect and never revives the revoked identity. Other-device pairing and remote saved sessions keep their existing flow. The Mac consumer decision is amended in clankie-app ADR 0085. -
Amended 2026-10-21 (VUH-2604): the handoff goes only to an app the owner trusts, not to every same-user process. Two isolated proof builds took the owner's "Clankie on this Mac" session on launch, because the same-UID file, socket and loopback checks bound the handoff to the account, not the app. The service now identifies the process on the client end of a redeem's loopback connection (
lsof). It mints a session only when that process's running code satisfies one of the owner's trusted code requirements (codesign --verify -R).- Recording trust:
clankie companion trust APP_PATHrecords an app's designated requirement. A Developer ID release is bound to its team and bundle id and keeps working across updates. An ad-hoc or development build can only be bound to its exact code hash, so each new build is trusted again explicitly. An ad-hoc bundle id alone proves nothing. - Default: the list starts empty, and an empty list refuses every handoff. No team is hardcoded, because no signed release exists yet.
- Refusals: a refused redeem answers
403 companion_app_untrusted, consumes nothing, and recordspairing.local_companion.refusedwith the app's pid, identifier, team, code hash and path. - Audit: an accepted redeem records the same identity, and the trusted
entry's id, on
device.activatedordevice.session.refreshed. - Scope: trusted apps are machine settings. They never move with a body transfer (ADR 0286).
- What it doesn't stop: code that already runs as the owner can still read the owner's files and settings. This binds the automatic handoff to the app; it does not make the account boundary stronger.
- Recording trust:
