diff --git a/apps/worker/src/mcp-server-context.ts b/apps/worker/src/mcp-server-context.ts index d77858c..109c1df 100644 --- a/apps/worker/src/mcp-server-context.ts +++ b/apps/worker/src/mcp-server-context.ts @@ -26,8 +26,14 @@ export function createVoidbotMcpContext(): VoidbotMcpContext { process.chdir(workspaceRoot); const config = loadConfig(); - const archiveRepository = new FileMessageArchiveRepository(config.ragArchivePath); - const sourceArchiveRepository = new FileSourceDocumentArchiveRepository(config.ragSourceArchivePath); + const archiveOptions = { + readOnly: process.env.VOIDBOT_MCP_ARCHIVES_READ_ONLY === "true", + }; + const archiveRepository = new FileMessageArchiveRepository(config.ragArchivePath, archiveOptions); + const sourceArchiveRepository = new FileSourceDocumentArchiveRepository( + config.ragSourceArchivePath, + archiveOptions, + ); const embedder = createTextEmbedder({ backend: config.ragEmbeddingBackend, hashDimensions: config.ragEmbeddingDimensions, diff --git a/notes/voidbot-current-system-map.md b/notes/voidbot-current-system-map.md index 0515504..299cb61 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -352,3 +352,7 @@ standalone `apps/worker/src/mcp-http-main.ts` entrypoint reuses the canonical MCP context, factory, archive repositories, vector stores, and HTTP transport. Its deployment allow-list contains retrieval tools only. It owns no Discord, job, Persona, moderation, Bifrost, Idunn, or state-mutation authority. +`VOIDBOT_MCP_ARCHIVES_READ_ONLY=true` is an explicit storage-port mode: +snapshots read without creating writer locks, while overwrite, mutation, and +normalization fail before touching the filesystem. Writable runtimes retain +the existing interprocess lock path. diff --git a/packages/rag/src/file-store.ts b/packages/rag/src/file-store.ts index 984eae7..e7ead2a 100644 --- a/packages/rag/src/file-store.ts +++ b/packages/rag/src/file-store.ts @@ -7,20 +7,27 @@ export class SerializedFileStore { public constructor( private readonly filePath: string, private readonly createDefault: () => T, + private readonly options: { readOnly?: boolean } = {}, ) {} public async snapshot(): Promise { - const store = await this.serialize(() => this.withInterprocessLock(() => this.readUnlocked())); + const store = await this.serialize(() => + this.options.readOnly + ? this.readUnlocked() + : this.withInterprocessLock(() => this.readUnlocked()), + ); return structuredClone(store); } public async overwrite(nextValue: T): Promise { + this.requireWritable(); await this.serialize(async () => { await this.withInterprocessLock(() => this.writeUnlocked(nextValue)); }); } public async mutate(mutator: (current: T) => R | Promise): Promise { + this.requireWritable(); return this.serialize(async () => { return this.withInterprocessLock(async () => { const current = await this.readUnlocked(); @@ -32,6 +39,7 @@ export class SerializedFileStore { } public async normalize(): Promise { + this.requireWritable(); await this.serialize(async () => { await this.withInterprocessLock(async () => { const current = await this.readUnlocked(); @@ -49,6 +57,12 @@ export class SerializedFileStore { return pending; } + private requireWritable(): void { + if (this.options.readOnly) { + throw new Error(`Read-only file store cannot mutate: ${this.filePath}`); + } + } + private async readUnlocked(): Promise { try { const raw = await readFile(this.filePath, "utf8"); diff --git a/packages/rag/src/message-archive.ts b/packages/rag/src/message-archive.ts index 9b13f52..d6de195 100644 --- a/packages/rag/src/message-archive.ts +++ b/packages/rag/src/message-archive.ts @@ -56,11 +56,11 @@ interface HistoryArchiveFilters export class FileMessageArchiveRepository { private readonly store: SerializedFileStore; - public constructor(filePath: string) { + public constructor(filePath: string, options: { readOnly?: boolean } = {}) { this.store = new SerializedFileStore(filePath, () => ({ version: 1, messages: [], - })); + }), options); } public async upsert(messages: ArchivedMessage[]): Promise { diff --git a/packages/rag/src/source-document-archive.ts b/packages/rag/src/source-document-archive.ts index a2a2746..4318283 100644 --- a/packages/rag/src/source-document-archive.ts +++ b/packages/rag/src/source-document-archive.ts @@ -57,11 +57,14 @@ export class FileSourceDocumentArchiveRepository { private readonly shardRoot: string; private initialization?: Promise; - public constructor(private readonly filePath: string) { + public constructor( + private readonly filePath: string, + private readonly options: { readOnly?: boolean } = {}, + ) { this.manifestStore = new SerializedFileStore(filePath, () => ({ version: 2, repos: [], - })); + }), options); this.shardRoot = deriveShardRoot(filePath); } @@ -277,7 +280,7 @@ export class FileSourceDocumentArchiveRepository { version: 1, repoName, documents: [], - })); + }), this.options); } private async syncRepoSummary(repoName: string, documentCount: number): Promise { diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 4596ad2..1e1962b 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -460,3 +460,4 @@ {"ts":"2026-07-19T14:25:00Z","type":"architecture","status":"ok","note":"The per-call SSH MCP relay was identified as a transport compensator and deleted. The resident Yggdrasil worker now owns a loopback streamable-HTTP MCP organ injected with its existing archive repositories, source ingester, and retrieval service. The canonical workstation tunnel owns the sole Starfire crossing at 127.0.0.1:17875; Codex uses the URL and tool calls start no processes or alternate retrieval authorities."} {"ts":"2026-07-19T20:10:00Z","type":"repair","status":"ok","note":"Direct Codex streamable-HTTP registration still closed permanently after a brief Idunn worker deployment or SSH-tunnel gap; stateless server sessions did not imply client transport recovery. Codex now attaches to a task-local stdio continuity bridge with no retrieval authority. Each tool call uses a lightweight HTTP client to the resident daemon, and the bridge lowers endpoint failure to a tool error. A single outer MCP client succeeded before a forced endpoint outage, received isError during the outage, and succeeded again after the same endpoint returned."} {"ts":"2026-08-16T17:30:00Z","type":"recovery","status":"ok","note":"Big Yggdrasil was retired after an incomplete evacuation and its recycled 94.130.223.131 address is not trusted. Old Yggdrasil retained no VoidBot state, but Raven held a verified June 3 offsite archive (SHA-256 47572df766e63212012eb75762359113a11c8bbf149b497e120fc4fda7191dfc); the June 4 archive is corrupt. Recovery is intentionally bounded to a loopback read-only MCP over exact archives and VoidBot-owned vectors while lost Discord, Idunn, Odin, Epiphany, Persona, moderation, and delivery authority remains stopped."} +{"ts":"2026-08-16T17:40:00Z","type":"architecture","status":"ok","note":"Read-only MCP recovery exposed that SerializedFileStore snapshots always created writer locks. Archive repositories now accept an explicit read-only mode: snapshots read without filesystem locks and every mutation method fails immediately. A Node 24 build and a nobody-user probe on a non-writable directory proved snapshot success plus mutation rejection; writable runtimes retain interprocess locking."}