Discovery has exactly one source
The tailnet is the device list
Connector does not run mDNS discovery, does not maintain a server registry, and does not ask you to
type IP addresses. It runs one subprocess — tailscale status --json — and treats the resulting
Self and Peer objects as the complete population of machines it is allowed to think about.
If the Tailscale CLI cannot be found at all, refreshDevices() short-circuits: it clears local tailnet
state, empties the device model, and sets the activity line to "Tailscale CLI not found. Device discovery is
unavailable." There is no fallback discovery path. That is a design choice, not an omission — the tailnet is
also the security boundary.
4124void ConnectorBackend::refreshDevices() { 4125 ++m_deviceRefreshGeneration; 4126 m_deviceConnectivityRetryCounts.clear(); 4127 if (m_tailscalePath.isEmpty()) { 4128 resetLocalTailscaleState(); 4129 m_deviceModel.setDevices({}); 4130 refreshThisDevice(); 4131 setActivityMessage("Tailscale CLI not found. Device discovery is unavailable."); 4132 emit commandPathsChanged(); 4133 return; 4134 } /* ... kill any in-flight refresh ... */ 4142 m_refreshProcess->setProgram(m_tailscalePath); 4143 m_refreshProcess->setArguments({"status", "--json"}); 4144 m_refreshProcess->start(); 4145 setActivityMessage("Refreshing devices..."); 4146}
m_deviceRefreshGeneration counter is the concurrency guard. Every async
probe launched during a refresh captures the generation it belongs to and silently drops its result if the
generation has moved on. That is how the model avoids applying stale probe answers from a previous tailnet snapshot.What the CLI lookup accepts
CommandPaths::tailscale() returns the first candidate that exists and is executable, checking absolute
paths directly and everything else through QStandardPaths::findExecutable.
- 1 /Applications/Tailscale.app/Contents/MacOS/Tailscale macOS only
- 2 tailscale on PATH
- 3 Tailscale on PATH
The same helper resolves the Sunshine binary and config path, which is where the macOS
bundle's embedded host runtime is found:
Helpers/ConnectorHost.app/Contents/MacOS/Sunshine inside the app bundle, falling back to
~/.local/bin/sunshine and /usr/local/bin/sunshine on Linux.
Tailnet topology as Connector sees it
Every node in the tailnet becomes a DeviceRecord, but they are not equal. The Self node is
marked isSelf and is deliberately excluded from streaming and pairing — "Connector does not prepare
remote trusted access or stream into the current machine in the locked v1 scope." Peers get a
route of "Tailscale"; manually added endpoints carry manual = true and render
as MANUAL in the rail.
"Do not treat Tailscale reachability as proof that remote desktop streaming is ready. SSH readiness and Connector streaming readiness are separate."
From JSON to device record to lamp
The pipeline below is the whole of device state. Twenty-seven roles come out of DeviceModel::roleNames();
the QML rail reads about a dozen of them, and the RPC surface serialises all of them verbatim through
QJsonObject::fromVariantMap.
clientTrusted is required for the green lamp but not for the
CONNECT button. That is deliberate — pressing CONNECT on an untrusted-but-reachable host starts pairing first and
queues the connection behind it, which is the sequence documented on the next page.
Reading the rail from a shell
The device model is not a private structure. Every field is serialised straight onto the control socket, so the CLI view and the QML view are literally the same data.
$ build/connector-app/connectorctl devices get nZ55Q5PqHN11CNTRL { "advancedReady": true, "apps": [ { "hdr": false, "id": 1, "name": "Desktop" } ], "clientTrusted": true, "defaultApp": "Desktop", "deviceId": "nZ55Q5PqHN11CNTRL", "dnsName": "linux-host", "favorite": false, "hostCheckPending": false, "hostReachable": true, "ip": "100.64.0.11", "isSelf": false, "manual": false, "name": "linux-host", "online": true, "os": "linux", "route": "Tailscale", "sshAvailable": true, "sshMode": "tailscale", "sshModeDefault": "tailscale", "sshModeExplicit": true, "sshReady": true, "sshTarget": "100.64.0.11", "sshUser": "ian", "state": "Online", "statusText": "Ready to connect" }
Field names above are exactly the twenty-seven role names declared in
DeviceModel::roleNames(); the id role is exposed as deviceId, which is why RPC parameters use
device_id while the record itself carries deviceId.
SSH profile resolution
Each device carries a shell profile with three modes. auto is not a runtime probe — it resolves to the
device's explicit mode if one was set, otherwise to plain ssh.
| Mode | Program | Argument shape |
|---|---|---|
tailscale |
Tailscale CLI | ssh <user@host> -- sh -lc <cmd> |
ssh |
system ssh |
hardened option set, then <user@host> <cmd> |
auto |
resolved | falls through to the device's explicit mode, else ssh |
Hardened option set
- BatchMode=yes
- NumberOfPasswordPrompts=0
- KbdInteractiveAuthentication=no
- PasswordAuthentication=no
- PreferredAuthentications=publickey
- ConnectTimeout=5
- StrictHostKeyChecking=accept-new
- UseKeychain=no macOS
- UserKnownHostsFile=<connector known_hosts>
Password authentication is disabled outright and prompts are capped at zero. If a key is not already installed, the probe fails fast rather than hanging a background thread on a TTY that does not exist.
Name resolution for humans and agents
Node ids like nZ55Q5PqHN11CNTRL are correct and unusable. The MCP layer therefore ships a scoring
resolver that accepts an id, a hostname, an IP, a DNS name, an SSH target, an SSH user, the leading label of a DNS
name, or a parenthesised alias inside a device name — and then breaks ties using live readiness.
| Condition | Score | Reason string |
|---|---|---|
| Exact device id (checked before scoring) | 1100 | exact device id |
| Raw or normalised equality | 1000 | exact match on … |
| Curated alias hit | 900 | alias match via … |
| Normalised prefix | 760 | prefix match on … |
| Normalised substring | 680 | contains match on … |
| All query words present | 560 | word match on … |
Bonus · sshReady | +25 | — |
Bonus · hostReachable | +15 | — |
Bonus · online | +10 | — |
Anything within twenty points of the winner counts as a close match. If more than one candidate lands in that band
and the caller did not pass allow_ambiguous, the resolver raises rather than guessing — and the exception
text lists up to five colliding devices by name and id. An agent that guesses which machine to reboot is worse than an
agent that asks.
DEVICE_ALIAS_HINTS maps short names like desktop2, miso and
zflow onto hostnames, tailnet IPs and node ids. The source comments it honestly:
"These are only resolver hints. Connector live device state still wins." A hint can only raise a candidate's
score against devices that actually exist in the current devices.list response — it can never
manufacture a device.