Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fixed the Windows daemon endpoint: the named pipe is now named per user and agent directory, the daemon refuses to start when the endpoint is already occupied, and clients and daemon prove they share the owner-only endpoint secret before any session data or launch environment is sent (`PRIME_AGENT_DAEMON_REQUIRE_ENDPOINT_IDENTITY=1` opts in on other platforms).
12 changes: 12 additions & 0 deletions packages/coding-agent/docs/daemon.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,18 @@ Protocol v1 is retained only for the one-release update handoff that prepares an

JSON and RPC client modes do not expose daemon greetings, envelopes, snapshot records, lifecycle events, or connection metadata.

## Endpoint Ownership and Peer Identity

The public endpoint is a Unix socket in a per-user 0700 directory (`$TMPDIR/prime-agent-<uid>/daemon.sock`, mode 0600) or, on Windows, the named pipe `\\.\pipe\prime-agent-daemon-<key>` where `<key>` hashes the account (domain and username) and the agent directory. A supervisor refuses to start when something already answers on its endpoint.

Unix peers are the same user by construction. Named pipes give no such guarantee, so on Windows (or anywhere with `PRIME_AGENT_DAEMON_REQUIRE_ENDPOINT_IDENTITY=1`) both sides prove they can read `<agent dir>/daemon-endpoint-secret`, a 32-byte owner-only file created on first use, before anything else happens on a connection:

1. `daemon_hello` carries the `endpoint_identity` capability, a per-connection `endpointChallenge`, and `endpointHandshakeRequired: true` when the daemon enforces the check.
2. The client sends `endpoint_handshake { nonce, proof }` with `proof = HMAC(secret, "client", challenge, nonce)`. The daemon rejects every other command from an unverified connection and closes it.
3. The daemon answers with `HMAC(secret, "daemon", challenge, nonce)`. The client sends `create`, `attach`, prompts, and the launch environment only after that proof verifies; a wrong proof drops the connection.

The secret never crosses the endpoint. Where identity is required, a client sends only `list` and `shutdown` to a daemon that lacks the capability (enough to retire a stale daemon before starting a current one); an old client that never sends the handshake gets an explicit `Endpoint handshake required` error from a new daemon. On Unix the check is off by default and the wire behaviour is unchanged.

## Reconnect, Replay, and Snapshots

