Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
81fa1f9
feat(daemon): add an opt-in TCP listener for tailnet mesh connections…
sethkarten Sep 21, 2026
7e75750
chore: add changeset fragment for the daemon TCP listener
sethkarten Sep 21, 2026
8c5e95e
fix(daemon): harden the TCP listener lifecycle and admission controls
sethkarten Sep 21, 2026
2350a99
fix(daemon): arm the TCP auth deadline from daemon_hello, not accept
sethkarten Sep 22, 2026
67cdab2
Merge branch 'main' into rsi/tailscale-tcp
sethkarten Sep 22, 2026
5d4c5b3
Merge branch 'main' into rsi/tailscale-tcp
sethkarten Sep 22, 2026
1bdfbb1
fix(daemon): name the refused envelope command in TCP auth refusals
sethkarten Sep 22, 2026
2d3010e
fix(daemon): keep the TCP admission deadline absolute under dribbled …
sethkarten Sep 22, 2026
9a6ef08
docs(daemon): state the dispatchable TCP line format in the auth helper
sethkarten Sep 22, 2026
871fcd5
Merge branch 'main' into rsi/tailscale-tcp
sethkarten Sep 23, 2026
79f0c1b
fix(daemon): keep supervisor identity out of the unauthenticated TCP …
sethkarten Sep 23, 2026
c657b8d
fix(daemon): bind the TCP listener to the tailnet, not 0.0.0.0
sethkarten Sep 23, 2026
bfab7ed
fix(daemon): keep the tailscale bind detection module-private
sethkarten Sep 23, 2026
ee937ee
fix(daemon): warn for every spelling of the unspecified IPv6 bind host
sethkarten Sep 23, 2026
4ac8cdc
docs(daemon): state the fail-closed mesh refusal accurately
sethkarten Sep 23, 2026
081b5c5
Merge branch 'main' into rsi/tailscale-tcp
sethkarten Sep 23, 2026
64a076a
Merge branch 'main' into rsi/tailscale-tcp
sethkarten Sep 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/coding-agent/.changes/daemon-tcp-listener.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added an opt-in daemon TCP listener for tailnet mesh connections: `--daemon-port <n>` (or `PRIME_AGENT_DAEMON_PORT` / settings `daemonPort`) listens on this machine's Tailscale address alongside the unix socket, serving the same protocol with per-machine token auth. The token and its commands cross TCP in plaintext, so the listener binds the tailnet only: a wider interface needs an explicit `--daemon-bind <address>` (or `PRIME_AGENT_DAEMON_BIND_HOST` / settings `daemonTcpBindHost`), and a machine with no Tailscale address refuses to start the listener instead of falling back to `0.0.0.0`. A mesh connection receives the protocol banner in `daemon_hello` and nothing else until its first authenticated line: the supervisor ownership token, pids, process start id, and local paths stay on local connections. No behavior change when unset.
6 changes: 6 additions & 0 deletions packages/coding-agent/docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,9 +326,15 @@ Normally the package manager's global modules location is queried using `root -g
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `idleEvictionMinutes` | number or `"off"` | `90` | Idle threshold in minutes for whole-tree worker eviction and individual idle-child passivation; `"off"` disables both. |
| `daemonPort` | number | - | TCP port for the optional daemon mesh listener. When set, the daemon also listens on the machine's Tailscale address at this port for remote agents. |
| `daemonTcpBindHost` | string | the machine's Tailscale address | Address the daemon mesh listener binds. Set it only to a trusted interface: TCP carries the per-machine token in plaintext, so `0.0.0.0` exposes it to every on-path peer. |

`idleEvictionMinutes` is a global daemon policy and is read only from `~/.prime/agent/settings.json`. Set it to a positive number to configure the idle threshold.

`daemonPort` is a global daemon policy read only from `~/.prime/agent/settings.json`. The TCP listener speaks the same JSONL protocol as the unix socket and is disabled when unset. The `--daemon-port` CLI flag and `PRIME_AGENT_DAEMON_PORT` env var override it (flag > env > setting). Every command line sent over TCP must carry the per-machine identity token in a top-level `auth: { token }` field; the token is generated on first daemon start and stored in `~/.prime/agent/daemon-tcp-token` (mode `0600`).

`daemonTcpBindHost` is a global daemon policy read only from `~/.prime/agent/settings.json`. It follows the same override order as the port: `--daemon-bind <address>` > `PRIME_AGENT_DAEMON_BIND_HOST` > `daemonTcpBindHost` > the machine's Tailscale address. The address must be an IP literal. Because the token and every authenticated command cross TCP in plaintext, the listener binds the tailnet by default: Tailscale encrypts node-to-node traffic, while a wildcard address would hand the same token to every on-path peer on the LAN. When the port is set and this machine has no Tailscale address, the daemon refuses to start and says so, so unset `daemonPort` (or set an explicit bind host) to run without a mesh listener.

### Sessions

| Setting | Type | Default | Description |
Expand Down
29 changes: 29 additions & 0 deletions packages/coding-agent/src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* CLI argument parsing and help display
*/

