/** * The client side of the local daemon endpoint, used by `cotal daemon status` and the other verbs. * * On Linux and macOS it refuses before connecting unless the directory and socket pass the same * ownership and mode checks the server applies. On every platform it refuses after connecting, * before writing a single byte, unless the kernel reports the answering process as this same user * and as the live holder of the daemon's lifetime lock. On Windows the native pipe open reads that * process's identity before Node sees the connection. So a squatter under another user, a process * that is not this daemon, or a socket in a loosened directory never receives a request. */ import { closeSync } from "node:fs"; import { connect as netConnect, type Socket } from "node:net"; import { validAccountComputerStatus } from "./account-state.js"; import { randomUUID } from "node:crypto"; import { connectPipe, windowsUserSid, type PipePeer } from "@cotal-ai/seat"; import { DAEMON_PROTOCOL_VERSION, MAX_LINE_BYTES, EndpointRefusal, adoptPipe, assertSupportedPlatform, checkDir, checkSocket, defaultPeerPid, defaultPeerUid, type DaemonPaths, type PeerPid, type PeerUid } from "./endpoint.js"; import { encode, type ConnectCheckResult, type DaemonRequest, type DaemonResponse, type DaemonStatus, type HostedManager, type LoginPrompt, type LoginResult, type RegisterResult, type ServiceConnectionResult } from "./protocol.js"; import { processBirth, readLifetimeRecord } from "./lifetime.js"; import { CommandFailure } from "@cotal-ai/workspace"; /** Ask the daemon whether it could register with `space`. Nothing is registered either way. */ export async function connectCheckViaDaemon(opts: Pick & { readonly space: string }): Promise< { readonly ok: true; readonly result: ConnectCheckResult } | { readonly ok: false; readonly code: string; readonly message: string }> { const sock = await openVerified({ ...opts, timeoutMs: 5000 }); try { sock.write(encode({ v: DAEMON_PROTOCOL_VERSION, id: randomUUID().replaceAll("-", ""), op: "connect-check", space: opts.space })); // A cold readiness check exchanges the IdP token, then reads authority metadata (30 s each). const reply = JSON.parse(await readLine(sock, 65_000)) as DaemonResponse; if (!reply.ok) return { ok: false, code: reply.error.code, message: reply.error.message }; if ("event" in reply || !("outcome" in reply.result) || !("space" in reply.result)) return { ok: false, code: "BAD_REPLY", message: "the daemon answered the connect check with something else" }; return { ok: true, result: reply.result as ConnectCheckResult }; } finally { sock.destroy(); } } /** Ask the daemon to submit its own service registration for `space` and report the space's answer. */ export async function registerViaDaemon(opts: Pick & { readonly space: string }): Promise< { readonly ok: true; readonly result: RegisterResult } | { readonly ok: false; readonly code: string; readonly message: string }> { const sock = await openVerified({ ...opts, timeoutMs: 5000 }); try { sock.write(encode({ v: DAEMON_PROTOCOL_VERSION, id: randomUUID().replaceAll("-", ""), op: "register", space: opts.space })); // Cold readiness has two reads, followed by the stock register call (30 s each). const reply = JSON.parse(await readLine(sock, 95_000)) as DaemonResponse; if (!reply.ok) return { ok: false, code: reply.error.code, message: reply.error.message }; if ("event" in reply || !("outcome" in reply.result) || !("space" in reply.result)) return { ok: false, code: "BAD_REPLY", message: "the daemon answered the registration with something else" }; return { ok: true, result: reply.result as RegisterResult }; } finally { sock.destroy(); } } export interface ClientCounters { bytesWritten: number; } /** Send a bounded machine-control request after authenticating the daemon's live process. */ export async function managerViaDaemon(opts: Pick, request: { op: "manager-start" | "manager-status"; space: string } | { op: "managers-stop" | "managers-prepare-restart" | "managers-abort-restart" }): Promise { const sock = await openVerified(opts); try { const id = randomUUID().replaceAll("-", ""); sock.write(encode({ v: DAEMON_PROTOCOL_VERSION, id, ...request } as DaemonRequest)); // A start may reconcile connectors for up to 120s, then wait for Manager readiness. const reply = JSON.parse(await readLine(sock, request.op === "manager-start" ? 250_000 : 130_000)) as DaemonResponse; if (!reply.ok) throw new CommandFailure("refused", "LIFECYCLE_BLOCKED", `the daemon refused ${request.op}: ${reply.error.code} ${reply.error.message}`, { native: { code: reply.error.code }, remedy: reply.error.remedy ?? (reply.error.code === "UNKNOWN_OP" ? "restart the recorded Cotal daemon user service with this platform's service manager, then retry" : "inspect the daemon's hosting records and retry the operation") }); if (reply.id !== id || "event" in reply || !("state" in reply.result)) throw new Error("invalid Manager response"); return reply.result as HostedManager | DaemonStatus; } finally { sock.destroy(); } } /** Ask the daemon to connect, disconnect or report its own service connection to `space`. */ export async function serviceViaDaemon(opts: Pick & { readonly space: string; readonly op: "service-connect" | "service-disconnect" | "service-status" }): Promise< { readonly ok: true; readonly result: ServiceConnectionResult } | { readonly ok: false; readonly code: string; readonly message: string }> { const sock = await openVerified({ ...opts, timeoutMs: 5000 }); try { sock.write(encode({ v: DAEMON_PROTOCOL_VERSION, id: randomUUID().replaceAll("-", ""), op: opts.op, space: opts.space })); // A connect adds cold readiness's token exchange to the register replay, first renewal, // broker connection and `ready` (30 s each); allow for all of them. const reply = JSON.parse(await readLine(sock, 160_000)) as DaemonResponse; if (!reply.ok) return { ok: false, code: reply.error.code, message: reply.error.message }; if ("event" in reply || !("state" in reply.result) || !("space" in reply.result)) return { ok: false, code: "BAD_REPLY", message: "the daemon answered the service request with something else" }; return { ok: true, result: reply.result as ServiceConnectionResult }; } finally { sock.destroy(); } } export interface QueryOptions { readonly paths: DaemonPaths; /** Test hook (Linux, macOS): read the peer's kernel uid. Defaults to the native reader. Windows * reads the peer inside the native pipe open and does not consult it. */ readonly peerUid?: PeerUid; /** Test hook (Linux, macOS): read the peer's kernel pid, which must be the live owner of the * daemon's lifetime lock. Defaults to the native reader; not consulted on Windows. */ readonly peerPid?: PeerPid; readonly uid?: number; readonly timeoutMs?: number; /** Test hook: observe what the client wrote, including that it wrote nothing. */ readonly counters?: ClientCounters; /** Test/contract hook: send this exact line instead of a well-formed status request. */ readonly rawLine?: string; } function connectTo(path: string, timeoutMs: number): Promise { return new Promise((resolve, reject) => { const s = netConnect(path); const t = setTimeout(() => { s.destroy(); reject(new Error(`timed out connecting to ${path}`)); }, timeoutMs); s.once("connect", () => { clearTimeout(t); resolve(s); }); s.once("error", (e: NodeJS.ErrnoException) => { clearTimeout(t); if (e.code === "ENOENT" || e.code === "ECONNREFUSED") reject(new EndpointRefusal("NOT_RUNNING", `no daemon answers at ${path}`)); else reject(e); }); }); } /** Read one newline-terminated reply, refusing one longer than the line limit. */ export function readLine(sock: Socket, timeoutMs: number): Promise { return new Promise((resolve, reject) => { let buf = ""; const t = setTimeout(() => finish(new Error("timed out waiting for the daemon's reply")), timeoutMs); const finish = (err?: Error, line?: string) => { clearTimeout(t); sock.removeAllListeners("data"); sock.removeAllListeners("end"); sock.removeAllListeners("error"); if (err) reject(err); else resolve(line!); }; sock.setEncoding("utf8"); sock.on("data", (chunk: string) => { buf += chunk; const nl = buf.indexOf("\n"); if (nl !== -1) finish(undefined, buf.slice(0, nl)); else if (Buffer.byteLength(buf, "utf8") > MAX_LINE_BYTES) finish(new Error("daemon reply exceeds the line limit")); }); sock.once("end", () => finish(new Error("daemon closed the connection without a reply"))); sock.once("error", (e) => finish(e)); }); } /** The only replies `queryDaemon` sees: it sends status (or a raw contract probe), never login-start, * so no sign-in event can arrive on its connection. */ export type QueryReply = Extract | { readonly v: DaemonResponse["v"]; readonly id: string | null; readonly ok: true; readonly result: DaemonStatus }; /** Send one request and return the daemon's parsed reply. */ export async function queryDaemon(opts: QueryOptions): Promise { const sock = await openVerified(opts); try { const line = opts.rawLine !== undefined ? `${opts.rawLine}\n` : encode({ v: DAEMON_PROTOCOL_VERSION, id: randomUUID().replaceAll("-", ""), op: "status" }); (opts.counters ?? { bytesWritten: 0 }).bytesWritten += Buffer.byteLength(line, "utf8"); sock.write(line); // Status includes parallel hosted-Manager observations, each bounded to fifteen seconds. const reply = await readLine(sock, opts.timeoutMs ?? 20_000); const parsed = JSON.parse(reply) as QueryReply; if (parsed.ok && parsed.result.account !== undefined && !validAccountComputerStatus(parsed.result.account)) throw new Error("the daemon returned malformed account computer status"); return parsed; } finally { sock.destroy(); } } /** Connect only after the directory and socket checks pass, and keep the connection only when the * kernel reports the listener as this same uid and as the holder of the daemon's lifetime lock. * Nothing is written before all of them. */ export async function openVerified(opts: Pick): Promise { assertSupportedPlatform(); if (process.platform === "win32") return openPipe(opts.paths, opts.timeoutMs ?? 5000); const uid = opts.uid ?? process.geteuid!(); const { paths } = opts; try { if (paths.parent !== undefined) checkDir(paths.parent, uid); checkDir(paths.dir!, uid); checkSocket(paths.socket, uid); } catch (e) { if ((e as NodeJS.ErrnoException).code === "ENOENT") throw new EndpointRefusal("NOT_RUNNING", `no daemon endpoint at ${paths.socket}`); throw e; } const sock = await connectTo(paths.socket, opts.timeoutMs ?? 5000); let peer: number; try { peer = (opts.peerUid ?? defaultPeerUid)(sock); } catch (e) { sock.destroy(); throw new EndpointRefusal("PEER_UID_MISMATCH", `could not read the daemon's peer credentials: ${(e as Error).message}`); } if (peer !== uid) { sock.destroy(); throw new EndpointRefusal("PEER_UID_MISMATCH", `the process answering at ${paths.socket} runs as uid ${peer}, not ${uid}; nothing was sent`); } // Same uid is not enough: the answering process must be the one holding this daemon's lifetime // lock, the same process (pid and process-start token) that lock's record names. The record is // read, never the lock itself, because taking the lock to test it would refuse a legitimate // start. The record is written only under the lock and cleared on a clean release. let pid: number | undefined; try { pid = (opts.peerPid ?? defaultPeerPid)(sock); } catch { pid = undefined; } if (pid === undefined || !isLockOwner(paths.lock, pid, processBirth(pid))) { sock.destroy(); throw new EndpointRefusal("PEER_NOT_LOCK_OWNER", `the process answering at ${paths.socket} is not the daemon holding ${paths.lock}; nothing was sent`); } return sock; } /** Whether the lifetime lock at `lock` names process `pid` born at `birth`. */ function isLockOwner(lock: string, pid: number, birth: string | undefined): boolean { const owner = readLifetimeRecord(lock); return owner !== undefined && owner.pid === pid && owner.start !== undefined && birth === owner.start; } /** The pipe-open steps that read the server's identity. A failure in one means that identity could * not be established, so nothing may be sent. */ const IDENTITY_STEPS = new Set(["GetNamedPipeServerProcessId", "OpenProcess", "GetProcessTimes", "OpenProcessToken", "GetTokenInformation", "ConvertSidToStringSidW"]); /** Windows: open the daemon's pipe, which the native side authenticates, and keep it only when its * server is this user and the holder of the lifetime lock. Nothing is written before both. */ async function openPipe(paths: DaemonPaths, timeoutMs: number): Promise { let peer: PipePeer; try { peer = await connectPipe(paths.socket, timeoutMs); } catch (e) { const err = e as NodeJS.ErrnoException; if (err.code === "ENOENT") throw new EndpointRefusal("NOT_RUNNING", `no daemon answers at ${paths.socket}`); if (err.code === "ETIMEDOUT") throw new Error(`timed out connecting to ${paths.socket}`); // The pipe exists but its DACL does not admit this user: another account's process serves it. if (err.code === "EACCES" && err.syscall === "CreateFileW") throw new EndpointRefusal("PEER_UID_MISMATCH", `${paths.socket} is served by a process that does not admit this user; nothing was sent`); if (err.syscall !== undefined && IDENTITY_STEPS.has(err.syscall)) throw new EndpointRefusal("PEER_UID_MISMATCH", `could not read the daemon's peer identity: ${err.message}`); if (err.syscall === undefined) throw new EndpointRefusal("UNSUPPORTED_PLATFORM", `the daemon pipe needs this platform's native daemon helper: ${err.message}`); throw e; } const refuse = (code: "PEER_UID_MISMATCH" | "PEER_NOT_LOCK_OWNER", message: string): never => { closeSync(peer.fd); throw new EndpointRefusal(code, message); }; const own = windowsUserSid(); if (peer.user !== own) refuse("PEER_UID_MISMATCH", `the process answering at ${paths.socket} runs as ${peer.user}, not ${own}; nothing was sent`); if (!isLockOwner(paths.lock, peer.pid, peer.birth)) refuse("PEER_NOT_LOCK_OWNER", `the process answering at ${paths.socket} is not the daemon holding ${paths.lock}; nothing was sent`); return adoptPipe(peer.fd); } export interface LoginOptions extends Pick { readonly idp: string; readonly clientId?: string; readonly onPrompt: (prompt: LoginPrompt) => void; /** Aborting sends login-cancel for this attempt and waits for the daemon's answer. */ readonly signal?: AbortSignal; } export type LoginReply = | { readonly ok: true; readonly attempt: string; readonly result: LoginResult } | { readonly ok: false; readonly code: string; readonly message: string } /** Aborted before the daemon named an attempt. The connection was closed, which makes the daemon * cancel whatever it had started; no daemon outcome was received. */ | { readonly ok: false; readonly code: "ABANDONED"; readonly message: string }; /** Ask the daemon to sign in, show its prompt, and return its single result. */ export async function loginViaDaemon(opts: LoginOptions): Promise { const sock = await openVerified(opts); try { const id = randomUUID().replaceAll("-", ""); return await new Promise((resolve, reject) => { let buf = ""; let attempt: string | undefined; const cleanup = () => { sock.removeAllListeners("data"); sock.removeAllListeners("end"); sock.removeAllListeners("error"); opts.signal?.removeEventListener("abort", onAbort); }; const finish = (r: LoginReply | Error) => { cleanup(); if (r instanceof Error) reject(r); else resolve(r); }; const abandoned = { ok: false as const, code: "ABANDONED" as const, message: "stopped before the daemon showed a prompt; the connection was closed, which cancels the sign-in on the daemon" }; const onAbort = () => { if (!attempt) return finish(abandoned); sock.write(encode({ v: DAEMON_PROTOCOL_VERSION, id: `${id}c`.slice(0, 64), op: "login-cancel", attempt })); }; sock.setEncoding("utf8"); sock.on("data", (chunk: string) => { buf += chunk; if (Buffer.byteLength(buf, "utf8") > MAX_LINE_BYTES && !buf.includes("\n")) return finish(new Error("daemon reply exceeds the line limit")); let nl: number; while ((nl = buf.indexOf("\n")) !== -1) { const msg = JSON.parse(buf.slice(0, nl)) as DaemonResponse; buf = buf.slice(nl + 1); if (msg.id !== id) continue; if (!msg.ok) return finish({ ok: false, code: msg.error.code, message: msg.error.message }); if ("event" in msg && msg.event === "login-prompt") { attempt = msg.attempt; opts.onPrompt(msg.prompt); } else if ("event" in msg && msg.event === "login-result") return finish({ ok: true, attempt: msg.attempt, result: msg.result }); } }); sock.once("end", () => finish(new Error("the daemon closed the connection before the sign-in finished"))); sock.once("error", (e) => finish(e)); if (opts.signal?.aborted) return finish(abandoned); opts.signal?.addEventListener("abort", onAbort, { once: true }); sock.write(encode({ v: DAEMON_PROTOCOL_VERSION, id, op: "login-start", idp: opts.idp, ...(opts.clientId !== undefined ? { clientId: opts.clientId } : {}) })); }); } finally { sock.destroy(); } }