Skip to content

[PR -- Features] Daemon TCP listener for tailnet mesh connections (mesh 2/5) - #2517

Open
sethkarten wants to merge 17 commits into
mainfrom
rsi/tailscale-tcp
Open

sethkarten wants to merge 17 commits into
mainfrom
rsi/tailscale-tcp

Conversation

@sethkarten

@sethkarten sethkarten commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

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

  • Optional TCP listener mode for the daemon: CLI flag --daemon-port <n>, env PRIME_AGENT_DAEMON_PORT, or settings field daemonPort. When resolved, the daemon listens on 0.0.0.0:<port> in addition to the unix socket (never replaces it). Zero behavior change when unset.
  • The TCP listener serves the same JSONL protocol and command dispatch as the unix socket.
  • Per-machine token auth: auto-generated on first daemon start (stored in the daemon state dir as JSON). Every TCP command line must carry the token (in an auth: {token} envelope). Without it: correlatable failure response + socket closed. Token comparison is timing-safe. Corrupt token files are refused, not regenerated.
  • New error code tcp_auth_failed in the daemon protocol for refused lines.
  • Binding failures (busy port, permissions) fail daemon startup loudly.

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

  • 9/9 unit tests (token store, port resolution with CLI > env > settings precedence, invalid-port rejection, timing-safe token comparison, raw and envelope auth acceptance, missing/wrong/empty token refusal)
  • Full daemon suites green: 121/121 admission + monitor tests
  • tsgo clean, biome clean, npm run check green, test-line budget green vs current main

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 global daemonPort; optional bind via --daemon-bind (IP literal only), PRIME_AGENT_DAEMON_BIND_HOST, or daemonTcpBindHost. 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 in daemon-tcp-token (0600) with timing-safe checks; failures use new protocol code tcp_auth_failed. TCP peers get a minimal daemon_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 new daemon-tcp helpers 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 DaemonSupervisor for tailnet mesh connections

  • Adds CLI flags, environment variables, and global settings for daemonPort and daemonTcpBindHost to configure the listener.
  • Defaults to binding the listener to the machine's Tailscale IP address, failing startup if no address is detected and no explicit bind host is set.
  • Generates a per-machine token file with 0600 permissions and requires token authentication on every TCP command line; local Unix socket connections remain unauthenticated.
  • DaemonSupervisor.startTcpListener creates the server, limits concurrent connections, and dispatches authenticated sockets through the existing connection handler.
  • Risk: Fails closed if no Tailscale address exists and no explicit bind host is set; explicitly configured wildcard binds emit a plaintext-token exposure warning.

Macroscope summarized 64a076a.

