mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-01 07:02:48 +00:00
mcp: add daemon data root layout
This commit is contained in:
1 parent
d497c90d79
commit
5a4c6e4b83
2 files changed
+175
-17
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user