import { isIP } from "node:net";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { APP_NAME } from "../config.js";
import { THINKING_LEVELS } from "../core/thinking-levels.js";
Expand All @@ -22,6 +23,8 @@ export interface Args {
version?: boolean;
mode?: Mode;
daemonSocket?: string;
daemonPort?: number;
daemonBindHost?: string;
noSession?: boolean;
fork?: string;
sessionDir?: string;
Expand Down Expand Up @@ -119,6 +122,32 @@ export function parseArgs(args: string[]): Args {
if (hasRequiredOptionValue(args, i, arg, result)) {
result.daemonSocket = args[++i];
}
} else if (arg === "--daemon-port") {
if (hasRequiredOptionValue(args, i, arg, result)) {
const port = Number(args[++i]);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
result.diagnostics.push({
type: "error",
message: `Invalid --daemon-port "${args[i]}": expected an integer between 1 and 65535`,
});
} else {
result.daemonPort = port;
}
}
} else if (arg === "--daemon-bind") {
if (hasRequiredOptionValue(args, i, arg, result)) {
const host = args[++i]!.trim();
// An IP literal binds exactly one interface; a hostname would resolve
// through DNS at listen time and could dodge the tailnet-only default.
if (isIP(host) === 0) {
result.diagnostics.push({
type: "error",
message: `Invalid --daemon-bind "${args[i]}": expected an IP address (e.g. the tailnet address of this machine)`,
});
} else {
result.daemonBindHost = host;
}
}
} else if (arg === "--continue" || arg === "-c") {
result.continue = true;
} else if (arg === "--resume" || arg === "-r") {
Expand Down
25 changes: 25 additions & 0 deletions packages/coding-agent/src/core/settings-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,21 @@ export interface Settings {
sessionDir?: string; // Custom session storage directory (same format as --session-dir CLI flag)
/** Log per-request provider timing phases to the diagnostic log. Default: false */
requestTiming?: boolean;
/**
* TCP port for the optional daemon mesh listener (read from the global
* scope only). When set, the daemon listens on the machine's Tailscale
* address at this port in addition to the unix socket, requiring the
* per-machine token on every command.
* Default: unset - no TCP listener.
*/
daemonPort?: number;
/**
* Address the daemon TCP listener binds (read from the global scope only).
* Default: the machine's Tailscale address. TCP carries the per-machine
* token in plaintext, so set this only to a trusted interface; 0.0.0.0
* exposes the token to every on-path peer.
*/
daemonTcpBindHost?: string;
}

export interface AgentTracesSettings {
Expand Down Expand Up @@ -916,6 +931,16 @@ export class SettingsManager {
return this.globalSettings.rlmMaxDepth;
}

getDaemonPort(): number | undefined {
const port: unknown = this.globalSettings.daemonPort;
return typeof port === "number" && Number.isInteger(port) && port >= 1 && port <= 65535 ? port : undefined;
}

getDaemonTcpBindHost(): string | undefined {
const host: unknown = this.globalSettings.daemonTcpBindHost;
return typeof host === "string" && host.trim() !== "" ? host.trim() : undefined;
}

setRlmMaxDepth(maxDepth: number): void {
this.globalSettings.rlmMaxDepth = maxDepth;
this.markModified("rlmMaxDepth");
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1391,6 +1391,8 @@ export async function main(args: string[], options?: MainOptions) {
await runDaemonSupervisorMode({
socketPath: parsed.daemonSocket,
defaultSessionConfig: daemonDefaultSessionConfig,
...(parsed.daemonPort !== undefined ? { tcpPort: parsed.daemonPort } : {}),
...(parsed.daemonBindHost !== undefined ? { tcpBindHost: parsed.daemonBindHost } : {}),
});
}
return;
Expand Down
12 changes: 10 additions & 2 deletions packages/coding-agent/src/modes/daemon/daemon-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1040,7 +1040,9 @@ export type DaemonErrorInfo =
| { code: "session_already_active"; sessionPath: string; activeSessionId?: string }
| { code: "session_recovering"; activeSessionId: string }
| { code: "update_restarting" }
| { code: "command_result_uncertain"; clientId: DaemonClientId; commandId: DaemonCommandId };
| { code: "command_result_uncertain"; clientId: DaemonClientId; commandId: DaemonCommandId }
/** TCP listener refused a command line whose per-machine token was missing or wrong. */
| { code: "tcp_auth_failed" };

export type DaemonSessionClosedReason = "killed" | "shutdown" | "completed" | "replaced" | "update";
export type DaemonClosingReason = "shutdown" | "update";
Expand Down Expand Up @@ -1112,13 +1114,19 @@ export type DaemonOutbound =
| DaemonRequestProgress
| {
type: "daemon_hello";
socketPath: string;
/** Local unix socket identity; absent on a TCP connection before it authenticates. */
socketPath?: string;
protocol: DaemonProtocolInfo;
schemaId?: string;
/** Monotonic wire-schema revision for field-sensitive compatibility checks. */
schemaRevision?: number;
/** App version of the daemon process, used to detect stale daemons after self-update. */
appVersion?: string;
/**
* Local-trust identity below: the supervisor's ownership token, pid,
* process start id, and filesystem paths. Written to connections on
* this machine only, so an unauthenticated TCP peer cannot read them.
*/
runtime?: DaemonRuntimeIdentity;
/** Changes whenever the public supervisor process is replaced. */
supervisorGeneration?: string;
Expand Down
Loading
Loading