diff --git a/packages/coding-agent/.changes/eng-5340-windows-daemon-pipe.md b/packages/coding-agent/.changes/eng-5340-windows-daemon-pipe.md new file mode 100644 index 0000000000..d4f1f47637 --- /dev/null +++ b/packages/coding-agent/.changes/eng-5340-windows-daemon-pipe.md @@ -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). diff --git a/packages/coding-agent/docs/daemon.md b/packages/coding-agent/docs/daemon.md index 35e4ce9dd9..b66bd4f851 100644 --- a/packages/coding-agent/docs/daemon.md +++ b/packages/coding-agent/docs/daemon.md @@ -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-/daemon.sock`, mode 0600) or, on Windows, the named pipe `\\.\pipe\prime-agent-daemon-` where `` 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 `/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. diff --git a/packages/coding-agent/docs/windows.md b/packages/coding-agent/docs/windows.md index 3f7da6c61d..38161cb512 100644 --- a/packages/coding-agent/docs/windows.md +++ b/packages/coding-agent/docs/windows.md @@ -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-`), 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`. diff --git a/packages/coding-agent/src/modes/daemon/active-session-state.ts b/packages/coding-agent/src/modes/daemon/active-session-state.ts index f71711831a..6c68ee64ab 100644 --- a/packages/coding-agent/src/modes/daemon/active-session-state.ts +++ b/packages/coding-agent/src/modes/daemon/active-session-state.ts @@ -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; diff --git a/packages/coding-agent/src/modes/daemon/daemon-client.ts b/packages/coding-agent/src/modes/daemon/daemon-client.ts index f75cbed8e2..f815785caa 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-client.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-client.ts @@ -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, @@ -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 = 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" } @@ -157,13 +203,21 @@ export class DaemonClient { private helloMessage?: DaemonHello; private daemonClosingReason?: DaemonClosingReason; private reconnectPromise?: Promise; + private readonly requirePeerIdentity: boolean; + /** One handshake per connection; parked replays and requests share it. */ + private peerVerification?: { socket: Socket; promise: Promise }; private readonly helloWaiters = new Set<{ resolve: (hello: DaemonHello) => void; reject: (error: Error) => void; timeout: ReturnType; }>(); - 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; @@ -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, @@ -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 { + 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 { + 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); @@ -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); } } } @@ -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 { + 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 ( diff --git a/packages/coding-agent/src/modes/daemon/daemon-endpoint-identity.ts b/packages/coding-agent/src/modes/daemon/daemon-endpoint-identity.ts new file mode 100644 index 0000000000..388fb6f744 --- /dev/null +++ b/packages/coding-agent/src/modes/daemon/daemon-endpoint-identity.ts @@ -0,0 +1,154 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getAgentDir } from "../../config.js"; + +/** + * Peer identity for the public daemon endpoint. + * + * Unix sockets inherit ownership from the filesystem (0700 directory, 0600 + * socket), so both sides already know the peer runs as the same user. Named + * pipes on Windows carry no equivalent guarantee that this code can check, so + * the client and the daemon prove to each other that they can read the same + * owner-only secret file in the agent directory before any session data or + * launch environment crosses the pipe. The proof is an HMAC over per-connection + * nonces from both sides; the secret itself never travels over the endpoint. + */ + +export const DAEMON_ENDPOINT_SECRET_FILE_NAME = "daemon-endpoint-secret"; +export const DAEMON_REQUIRE_ENDPOINT_IDENTITY_ENV = "PRIME_AGENT_DAEMON_REQUIRE_ENDPOINT_IDENTITY"; + +const SECRET_BYTES = 32; +const NONCE_BYTES = 32; +const SECRET_PATTERN = /^[0-9a-f]{64}$/; +const PROOF_DOMAIN = "prime-agent.daemon.endpoint-identity.v1"; +const SECRET_READ_ATTEMPTS = 20; + +export type DaemonEndpointProofRole = "client" | "daemon"; + +/** True when both sides must verify each other before any command flows: always on Windows, opt-in elsewhere. */ +export function daemonEndpointIdentityRequired( + environment: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): boolean { + if (platform === "win32") { + return true; + } + const flag = (environment[DAEMON_REQUIRE_ENDPOINT_IDENTITY_ENV] ?? "").trim().toLowerCase(); + return flag === "1" || flag === "true" || flag === "yes"; +} + +export function daemonEndpointSecretPath(agentDir: string = getAgentDir()): string { + return join(agentDir, DAEMON_ENDPOINT_SECRET_FILE_NAME); +} + +/** + * Read the shared endpoint secret, creating it owner-only on first use. + * Creation is exclusive (`wx`), so concurrent first users converge on one + * value; a reader that observes the file before its single write lands retries + * briefly instead of adopting a truncated secret. + */ +export function loadDaemonEndpointSecret(agentDir: string = getAgentDir()): string { + const path = daemonEndpointSecretPath(agentDir); + const existing = readDaemonEndpointSecret(path); + if (existing) { + return existing; + } + mkdirSync(agentDir, { recursive: true }); + const secret = randomBytes(SECRET_BYTES).toString("hex"); + try { + writeFileSync(path, `${secret}\n`, { mode: 0o600, flag: "wx" }); + restrictSecretFile(path); + return secret; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") { + throw error; + } + } + for (let attempt = 0; attempt < SECRET_READ_ATTEMPTS; attempt++) { + const raced = readDaemonEndpointSecret(path); + if (raced) { + return raced; + } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5); + } + throw new Error(`Daemon endpoint secret at ${path} is not readable or has an invalid format`); +} + +function readDaemonEndpointSecret(path: string): string | undefined { + let content: string; + try { + content = readFileSync(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return undefined; + } + throw error; + } + const secret = content.trim(); + if (!SECRET_PATTERN.test(secret)) { + return undefined; + } + restrictSecretFile(path); + return secret; +} + +function restrictSecretFile(path: string): void { + if (process.platform === "win32") { + // NTFS ACLs come from the agent directory (the user's profile); chmod only toggles read-only there. + return; + } + try { + if ((statSync(path).mode & 0o077) !== 0) { + chmodSync(path, 0o600); + } + } catch { + // Best effort: the secret is still usable; a wider mode is repaired on the next load. + } +} + +export function createDaemonEndpointNonce(): string { + return randomBytes(NONCE_BYTES).toString("hex"); +} + +/** + * Proof that the caller holds the secret, bound to both connection nonces and + * to the role, so a captured client proof can never be replayed as a daemon + * proof (or vice versa) and neither side can reflect the other's proof back. + */ +export function createDaemonEndpointProof( + secret: string, + role: DaemonEndpointProofRole, + daemonChallenge: string, + clientNonce: string, +): string { + return createHmac("sha256", Buffer.from(secret, "hex")) + .update(`${PROOF_DOMAIN}\n${role}\n${daemonChallenge}\n${clientNonce}`) + .digest("hex"); +} + +export function verifyDaemonEndpointProof( + secret: string, + role: DaemonEndpointProofRole, + daemonChallenge: string, + clientNonce: string, + proof: unknown, +): boolean { + if ( + typeof proof !== "string" || + typeof daemonChallenge !== "string" || + typeof clientNonce !== "string" || + daemonChallenge.length === 0 || + clientNonce.length === 0 + ) { + return false; + } + const expected = Buffer.from(createDaemonEndpointProof(secret, role, daemonChallenge, clientNonce), "hex"); + let presented: Buffer; + try { + presented = Buffer.from(proof, "hex"); + } catch { + return false; + } + return presented.length === expected.length && timingSafeEqual(presented, expected); +} diff --git a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts index 557630b878..2a279d66f8 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts @@ -73,8 +73,9 @@ export const DAEMON_COMMAND_ENVELOPE_MIN_PROTOCOL_VERSION = 7; // Revision 25 adds capability-gated direct worker peer transport discovery. // Revision 26 publishes own-session usage totals on session summary and saved-session rows. // Revision 27 adds structured session_recovering failure info for known-but-unaddressable sessions. -export const DAEMON_SCHEMA_REVISION = 27; -export const DAEMON_SCHEMA_ID = "protocol-7-schema-27-962b8b4c5e35"; +// Revision 28 adds the capability-gated endpoint identity handshake (hello challenge + endpoint_handshake). +export const DAEMON_SCHEMA_REVISION = 28; +export const DAEMON_SCHEMA_ID = "protocol-7-schema-28-da026c968f60"; export type DaemonProtocolName = typeof DAEMON_PROTOCOL_NAME; export type DaemonProtocolVersion = number; @@ -121,7 +122,12 @@ export type DaemonServerCapability = | "session_input_pause" | "owned_prompt_cancellation" | "acp_mcp_servers" - | "direct_peer_transport"; + | "direct_peer_transport" + // The daemon proves it holds the user's endpoint secret (daemon_hello carries + // endpointChallenge; endpoint_handshake answers it). Clients must check the + // capability before sending the handshake; on Windows they refuse to send + // anything else to a daemon without it. + | "endpoint_identity"; export type DaemonReplayStatus = "complete" | "partial" | "unavailable"; @@ -245,6 +251,19 @@ export function collectDaemonClientEnv(source: NodeJS.ProcessEnv = process.env): return Object.keys(env).length > 0 ? env : undefined; } +/** + * The client's full environment, forwarded on create so the supervisor can + * spawn the session worker with the environment the user launched from. The + * worker is a detached process started by a long-lived daemon whose own env + * reflects whichever shell started it first; the client's PATH, HOME, provider + * keys and base URLs, proxy settings, locale, and tool configuration must win + * for the session to behave like a locally launched agent, and there is no + * allowlist that covers arbitrary user setups without silently breaking some + * of them. Only PRIME_AGENT_INTERNAL_* (daemon plumbing) is dropped. Because + * this carries credentials, clients must not send it before the peer's + * identity is established: Unix relies on the 0600 socket, Windows on the + * endpoint_identity handshake (see daemon-endpoint-identity.ts). + */ export function collectDaemonLaunchEnv(source: NodeJS.ProcessEnv = process.env): Record { const env: Record = {}; for (const [key, value] of Object.entries(source)) { @@ -402,6 +421,8 @@ export type DaemonCommand = | { id?: string; type: "get_direct_worker_transport"; activeSessionId: string } | { id?: string; type: "roster_subscribe" } | { id?: string; type: "roster_unsubscribe" } + /** Mutual proof of the shared endpoint secret; nonce is the client's, proof answers the hello challenge. */ + | { id?: string; type: "endpoint_handshake"; nonce: string; proof: string } | ({ id?: string; type: "create"; @@ -743,6 +764,11 @@ const DIRECT_PEER_TRANSPORT_COMMAND = { minSchemaRevision: 25, capability: "direct_peer_transport", } as const; +const ENDPOINT_IDENTITY_COMMAND = { + minProtocol: 7, + minSchemaRevision: 28, + capability: "endpoint_identity", +} as const; export const DAEMON_COMMAND_COMPATIBILITY = { ack_result: LEGACY_DAEMON_COMMAND, @@ -750,6 +776,7 @@ export const DAEMON_COMMAND_COMPATIBILITY = { list_saved_sessions: LEGACY_DAEMON_COMMAND, list_agent_peers: AGENT_PEER_LIST_COMMAND, get_direct_worker_transport: DIRECT_PEER_TRANSPORT_COMMAND, + endpoint_handshake: ENDPOINT_IDENTITY_COMMAND, create: LEGACY_DAEMON_COMMAND, attach: LEGACY_DAEMON_COMMAND, reattach: LEGACY_DAEMON_COMMAND, @@ -868,6 +895,7 @@ export const DAEMON_COMMAND_PLANE = { list_saved_sessions: "control", list_agent_peers: "control", get_direct_worker_transport: "control", + endpoint_handshake: "control", create: "control", attach: "session", reattach: "control", @@ -1118,6 +1146,10 @@ export type DaemonOutbound = supervisorSocketPath?: string; clientId: DaemonClientId; serverCapabilities: readonly DaemonServerCapability[]; + /** Per-connection nonce for endpoint_handshake; present with the endpoint_identity capability. */ + endpointChallenge?: string; + /** The daemon rejects every other command until the handshake succeeds (Windows, or opt-in). */ + endpointHandshakeRequired?: true; } | { type: "daemon_closing"; reason: DaemonClosingReason } | { type: "heartbeats_changed" } @@ -1285,6 +1317,7 @@ const READ_ONLY_DAEMON_COMMANDS: ReadonlySet = new Set([ "list_saved_sessions", "list_agent_peers", "get_direct_worker_transport", + "endpoint_handshake", "attach", "reattach", "roster_subscribe", diff --git a/packages/coding-agent/src/modes/daemon/daemon-socket.ts b/packages/coding-agent/src/modes/daemon/daemon-socket.ts index 99a3a15105..f2b2656fb0 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-socket.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-socket.ts @@ -1,8 +1,10 @@ +import { createHash } from "node:crypto"; import { chmodSync, existsSync, lstatSync, mkdirSync, unlinkSync } from "node:fs"; import { createConnection } from "node:net"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { tmpdir, userInfo } from "node:os"; +import { dirname, join, resolve } from "node:path"; import lockfile from "proper-lockfile"; +import { getAgentDir } from "../../config.js"; export { normalizeSocketPath } from "../../utils/daemon-socket-path.js"; @@ -67,9 +69,39 @@ export interface DaemonSocketIdentity { ino: number; } +/** + * Per-user endpoint key for Windows named pipes, which live in one global + * namespace with no per-user directory to scope them. The key binds the pipe + * name to the account (domain and username) and the agent directory so + * accounts and profiles on one machine never share an endpoint name. It is a + * namespace, not a secret: peer identity is proven separately by + * daemon-endpoint-identity.ts. + */ +export function daemonEndpointOwnerKey( + agentDir: string = getAgentDir(), + environment: NodeJS.ProcessEnv = process.env, + username: string = currentUsername(environment), +): string { + const domain = environment.USERDOMAIN ?? ""; + return createHash("sha256") + .update(`${domain}\\${username}\u0000${resolve(agentDir).toLowerCase()}`) + .digest("hex") + .slice(0, 16); +} + +function currentUsername(environment: NodeJS.ProcessEnv): string { + try { + const name = userInfo().username; + if (name) return name; + } catch { + // No account database entry for this uid; fall back to the environment. + } + return environment.USERNAME || environment.USER || "user"; +} + export function defaultDaemonSocketPath(): string { if (process.platform === "win32") { - return "\\\\.\\pipe\\prime-agent-daemon"; + return `\\\\.\\pipe\\prime-agent-daemon-${daemonEndpointOwnerKey()}`; } return join(defaultDaemonSocketDir(), "daemon.sock"); } @@ -105,6 +137,12 @@ export async function prepareDaemonSocketPath(socketPath: string, lease?: Daemon ensureDefaultDaemonSocketDir(socketPath); if (process.platform === "win32") { + // A named pipe has no filesystem entry to inspect or unlink; the only + // occupancy signal is whether something answers on it. Refuse to start + // over a live server, whoever owns it, instead of racing it for clients. + if (await canConnectToEndpoint(socketPath)) { + throw new Error(`Daemon socket already in use: ${socketPath}`); + } return; } if (lease) { @@ -116,7 +154,7 @@ export async function prepareDaemonSocketPath(socketPath: string, lease?: Daemon if (!existsSync(socketPath)) { return; } - if (await canConnectToUnixSocket(socketPath)) { + if (await canConnectToEndpoint(socketPath)) { throw new Error(`Daemon socket already in use: ${socketPath}`); } const ownedLease = await acquireDaemonSocketPathLease(socketPath); @@ -146,7 +184,7 @@ async function prepareUnixDaemonSocketPath(socketPath: string, lease?: DaemonSoc } const staleIdentity: DaemonSocketIdentity = { dev: stat.dev, ino: stat.ino }; - if (await canConnectToUnixSocket(socketPath)) { + if (await canConnectToEndpoint(socketPath)) { throw new Error(`Daemon socket already in use: ${socketPath}`); } const deadline = Date.now() + DAEMON_SOCKET_RELEASE_GRACE_MS; @@ -167,7 +205,7 @@ async function prepareUnixDaemonSocketPath(socketPath: string, lease?: DaemonSoc if (!currentIdentity || currentIdentity.dev !== staleIdentity.dev || currentIdentity.ino !== staleIdentity.ino) { throw new Error(`Daemon socket changed ownership while waiting for cleanup: ${socketPath}`); } - if (await canConnectToUnixSocket(socketPath)) { + if (await canConnectToEndpoint(socketPath)) { throw new Error(`Daemon socket already in use: ${socketPath}`); } } @@ -302,7 +340,7 @@ function ensureDefaultDaemonSocketDir(socketPath: string): void { chmodSync(defaultDaemonSocketDir(), DAEMON_SOCKET_DIR_MODE); } -function canConnectToUnixSocket(socketPath: string): Promise { +function canConnectToEndpoint(socketPath: string): Promise { return new Promise((resolveConnect) => { const socket = createConnection(socketPath); let settled = false; diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 5a2782c777..965f038ed2 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -76,6 +76,13 @@ import { import { CommandRecoveryJournal, createCommandIdempotencyKey } from "./command-recovery-journal.js"; import { CompactAssistantStreamReconstructor, isCompactAssistantDelta } from "./compact-session-stream.js"; import { DAEMON_CATALOG_ROLE_ENV, DaemonCatalogClient } from "./daemon-catalog-process.js"; +import { + createDaemonEndpointNonce, + createDaemonEndpointProof, + daemonEndpointIdentityRequired, + loadDaemonEndpointSecret, + verifyDaemonEndpointProof, +} from "./daemon-endpoint-identity.js"; import { DaemonSessionRecoveringError, deserializeDaemonError, serializeDaemonError } from "./daemon-errors.js"; import { collectDaemonClientEnv, @@ -230,6 +237,7 @@ const DAEMON_COMMAND_TYPES: ReadonlySet = new Set([ "list", "list_agent_peers", "get_direct_worker_transport", + "endpoint_handshake", "roster_subscribe", "roster_unsubscribe", "list_saved_sessions", @@ -391,6 +399,12 @@ interface DaemonSupervisorOptions { socketPath?: string; defaultSessionConfig: AgentSessionRuntimeConfig; descriptorDir?: string; + /** + * Reject every public command until the client completes endpoint_handshake. + * Defaults to daemonEndpointIdentityRequired(): on Windows the named pipe + * gives no ownership guarantee, so the proof is the only peer check. + */ + requireEndpointHandshake?: boolean; } interface PersistedSupervisorConfig { @@ -744,6 +758,9 @@ export class DaemonSupervisor { private scheduledWakeRecompute?: Promise; private scheduledWakeRecomputeQueued = false; private readonly scheduledWakeFailures = new Map(); + private readonly requireEndpointHandshake: boolean; + /** Shared with same-user clients through the agent dir; absent only when that file is unusable and not required. */ + private readonly endpointSecret?: string; constructor( private readonly socketPath: string, @@ -758,6 +775,17 @@ export class DaemonSupervisor { if (!agentDir) { throw new Error("Daemon supervisor config is missing agentDir"); } + this.requireEndpointHandshake = options.requireEndpointHandshake ?? daemonEndpointIdentityRequired(); + try { + this.endpointSecret = loadDaemonEndpointSecret(agentDir); + } catch (error) { + if (this.requireEndpointHandshake) { + throw new Error( + `Daemon endpoint identity is required but the endpoint secret could not be prepared: ${String(error)}`, + ); + } + this.endpointSecret = undefined; + } this.descriptorDir = options.descriptorDir ?? defaultWorkerDescriptorDir(agentDir, socketPath); this.supervisorConfigPath = join(this.descriptorDir, SUPERVISOR_CONFIG_FILE_NAME); this.defaultSessionConfig = mergeAgentSessionRuntimeConfig( @@ -1436,7 +1464,10 @@ export class DaemonSupervisor { attachedActiveSessionIds: new Set(), catchupActiveSessionIds: new Set(), backpressured: false, - authenticated: true, + // A Unix socket peer is already the same user (0700 dir, 0600 socket); a + // named-pipe peer is trusted only after endpoint_handshake. + authenticated: !this.requireEndpointHandshake, + ...(this.endpointSecret ? { endpointChallenge: createDaemonEndpointNonce() } : {}), snapshotActiveSessionIds: new Set(), detachInput: () => {}, supportsExtensionUi: false, @@ -1463,7 +1494,11 @@ export class DaemonSupervisor { supervisorProcessStartId: this.ownership?.record.processStartId, supervisorSocketPath: this.ownership?.record.socketPath, clientId: client.id, - serverCapabilities: SUPERVISOR_SERVER_CAPABILITIES, + serverCapabilities: client.endpointChallenge + ? [...SUPERVISOR_SERVER_CAPABILITIES, "endpoint_identity"] + : SUPERVISOR_SERVER_CAPABILITIES, + ...(client.endpointChallenge ? { endpointChallenge: client.endpointChallenge } : {}), + ...(this.requireEndpointHandshake ? { endpointHandshakeRequired: true as const } : {}), }); } }, @@ -1731,6 +1766,38 @@ export class DaemonSupervisor { }; } + /** + * Mutual proof of the shared endpoint secret. The client's proof binds our + * per-connection challenge to its nonce; ours answers with the daemon role + * so neither proof can be reflected. A failed proof ends the connection. + */ + private handleEndpointHandshake( + client: DaemonSocketClient, + command: Extract, + ): void { + const challenge = client.endpointChallenge; + const secret = this.endpointSecret; + if ( + !secret || + !challenge || + typeof command.nonce !== "string" || + command.nonce.length === 0 || + !verifyDaemonEndpointProof(secret, "client", challenge, command.nonce, command.proof) + ) { + client.endpointChallenge = undefined; + this.write(client, failure(command.id, command.type, "Endpoint handshake failed")); + client.socket.end(); + return; + } + client.authenticated = true; + this.write( + client, + success(command.id, command.type, { + proof: createDaemonEndpointProof(secret, "daemon", challenge, command.nonce), + }), + ); + } + private async handleLine(client: DaemonSocketClient, line: string): Promise { try { this.assertSupervisorServing(); @@ -1747,6 +1814,23 @@ export class DaemonSupervisor { } const command = preParsed.command; const parsedAdmission = preParsed.admission; + if (command.type === "endpoint_handshake") { + this.handleEndpointHandshake(client, command); + return; + } + if (this.requireEndpointHandshake && client.authenticated !== true) { + if (parsedAdmission) this.deletePromptAdmission(parsedAdmission); + this.write( + client, + failure( + command.id, + command.type, + "Endpoint handshake required: this daemon accepts commands only from clients that prove they hold the current user's daemon endpoint secret", + ), + ); + client.socket.end(); + return; + } if (command.type === "cancel_prompt_admission" && this.updateRestartPhase !== undefined) { this.write(client, failure(command.id, command.type, "Daemon is preparing an update restart")); return; diff --git a/packages/coding-agent/test/daemon-endpoint-identity-process.test.ts b/packages/coding-agent/test/daemon-endpoint-identity-process.test.ts new file mode 100644 index 0000000000..c1aa09f796 --- /dev/null +++ b/packages/coding-agent/test/daemon-endpoint-identity-process.test.ts @@ -0,0 +1,174 @@ +import { type ChildProcess, spawn } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs"; +import { createConnection } from "node:net"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { ENV_AGENT_DIR } from "../src/config.js"; +import { DaemonClient, DaemonPeerIdentityError } from "../src/modes/daemon/daemon-client.js"; +import { + DAEMON_REQUIRE_ENDPOINT_IDENTITY_ENV, + daemonEndpointSecretPath, + loadDaemonEndpointSecret, +} from "../src/modes/daemon/daemon-endpoint-identity.js"; +import { createDaemonCommandEnvelope, type DaemonResponse } from "../src/modes/daemon/daemon-protocol.js"; + +// ENG-5340 end to end against a real supervisor process with the Windows +// endpoint policy switched on through the environment. + +const cliPath = resolve(__dirname, "../src/cli.ts"); +const tsxPath = resolve(__dirname, "../../../node_modules/tsx/dist/cli.mjs"); +const tempDirs: string[] = []; +const children = new Set(); +const sockets = new Set(); + +afterEach(async () => { + for (const socketPath of sockets) { + const client = new DaemonClient(socketPath, { requirePeerIdentity: false }); + try { + await client.connect(250); + await client.request({ type: "shutdown", force: true }, 2000); + } catch { + // Already gone. + } finally { + client.close(); + } + } + sockets.clear(); + for (const child of children) { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM"); + } + await Promise.all([...children].map((child) => waitForExit(child).catch(() => undefined))); + children.clear(); + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); + +function spawnSupervisor(agentDir: string, socketPath: string, cwd: string): ChildProcess { + sockets.add(socketPath); + const child = spawn( + process.execPath, + [tsxPath, cliPath, "--mode", "daemon", "--daemon-socket", socketPath, "--offline"], + { + cwd, + env: { + ...process.env, + [ENV_AGENT_DIR]: agentDir, + [DAEMON_REQUIRE_ENDPOINT_IDENTITY_ENV]: "1", + PI_OFFLINE: "1", + TSX_TSCONFIG_PATH: resolve(__dirname, "../../../tsconfig.json"), + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + children.add(child); + return child; +} + +async function waitForExit(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + await new Promise((resolveExit, reject) => { + const timeout = setTimeout(() => reject(new Error("Timed out waiting for process exit")), 10_000); + child.once("exit", () => { + clearTimeout(timeout); + resolveExit(); + }); + }); +} + +async function waitForHello(socketPath: string, child: ChildProcess): Promise { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`Supervisor exited early with code ${child.exitCode}`); + const probe = new DaemonClient(socketPath, { requirePeerIdentity: false }); + try { + await probe.connect(250); + await probe.waitForHello(1000); + return; + } catch { + await new Promise((resolveDelay) => setTimeout(resolveDelay, 50)); + } finally { + probe.close(); + } + } + throw new Error("Timed out waiting for the supervisor hello"); +} + +/** An old client: speaks the protocol but never sends endpoint_handshake. */ +function legacyList(socketPath: string): Promise<{ response: DaemonResponse; closed: boolean }> { + return new Promise((resolveResult, reject) => { + const socket = createConnection(socketPath); + let buffer = ""; + let response: DaemonResponse | undefined; + socket.on("error", reject); + socket.on("close", () => { + if (response) resolveResult({ response, closed: true }); + else reject(new Error("Socket closed without a response")); + }); + socket.on("data", (chunk) => { + buffer += chunk.toString(); + for (const line of buffer.split("\n")) { + if (!line.trim()) continue; + const message = JSON.parse(line) as { type: string }; + if (message.type === "daemon_hello") { + socket.write(`${JSON.stringify(createDaemonCommandEnvelope({ type: "list" }, "legacy-1", "legacy"))}\n`); + } else if (message.type === "response") { + response = message as DaemonResponse; + } + } + buffer = buffer.slice(buffer.lastIndexOf("\n") + 1); + }); + }); +} + +describe("daemon supervisor endpoint identity (process)", () => { + it("admits only clients that prove the shared endpoint secret", async () => { + if (process.platform === "win32") return; + const root = mkdtempSync(join(tmpdir(), "pa-endpoint-process-")); + tempDirs.push(root); + const agentDir = join(root, "agent"); + const projectDir = join(root, "project"); + const socketPath = join(root, "daemon.sock"); + mkdirSync(projectDir, { recursive: true }); + + const supervisor = spawnSupervisor(agentDir, socketPath, projectDir); + await waitForHello(socketPath, supervisor); + + // The supervisor created the owner-only secret in its agent dir. + expect(statSync(daemonEndpointSecretPath(agentDir)).mode & 0o777).toBe(0o600); + + const current = new DaemonClient(socketPath, { + requirePeerIdentity: true, + loadEndpointSecret: () => loadDaemonEndpointSecret(agentDir), + }); + await current.connect(1000); + const hello = await current.waitForHello(5000); + expect(hello.serverCapabilities).toContain("endpoint_identity"); + expect(hello.endpointChallenge).toMatch(/^[0-9a-f]{64}$/); + expect(hello.endpointHandshakeRequired).toBe(true); + const listed = await current.request({ type: "list" }); + expect(listed.success).toBe(true); + current.close(); + + // Old client, new daemon: explicit rejection and disconnect, no silent hang. + const legacy = await legacyList(socketPath); + expect(legacy.response).toMatchObject({ + id: "legacy-1", + command: "list", + success: false, + error: expect.stringMatching(/Endpoint handshake required/), + }); + expect(legacy.closed).toBe(true); + + // Another account's secret never verifies. + const impostor = new DaemonClient(socketPath, { + requirePeerIdentity: true, + loadEndpointSecret: () => "ff".repeat(32), + }); + await impostor.connect(1000); + await impostor.waitForHello(5000); + await expect(impostor.request({ type: "list" })).rejects.toBeInstanceOf(DaemonPeerIdentityError); + impostor.close(); + }, 90_000); +}); diff --git a/packages/coding-agent/test/daemon-endpoint-identity.test.ts b/packages/coding-agent/test/daemon-endpoint-identity.test.ts new file mode 100644 index 0000000000..00ee7fc4f9 --- /dev/null +++ b/packages/coding-agent/test/daemon-endpoint-identity.test.ts @@ -0,0 +1,624 @@ +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { createServer, type Server, type Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { DaemonSocketClient } from "../src/modes/daemon/active-session-state.js"; +import { + DaemonCapabilityUnavailableError, + DaemonClient, + DaemonPeerIdentityError, +} from "../src/modes/daemon/daemon-client.js"; +import { + createDaemonEndpointNonce, + createDaemonEndpointProof, + DAEMON_ENDPOINT_SECRET_FILE_NAME, + DAEMON_REQUIRE_ENDPOINT_IDENTITY_ENV, + daemonEndpointIdentityRequired, + loadDaemonEndpointSecret, + verifyDaemonEndpointProof, +} from "../src/modes/daemon/daemon-endpoint-identity.js"; +import { + createDaemonCommandEnvelope, + DAEMON_PROTOCOL_VERSION, + DAEMON_SCHEMA_ID, + DAEMON_SCHEMA_REVISION, + type DaemonCommand, + type DaemonResponse, +} from "../src/modes/daemon/daemon-protocol.js"; +import { + daemonEndpointOwnerKey, + defaultDaemonSocketPath, + prepareDaemonSocketPath, +} from "../src/modes/daemon/daemon-socket.js"; +import { DaemonSupervisor } from "../src/modes/daemon/daemon-supervisor.js"; +import { MutationDrainLatch } from "../src/modes/daemon/mutation-drain-latch.js"; + +// ENG-5340: the Windows named pipe had no ownership, occupancy, or peer identity +// checks, so a process that pre-created the pipe received the client's create +// command with its full launch environment. These tests run on Linux: platform +// behaviour is stubbed, and Unix sockets stand in for the pipe. + +const tempDirs: string[] = []; +const servers: Server[] = []; +const clients: DaemonClient[] = []; + +function tempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + for (const client of clients.splice(0)) client.close(); + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve) => { + if (!server.listening) return resolve(); + server.close(() => resolve()); + }), + ), + ); + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +function withPlatform(platform: NodeJS.Platform, fn: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(process, "platform")!; + Object.defineProperty(process, "platform", { value: platform }); + try { + return fn(); + } finally { + Object.defineProperty(process, "platform", descriptor); + } +} + +async function withPlatformAsync(platform: NodeJS.Platform, fn: () => Promise): Promise { + const descriptor = Object.getOwnPropertyDescriptor(process, "platform")!; + Object.defineProperty(process, "platform", { value: platform }); + try { + return await fn(); + } finally { + Object.defineProperty(process, "platform", descriptor); + } +} + +describe("daemon endpoint secret", () => { + it("creates an owner-only secret once and reuses it", () => { + const agentDir = join(tempDir("pa-endpoint-secret-"), "agent"); + const first = loadDaemonEndpointSecret(agentDir); + const second = loadDaemonEndpointSecret(agentDir); + + expect(first).toMatch(/^[0-9a-f]{64}$/); + expect(second).toBe(first); + const path = join(agentDir, DAEMON_ENDPOINT_SECRET_FILE_NAME); + expect(readFileSync(path, "utf8").trim()).toBe(first); + if (process.platform !== "win32") { + expect(statSync(path).mode & 0o777).toBe(0o600); + } + }); + + it("repairs a secret file left readable by other users", () => { + if (process.platform === "win32") return; + const agentDir = tempDir("pa-endpoint-secret-mode-"); + const path = join(agentDir, DAEMON_ENDPOINT_SECRET_FILE_NAME); + writeFileSync(path, `${"ab".repeat(32)}\n`, { mode: 0o644 }); + + expect(loadDaemonEndpointSecret(agentDir)).toBe("ab".repeat(32)); + expect(statSync(path).mode & 0o777).toBe(0o600); + }); + + it("rejects a malformed secret file instead of adopting it", () => { + const agentDir = tempDir("pa-endpoint-secret-bad-"); + writeFileSync(join(agentDir, DAEMON_ENDPOINT_SECRET_FILE_NAME), "not-a-secret\n"); + + expect(() => loadDaemonEndpointSecret(agentDir)).toThrow(/invalid format/); + }); + + it("binds proofs to the role and both nonces", () => { + const secret = "11".repeat(32); + const challenge = createDaemonEndpointNonce(); + const nonce = createDaemonEndpointNonce(); + const clientProof = createDaemonEndpointProof(secret, "client", challenge, nonce); + const daemonProof = createDaemonEndpointProof(secret, "daemon", challenge, nonce); + + expect(verifyDaemonEndpointProof(secret, "client", challenge, nonce, clientProof)).toBe(true); + expect(verifyDaemonEndpointProof(secret, "daemon", challenge, nonce, daemonProof)).toBe(true); + // A reflected proof or one under another secret/nonce never verifies. + expect(verifyDaemonEndpointProof(secret, "daemon", challenge, nonce, clientProof)).toBe(false); + expect(verifyDaemonEndpointProof(secret, "client", challenge, nonce, daemonProof)).toBe(false); + expect(verifyDaemonEndpointProof("22".repeat(32), "client", challenge, nonce, clientProof)).toBe(false); + expect(verifyDaemonEndpointProof(secret, "client", createDaemonEndpointNonce(), nonce, clientProof)).toBe(false); + expect(verifyDaemonEndpointProof(secret, "client", challenge, createDaemonEndpointNonce(), clientProof)).toBe( + false, + ); + expect(verifyDaemonEndpointProof(secret, "client", challenge, nonce, undefined)).toBe(false); + expect(verifyDaemonEndpointProof(secret, "client", challenge, nonce, "zz")).toBe(false); + expect(verifyDaemonEndpointProof(secret, "client", "", nonce, clientProof)).toBe(false); + }); + + it("is required on Windows and opt-in elsewhere", () => { + expect(daemonEndpointIdentityRequired({}, "win32")).toBe(true); + expect(daemonEndpointIdentityRequired({}, "linux")).toBe(false); + expect(daemonEndpointIdentityRequired({}, "darwin")).toBe(false); + expect(daemonEndpointIdentityRequired({ [DAEMON_REQUIRE_ENDPOINT_IDENTITY_ENV]: "1" }, "linux")).toBe(true); + expect(daemonEndpointIdentityRequired({ [DAEMON_REQUIRE_ENDPOINT_IDENTITY_ENV]: "true" }, "darwin")).toBe(true); + expect(daemonEndpointIdentityRequired({ [DAEMON_REQUIRE_ENDPOINT_IDENTITY_ENV]: "0" }, "linux")).toBe(false); + }); +}); + +describe("Windows daemon endpoint naming", () => { + it("derives a per-user, per-agent-dir pipe name", () => { + const env = { USERDOMAIN: "HOST" }; + const keyA = daemonEndpointOwnerKey("C:\\Users\\alice\\.prime\\agent", env, "alice"); + + expect(keyA).toMatch(/^[0-9a-f]{16}$/); + expect(daemonEndpointOwnerKey("C:\\Users\\alice\\.prime\\agent", env, "alice")).toBe(keyA); + // Another account, another domain, or another agent dir gets its own endpoint. + expect(daemonEndpointOwnerKey("C:\\Users\\alice\\.prime\\agent", env, "bob")).not.toBe(keyA); + expect(daemonEndpointOwnerKey("C:\\Users\\alice\\.prime\\agent", { USERDOMAIN: "OTHER" }, "alice")).not.toBe( + keyA, + ); + expect(daemonEndpointOwnerKey("C:\\Users\\alice\\other", env, "alice")).not.toBe(keyA); + // Case differences in the agent dir (case-insensitive filesystem) do not fork the endpoint. + expect(daemonEndpointOwnerKey("c:\\users\\ALICE\\.prime\\AGENT", env, "alice")).toBe(keyA); + // The default username comes from the OS account, not from a spoofable environment variable. + expect(daemonEndpointOwnerKey("/tmp/agent", { USERNAME: "someone-else" })).toBe( + daemonEndpointOwnerKey("/tmp/agent", {}), + ); + }); + + it("names the Windows pipe after the endpoint owner key", () => { + const pipe = withPlatform("win32", () => defaultDaemonSocketPath()); + expect(pipe).toBe(`\\\\.\\pipe\\prime-agent-daemon-${daemonEndpointOwnerKey()}`); + expect(pipe).not.toBe("\\\\.\\pipe\\prime-agent-daemon"); + }); + + it("refuses to start over an endpoint that already answers", async () => { + if (process.platform === "win32") return; + const dir = tempDir("pa-endpoint-occupied-"); + const endpoint = join(dir, "pipe-stand-in.sock"); + const server = createServer((socket) => socket.destroy()); + servers.push(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(endpoint, resolve); + }); + + await expect(withPlatformAsync("win32", () => prepareDaemonSocketPath(endpoint))).rejects.toThrow( + /socket already in use/i, + ); + // A free endpoint is accepted without touching the filesystem. + await expect( + withPlatformAsync("win32", () => prepareDaemonSocketPath(join(dir, "free-pipe-stand-in.sock"))), + ).resolves.toBeUndefined(); + }); +}); + +interface FakeDaemonOptions { + serverCapabilities?: string[]; + endpointChallenge?: string; + endpointHandshakeRequired?: true; + secret?: string; + sendHello?: boolean; + onCommand?: (command: { type: string; id: string; body: Record }, socket: Socket) => void; +} + +interface FakeDaemon { + socketPath: string; + received: Array<{ type: string; body: Record }>; + connections: Socket[]; +} + +function reply(socket: Socket, response: DaemonResponse): void { + socket.write(`${JSON.stringify(response)}\n`); +} + +/** Stands in for whoever owns the endpoint: honest daemon, old daemon, or a squatter without the secret. */ +async function startFakeDaemon(options: FakeDaemonOptions = {}): Promise { + const dir = tempDir("pa-endpoint-fake-"); + const socketPath = join(dir, "daemon.sock"); + const fake: FakeDaemon = { socketPath, received: [], connections: [] }; + const server = createServer((socket) => { + fake.connections.push(socket); + socket.on("error", () => undefined); + if (options.sendHello ?? true) { + socket.write( + `${JSON.stringify({ + type: "daemon_hello", + socketPath, + protocol: { name: "prime-agent.daemon", version: DAEMON_PROTOCOL_VERSION }, + schemaId: DAEMON_SCHEMA_ID, + schemaRevision: DAEMON_SCHEMA_REVISION, + clientId: "fake-client", + serverCapabilities: options.serverCapabilities ?? [], + ...(options.endpointChallenge ? { endpointChallenge: options.endpointChallenge } : {}), + ...(options.endpointHandshakeRequired ? { endpointHandshakeRequired: true } : {}), + })}\n`, + ); + } + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk.toString(); + let newline = buffer.indexOf("\n"); + while (newline !== -1) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + newline = buffer.indexOf("\n"); + if (!line.trim()) continue; + const wire = JSON.parse(line) as { id: string; type: string; command?: DaemonCommand }; + const body = (wire.command ?? wire) as unknown as Record; + const type = String(body.type); + fake.received.push({ type, body }); + if (options.onCommand) { + options.onCommand({ type, id: wire.id, body }, socket); + continue; + } + if (type === "endpoint_handshake") { + const secret = options.secret; + const challenge = options.endpointChallenge ?? ""; + const nonce = String(body.nonce); + if (secret && verifyDaemonEndpointProof(secret, "client", challenge, nonce, body.proof)) { + reply(socket, { + id: wire.id, + type: "response", + command: type, + success: true, + data: { proof: createDaemonEndpointProof(secret, "daemon", challenge, nonce) }, + }); + } else { + // A squatter cannot compute the proof; the best it can do is guess. + reply(socket, { + id: wire.id, + type: "response", + command: type, + success: true, + data: { proof: "00".repeat(32) }, + }); + } + continue; + } + reply(socket, { id: wire.id, type: "response", command: type, success: true, data: { sessions: [] } }); + } + }); + }); + servers.push(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); + return fake; +} + +async function connectClient( + socketPath: string, + options: { requirePeerIdentity?: boolean; secret?: string } = {}, +): Promise { + const client = new DaemonClient(socketPath, { + requirePeerIdentity: options.requirePeerIdentity, + loadEndpointSecret: () => options.secret ?? "cc".repeat(32), + }); + clients.push(client); + await client.connect(1000); + await client.waitForHello(2000); + return client; +} + +const SENSITIVE_CREATE = { + type: "create" as const, + config: { apiKey: "SYNTH-PROVIDER-KEY-5340" }, + launchEnv: { OPENAI_API_KEY: "SYNTH-OPENAI-KEY-5340", PATH: "/usr/bin" }, +}; + +describe("DaemonClient peer identity (Windows policy)", () => { + it("refuses to send create to an endpoint owner that cannot prove the secret", async () => { + const secret = "aa".repeat(32); + // Squatter: advertises everything a current daemon would, but has no secret. + const squatter = await startFakeDaemon({ + serverCapabilities: ["endpoint_identity"], + endpointChallenge: createDaemonEndpointNonce(), + }); + const client = await connectClient(squatter.socketPath, { requirePeerIdentity: true, secret }); + + await expect(client.request(SENSITIVE_CREATE)).rejects.toBeInstanceOf(DaemonPeerIdentityError); + expect(squatter.received.map((entry) => entry.type)).toEqual(["endpoint_handshake"]); + expect(JSON.stringify(squatter.received)).not.toContain("SYNTH-"); + // The connection is dropped so nothing else can leak on it. + await vi.waitFor(() => expect(client.isConnected).toBe(false)); + }); + + it("does not hand the secret itself to the endpoint owner", async () => { + const secret = "ab".repeat(32); + const squatter = await startFakeDaemon({ + serverCapabilities: ["endpoint_identity"], + endpointChallenge: createDaemonEndpointNonce(), + }); + const client = await connectClient(squatter.socketPath, { requirePeerIdentity: true, secret }); + + await client.request(SENSITIVE_CREATE).catch(() => undefined); + expect(JSON.stringify(squatter.received)).not.toContain(secret); + }); + + it("refuses an old daemon without endpoint identity except for stale-daemon retirement", async () => { + // New client, old daemon: hello has no capability and no challenge. + const oldDaemon = await startFakeDaemon({ serverCapabilities: ["session_input_admission"] }); + const client = await connectClient(oldDaemon.socketPath, { requirePeerIdentity: true }); + + const error = await client.request(SENSITIVE_CREATE).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(DaemonCapabilityUnavailableError); + expect((error as DaemonCapabilityUnavailableError).capability).toBe("endpoint_identity"); + expect(oldDaemon.received).toEqual([]); + + await expect(client.request({ type: "list" })).resolves.toMatchObject({ success: true }); + await expect(client.request({ type: "shutdown" })).resolves.toMatchObject({ success: true }); + expect(oldDaemon.received.map((entry) => entry.type)).toEqual(["list", "shutdown"]); + expect(JSON.stringify(oldDaemon.received)).not.toContain("SYNTH-"); + }); + + it("sends create only after the daemon proves the shared secret", async () => { + const secret = "ad".repeat(32); + const daemon = await startFakeDaemon({ + serverCapabilities: ["endpoint_identity"], + endpointChallenge: createDaemonEndpointNonce(), + secret, + }); + const client = await connectClient(daemon.socketPath, { requirePeerIdentity: true, secret }); + + const [first, second] = await Promise.all([client.request(SENSITIVE_CREATE), client.request({ type: "list" })]); + expect(first.success).toBe(true); + expect(second.success).toBe(true); + // One handshake per connection, ahead of every command. + expect(daemon.received.map((entry) => entry.type)).toEqual(["endpoint_handshake", "create", "list"]); + expect(daemon.received[0]!.body.proof).toMatch(/^[0-9a-f]{64}$/); + expect(daemon.received[1]!.body.launchEnv).toEqual(SENSITIVE_CREATE.launchEnv); + }); + + it("rejects a daemon whose proof was made with another user's secret", async () => { + const daemon = await startFakeDaemon({ + serverCapabilities: ["endpoint_identity"], + endpointChallenge: createDaemonEndpointNonce(), + secret: "ae".repeat(32), + }); + const client = await connectClient(daemon.socketPath, { requirePeerIdentity: true, secret: "af".repeat(32) }); + + await expect(client.request({ type: "list" })).rejects.toBeInstanceOf(DaemonPeerIdentityError); + expect(daemon.received.map((entry) => entry.type)).toEqual(["endpoint_handshake"]); + }); + + it("keeps Unix behaviour unchanged when identity is not required", async () => { + const daemon = await startFakeDaemon({ + serverCapabilities: ["endpoint_identity"], + endpointChallenge: createDaemonEndpointNonce(), + secret: "ba".repeat(32), + }); + const client = await connectClient(daemon.socketPath, { requirePeerIdentity: false }); + + await expect(client.request({ type: "list" })).resolves.toMatchObject({ success: true }); + expect(daemon.received.map((entry) => entry.type)).toEqual(["list"]); + }); + + it("verifies the peer when the daemon declares the handshake required", async () => { + const secret = "bb".repeat(32); + const daemon = await startFakeDaemon({ + serverCapabilities: ["endpoint_identity"], + endpointChallenge: createDaemonEndpointNonce(), + endpointHandshakeRequired: true, + secret, + }); + const client = await connectClient(daemon.socketPath, { requirePeerIdentity: false, secret }); + + await expect(client.request({ type: "list" })).resolves.toMatchObject({ success: true }); + expect(daemon.received.map((entry) => entry.type)).toEqual(["endpoint_handshake", "list"]); + }); + + it("verifies a reconnected daemon before replaying parked commands", async () => { + const secret = "bc".repeat(32); + const challenge = createDaemonEndpointNonce(); + const daemon = await startFakeDaemon({ + serverCapabilities: ["endpoint_identity"], + endpointChallenge: challenge, + onCommand: ({ type, id, body }, socket) => { + if (type === "endpoint_handshake") { + reply(socket, { + id, + type: "response", + command: type, + success: true, + data: { proof: createDaemonEndpointProof(secret, "daemon", challenge, String(body.nonce)) }, + }); + return; + } + if (type === "create" && daemon.connections.length === 1) { + // The first incarnation dies mid-command; the client parks the create for replay. + socket.destroy(); + return; + } + reply(socket, { id, type: "response", command: type, success: true, data: {} }); + }, + }); + const client = new DaemonClient(daemon.socketPath, { + requirePeerIdentity: true, + loadEndpointSecret: () => secret, + }); + clients.push(client); + client.enableAutoReconnect({ recoverDaemon: async () => {} }); + await client.connect(1000); + await client.waitForHello(2000); + + const response = await client.request(SENSITIVE_CREATE, 10_000); + expect(response.success).toBe(true); + expect(daemon.connections).toHaveLength(2); + // The replayed create waits for the new connection's handshake. + expect(daemon.received.map((entry) => entry.type)).toEqual([ + "endpoint_handshake", + "create", + "endpoint_handshake", + "create", + ]); + }); +}); + +// Constructor-bypass harness mirroring daemon-supervisor-admission.test.ts. +interface SupervisorHarness { + handleLine(client: DaemonSocketClient, line: string): Promise; + write: ReturnType; +} + +function createSupervisorHarness(options: { requireEndpointHandshake: boolean; secret?: string }): SupervisorHarness { + return Object.assign(Object.create(DaemonSupervisor.prototype), { + ready: Promise.resolve(), + ownership: { + assertCurrent: vi.fn(async () => undefined), + record: { token: "test-owner", processStartId: "test-process", socketPath: "/tmp/test.sock" }, + }, + workers: new Map(), + clients: new Set(), + connectionIds: new WeakMap(), + sessionInputPauseEpochs: new WeakMap(), + detachingInputPauseSessions: new WeakMap(), + protocolClientIds: new WeakMap(), + promptAdmissions: new Map(), + sessionInputPauses: new Map(), + mutationDrain: new MutationDrainLatch(), + commandJournal: { + lookup: vi.fn(() => undefined), + begin: vi.fn(() => ({ status: "new" })), + recordResult: vi.fn(), + acknowledge: vi.fn(), + }, + requireEndpointHandshake: options.requireEndpointHandshake, + endpointSecret: options.secret, + findWorkerForClient: vi.fn(), + forwardToWorker: vi.fn(), + write: vi.fn(), + log: vi.fn(), + }) as SupervisorHarness; +} + +function harnessClient( + id: string, + options: { authenticated: boolean; endpointChallenge?: string }, +): DaemonSocketClient { + return { + id, + socket: { end: vi.fn(), destroyed: false } as unknown as Socket, + attachedActiveSessionIds: new Set(), + capabilities: new Set(), + authenticated: options.authenticated, + endpointChallenge: options.endpointChallenge, + detachInput: () => {}, + supportsExtensionUi: false, + }; +} + +function commandLine(command: DaemonCommand & { id: string }): string { + return JSON.stringify(createDaemonCommandEnvelope(command, command.id, "client-1")); +} + +function lastResponse(supervisor: SupervisorHarness): DaemonResponse { + return supervisor.write.mock.calls.at(-1)![1] as DaemonResponse; +} + +describe("daemon supervisor endpoint handshake", () => { + const secret = "dd".repeat(32); + + it("rejects and disconnects an unverified client before any command runs", async () => { + // Old client, new daemon: the client never sends endpoint_handshake. + const supervisor = createSupervisorHarness({ requireEndpointHandshake: true, secret }); + const client = harnessClient("legacy", { authenticated: false, endpointChallenge: createDaemonEndpointNonce() }); + + await supervisor.handleLine(client, commandLine({ id: "c1", type: "list" })); + + expect(lastResponse(supervisor)).toMatchObject({ + id: "c1", + command: "list", + success: false, + error: expect.stringMatching(/Endpoint handshake required/), + }); + expect(client.socket.end).toHaveBeenCalledOnce(); + }); + + it("authenticates a client that answers the challenge and returns the daemon proof", async () => { + const supervisor = createSupervisorHarness({ requireEndpointHandshake: true, secret }); + const challenge = createDaemonEndpointNonce(); + const client = harnessClient("current", { authenticated: false, endpointChallenge: challenge }); + const nonce = createDaemonEndpointNonce(); + + await supervisor.handleLine( + client, + commandLine({ + id: "h1", + type: "endpoint_handshake", + nonce, + proof: createDaemonEndpointProof(secret, "client", challenge, nonce), + }), + ); + + const response = lastResponse(supervisor); + expect(response).toMatchObject({ id: "h1", command: "endpoint_handshake", success: true }); + const proof = (response as { data?: { proof?: unknown } }).data?.proof; + expect(verifyDaemonEndpointProof(secret, "daemon", challenge, nonce, proof)).toBe(true); + expect(client.authenticated).toBe(true); + expect(client.socket.end).not.toHaveBeenCalled(); + + // Subsequent commands pass the gate (and fail later only because the harness has no workers wired). + await supervisor.handleLine(client, commandLine({ id: "c2", type: "list" })); + expect(lastResponse(supervisor)).toMatchObject({ id: "c2", command: "list" }); + expect((lastResponse(supervisor) as { error?: string }).error ?? "").not.toMatch(/Endpoint handshake required/); + }); + + it("rejects a proof computed without the secret or against another challenge", async () => { + const supervisor = createSupervisorHarness({ requireEndpointHandshake: true, secret }); + const challenge = createDaemonEndpointNonce(); + const nonce = createDaemonEndpointNonce(); + const wrongSecret = harnessClient("wrong-secret", { authenticated: false, endpointChallenge: challenge }); + await supervisor.handleLine( + wrongSecret, + commandLine({ + id: "h2", + type: "endpoint_handshake", + nonce, + proof: createDaemonEndpointProof("ee".repeat(32), "client", challenge, nonce), + }), + ); + expect(lastResponse(supervisor)).toMatchObject({ id: "h2", success: false, error: "Endpoint handshake failed" }); + expect(wrongSecret.socket.end).toHaveBeenCalledOnce(); + expect(wrongSecret.authenticated).toBe(false); + + const replayed = harnessClient("replayed", { authenticated: false, endpointChallenge: challenge }); + await supervisor.handleLine( + replayed, + commandLine({ + id: "h3", + type: "endpoint_handshake", + nonce, + proof: createDaemonEndpointProof(secret, "client", createDaemonEndpointNonce(), nonce), + }), + ); + expect(lastResponse(supervisor)).toMatchObject({ id: "h3", success: false, error: "Endpoint handshake failed" }); + expect(replayed.socket.end).toHaveBeenCalledOnce(); + + const reflected = harnessClient("reflected", { authenticated: false, endpointChallenge: challenge }); + await supervisor.handleLine( + reflected, + commandLine({ + id: "h4", + type: "endpoint_handshake", + nonce, + proof: createDaemonEndpointProof(secret, "daemon", challenge, nonce), + }), + ); + expect(lastResponse(supervisor)).toMatchObject({ id: "h4", success: false }); + }); + + it("keeps accepting unauthenticated Unix clients when the handshake is not required", async () => { + const supervisor = createSupervisorHarness({ requireEndpointHandshake: false, secret }); + const client = harnessClient("unix", { authenticated: true, endpointChallenge: createDaemonEndpointNonce() }); + + await supervisor.handleLine(client, commandLine({ id: "c3", type: "list" })); + + expect((lastResponse(supervisor) as { error?: string }).error ?? "").not.toMatch(/Endpoint handshake required/); + expect(client.socket.end).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/coding-agent/test/daemon-protocol.test.ts b/packages/coding-agent/test/daemon-protocol.test.ts index 1cdde72a54..a8a8b6ebf0 100644 --- a/packages/coding-agent/test/daemon-protocol.test.ts +++ b/packages/coding-agent/test/daemon-protocol.test.ts @@ -22,6 +22,7 @@ import { isDaemonCommandEnvelope, isDaemonMutatingCommand, isSessionPlaneDaemonCommand, + meetsDaemonCommandCompatibility, salvageDaemonCommandId, } from "../src/modes/daemon/daemon-protocol.js"; import { @@ -384,6 +385,53 @@ describe("daemon protocol helpers", () => { ).toBe(true); }); + it("capability- and schema-gates the endpoint identity handshake in both directions", () => { + expect(DAEMON_COMMAND_COMPATIBILITY.endpoint_handshake).toEqual({ + minProtocol: 7, + minSchemaRevision: 28, + capability: "endpoint_identity", + }); + expect(DAEMON_COMMAND_PLANE.endpoint_handshake).toBe("control"); + // Not a mutation: no journal entry, no ack, and safe to repeat per connection. + expect(isDaemonMutatingCommand({ type: "endpoint_handshake" })).toBe(false); + // Workers never advertise it; only the supervisor answers the challenge. + expect(DAEMON_DEFAULT_SERVER_CAPABILITIES).not.toContain("endpoint_identity"); + const handshake: DaemonCommand = { type: "endpoint_handshake", nonce: "n", proof: "p" }; + // New client, old daemon: the hello has no capability, so the client must not send it. + expect( + getDaemonCommandCompatibilities(handshake).every((compatibility) => + meetsDaemonCommandCompatibility( + { protocol: { name: "prime-agent.daemon", version: 7 }, schemaRevision: 27, serverCapabilities: [] }, + compatibility, + ), + ), + ).toBe(false); + // New client, new daemon. + expect( + getDaemonCommandCompatibilities(handshake).every((compatibility) => + meetsDaemonCommandCompatibility( + { + protocol: { name: "prime-agent.daemon", version: 7 }, + schemaRevision: 28, + serverCapabilities: ["endpoint_identity"], + }, + compatibility, + ), + ), + ).toBe(true); + // Old client, new daemon: the hello additions are optional fields an old client ignores. + const hello: Extract = { + type: "daemon_hello", + socketPath: "/tmp/daemon.sock", + protocol: DAEMON_PROTOCOL_INFO, + clientId: "client", + serverCapabilities: ["endpoint_identity"], + endpointChallenge: "challenge", + endpointHandshakeRequired: true, + }; + expect(DAEMON_OUTBOUND_COMPATIBILITY[hello.type]).toEqual({ minProtocol: 7 }); + }); + it("capability-gates direct worker transport discovery as a supervisor-only surface", () => { expect(DAEMON_COMMAND_COMPATIBILITY.get_direct_worker_transport).toEqual({ minProtocol: 7, diff --git a/packages/coding-agent/test/daemon-socket.test.ts b/packages/coding-agent/test/daemon-socket.test.ts index 8237d68f23..a33a8faa4f 100644 --- a/packages/coding-agent/test/daemon-socket.test.ts +++ b/packages/coding-agent/test/daemon-socket.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from "vitest"; import { cleanupDaemonSocketPath, DaemonSocketPathLease, + daemonEndpointOwnerKey, defaultDaemonSocketPath, getDaemonSocketIdentity, normalizeSocketPath, @@ -22,12 +23,12 @@ describe("normalizeSocketPath", () => { }); describe("defaultDaemonSocketPath", () => { - it("uses a fixed Windows named pipe path", () => { + it("uses a per-user Windows named pipe path", () => { if (process.platform !== "win32") { return; } - expect(defaultDaemonSocketPath()).toBe("\\\\.\\pipe\\prime-agent-daemon"); + expect(defaultDaemonSocketPath()).toBe(`\\\\.\\pipe\\prime-agent-daemon-${daemonEndpointOwnerKey()}`); }); it("uses a per-user Unix socket directory", () => { diff --git a/packages/coding-agent/test/daemon-worker-windows-timeouts.test.ts b/packages/coding-agent/test/daemon-worker-windows-timeouts.test.ts index 5cfca84593..8563f3f306 100644 --- a/packages/coding-agent/test/daemon-worker-windows-timeouts.test.ts +++ b/packages/coding-agent/test/daemon-worker-windows-timeouts.test.ts @@ -150,7 +150,8 @@ describe("daemon request timeouts", () => { vi.useFakeTimers(); Object.defineProperty(process, "platform", { value: platform }); const socket = { destroyed: false, write: vi.fn(), end: vi.fn(), destroy: vi.fn() } as unknown as Socket; - const client = new DaemonClient(hello.socketPath); + // Timeouts only: the Windows endpoint identity policy is covered by daemon-endpoint-identity.test.ts. + const client = new DaemonClient(hello.socketPath, { requirePeerIdentity: false }); Object.assign(client, { socket, helloMessage: hello }); let settled = false; const request = client.request({ type: command } as DaemonCommandBody, override);