One protocol, two servers
The socket is the product
Connector's UI, its CLI, its daemon and its MCP bridge are four consumers of the same thing: a local Unix domain socket that accepts one JSON request per connection and returns one length-prefixed JSON response. There is no HTTP server, no authentication token, no bearer header. The security model is filesystem permissions and the assumption — stated plainly in the README — that the caller is the same human who started the app.
The protocol is deliberately small enough to reimplement in an afternoon, and it has in fact been implemented twice
in this repository: once in C++ (shared/src/connectorcontrolclient.cpp) and once in Python
(scripts/connector_control.py). Both are about a hundred lines.
Socket hardening
Server side
QLocalServer::UserAccessOptionbeforelisten().- If the path already exists, probe it: if something answers, refuse to start rather than steal the name.
- Only if the probe returns
ServerNotFoundErrororConnectionRefusedErroris the stale socket removed. - After a successful listen,
setPermissions(ReadOwner | WriteOwner).
Client side (Python)
- Socket
st_uidmust equalos.getuid(). - Parent directory
st_uidmust match too. mode & 0o077must be zero on both the socket and its directory.- Any failure raises before a single byte is sent.
CONNECTOR_ALLOW_INSECURE_SOCKET=1 skips the client-side checks entirely. It exists for container and
CI scenarios where /tmp semantics differ. It is opt-in, and named so that nobody sets it by accident.
The method surface
Both servers dispatch on a flat method string. The daemon implements a strict subset and answers the rest with a single, informative refusal. The table below is the complete difference.
| Namespace | Methods | ConnectorApp | connectord |
|---|---|---|---|
app.* |
ping, state |
yes | yes — reports mode: "daemon" |
devices.* |
list, get, select, add, remove, rename, connect, pair, load_apps, test_connection, start_host, set_ssh_profile, set_desktop_scale |
all + toggle_favorite |
all |
session.* |
state, disconnect, toggle_stats, toggle_fullscreen, toggle_mouse, release_mouse, switch_display |
yes | yes — proxied to a launched connectorstream |
ssh.* |
prepare, exec |
yes | yes |
settings.* |
get, set |
yes | yes |
actions.* |
global, device |
live, derived from UI state | static list |
host.state |
read-only local host snapshot | yes | yes |
host.set_* |
set_enabled, set_credentials, approve_pair, revoke_client, set_default_monitor |
yes | refused |
ui.* |
tree, state, activate, set_value, screenshot, navigate, search, set_available_only, set_selected_app |
yes | refused |
"<method> requires the Connector desktop app or streaming session process. The headless daemon only
exposes device discovery, settings, and SSH control right now." — ConnectorDaemonServer::desktopRequired().
The phrase "right now" is doing honest work: this is a scope boundary, not an architectural one.
connectorctl
The CLI is a thin, explicit mapping from verbs to methods. Its usage block is the closest thing the project has to a
protocol specification, and every flag below is parsed by hand in main() — there is no
QCommandLineParser here.
16Usage: 17 connectorctl state 18 connectorctl rpc <method> [json-params] 19 connectorctl actions global 20 connectorctl actions device <device-id> 21 connectorctl devices list 22 connectorctl devices get <device-id> 23 connectorctl devices select <device-id> 24 connectorctl devices connect <device-id> [app-name] 25 connectorctl devices pair <device-id> 26 connectorctl devices rename <device-id> <name> 27 connectorctl devices ssh-profile <device-id> [--mode auto|ssh|tailscale] [--user <user>] 28 connectorctl host state 29 connectorctl host set-enabled <true|false> 30 connectorctl host set-credentials <username> <password> 31 connectorctl ui tree [--include-hidden] [--include-internal] 32 connectorctl ui screenshot [--page <page>] [--control <id>] [--out <path>] [--inline] 33 connectorctl ui activate <id> 34 connectorctl ui set <id> <value> 35 connectorctl ssh exec <device-id> [--user <user>] [--mode auto|ssh|tailscale] -- <command...>
ui set coerces its value with
parseCliValue(): the literals true, false and null are recognised
case-insensitively, then integer parsing, then double, then plain string — so ui set foo 1080 sends a
number, not "1080". And ui screenshot sets include_base64 automatically when
no --out was given, then strips the base64 back out of the printed result when it did write a file.$ build/connector-app/connectorctl rpc ssh.prepare \ '{"device_id":"nZ55Q5PqHN11CNTRL","command":["systemctl","--user","is-active","sunshine"],"mode":"tailscale"}' { "args": [ "ssh", "[email protected]", "--", "sh", "-lc", "systemctl --user is-active sunshine" ], "program": "/Applications/Tailscale.app/Contents/MacOS/Tailscale", "timeout_ms": 10000 } $ build/connector-app/connectorctl ssh exec nZ55Q5PqHN11CNTRL --mode auto -- whoami { "args": [ "-o", "BatchMode=yes", "-o", "NumberOfPasswordPrompts=0", "-o", "KbdInteractiveAuthentication=no", "-o", "PasswordAuthentication=no", "-o", "PreferredAuthentications=publickey", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=accept-new", "-o", "UseKeychain=no", "-o", "UserKnownHostsFile=/Users/ian/Library/Application Support/Connector/known_hosts", "[email protected]", "whoami" ], "exit_code": 0, "finished": true, "program": "/usr/bin/ssh", "started": true, "stderr": "", "stdout": "ian\n", "timed_out": false }
ssh.prepare exists so a caller can see the exact command before authorising it. Both methods return the
same program and args pair; only one of them runs it. That separation is what makes the MCP
tool connector_prepare_ssh_command safe to expose to an agent that is still deciding.
Daemon lifecycle
Nothing calls connectord directly. Everything goes through ensure-connectord.py, which is
considerably more careful than a pgrep would be about deciding whether a daemon is already there.
app.ping on the socket with a 1.5 s timeout. A live answer ends the story.process_looks_like_connectord(pid) reads /proc/<pid>/cmdline and compares the resolved binary path — not just the process name.process_owns_socket(pid, path) matches the socket inode against the process's open file descriptors.CONNECTOR_DAEMON_SYSTEMD_SERVICE is set, restart through systemctl --user instead of spawning a stray process.Flags
| Flag | Default | Meaning |
|---|---|---|
--socket | empty | Daemon control socket path; falls back to CONNECTOR_DAEMON_CONTROL_SOCKET |
--timeout | 10.0 | Seconds to wait for startup |
--print-socket | off | Print the socket path once it is ready |
Deployment units
Three files turn the daemon into a permanently available control node. They are plain systemd user units — no installer, no root.
1[Unit] 2Description=Connector headless control daemon 3After=default.target tailscaled.service 4 5[Service] 6Type=simple 7WorkingDirectory=%h/Desktop/realconnector 8ExecStartPre=/bin/mkdir -p %t/connector 9ExecStartPre=/bin/chmod 700 %t/connector 10Environment=CONNECTOR_SECRET_STORE_MODE=settings 11Environment=CONNECTOR_DAEMON_CONTROL_SOCKET=%t/connector/daemon.sock 12Environment=CONNECTOR_CONTROL_SOCKET=%t/connector/daemon.sock 13Environment=CONNECTOR_DAEMON_PID_FILE=%t/connector/daemon.pid 14ExecStart=%h/Desktop/realconnector/scripts/run-connectord-daemon.sh 15Restart=on-failure 16RestartSec=3
%t expands to $XDG_RUNTIME_DIR, which is why the two
ExecStartPre lines can guarantee the 0700 directory the client-side permission check will later demand.- connectord.service — the daemon itself
- connector-mcp-http.service — Requires= the daemon, serves MCP over 127.0.0.1:8876/mcp
- ian-brain-connector-order.conf — a drop-in for a consuming service, gating its start on
wait-for-port.py 127.0.0.1 8876 30
UI automation over the same socket
When the desktop app is the one listening, the socket also exposes the live QML object tree. Nodes are filtered by
shouldExposeUiObject(), which honours --include-hidden and --include-internal,
and identified by the automationId property that QML components set on themselves.
$ build/connector-app/connectorctl ui activate nav-this-computer { "activated": true, "id": "nav-this-computer" } $ build/connector-app/connectorctl ui screenshot --page page-this-computer --out /tmp/shot.png { "height": 1880, "path": "/tmp/shot.png", "width": 3000 } $ build/connector-app/connectorctl ui set settings-absolute-mouse false { "id": "settings-absolute-mouse", "value": false } $ scripts/connectorctl-daemon.sh ui activate nav-this-computer ui.activate requires the Connector desktop app or streaming session process. The headless daemon only exposes device discovery, settings, and SSH control right now.
This is what the connector_automation ctest suite exercises: it launches Connector offscreen, waits for
the socket, validates state and action discovery, exports the tree, captures screenshots, checks
connectorctl, and smoke-tests the MCP wrapper's import path — all through this one interface.
Every control the human can press has a stable string id, and every one of those ids is reachable from a shell. The GUI is not a special case; it is one more client of the socket.