Every sequenced event belongs to a worker generation. Clients retain the last `{ generation, sequence }` cursor and present it on attach. The server reports whether the requested interval is complete, partial, or unavailable.
Expand Down
4 changes: 4 additions & 0 deletions packages/coding-agent/docs/windows.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,7 @@ For most users, [Git for Windows](https://git-scm.com/download/win) is sufficien
"shellPath": "C:\\cygwin64\\bin\\bash.exe"
}
```

## Daemon Endpoint

The background daemon listens on a named pipe whose name is derived from your Windows account and agent directory (`\\.\pipe\prime-agent-daemon-<key>`), so accounts on a shared machine never share an endpoint. Because named pipes do not carry Unix-style ownership, the client and the daemon prove to each other that they can read `%USERPROFILE%\.prime\agent\daemon-endpoint-secret` before any session data is exchanged. That file inherits your profile's permissions; do not share it or widen its ACL. If the daemon reports `Daemon socket already in use`, another process owns the pipe: stop it, or pick another endpoint with `--daemon-socket`.
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export interface DaemonSocketClient {
rosterResyncPending?: boolean;
authenticated?: boolean;
authenticationRole?: "supervisor" | "session_client";
/** Nonce sent in daemon_hello; endpoint_handshake must answer it before the client is trusted. */
endpointChallenge?: string;
transport?: "jsonl" | "private-framed";
snapshotStreaming?: boolean;
snapshotActiveSessionIds?: Set<string>;
Expand Down
225 changes: 203 additions & 22 deletions packages/coding-agent/src/modes/daemon/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ import { randomUUID } from "node:crypto";
import { createConnection, type Socket } from "node:net";
import { getDaemonLogPath } from "../../config.js";
import { attachJsonlLineReader, serializeJsonLine } from "../rpc/jsonl.js";
import {
createDaemonEndpointNonce,
createDaemonEndpointProof,
daemonEndpointIdentityRequired,
loadDaemonEndpointSecret,
verifyDaemonEndpointProof,
} from "./daemon-endpoint-identity.js";
import {
createDaemonCommandEnvelope,
DAEMON_COMMAND_ENVELOPE_MIN_PROTOCOL_VERSION,
Expand Down Expand Up @@ -92,10 +99,49 @@ export class DaemonCapabilityUnavailableError extends Error {
}
}

export class DaemonPeerIdentityError extends Error {
constructor(socketPath: string, detail: string) {
super(
`Could not verify that the process serving the Prime Agent daemon endpoint belongs to the current user (${detail}). ` +
`No session data was sent to it. ${daemonEndpointDetails(socketPath)}`,
);
this.name = "DaemonPeerIdentityError";
}
}

export function getDaemonSocketCloseReason(error: Error): DaemonClosingReason | undefined {
return error instanceof DaemonSocketClosedError ? error.daemonClosingReason : undefined;
}

/**
* Commands a client may send to a daemon whose identity it cannot verify: they
* carry no client secrets and are what the launcher needs to retire a stale
* (pre-endpoint-identity) daemon before starting a current one.
*/
export const DAEMON_UNVERIFIED_PEER_COMMANDS: ReadonlySet<DaemonCommand["type"]> = new Set(["list", "shutdown"]);

export interface DaemonClientOptions {
/**
* Verify the daemon holds this user's endpoint secret before sending
* anything but DAEMON_UNVERIFIED_PEER_COMMANDS. Defaults to
* daemonEndpointIdentityRequired(): always on Windows, opt-in elsewhere. A
* daemon whose hello says the handshake is required is verified regardless.
*/
requirePeerIdentity?: boolean;
/** Source of the shared secret; defaults to the agent-dir secret file. */
loadEndpointSecret?: () => string;
}

const ENDPOINT_HANDSHAKE_TIMEOUT_MS = 5000;

function helloSupportsPeerIdentity(hello: DaemonHello): hello is DaemonHello & { endpointChallenge: string } {
return (
hello.serverCapabilities?.includes("endpoint_identity") === true &&
typeof hello.endpointChallenge === "string" &&
hello.endpointChallenge.length > 0
);
}

export type DaemonClientReconnectStatus =
| { status: "reconnecting"; error: string }
| { status: "connected" }
Expand Down Expand Up @@ -157,13 +203,21 @@ export class DaemonClient {
private helloMessage?: DaemonHello;
private daemonClosingReason?: DaemonClosingReason;
private reconnectPromise?: Promise<void>;
private readonly requirePeerIdentity: boolean;
/** One handshake per connection; parked replays and requests share it. */
private peerVerification?: { socket: Socket; promise: Promise<void> };
private readonly helloWaiters = new Set<{
resolve: (hello: DaemonHello) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
}>();

constructor(private readonly socketPath: string) {}
constructor(
private readonly socketPath: string,
private readonly options: DaemonClientOptions = {},
) {
this.requirePeerIdentity = options.requirePeerIdentity ?? daemonEndpointIdentityRequired();
}

get hello(): DaemonHello | undefined {
return this.helloMessage;
Expand Down Expand Up @@ -346,6 +400,10 @@ export class DaemonClient {
if (missingCompatibility) {
throw new DaemonCapabilityUnavailableError(command.type, missingCompatibility.capability);
}
// Awaiting only when needed keeps the write synchronous for trusted Unix sockets.
if (this.peerIdentityNeeded(hello)) {
await this.ensurePeerIdentity(hello, command.type);
}
const envelopeProtocolVersion = Math.min(hello.protocol.version, DAEMON_PROTOCOL_VERSION);
return this.requestWire(
command,
Expand Down Expand Up @@ -414,6 +472,77 @@ export class DaemonClient {
});
}

private peerIdentityNeeded(hello: DaemonHello): boolean {
return this.requirePeerIdentity || hello.endpointHandshakeRequired === true;
}

/**
* Resolve once the current connection's peer has proven it holds the shared
* endpoint secret, or when no proof is needed. A daemon without the
* capability is refused for everything but DAEMON_UNVERIFIED_PEER_COMMANDS;
* a failed proof rejects and drops the connection so nothing else is sent.
*/
private async ensurePeerIdentity(hello: DaemonHello, commandType?: DaemonCommand["type"]): Promise<void> {
if (!this.peerIdentityNeeded(hello)) {
return;
}
const socket = this.socket;
if (!socket || socket.destroyed) {
throw new Error(
`Cannot verify the Prime Agent daemon endpoint because the daemon is not connected. ${daemonEndpointDetails(this.socketPath)}`,
);
}
if (!helloSupportsPeerIdentity(hello)) {
if (commandType !== undefined && DAEMON_UNVERIFIED_PEER_COMMANDS.has(commandType)) {
return;
}
throw new DaemonCapabilityUnavailableError(commandType ?? "endpoint_handshake", "endpoint_identity");
}
if (this.peerVerification?.socket !== socket) {
this.peerVerification = { socket, promise: this.performEndpointHandshake(hello, socket) };
}
await this.peerVerification.promise;
}

private async performEndpointHandshake(
hello: DaemonHello & { endpointChallenge: string },
socket: Socket,
): Promise<void> {
const secret = (this.options.loadEndpointSecret ?? loadDaemonEndpointSecret)();
const nonce = createDaemonEndpointNonce();
const proof = createDaemonEndpointProof(secret, "client", hello.endpointChallenge, nonce);
const protocolVersion = Math.min(hello.protocol.version, DAEMON_PROTOCOL_VERSION);
let response: DaemonResponse;
try {
response = await this.requestWire(
{ type: "endpoint_handshake", nonce, proof },
ENDPOINT_HANDSHAKE_TIMEOUT_MS,
{ recoverable: false },
protocolVersion >= DAEMON_COMMAND_ENVELOPE_MIN_PROTOCOL_VERSION ? protocolVersion : undefined,
);
} catch (error) {
throw new DaemonPeerIdentityError(this.socketPath, error instanceof Error ? error.message : String(error));
}
const daemonProof =
response.success && response.data && typeof response.data === "object"
? (response.data as { proof?: unknown }).proof
: undefined;
if (
response.success &&
verifyDaemonEndpointProof(secret, "daemon", hello.endpointChallenge, nonce, daemonProof)
) {
return;
}
const error = new DaemonPeerIdentityError(
this.socketPath,
response.success ? "the daemon returned an invalid endpoint proof" : response.error,
);
if (this.socket === socket && !socket.destroyed) {
socket.destroy(error);
}
throw error;
}

private armPendingRequestTimeout(id: string, pending: PendingDaemonRequest): void {
pending.timeout = setTimeout(() => {
this.pendingRequests.delete(id);
Expand Down Expand Up @@ -459,33 +588,17 @@ export class DaemonClient {

if (isDaemonHello(message)) {
this.helloMessage = message;
this.peerVerification = undefined;
for (const waiter of [...this.helloWaiters]) {
clearTimeout(waiter.timeout);
this.helloWaiters.delete(waiter);
waiter.resolve(message);
}
if (this.socket && !this.socket.destroyed) {
for (const [id, pending] of this.pendingRequests) {
if (!pending.awaitingReconnect) {
continue;
}
pending.awaitingReconnect = false;
const missingCompatibility = pending.compatibilities.find(
(compatibility) => !meetsDaemonCommandCompatibility(message, compatibility),
);
if (missingCompatibility) {
this.pendingRequests.delete(id);
pending.reject(
new DaemonCapabilityUnavailableError(
pending.commandType as DaemonCommand["type"],
missingCompatibility.capability,
true,
),
);
continue;
}
this.armPendingRequestTimeout(id, pending);
this.socket.write(pending.wireData);
if (this.peerIdentityNeeded(message)) {
void this.replayParkedRequestsAfterPeerVerification(message, this.socket);
} else {
this.replayParkedRequests(message, this.socket);
}
}
}
Expand Down Expand Up @@ -524,6 +637,74 @@ export class DaemonClient {
}
}

private replayParkedRequests(hello: DaemonHello, socket: Socket): void {
for (const [id, pending] of this.pendingRequests) {
if (!pending.awaitingReconnect) {
continue;
}
pending.awaitingReconnect = false;
const missingCompatibility = pending.compatibilities.find(
(compatibility) => !meetsDaemonCommandCompatibility(hello, compatibility),
);
if (missingCompatibility) {
this.pendingRequests.delete(id);
pending.reject(
new DaemonCapabilityUnavailableError(
pending.commandType as DaemonCommand["type"],
missingCompatibility.capability,
true,
),
);
continue;
}
this.armPendingRequestTimeout(id, pending);
socket.write(pending.wireData);
}
}

/** Parked commands must not reach a reconnected daemon before it has proven its identity. */
private async replayParkedRequestsAfterPeerVerification(hello: DaemonHello, socket: Socket): Promise<void> {
const parked = [...this.pendingRequests].filter(([, pending]) => pending.awaitingReconnect);
if (parked.length === 0) {
return;
}
if (!helloSupportsPeerIdentity(hello)) {
for (const [id, pending] of parked) {
if (DAEMON_UNVERIFIED_PEER_COMMANDS.has(pending.commandType as DaemonCommand["type"])) {
continue;
}
pending.awaitingReconnect = false;
this.pendingRequests.delete(id);
pending.reject(
new DaemonCapabilityUnavailableError(
pending.commandType as DaemonCommand["type"],
"endpoint_identity",
true,
),
);
}
this.replayParkedRequests(hello, socket);
return;
}
try {
await this.ensurePeerIdentity(hello);
} catch (error) {
const failure = error instanceof Error ? error : new Error(String(error));
for (const [id, pending] of this.pendingRequests) {
if (!pending.awaitingReconnect) {
continue;
}
pending.awaitingReconnect = false;
this.pendingRequests.delete(id);
pending.reject(failure);
}
return;
}
if (this.socket === socket && !socket.destroyed) {
this.replayParkedRequests(hello, socket);
}
}

private acknowledgeCommandResult(commandId: string): void {
const hello = this.helloMessage;
if (
Expand Down
Loading
Loading