[PR -- Features] Daemon TCP listener for tailnet mesh connections (mesh 2/5) - #2517
sethkarten wants to merge 17 commits into
Conversation
… (mesh 2/5) The daemon accepts an optional TCP port (CLI --daemon-port > PRIME_AGENT_DAEMON_PORT env > settings daemonPort). When set, it listens on 0.0.0.0:<port> alongside the unix socket, serving the same JSONL protocol and command dispatch. Every TCP command line must carry the per-machine token (auto-generated in the daemon state dir, displayed in tailscale --json). TCP lines without the token get a correlatable failure response and the socket is closed. Binding failures fail startup loudly. No behavior change when no port is configured.
Prime Agent performance — completedPR Overall: 0 regressed · 0 improved · 42 no clear change.
Python runtime
Session transport
UI interactions
Sandbox cost: ~$0.1099 — no inference calls. Methodology and samplesMain resolved at 2026-09-23T06:11:54.241700+00:00. Harness
|
Review-thread fixes for the daemon TCP listener (mesh 2/5): - fenceSupervisorSocket and shutdown now close the TCP listener too, and the update-restart relaunch waits (bounded grace) for the port release before spawning the successor - the relaunch spec restores --daemon-port when the CLI flag configured the listener; env/settings-derived ports re-resolve on their own - TCP command lines are bounded (1 MiB) and an oversized line destroys the connection instead of buffering it - unauthenticated TCP connections get a 30s admission deadline, an authenticated socket switches to a generous idle window, and the listener caps concurrent connections - checkDaemonTcpLineAuth refuses JSON primitive lines instead of throwing from the socket data handler - token creation is exclusive (wx): concurrent creators converge on the winner's token instead of overwriting it - drop test-only exports from the TCP auth module (repo review rule) - docs: the token file is daemon-tcp-token, not daemon-tcp-token.json
The TCP admission deadline was armed on accept, but daemon_hello is only written once startup completes: the listener binds before worker adoption, which can spend the whole connect budget, and mesh clients wait for hello before sending their first token. A client that connected as soon as the port opened was therefore closed as unauthenticated before it ever saw the handshake. TCP sockets now get a generous absolute admission budget from accept (DAEMON_TCP_PRE_READY_TIMEOUT_MS, covering the worst worker connect window plus the auth deadline) and daemon_hello re-arms the short 30s deadline, so pre-ready clients survive startup while unauthenticated parking stays bounded. A line that authenticates before hello keeps the idle window.
checkDaemonTcpLineAuth read the command name as `parsed.type ?? parsed.command?.type`, but an envelope always carries `type: "command"`, so every refused mesh command was logged and answered as `command` instead of its real name. Prefer the envelope's inner `command.type` and fall back to `type` for raw lines. Pins: the envelope verdict reports `command: "list"` (daemon-tcp.test.ts).
…bytes The unauthenticated TCP deadline used `socket.setTimeout`, which Node refreshes on any I/O: a peer sending one byte per window never completes a line, renews its own deadline, and holds its slot until `maxConnections` (256) blocks real mesh clients. Reproduced on a real socket with one byte every 5s: the connection was still open at 46s, past the 30s window. Arm the admission deadline with an explicit unref'd timer instead: the pre-ready budget at accept, re-armed to the auth window at daemon_hello, cleared on the first authenticated line and on close. `socket.setTimeout` now covers only the authenticated idle window, where resetting on traffic is the intent. Pins in daemon-supervisor-admission.test.ts: an unauthenticated socket arms no socket timeout and is destroyed on the pre-ready budget even after a partial line; the deadline re-arms at hello, so 60s of startup survives and the auth window applies afterwards.
|
Both open Cursor Bugbot threads on this PR are valid; both are fixed at 1. Envelope auth reports the wrong command (Low) — fixed
2. Auth timeout resets on any byte (Medium) — fixedCorrect: Reproduced on a real socket against this branch's supervisor, one byte every 5s and never a newline: The unauthenticated deadline is now an explicit unref'd timer: armed with the pre-ready budget at accept, re-armed to the auth window at Pins in Checks
Every changed assertion was verified to fail on the pre-fix code (each source fix reverted in turn). — Prime Agent (sethkarten's agent) |
The helper's docstring claimed support for raw {"id","type","auth":{...}}
records next to the daemon envelope. Only envelopes are dispatchable:
parseCommandAndRegisterPromptAdmission requires the envelope protocol for
every client, unix included, so a raw record with a valid token
authenticates here and is then refused by the dispatcher with "Daemon
commands require protocol 7 or newer", leaving the socket open.
Comment-only change: the token check and the refusal naming are unchanged.
|
Follow-up round: the fresh Macroscope thread on this push ("Authenticated raw TCP commands are rejected with a parse failure instead of being dispatched") is answered at Verified against the supervisor at Raw records are not a TCP-specific gap: Gates re-run on — Prime Agent (sethkarten's agent) |
…hello daemon_hello is written before the first authenticated line, so every TCP peer that could reach the listener read the supervisor ownership token, pid, process start id, the local unix socket paths, and the runtime executable paths. Token auth only gates command lines, so none of those local-trust values were ever gated. A remote peer needs the protocol banner to finish the handshake and nothing else, so an untrusted connection now receives protocol, schema id, schema revision, app version, client id, and server capabilities only. Local connections (unix socket) keep the full identity: the update-restart coordinator fences successor daemons on it, and daemon ps reports runtime paths from it.
Pre-auth
|
The daemon TCP listener bound every interface, so the per-machine bearer token and every authenticated command crossed the LAN in plaintext: an on-path peer could capture the token and issue arbitrary daemon commands. Tailscale already encrypts node-to-node traffic, so the tailnet is the intended trust boundary, and the wildcard bind sat outside it. The listener now resolves its bind host as --daemon-bind > PRIME_AGENT_DAEMON_BIND_HOST > settings daemonTcpBindHost > this machine's Tailscale address, read from `tailscale status --json` (the same detection core the Tailscale mesh helpers use, IPv4 preferred). A host that is not an IP literal is rejected with the source named. When the port is set and the machine has no Tailscale address and no host was configured, the listener refuses to start (fail closed) with an error that names every escape hatch, instead of falling back to 0.0.0.0; an explicitly configured wildcard binds and logs a warning that the token is exposed on every interface. Like --daemon-port, the --daemon-bind flag is restored on an update-restart relaunch.
|
The open Macroscope finding on this PR is valid, and it is fixed at Valid: What changedThe listener now resolves its bind host the same way it resolves its port:
Deliberate tradeoff: the refusal fails startup, not just the listenerA mesh endpoint that silently does not exist is the failure mode this file already refuses for a busy port and a corrupt token file, so a missing tailnet address follows the same rule. The user sees it rather than a dead port: TestsNew
Mutation probes, each run against the pre-fix source: Checks
Local-only note: — Prime Agent (sethkarten's agent) |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit c657b8d. Configure here.
Cursor Bugbot: the detector was exported while its only production caller is the bind host resolution in the same module, so it was speculative surface on the path that decides where the plaintext-token listener binds. The resolution function already exposes the behavior for tests.
|
Second push for the bind-host fix: Fresh thread:
|
Macroscope: isWildcardBindHost only recognized `::`, so an explicit `0:0:0:0:0:0:0:0` (which binds the same wildcard interface) skipped the plaintext-token exposure warning. The classifier now treats every all-zero IPv6 spelling as the unspecified address, and the unit pin covers `::`, `::0`, and the fully expanded form plus the negative cases.
|
Third push: Fresh Macroscope thread: wildcard IPv6 spellings skipped the exposure warning (High) — fixedValid. The classifier now treats any all-zero IPv6 group set as the unspecified address ( Checks
— Prime Agent (sethkarten's agent) |
The changelog fragment said a machine without a Tailscale address "refuses to start the listener"; the listener refusal fails the daemon startup, and settings.md already says so. One line, docs only.
|
Fourth push: The changelog fragment said a machine without a Tailscale address "refuses to start the listener", while the refusal actually fails daemon startup ( No code changed in this commit. — Prime Agent (sethkarten's agent) |

Motivation
PR 2 of the Tailscale remote-agent mesh stack. The user wants to see their agents running across Tailscale, treating depth-0 sessions as siblings across tailnet connections. This PR gives the daemon a wire so remote machines can reach it.
What it adds
--daemon-port <n>, envPRIME_AGENT_DAEMON_PORT, or settings fielddaemonPort. When resolved, the daemon listens on0.0.0.0:<port>in addition to the unix socket (never replaces it). Zero behavior change when unset.auth: {token}envelope). Without it: correlatable failure response + socket closed. Token comparison is timing-safe. Corrupt token files are refused, not regenerated.tcp_auth_failedin the daemon protocol for refused lines.Stack context
Stacked on PR #2512 (Tailscale detection core, mesh 1/5). PR 3 (tailnet peer discovery) and PR 5 (cross-machine messaging/spawn) compose on this PR's TCP listener.
Verification
Note
Medium Risk
Expands the daemon attack surface when enabled (network listener and shared token auth), though defaults bind the tailnet and refuse startup without a safe bind address.
Overview
Adds an opt-in daemon TCP listener alongside the existing Unix socket so tailnet peers can speak the same JSONL protocol remotely. Enable it with
--daemon-port,PRIME_AGENT_DAEMON_PORT, or globaldaemonPort; optional bind via--daemon-bind(IP literal only),PRIME_AGENT_DAEMON_BIND_HOST, ordaemonTcpBindHost. Unix-only behavior is unchanged when the port is unset.Binding defaults to this machine’s Tailscale address (via
tailscale status --json), not all interfaces. If a port is configured but no Tailscale address exists and no bind host is set, daemon startup fails closed with guidance to disable the listener or set an explicit bind. Wildcard binds are allowed only when configured and log a plaintext-token exposure warning.Remote connections require a per-machine token on every command line (
auth: { token }), stored indaemon-tcp-token(0600) with timing-safe checks; failures use new protocol codetcp_auth_failed. TCP peers get a minimaldaemon_hello(protocol banner only)—supervisor ownership tokens, PIDs, and local paths stay on local connections. The supervisor adds connection limits, line-length caps, auth/idle deadlines, TCP teardown on shutdown/relaunch, and wires CLI/settings through newdaemon-tcphelpers plus docs and tests.Reviewed by Cursor Bugbot for commit 64a076a. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add optional TCP listener to
DaemonSupervisorfor tailnet mesh connectionsdaemonPortanddaemonTcpBindHostto configure the listener.0600permissions and requires token authentication on every TCP command line; local Unix socket connections remain unauthenticated.DaemonSupervisor.startTcpListenercreates the server, limits concurrent connections, and dispatches authenticated sockets through the existing connection handler.Macroscope summarized 64a076a.