04 CONNECTORD // Local control plane & CLI SRC shared/src/ · connectorcontrolserver.cpp
04.A

One protocol, two servers

connectorcontrolprotocol.cpp

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.

Control socket wire protocol A wire protocol diagram showing the request line format, the two-part response framing with a decimal length header followed by a compact JSON body, the success and error envelope shapes, and the connection lifecycle including the server-side disconnect after a single response. CLIENT connectorctl · python · MCP SERVER QLocalServer in app or daemon 1 · connect(AF_UNIX, SOCK_STREAM) to the resolved socket path client-side precondition — the Python client stats the socket and its directory first 2 · REQUEST — compact JSON, exactly one line, terminated by \n {"id":"7431996281550983743","method":"devices.connect", "params":{"device_id":"nZ55Q5PqHN11CNTRL","app":"Desktop"}}\n id is a 64-bit random value stringified; it is echoed back but not otherwise used 3 · RESPONSE — decimal byte count, newline, then exactly that many bytes FRAME HEADER 184\n ASCII decimal length of the body that follows, no padding unparseable → hard error SUCCESS BODY {"id":"…", "ok":true, "result":{ … }} clients return result, not envelope ERROR BODY {"id":"…", "ok":false, "error":{"message":"…"}} raised as an exception client-side 4 · the server flushes, calls disconnectFromServer(), and deleteLater()s the socket — one request per connection SOCKET PATH RESOLUTION — ConnectorControlProtocol::serverName() 1 · CONNECTOR_CONTROL_SOCKET if set, used verbatim used by tests, isolated agent runs, and parallel local sessions systemd units set it explicitly 2 · derived from the home path sha1(homePath).toHex().left(12) /tmp/connector-control-<hash>/control.sock falls back to $USER, $USERNAME, "default" directory forced to owner rwx only 3 · daemon variant CONNECTOR_DAEMON_CONTROL_SOCKET, else $XDG_RUNTIME_DIR/connector/daemon.sock else /run/user/<uid>/connector/daemon.sock else falls back to the /tmp directory above
Figure 04.1 — the control protocol on the wire The response is length-prefixed rather than newline-delimited because results routinely contain embedded newlines — SSH stdout, UI trees, base64 PNGs. The request is newline-delimited because it never does.
04.B

Socket hardening

both ends

Server side

  • QLocalServer::UserAccessOption before listen().
  • If the path already exists, probe it: if something answers, refuse to start rather than steal the name.
  • Only if the probe returns ServerNotFoundError or ConnectionRefusedError is the stale socket removed.
  • After a successful listen, setPermissions(ReadOwner | WriteOwner).

Client side (Python)

  • Socket st_uid must equal os.getuid().
  • Parent directory st_uid must match too.
  • mode & 0o077 must be zero on both the socket and its directory.
  • Any failure raises before a single byte is sent.
The escape hatch

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.

04.C

The method surface

43 in the app · 34 in the daemon

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.

RPC methods by namespace — sourced from the dispatch chains in connectorcontrolserver.cpp and connectordaemonserver.cpp
NamespaceMethodsConnectorAppconnectord
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
The refusal, verbatim

"<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.

04.D

connectorctl

connectorctl_main.cpp — 335 lines, links Qt Core + Network only

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.

connector-app/src/connectorctl_main.cpp printUsage() the complete verb list
  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...>
Two details worth noting. 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.
connectorctl — shell execution over the tailnet
$ 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.

04.E

Daemon lifecycle

scripts/ensure-connectord.py — 418 lines

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.

ping firstTry app.ping on the socket with a 1.5 s timeout. A live answer ends the story.
identifyprocess_looks_like_connectord(pid) reads /proc/<pid>/cmdline and compares the resolved binary path — not just the process name.
ownershipprocess_owns_socket(pid, path) matches the socket inode against the process's open file descriptors.
systemd awareIf CONNECTOR_DAEMON_SYSTEMD_SERVICE is set, restart through systemctl --user instead of spawning a stray process.
refuseA stale daemon it cannot identify produces an explicit instruction: "Stop the stale daemon or set CONNECTOR_DAEMON_PID_FILE so Connector can restart it."

Flags

ensure-connectord.py argument parser
FlagDefaultMeaning
--socketemptyDaemon control socket path; falls back to CONNECTOR_DAEMON_CONTROL_SOCKET
--timeout10.0Seconds to wait for startup
--print-socketoffPrint the socket path once it is ready
04.F

Deployment units

deploy/

Three files turn the daemon into a permanently available control node. They are plain systemd user units — no installer, no root.

deploy/connectord.service
   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
04.G

UI automation over the same socket

ui.tree · ui.screenshot · ui.activate · ui.set_value

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.

connectorctl — UI automation
$ 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.

Observed behaviour of ui.activate / automationId across the QML tree
04.H

Continue