diff --git a/src/mcp/daemonRecord.ts b/src/mcp/daemonRecord.ts new file mode 100644 index 0000000..c46b6b1 --- /dev/null +++ b/src/mcp/daemonRecord.ts @@ -0,0 +1,117 @@ +import fs from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { resolveHunkMcpConfig, type ResolvedHunkMcpConfig } from "./config"; + +export interface HunkDaemonRecord { + pid: number; + startedAt: string; + instanceId: string; +} + +function sanitizeHost(host: string) { + return host.replace(/[^a-z0-9_.:-]+/gi, "_"); +} + +function runtimeUserSuffix(env: NodeJS.ProcessEnv = process.env) { + if (typeof process.getuid === "function") { + return String(process.getuid()); + } + + return env.USER?.trim() || env.USERNAME?.trim() || "shared"; +} + +function resolveDaemonRuntimeDir(env: NodeJS.ProcessEnv = process.env) { + const configured = env.XDG_RUNTIME_DIR?.trim(); + if (configured) { + return join(configured, "hunk"); + } + + return join(tmpdir(), `hunk-${runtimeUserSuffix(env)}`); +} + +/** Resolve the per-user daemon record path for one host/port pair. */ +export function resolveDaemonRecordPath( + config: ResolvedHunkMcpConfig = resolveHunkMcpConfig(), + env: NodeJS.ProcessEnv = process.env, +) { + return join( + resolveDaemonRuntimeDir(env), + `daemon-${sanitizeHost(config.host)}-${config.port}.json`, + ); +} + +function isDaemonRecord(value: unknown): value is HunkDaemonRecord { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as Record; + return ( + typeof candidate.pid === "number" && + Number.isInteger(candidate.pid) && + candidate.pid > 0 && + typeof candidate.startedAt === "string" && + candidate.startedAt.length > 0 && + typeof candidate.instanceId === "string" && + candidate.instanceId.length > 0 + ); +} + +/** Read one previously persisted daemon record, if it looks valid. */ +export function readDaemonRecord( + config: ResolvedHunkMcpConfig = resolveHunkMcpConfig(), + env: NodeJS.ProcessEnv = process.env, +) { + const path = resolveDaemonRecordPath(config, env); + if (!fs.existsSync(path)) { + return null; + } + + try { + const parsed = JSON.parse(fs.readFileSync(path, "utf8")) as unknown; + return isDaemonRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +/** Persist the currently running daemon's identity so sibling Hunk commands can verify it later. */ +export function writeDaemonRecord( + record: HunkDaemonRecord, + config: ResolvedHunkMcpConfig = resolveHunkMcpConfig(), + env: NodeJS.ProcessEnv = process.env, +) { + const path = resolveDaemonRecordPath(config, env); + const directory = dirname(path); + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + + const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(record), { mode: 0o600 }); + fs.renameSync(tempPath, path); +} + +/** Remove the persisted daemon identity if it still belongs to the same daemon instance. */ +export function clearDaemonRecord( + expected: Pick, + config: ResolvedHunkMcpConfig = resolveHunkMcpConfig(), + env: NodeJS.ProcessEnv = process.env, +) { + const path = resolveDaemonRecordPath(config, env); + const current = readDaemonRecord(config, env); + if (!current) { + return; + } + + if (current.pid !== expected.pid || current.instanceId !== expected.instanceId) { + return; + } + + try { + fs.unlinkSync(path); + } catch (error) { + if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") { + throw error; + } + } +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index b7bec51..32cd6ca 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,4 +1,6 @@ +import { randomUUID } from "node:crypto"; import { HUNK_SESSION_SOCKET_PATH, resolveHunkMcpConfig } from "./config"; +import { clearDaemonRecord, writeDaemonRecord } from "./daemonRecord"; import { HunkDaemonState } from "./daemonState"; import type { SessionClientMessage } from "./types"; import { @@ -158,6 +160,8 @@ export function serveHunkMcpServer() { const config = resolveHunkMcpConfig(); const state = new HunkDaemonState(); const startedAt = Date.now(); + const startedAtIso = new Date(startedAt).toISOString(); + const instanceId = randomUUID(); let shuttingDown = false; const sweepTimer = setInterval(() => { @@ -178,7 +182,8 @@ export function serveHunkMcpServer() { return Response.json({ ok: true, pid: process.pid, - startedAt: new Date(startedAt).toISOString(), + startedAt: startedAtIso, + instanceId, uptimeMs: Date.now() - startedAt, sessionApi: `${config.httpOrigin}${HUNK_SESSION_API_PATH}`, sessionCapabilities: `${config.httpOrigin}${HUNK_SESSION_CAPABILITIES_PATH}`, @@ -252,6 +257,15 @@ export function serveHunkMcpServer() { throw formatDaemonServeError(error, config.host, config.port); } + writeDaemonRecord( + { + pid: process.pid, + startedAt: startedAtIso, + instanceId, + }, + config, + ); + const shutdown = () => { if (shuttingDown) { return; @@ -262,6 +276,7 @@ export function serveHunkMcpServer() { process.off("SIGINT", shutdown); process.off("SIGTERM", shutdown); + clearDaemonRecord({ pid: process.pid, instanceId }, config); state.shutdown(); server.stop(true); }; diff --git a/src/session/commands.ts b/src/session/commands.ts index a90b014..8c69142 100644 --- a/src/session/commands.ts +++ b/src/session/commands.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import type { SessionCommandInput, @@ -17,6 +18,7 @@ import { waitForHunkDaemonHealth, } from "../mcp/daemonLauncher"; import { resolveHunkMcpConfig } from "../mcp/config"; +import { readDaemonRecord } from "../mcp/daemonRecord"; import type { AppliedCommentResult, ClearedCommentsResult, @@ -214,6 +216,14 @@ class HttpHunkDaemonCliClient implements HunkDaemonCliClient { } } +interface HunkDaemonHealth { + ok: boolean; + pid?: number; + startedAt?: string; + instanceId?: string; + sessions?: number; +} + async function readDaemonHealth() { const config = resolveHunkMcpConfig(); @@ -223,14 +233,52 @@ async function readDaemonHealth() { return null; } - return (await response.json()) as { - ok: boolean; - pid?: number; - sessions?: number; - }; + return (await response.json()) as HunkDaemonHealth; + } catch { + return null; + } +} + +function looksLikeHunkDaemonProcess(pid: number) { + try { + const tokens = readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").filter(Boolean); + if (tokens.length < 2 || tokens.at(-2) !== "mcp" || tokens.at(-1) !== "serve") { + return false; + } + + return tokens.some( + (token) => + /(^|[\\/])hunk(?:\.[cm]?js)?$/i.test(token) || /(^|[\\/])src[\\/]main\.tsx$/i.test(token), + ); } catch { + return false; + } +} + +function isTrustedManagedDaemon(health: HunkDaemonHealth | null) { + if (!health || typeof health.pid !== "number") { return null; } + + const record = readDaemonRecord(); + if ( + record && + record.pid === health.pid && + record.startedAt === health.startedAt && + record.instanceId === health.instanceId + ) { + return record; + } + + if (looksLikeHunkDaemonProcess(health.pid)) { + return { + pid: health.pid, + startedAt: health.startedAt ?? "", + instanceId: health.instanceId ?? "legacy-daemon", + }; + } + + return null; } async function waitForDaemonShutdown(timeoutMs = 3_000) { @@ -290,16 +338,16 @@ async function restartDaemonForMissingAction( selector?: SessionSelectorInput, ) { const health = await readDaemonHealth(); - const pid = health?.pid; + const trustedDaemon = isTrustedManagedDaemon(health); const hadSessions = (health?.sessions ?? 0) > 0; - if (!pid || pid === process.pid) { + if (!trustedDaemon || trustedDaemon.pid === process.pid) { throw new Error( - `The running Hunk session daemon is missing required support for ${action}. ` + + `The running Hunk session daemon is missing required support for ${action}, but Hunk could not verify that it owns the process on this port. ` + `Restart Hunk so it can launch a fresh daemon from the current source tree.`, ); } - process.kill(pid, "SIGTERM"); + process.kill(trustedDaemon.pid, "SIGTERM"); const shutDown = await waitForDaemonShutdown(); if (!shutDown) { diff --git a/test/daemon-record.test.ts b/test/daemon-record.test.ts new file mode 100644 index 0000000..8f4b9ca --- /dev/null +++ b/test/daemon-record.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + clearDaemonRecord, + readDaemonRecord, + resolveDaemonRecordPath, + writeDaemonRecord, +} from "../src/mcp/daemonRecord"; +import type { ResolvedHunkMcpConfig } from "../src/mcp/config"; + +const originalRuntimeDir = process.env.XDG_RUNTIME_DIR; + +const config: ResolvedHunkMcpConfig = { + host: "127.0.0.1", + port: 47657, + httpOrigin: "http://127.0.0.1:47657", + wsOrigin: "ws://127.0.0.1:47657", +}; + +afterEach(() => { + if (originalRuntimeDir === undefined) { + delete process.env.XDG_RUNTIME_DIR; + } else { + process.env.XDG_RUNTIME_DIR = originalRuntimeDir; + } +}); + +describe("daemon record", () => { + test("writes, reads, and clears one trusted daemon record", () => { + const runtimeDir = mkdtempSync(join(tmpdir(), "hunk-daemon-record-")); + process.env.XDG_RUNTIME_DIR = runtimeDir; + + const record = { + pid: 4242, + startedAt: "2026-03-23T00:00:00.000Z", + instanceId: "instance-1", + }; + + try { + expect(readDaemonRecord(config)).toBeNull(); + + writeDaemonRecord(record, config); + expect(readDaemonRecord(config)).toEqual(record); + + clearDaemonRecord({ pid: 9999, instanceId: "other-instance" }, config); + expect(readDaemonRecord(config)).toEqual(record); + + clearDaemonRecord({ pid: record.pid, instanceId: record.instanceId }, config); + expect(readDaemonRecord(config)).toBeNull(); + } finally { + rmSync(runtimeDir, { recursive: true, force: true }); + } + }); + + test("separates daemon records by host and port", () => { + const runtimeDir = mkdtempSync(join(tmpdir(), "hunk-daemon-record-paths-")); + process.env.XDG_RUNTIME_DIR = runtimeDir; + + try { + const first = resolveDaemonRecordPath(config); + const second = resolveDaemonRecordPath({ + ...config, + host: "localhost", + port: 47658, + httpOrigin: "http://localhost:47658", + wsOrigin: "ws://localhost:47658", + }); + + expect(first).not.toBe(second); + } finally { + rmSync(runtimeDir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/mcp-server.test.ts b/test/mcp-server.test.ts index 05a3131..2ec9eee 100644 --- a/test/mcp-server.test.ts +++ b/test/mcp-server.test.ts @@ -67,7 +67,7 @@ describe("Hunk session daemon server", () => { } }); - test("exposes session capabilities and rejects the old MCP tool endpoint", async () => { + test("exposes health + session capabilities and rejects the old MCP tool endpoint", async () => { const port = await reserveLoopbackPort(); process.env.HUNK_MCP_HOST = "127.0.0.1"; process.env.HUNK_MCP_PORT = String(port); @@ -75,6 +75,15 @@ describe("Hunk session daemon server", () => { const server = serveHunkMcpServer(); try { + const health = await fetch(`http://127.0.0.1:${port}/health`); + expect(health.status).toBe(200); + await expect(health.json()).resolves.toMatchObject({ + ok: true, + pid: process.pid, + startedAt: expect.any(String), + instanceId: expect.any(String), + }); + const capabilities = await fetch(`http://127.0.0.1:${port}/session-api/capabilities`); expect(capabilities.status).toBe(200); await expect(capabilities.json()).resolves.toMatchObject({