… (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.
@sethkarten
sethkarten enabled auto-merge (squash) September 21, 2026 22:55
@github-actions

github-actions Bot commented Sep 21, 2026

Copy link
Copy Markdown

Prime Agent performance — completed

PR 64a076a3 compared with main 298cf406.

Overall: 0 regressed · 0 improved · 42 no clear change.

Metric Main This PR Change
Cold startup 634.3 ms 654.1 ms ≈ +19.8 ms (+3.12%)
Warm startup 496.2 ms 504.3 ms ≈ +8.1 ms (+1.63%)
Installation 5.24 s 5.24 s ≈ -0.0023 s (-0.04%)
Compressed release artifacts 73.05 MB 73.10 MB ≈ +0.05 MB (+0.06%)
Installed footprint 595.49 MB 595.55 MB ≈ +0.06 MB (+0.01%)
Idle memory, summed RSS 650.56 MB 652.70 MB ≈ +2.14 MB (+0.33%)

Python runtime

Metric Main This PR Change
Python kernel startup 31.4 ms 31.3 ms ≈ -0.2 ms (-0.51%)
Python cell round trip 0.107 ms 0.109 ms ≈ +0.002 ms (+1.94%)
Empty bash command 2.0 ms 2.0 ms ≈ -0.025 ms (-1.24%)
Bash git status 2.7 ms 2.7 ms ≈ -0.043 ms (-1.56%)
Bash 32 KiB output 2.1 ms 2.1 ms ≈ -0.03 ms (-1.44%)
35 cells / 9 shell calls 27.1 ms 26.2 ms ≈ -0.9 ms (-3.30%)
Python interrupt to done 0.496 ms 0.466 ms ≈ -0.030 ms (-6.01%)
Python state snapshot 9.2 ms 9.4 ms ≈ +0.2 ms (+1.93%)
Python state restore 116.3 ms 116.3 ms ≈ +0.057 ms (+0.05%)
Python idle RSS 20.98 MB 21.16 MB ≈ +0.17 MB (+0.82%)
Python RSS after pandas workload 75.55 MB 75.65 MB ≈ +0.10 MB (+0.13%)

Session transport

Metric Main This PR Change
Full-history transfers per warm session switch 1.00 transfers 1.00 transfers ≈ +0.00 transfers (+0.00%)
Private frame decode, 32 MiB in 8 KiB chunks 14.3 ms 15.4 ms ≈ +1.1 ms (+7.76%)

UI interactions

Metric Main This PR Change
Resume large session (cold) 1,896.4 ms 1,866.5 ms ≈ -29.8 ms (-1.57%)
CPU, resume large session 1,890.0 ms 1,910.0 ms ≈ +20.0 ms (+1.06%)
Switch into large session 1,576.2 ms 1,535.6 ms ≈ -40.7 ms (-2.58%)
CPU, switch into large session 1,550.0 ms 1,470.0 ms ≈ -80.0 ms (-5.16%)
Open agents view from a session 142.0 ms 131.8 ms ≈ -10.2 ms (-7.20%)
CPU, open agents view 120.0 ms 70.0 ms ≈ -50.0 ms (-41.67%)
Full agents roster, many sessions 3.63 s 4.03 s ≈ +0.40 s (+10.96%)
CPU, full agents roster 0.91 s 0.94 s ≈ +0.03 s (+3.30%)
Open another session from agents view 2,094.2 ms 2,265.0 ms ≈ +170.7 ms (+8.15%)
CPU, open from agents view 1,000.0 ms 1,060.0 ms ≈ +60.0 ms (+6.00%)
Reopen resident large session 188.2 ms 190.0 ms ≈ +1.9 ms (+0.99%)
CPU, reopen resident session 190.0 ms 200.0 ms ≈ +10.0 ms (+5.26%)
Open subagent session at depth 6 19,563.8 ms 19,347.8 ms ≈ -215.9 ms (-1.10%)
CPU, open subagent at depth 6 4,630.0 ms 4,530.0 ms ≈ -100.0 ms (-2.16%)
Open chain parent from agents view 3,209.4 ms 3,318.5 ms ≈ +109.1 ms (+3.40%)
CPU, open chain parent 1,380.0 ms 1,430.0 ms ≈ +50.0 ms (+3.62%)
Scheduled catalog, first request 385.6 ms 425.2 ms ≈ +39.6 ms (+10.26%)
CPU, scheduled catalog 780.0 ms 830.0 ms ≈ +50.0 ms (+6.41%)
Scheduled catalog, repeated request 0.4 ms 0.5 ms ≈ +0.077 ms (+18.08%)
CPU, repeated catalog 0.0 ms 0.0 ms ≈ +0.0 ms (N/A)
Cold worker with three catalog scans 709.5 ms 819.0 ms ≈ +109.5 ms (+15.44%)
CPU, cold worker and scans 510.0 ms 480.0 ms ≈ -30.0 ms (-5.88%)
UI memory after interactions 1,729.42 MB 1,752.23 MB ≈ +22.81 MB (+1.32%)

Sandbox cost: ~$0.1099 — no inference calls.
Run, logs, and downloadable raw results

Methodology and samples

Main resolved at 2026-09-23T06:11:54.241700+00:00. Harness 298cf406.
Linux x64, 4 vCPU, 8 GB RAM, 20 GB disk; region us.
Image: node:24-bookworm@sha256:be23f54a88d34e8824c741b19b91064094f92c1c97b194144bfc8b50d67258e2.
Stock tools, skills, daemon, and Python bootstrap enabled; fresh homes and a fixed Git fixture.
Onboarding is dismissed; the editor starts without a selected model or submitted prompt.
Medians shown. Arrows require a 20% timing/memory change plus absolute floors and IQR.
These practical noise floors are not a statistical significance test.
Cold means stopped Prime processes; OS filesystem caches are not flushed.
No model requests or credentials. Installation excludes build/setup time.
Installer tarballs use loopback; npm/Python downloads use the network with fresh caches.
Artifact size counts release tarballs; footprint after first use includes registry packages.
MB is decimal. Summed RSS can double-count shared pages; PSS is recorded when available.
Provisioning, setup, and build durations are recorded separately in the raw results.
Kernel probes use the installed JSONL runtime, outside the TUI/TypeScript host.
Per trial: 50 Python cells, 5 calls per shell case, and one 35-cell mix (9 git status calls).
Cell/shell values are batch means; other runtime timings are single operations.
State fixture: a 10,000-row × 8-column integer DataFrame and a 10,000-integer list.
Restore runs in a fresh kernel, including pandas imports; kernel startup is excluded.
Kernel RSS covers the isolated Python process; loaded RSS follows the pandas workload.
Transport benches run node against the prepared source build, outside the installed home.
The switch benchmark drives one warm switch into a 48k-entry session through a real
daemon and counts full-history crossings: streamed replacement snapshots, inline
replacements, and full-history refetch responses.
Frame decode times one 32 MiB private frame, snapshot-chunk header, pushed in
8 KiB chunks; the wire shape of multi-MB frames on the daemon-worker channels.
UI trials use a fresh fixture set: 194 top-level sessions including one ~40 MB transcript,
40 ledger fan-out children, and a 6-deep subagent chain (~46 spawn edges).
Large fixtures hold 1,999 complete triples (~5 MB JSONL); medium 119; subagents 399 each.
Interactions: cold --resume of a large session, warm /resume switch, left-arrow to agents view,
roster settle with many saved sessions, search-and-open of another large session,
reattaching to that resident session, opening the chain parent, and drilling to depth 6.
Readiness is the rendered transcript tail plus a confirmed editor echo.
CPU metrics sum utime+stime across the whole benchmark-user process tree per interaction.
UI memory sums RSS after the interactions; PTY byte counts are in the raw results.
A separate catalog fixture has 2,300 sessions, 2,298 edges, and 13 paused scheduled-job owners.
Catalog timings cover first/repeated reads and cold worker creation under three pending scans.
All expected jobs and owner metadata are checked; worker readiness excludes TUI rendering.
Costs estimate full sandbox lifetimes at configured rates, including setup and build.
Budget target: $1; not a billing cap. Performance changes are informational.
Failed or incomplete execution fails the workflow; saved artifacts remain available.
Each side stops a phase after 2 identical consecutive failures.
Skipped trials are not attempted samples. Warm startup requires a successful cold launch.

Metric Main successful/attempted PR successful/attempted Main spread PR spread
Cold startup 10/10 10/10 IQR 19.5 ms IQR 22.6 ms
Warm startup 10/10 10/10 IQR 42.5 ms IQR 20.6 ms
Installation 3/3 3/3 range 0.31 s range 0.85 s
Compressed release artifacts 1/1 1/1
Installed footprint 1/1 1/1
Idle memory, summed RSS 10/10 10/10 IQR 4.12 MB IQR 8.97 MB
Python kernel startup 10/10 10/10 IQR 0.6 ms IQR 0.6 ms
Python cell round trip 10/10 10/10 IQR 0.009 ms IQR 0.028 ms
Empty bash command 10/10 10/10 IQR 0.091 ms IQR 0.096 ms
Bash git status 10/10 10/10 IQR 0.1 ms IQR 0.089 ms
Bash 32 KiB output 10/10 10/10 IQR 0.098 ms IQR 0.1 ms
35 cells / 9 shell calls 10/10 10/10 IQR 1.8 ms IQR 1.2 ms
Python interrupt to done 10/10 10/10 IQR 0.047 ms IQR 0.014 ms
Python state snapshot 10/10 10/10 IQR 0.3 ms IQR 0.1 ms
Python state restore 10/10 10/10 IQR 5.2 ms IQR 3.4 ms
Python idle RSS 10/10 10/10 IQR 0.17 MB IQR 0.18 MB
Python RSS after pandas workload 10/10 10/10 IQR 0.19 MB IQR 0.17 MB
Full-history transfers per warm session switch 10/10 10/10 IQR 0.00 transfers IQR 0.00 transfers
Private frame decode, 32 MiB in 8 KiB chunks 10/10 10/10 IQR 2.3 ms IQR 3.3 ms
Resume large session (cold) 3/3 3/3 range 163.4 ms range 398.9 ms
CPU, resume large session 3/3 3/3 range 160.0 ms range 160.0 ms
Switch into large session 3/3 3/3 range 55.9 ms range 217.1 ms
CPU, switch into large session 3/3 3/3 range 70.0 ms range 110.0 ms
Open agents view from a session 3/3 3/3 range 15.0 ms range 10.4 ms
CPU, open agents view 3/3 3/3 range 80.0 ms range 50.0 ms
Full agents roster, many sessions 3/3 3/3 range 0.41 s range 0.40 s
CPU, full agents roster 3/3 3/3 range 0.05 s range 0.08 s
Open another session from agents view 3/3 3/3 range 115.8 ms range 294.3 ms
CPU, open from agents view 3/3 3/3 range 60.0 ms range 190.0 ms
Reopen resident large session 3/3 3/3 range 8.7 ms range 9.9 ms
CPU, reopen resident session 3/3 3/3 range 30.0 ms range 30.0 ms
Open subagent session at depth 6 3/3 3/3 range 129.4 ms range 916.6 ms
CPU, open subagent at depth 6 3/3 3/3 range 30.0 ms range 90.0 ms
Open chain parent from agents view 3/3 3/3 range 27.9 ms range 340.8 ms
CPU, open chain parent 3/3 3/3 range 90.0 ms range 50.0 ms
Scheduled catalog, first request 3/3 3/3 range 31.4 ms range 60.2 ms
CPU, scheduled catalog 3/3 3/3 range 80.0 ms range 80.0 ms
Scheduled catalog, repeated request 3/3 3/3 range 0.4 ms range 0.1 ms
CPU, repeated catalog 3/3 3/3 range 0.0 ms range 0.0 ms
Cold worker with three catalog scans 3/3 3/3 range 107.5 ms range 283.5 ms
CPU, cold worker and scans 3/3 3/3 range 60.0 ms range 50.0 ms
UI memory after interactions 3/3 3/3 range 10.26 MB range 56.84 MB

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/coding-agent/src/modes/daemon/daemon-supervisor.ts
Comment thread packages/coding-agent/src/modes/daemon/daemon-supervisor.ts
Comment thread packages/coding-agent/src/modes/daemon/daemon-tcp.ts Outdated
Comment thread packages/coding-agent/src/modes/daemon/daemon-supervisor.ts Outdated
Comment thread packages/coding-agent/src/modes/daemon/daemon-tcp.ts
Comment thread packages/coding-agent/src/modes/daemon/daemon-tcp.ts Outdated
Comment thread packages/coding-agent/docs/settings.md Outdated
Comment thread packages/coding-agent/src/modes/daemon/daemon-supervisor.ts Outdated
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

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/coding-agent/src/modes/daemon/daemon-supervisor.ts
sethkarten and others added 2 commits September 21, 2026 20:18
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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/coding-agent/src/modes/daemon/daemon-tcp.ts Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/coding-agent/src/modes/daemon/daemon-supervisor.ts
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.
@sethkarten

Copy link
Copy Markdown
Contributor Author

Both open Cursor Bugbot threads on this PR are valid; both are fixed at 2d3010e91.

1. Envelope auth reports the wrong command (Low) — fixed

checkDaemonTcpLineAuth resolved the name as parsed.type ?? parsed.command?.type. Envelope lines always carry type: "command", so a refused mesh command was logged and answered as command instead of its own name.

pre-fix  {"ok":false,"id":"e1","command":"command","reason":"wrong_token"}
post-fix {"ok":false,"id":"e1","command":"list_sessions","reason":"wrong_token"}

daemon-tcp.ts now prefers the envelope's inner command.type and falls back to type for raw lines, so Refused TCP <command> command (<reason>) and the tcp_auth_failed response carry the real command name. authorizeDaemonTcpLine reports verdict.command verbatim, so the unit pin in daemon-tcp.test.ts (an envelope verdict now reports command: "list") covers both the logged and the returned name.

2. Auth timeout resets on any byte (Medium) — fixed

Correct: socket.setTimeout is an inactivity timer, so a peer that sends one byte before each window renews its own admission deadline and holds its slot until maxConnections (256) starves real mesh clients.

Reproduced on a real socket against this branch's supervisor, one byte every 5s and never a newline:

pre-fix   hello at 0.0s, dribbles to 45.0s → still open at 46.0s (past the 30s window)
post-fix  hello at 0.0s, dribbles to 30.0s → "Closed unauthenticated TCP client connection", closed at 30.0s

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 daemon_hello, cleared when the first line authenticates and when the client goes away. socket.setTimeout now covers only the authenticated idle window, where resetting on traffic is the intent.

Pins in daemon-supervisor-admission.test.ts (fake timers): 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 30s window applies afterwards; an authenticated socket arms only the idle window.

Checks

  • GITHUB_BASE_REF=main node scripts/check-test-policy.mjs — passes.
  • npm run check (biome, tsgo, test-policy, installer, push-guard, browser-smoke) — passes.
  • Targeted vitest: daemon-tcp, daemon-supervisor-admission, daemon-supervisor-eviction — 59 tests pass.

Every changed assertion was verified to fail on the pre-fix code (each source fix reverted in turn).

— Prime Agent (sethkarten's agent)

Comment thread packages/coding-agent/src/modes/daemon/daemon-supervisor.ts
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.
@sethkarten

Copy link
Copy Markdown
Contributor Author

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 9a6ef0890.

Verified against the supervisor at 2d3010e91 on a real socket, valid token in both lines:

raw      {"id":"r1","type":"list","auth":{"token":...}}
         <- {"id":"r1","type":"response","command":"parse","success":false,
             "error":"Daemon commands require protocol 7 or newer"}   (socket stays open)
envelope {"type":"command","id":"e1","command":{"type":"list"},"auth":{"token":...}}
         <- {"id":"e1","type":"response","command":"list","success":true,...}

Raw records are not a TCP-specific gap: handleLine -> parseCommandAndRegisterPromptAdmission requires isDaemonCommandEnvelope for every client, unix sockets included. So the behavior is intended, and the part that was actually wrong was the auth helper's documented format — it advertised raw-record support, and the fallback comment in the naming fix leaned on it. Both now state that only the envelope is dispatchable and that a raw record authenticates and is then refused by the dispatcher with the protocol-version error. Comment-only change; the token check, the refusal naming, and the admission deadline are unchanged.

Gates re-run on 9a6ef0890: GITHUB_BASE_REF=main node scripts/check-test-policy.mjs passes, npm run check passes, targeted vitest 59 tests pass.

— Prime Agent (sethkarten's agent)

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/coding-agent/src/modes/daemon/daemon-supervisor.ts
…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.
@sethkarten

Copy link
Copy Markdown
Contributor Author

Pre-auth daemon_hello no longer carries supervisor identity

Fixed in 79f0c1b17.

What was leaking. The greeting is written to every connection before the first authenticated line, and it was built from one literal that included the supervisor's local-trust values: supervisorOwnerToken (the durable owner marker in owner.json), supervisorPid, supervisorProcessStartId, supervisorSocketPath, socketPath, and runtime (executablePath / entrypointPath / launcherPath). Token auth only gated command lines, so an unauthenticated peer that could reach the 0.0.0.0 listener read all of them.

What changed. DaemonSupervisor.daemonHello() now splits the payload by transport trust:

  • An untrusted TCP connection gets the protocol banner only: type, protocol, schemaId, schemaRevision, appVersion, clientId, serverCapabilities. That is what a mesh peer needs to complete the handshake and pick its command set; the admission deadline still re-arms at hello, so the pre-ready path is unchanged.
  • A local connection (unix socket, no TCP auth gate) keeps the full identity. The update-restart coordinator fences a successor daemon on supervisorOwnerToken / supervisorGeneration / pid / process start id, daemon ps reports runtime paths, and daemon-agent-connection reads hello.socketPath for the log path, so those values stay exactly where they are consumed.

socketPath is now optional in the DaemonOutbound greeting type, with a comment stating that the identity fields below it are local-only.

Evidence.

  • test/daemon-supervisor-admission.test.ts → "greets an unauthenticated TCP peer with the protocol banner only" asserts the exact key set of the pre-auth hello and that authenticating a line afterwards does not hand the withheld identity over.
  • test/daemon-supervisor-admission.test.ts → "keeps the full supervisor identity hello for local connections" pins the local contract so the split cannot silently over-slim.
  • Full run: 408 tests across the daemon client, launch, supervisor, agents-view, and update-restart suites pass; npm run check passes (biome, tsgo, test-policy, installer, push-guard, browser-smoke).

Impact note for triage. The owner token is an identity fence, not an auth credential: TCP auth uses the separate per-machine token in <agentDir>/daemon-tcp-token, and using the owner token requires the target host's registry record plus local filesystem access. So this was over-disclosure of local-trust values rather than a cross-machine privilege. It is still wrong for a 0.0.0.0 listener to announce them, and the fix is small and behavior-preserving.

— Prime Agent (sethkarten's agent)

Comment thread packages/coding-agent/src/modes/daemon/daemon-supervisor.ts Outdated
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.
@sethkarten

Copy link
Copy Markdown
Contributor Author

The open Macroscope finding on this PR is valid, and it is fixed at c657b8d04.

Valid: 0.0.0.0 put the bearer token outside the tailnet trust boundary. The listener bound every interface, so the per-machine token and every authenticated command crossed the LAN in plaintext, where no Tailscale encryption applies — an on-path peer could capture the token and drive the daemon. The tailnet is the intended boundary; the wildcard bind was wider than it.

What changed

The listener now resolves its bind host the same way it resolves its port:

--daemon-bind <address>  >  PRIME_AGENT_DAEMON_BIND_HOST  >  settings daemonTcpBindHost  >  this machine's Tailscale address
  • The default is this machine's own Tailscale address, read from tailscale status --json (Self.TailscaleIPs, IPv4 preferred, with BackendState: "Running" covering a node that is up but currently offline). That is the same detection core the Tailscale mesh helpers use; it is re-implemented in daemon-tcp.ts because those helpers are not on this branch.
  • A host that is not an IP literal is rejected, naming the source that supplied it, so a hostname cannot quietly resolve somewhere other than the tailnet.
  • Never a wildcard default. With the port set, no configured host, and no tailnet address, the listener refuses to start (fail closed):
Refusing to start the daemon TCP listener: this machine has no Tailscale address to bind and
no bind host was configured. The per-machine token travels in plaintext over TCP, so the
listener binds the tailnet only. Set PRIME_AGENT_DAEMON_BIND_HOST, the --daemon-bind flag, or
settings daemonTcpBindHost to the local address to listen on (only when that network is
trusted), or unset the daemon port to disable the listener.
  • An explicitly configured wildcard still binds, and says why that is dangerous: Daemon TCP listener is binding every interface (0.0.0.0): the per-machine token and its commands travel in plaintext, so any on-path peer can capture them. Use the tailnet address unless this network is trusted.
  • --daemon-bind is restored on an update-restart relaunch, next to --daemon-port.
  • settings.md, the daemonPort/daemonTcpBindHost JSDoc, and the changelog fragment now describe the tailnet-only default instead of 0.0.0.0.

Deliberate tradeoff: the refusal fails startup, not just the listener

A 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: Could not start the background service: Refusing to start the daemon TCP listener: …. The escape hatches are in the message — set an explicit bind host, or unset daemonPort to run without a mesh listener.

Tests

New test/daemon-supervisor-tcp-bind.test.ts binds a real socket:

  1. no configured host → the listener binds the address the probe reports (127.0.0.1 stands in for the tailnet address so the bind is genuine here) and never 0.0.0.0;
  2. no tailnet address and no configured host → startTcpListener() rejects and leaves no server bound;
  3. settings daemonTcpBindHost binds as given with the probe never called, --daemon-bind wins over settings, and an explicit 0.0.0.0 binds and warns.

daemon-tcp.test.ts pins the precedence chain, per-source IP validation, and the probe's fail-closed cases (CLI missing, Stopped, unparseable status, no addresses). args.test.ts pins --daemon-bind parsing, and the relaunch pin in daemon-supervisor-eviction.test.ts now also asserts --daemon-bind 100.101.102.103.

Mutation probes, each run against the pre-fix source:

server.listen({ port, host })  ->  host: "0.0.0.0"    3 of 5 supervisor bind tests fail
fail-closed throw              ->  return "0.0.0.0"   both fail-closed pins fail

Checks

  • npm run check (biome, tsgo, test-policy, installer, push-guard, browser-smoke) — passes.
  • Targeted vitest (daemon-tcp, daemon-supervisor-tcp-bind, daemon-supervisor-eviction, daemon-supervisor-launch, daemon-mode, args, settings-manager) — 271 pass.

Local-only note: daemon-mode.test.ts > persists a real child completion for passive discovery, roster, and listing fails identically at the parent commit 79f0c1b17 on this machine (checked with the working tree stashed). The CI shards pass, and this diff does not touch that path.

— Prime Agent (sethkarten's agent)

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

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

Comment thread packages/coding-agent/src/modes/daemon/daemon-tcp.ts
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.
@sethkarten

Copy link
Copy Markdown
Contributor Author

Second push for the bind-host fix: bfab7edb4 on top of c657b8d04.

Fresh thread: detectTailscaleBindAddress exported but unused outside its module (Low) — fixed

Valid. The detector's only production caller is resolveDaemonTcpListenerHost in the same module, so it was speculative surface on the one path that decides where the plaintext-token listener binds. It is now module-private, and the tests reach its behavior through the resolver that production calls:

resolveDaemonTcpListenerHost(undefined, undefined, {})   -> the probed tailnet address, or the refusal
detectTailscaleBindAddress                              -> module-private

No assertion was lost: the IPv4 preference, the IPv6 fallback, and the four fail-closed probe cases (CLI missing, BackendState: "Stopped", unparseable status, empty address list) are all pinned through resolveDaemonTcpListenerHost.

The Macroscope critical thread is answered in-thread

The 0.0.0.0 bind is gone at c657b8d04 and the thread has a reply with the pins and mutation evidence. Recapping the shape so it is in one place:

  • default bind host = this machine's Tailscale address;
  • explicit host = --daemon-bind > PRIME_AGENT_DAEMON_BIND_HOST > settings daemonTcpBindHost, validated as an IP literal;
  • no tailnet address and no configured host = the listener refuses to start, and the CLI surfaces it (Could not start the background service: Refusing to start the daemon TCP listener: …);
  • an explicit wildcard binds only with a warning that names the exposure;
  • never a 0.0.0.0 default.

Checks

  • npm run check (biome, tsgo, test-policy, installer, push-guard, browser-smoke) — passes on both commits.
  • Targeted vitest (daemon-tcp, daemon-supervisor-tcp-bind, daemon-supervisor-eviction, daemon-supervisor-launch, args, settings-manager) — 271 pass.

— Prime Agent (sethkarten's agent)

Comment thread packages/coding-agent/src/modes/daemon/daemon-tcp.ts
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.
@sethkarten

Copy link
Copy Markdown
Contributor Author

Third push: ee937ee0d on top of bfab7edb4.

Fresh Macroscope thread: wildcard IPv6 spellings skipped the exposure warning (High) — fixed

Valid. isWildcardBindHost only recognized ::, so an explicitly configured 0:0:0:0:0:0:0:0 bound every interface without the warning that says so. Verified locally before fixing:

net.isIP("0:0:0:0:0:0:0:0")                       -> 6
server.listen({port: 0, host: "0:0:0:0:0:0:0:0"}) -> {"address":"::","family":"IPv6"}

The classifier now treats any all-zero IPv6 group set as the unspecified address (::, ::0, 0:0:0:0:0:0:0:0), and the pin covers the negatives too (::1, fd7a:115c:a1e0::1, fe80::, CGNAT IPv4, non-address). Reverting the classifier fails that pin. The bind behavior is unchanged: an explicit wildcard is still honored, and the default path never reaches one.

Checks

  • npm run check (biome, tsgo, test-policy, installer, push-guard, browser-smoke) — passes.
  • Targeted vitest (daemon-tcp, daemon-supervisor-tcp-bind) — 18 pass; with daemon-supervisor-eviction, daemon-supervisor-launch, args, settings-manager — 271 pass.

— 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.
@sethkarten

Copy link
Copy Markdown
Contributor Author

Fourth push: 4ac8cdc8e — docs only, one line.

The changelog fragment said a machine without a Tailscale address "refuses to start the listener", while the refusal actually fails daemon startup (settings.md already says that). Now both say the same thing:

... and a machine with no Tailscale address refuses to start (fail closed, with an
error naming the escape hatches) instead of falling back to 0.0.0.0.

No code changed in this commit. ee937ee0d is fully green: all 36 checks settled with none failing, and every review thread on the PR is resolved.

— Prime Agent (sethkarten's agent)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant