From 5a4c6e4b832f4e0f42b2ef81749a906feaae8097 Mon Sep 17 00:00:00 2001 From: shum Date: Sat, 24 Oct 2026 13:33:26 +0000 Subject: [PATCH] mcp: add daemon data root layout --- packages/simplex-chat-mcp/src/files.ts | 93 +++++++++++++++-- .../simplex-chat-mcp/test/unit/files.test.ts | 99 +++++++++++++++++-- 2 files changed, 175 insertions(+), 17 deletions(-) diff --git a/packages/simplex-chat-mcp/src/files.ts b/packages/simplex-chat-mcp/src/files.ts index 729c0efb01..8a67ab630c 100644 --- a/packages/simplex-chat-mcp/src/files.ts +++ b/packages/simplex-chat-mcp/src/files.ts @@ -1,8 +1,76 @@ -import {randomBytes} from "crypto" -import {chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync} from "fs" -import {join} from "path" +import {createHash, randomBytes} from "crypto" +import {chmodSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, statSync, truncateSync, unlinkSync, writeFileSync} from "fs" +import {homedir, tmpdir} from "os" +import {join, resolve} from "path" import {ExitError} from "./log" +const sessionIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +// sockaddr_un.sun_path holds 104 bytes on macOS (108 on Linux), including the terminating NUL +const maxSocketPath = 103 +const maxLogBytes = 10 * 1_048_576 + +export interface SessionFile { + number: number + name: string + project: string + created: string +} + +export class LockedError extends ExitError {} + +export function dataRoot(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string { + const given = env.SIMPLEX_MCP_HOME + return given ? resolve(given) : join(home, ".simplex-mcp") +} + +export function hubFolder(root: string): string { + return join(root, "_hub") +} + +export function isSessionId(id: string): boolean { + return sessionIdPattern.test(id) +} + +export function sessionFolder(root: string, id: string): string { + if (!isSessionId(id)) throw new ExitError(`Not a Claude Code session ID: ${id}`, 2) + return join(sessionsFolder(root), id.toLowerCase()) +} + +export function readSessionFile(folder: string): SessionFile | undefined { + const file = join(folder, "session.json") + return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : undefined +} + +export function writeSessionFile(folder: string, session: SessionFile): void { + writePrivateFile(join(folder, "session.json"), `${JSON.stringify(session)}\n`) +} + +export function sessionNumbers(root: string): number[] { + const folder = sessionsFolder(root) + if (!existsSync(folder)) return [] + return readdirSync(folder).flatMap(name => { + const session = isSessionId(name) ? readSessionFile(join(folder, name)) : undefined + return session === undefined ? [] : [session.number] + }) +} + +export function socketPath(root: string, platform: NodeJS.Platform = process.platform): string { + const hash = createHash("sha256").update(root).digest("hex").slice(0, 16) + if (platform === "win32") return `\\\\.\\pipe\\simplex-mcp-${hash}` + const path = join(root, "daemon.sock") + return Buffer.byteLength(path) <= maxSocketPath ? path : join(tmpdir(), `simplex-mcp-${hash}.sock`) +} + +export function logFile(root: string): string { + return join(root, "daemon.log") +} + +export function openLog(root: string): number { + const file = logFile(root) + if (existsSync(file) && statSync(file).size > maxLogBytes) truncateSync(file) + return openSync(file, "a", 0o600) +} + export function createDataFolder(dir: string): void { mkdirSync(dir, {recursive: true, mode: 0o700}) if (process.platform !== "win32") chmodSync(dir, 0o700) @@ -21,12 +89,21 @@ export function releaseLock(dir: string): void { unlinkSync(join(dir, "lock")) } -export function serverToken(dir: string, renew: boolean): string { +export function serverToken(dir: string): string { const file = join(dir, "token") - if (!renew && existsSync(file)) return readFileSync(file, "utf8").trim() const token = randomBytes(32).toString("base64url") - writePrivateFile(file, `${token}\n`) - return token + try { + writeFileSync(file, `${token}\n`, {flag: "wx", mode: 0o600}) + return token + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error + if (process.platform !== "win32") chmodSync(file, 0o600) + return readFileSync(file, "utf8").trim() + } +} + +function sessionsFolder(root: string): string { + return join(root, "_sessions") } function writePrivateFile(file: string, text: string): void { @@ -35,7 +112,7 @@ function writePrivateFile(file: string, text: string): void { } function lockedError(dir: string, file: string, pid: number | undefined): ExitError { - return new ExitError(`Another server uses ${dir} (PID ${pid}). If no server runs, remove ${file}.`, 1) + return new LockedError(`Another daemon uses ${dir} (PID ${pid}). If none runs, remove ${file}.`, 1) } function createLock(file: string): boolean { diff --git a/packages/simplex-chat-mcp/test/unit/files.test.ts b/packages/simplex-chat-mcp/test/unit/files.test.ts index 644c736ab4..109fa281f3 100644 --- a/packages/simplex-chat-mcp/test/unit/files.test.ts +++ b/packages/simplex-chat-mcp/test/unit/files.test.ts @@ -1,10 +1,12 @@ import {spawnSync} from "child_process" -import {chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync} from "fs" +import {chmodSync, closeSync, existsSync, fstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync} from "fs" import {tmpdir} from "os" import {join} from "path" -import {acquireLock, createDataFolder, releaseLock, serverToken} from "../../src/files" +import {LockedError, acquireLock, createDataFolder, dataRoot, hubFolder, logFile, openLog, readSessionFile, releaseLock, serverToken, sessionFolder, sessionNumbers, socketPath, writeSessionFile} from "../../src/files" import {ExitError} from "../../src/log" +const id = "34B7AEBF-E707-4D6F-ADD0-ECDFB4FFE281" + let dir: string beforeEach(() => { @@ -26,7 +28,10 @@ describe("lock", () => { it("refuses a lock of a running process", () => { const runningPid = process.ppid writeFileSync(join(dir, "lock"), `${runningPid}\n`) - expect(() => acquireLock(dir)).toThrow(new ExitError(`Another server uses ${dir} (PID ${runningPid}). If no server runs, remove ${join(dir, "lock")}.`, 1)) + const locked = new LockedError(`Another daemon uses ${dir} (PID ${runningPid}). If none runs, remove ${join(dir, "lock")}.`, 1) + expect(() => acquireLock(dir)).toThrow(locked) + expect(() => acquireLock(dir)).toThrow(LockedError) + expect(locked).toBeInstanceOf(ExitError) }) it("replaces a lock of a stopped process or of this process", () => { @@ -50,19 +55,95 @@ describe("data folder", () => { expect(statSync(dir).mode & 0o777).toBe(0o700) }) - it("keeps the token until renewed", () => { - const token = serverToken(dir, false) + it("keeps the token", () => { + const token = serverToken(dir) expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/) expect(statSync(join(dir, "token")).mode & 0o777).toBe(0o600) - expect(serverToken(dir, false)).toBe(token) - expect(serverToken(dir, true)).not.toBe(token) + expect(serverToken(dir)).toBe(token) }) it("makes the token private again when it exists", () => { const file = join(dir, "token") - writeFileSync(file, "{}\n") + writeFileSync(file, "existing\n") chmodSync(file, 0o644) - serverToken(dir, true) + expect(serverToken(dir)).toBe("existing") expect(statSync(file).mode & 0o777).toBe(0o600) }) }) + +describe("data root", () => { + it("is ~/.simplex-mcp unless SIMPLEX_MCP_HOME is set", () => { + expect(dataRoot({}, "/home/u")).toBe("/home/u/.simplex-mcp") + expect(dataRoot({SIMPLEX_MCP_HOME: "/data/mcp"}, "/home/u")).toBe("/data/mcp") + expect(dataRoot({SIMPLEX_MCP_HOME: ""}, "/home/u")).toBe("/home/u/.simplex-mcp") + }) + + it("keeps the hub and the sessions in folders 0.1 agent names can't take", () => { + expect(hubFolder("/r")).toBe("/r/_hub") + expect(sessionFolder("/r", id)).toBe("/r/_sessions/34b7aebf-e707-4d6f-add0-ecdfb4ffe281") + }) + + it("refuses session IDs that are not UUIDs", () => { + for (const bad of ["", "../x", "34b7aebf", `${id}/..`]) { + expect(() => sessionFolder("/r", bad)).toThrow(new ExitError(`Not a Claude Code session ID: ${bad}`, 2)) + } + }) +}) + +describe("session files", () => { + it("round-trip privately and list their numbers", () => { + const root = mkdtempSync(join(tmpdir(), "simplex-mcp-files-")) + const folder = sessionFolder(root, id) + mkdirSync(folder, {recursive: true}) + expect(readSessionFile(folder)).toBeUndefined() + const file = {number: 3, name: "simplex-chat-6", project: "/p/simplex-chat-6", created: "2026-10-24T10:00:00.000Z"} + writeSessionFile(folder, file) + expect(readSessionFile(folder)).toEqual(file) + expect(statSync(join(folder, "session.json")).mode & 0o777).toBe(0o600) + mkdirSync(join(root, "_sessions", "00000000-0000-0000-0000-000000000000")) + expect(sessionNumbers(root)).toEqual([3]) + expect(sessionNumbers(join(root, "missing"))).toEqual([]) + rmSync(root, {recursive: true, force: true}) + }) +}) + +describe("socket path", () => { + it("is in the root, in tmpdir when too long, and a named pipe on Windows", () => { + expect(socketPath("/home/u/.simplex-mcp", "linux")).toBe("/home/u/.simplex-mcp/daemon.sock") + const long = `/home/${"u".repeat(120)}/.simplex-mcp` + expect(socketPath(long, "darwin")).toMatch(new RegExp(`^${tmpdir().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/simplex-mcp-[0-9a-f]{16}\\.sock$`)) + expect(socketPath("C:\\Users\\u\\.simplex-mcp", "win32")).toMatch(/^\\\\\.\\pipe\\simplex-mcp-[0-9a-f]{16}$/) + expect(socketPath("/a", "win32")).not.toBe(socketPath("/b", "win32")) + }) +}) + +describe("token creation", () => { + it("is exclusive", () => { + const root = mkdtempSync(join(tmpdir(), "simplex-mcp-token-")) + const token = serverToken(root) + expect(serverToken(root)).toBe(token) + expect(readFileSync(join(root, "token"), "utf8")).toBe(`${token}\n`) + rmSync(root, {recursive: true, force: true}) + }) +}) + +describe("daemon log", () => { + it("is opened for append and truncated when over 10 MB", () => { + const root = mkdtempSync(join(tmpdir(), "simplex-mcp-log-")) + writeFileSync(logFile(root), "x".repeat(10 * 1_048_576 + 1)) + const fd = openLog(root) + expect(fstatSync(fd).size).toBe(0) + closeSync(fd) + writeFileSync(logFile(root), "kept\n") + closeSync(openLog(root)) + expect(readFileSync(logFile(root), "utf8")).toBe("kept\n") + rmSync(root, {recursive: true, force: true}) + }) + + it("is created private", () => { + const root = mkdtempSync(join(tmpdir(), "simplex-mcp-log-")) + closeSync(openLog(root)) + expect(statSync(logFile(root)).mode & 0o777).toBe(0o600) + rmSync(root, {recursive: true, force: true}) + }) +})