import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { homedir } from "node:os"; import { getSession } from "./neo4j.js"; import { encrypt, decrypt } from "./crypto.js"; import { encryptWith, decryptWith } from "./file-crypto.js"; // Storage abstraction so the same MCP body runs against Neo4j (platform mode) // or an on-disk encrypted file (standalone mode, spawned by Claude Code from // ~/.claude/). Selected by LOOP_STANDALONE at boot. Loop is multi-team — each // account can register many team keys — so the file backend stores a nested // map and the store API mirrors that. export interface LoopStoredKey { teamName: string; apiKey: string; teamAddress: string; agentId: string; permissions: string[]; createdAt: string; updatedAt: string; } export type LoopKeyInput = Omit; export interface LoopKeyStore { readonly backendName: string; readonly backendPath: string; loadAll(accountId: string): Promise; save(accountId: string, key: LoopKeyInput): Promise<{ created: boolean }>; delete(accountId: string, teamName: string): Promise; } class Neo4jLoopKeyStore implements LoopKeyStore { readonly backendName = "neo4j"; readonly backendPath = process.env.NEO4J_URI ?? ""; async loadAll(accountId: string): Promise { const session = getSession(); try { const result = await session.run( `MATCH (k:LoopTeamKey {accountId: $accountId}) RETURN k.teamName AS teamName, k.encryptedKey AS encryptedKey, k.teamAddress AS teamAddress, k.agentId AS agentId, k.permissions AS permissions, k.createdAt AS createdAt, k.updatedAt AS updatedAt ORDER BY k.teamName`, { accountId }, ); return result.records.map((r) => ({ teamName: r.get("teamName"), apiKey: decrypt(r.get("encryptedKey")), teamAddress: r.get("teamAddress") ?? "", agentId: r.get("agentId") ?? "", permissions: r.get("permissions") ?? [], createdAt: r.get("createdAt") ?? "", updatedAt: r.get("updatedAt") ?? "", })); } finally { await session.close(); } } async save(accountId: string, key: LoopKeyInput): Promise<{ created: boolean }> { const encryptedKey = encrypt(key.apiKey); const now = new Date().toISOString(); const session = getSession(); try { await session.run( `CREATE INDEX loop_team_key_account_team IF NOT EXISTS FOR (k:LoopTeamKey) ON (k.accountId, k.teamName)`, {}, ); const result = await session.run( `MERGE (k:LoopTeamKey {teamName: $teamName, accountId: $accountId}) ON CREATE SET k.encryptedKey = $encryptedKey, k.teamAddress = $teamAddress, k.agentId = $agentId, k.permissions = $permissions, k.createdAt = $now, k.updatedAt = $now RETURN k.createdAt = $now AS created`, { teamName: key.teamName, encryptedKey, teamAddress: key.teamAddress, agentId: key.agentId, accountId, permissions: key.permissions, now, }, ); return { created: Boolean(result.records[0]?.get("created")) }; } finally { await session.close(); } } async delete(accountId: string, teamName: string): Promise { const session = getSession(); try { const result = await session.run( `MATCH (k:LoopTeamKey {teamName: $teamName, accountId: $accountId}) DELETE k RETURN count(k) AS deleted`, { teamName, accountId }, ); const deleted = result.records[0]?.get("deleted")?.toNumber?.() ?? 0; return deleted > 0; } finally { await session.close(); } } } const LABEL = "loop"; interface FileEnvelope { [accountId: string]: { [teamName: string]: { encryptedKey: string; teamAddress: string; agentId: string; permissions: string[]; createdAt: string; updatedAt: string; }; }; } class FileLoopKeyStore implements LoopKeyStore { readonly backendName = "file"; readonly backendPath: string; private readonly storePath: string; private readonly keyFilePath: string; constructor() { const home = homedir(); this.storePath = resolve(home, ".claude/.realagent-loop-key.enc"); this.keyFilePath = resolve(home, ".claude/.realagent-loop-encryption-key"); this.backendPath = this.storePath; } private ensureDir(): void { mkdirSync(dirname(this.storePath), { recursive: true }); } private readAll(): FileEnvelope { if (!existsSync(this.storePath)) return {}; const blob = readFileSync(this.storePath, "utf-8").trim(); if (!blob) return {}; const json = decryptWith(blob, this.keyFilePath, LABEL); return JSON.parse(json) as FileEnvelope; } private writeAll(env: FileEnvelope): void { this.ensureDir(); const blob = encryptWith(JSON.stringify(env), this.keyFilePath, LABEL); writeFileSync(this.storePath, blob, { mode: 0o600 }); } async loadAll(accountId: string): Promise { const env = this.readAll(); const account = env[accountId]; if (!account) return []; return Object.entries(account) .map(([teamName, entry]) => ({ teamName, apiKey: decryptWith(entry.encryptedKey, this.keyFilePath, LABEL), teamAddress: entry.teamAddress, agentId: entry.agentId, permissions: entry.permissions, createdAt: entry.createdAt, updatedAt: entry.updatedAt, })) .sort((a, b) => a.teamName.localeCompare(b.teamName)); } async save(accountId: string, key: LoopKeyInput): Promise<{ created: boolean }> { const env = this.readAll(); const account = env[accountId] ?? {}; const prev = account[key.teamName]; const now = new Date().toISOString(); const encryptedKey = encryptWith(key.apiKey, this.keyFilePath, LABEL); account[key.teamName] = { encryptedKey, teamAddress: key.teamAddress, agentId: key.agentId, permissions: key.permissions, createdAt: prev?.createdAt ?? now, updatedAt: now, }; env[accountId] = account; this.writeAll(env); return { created: !prev }; } async delete(accountId: string, teamName: string): Promise { const env = this.readAll(); const account = env[accountId]; if (!account || !(teamName in account)) return false; delete account[teamName]; if (Object.keys(account).length === 0) delete env[accountId]; else env[accountId] = account; this.writeAll(env); return true; } } let cached: LoopKeyStore | null = null; export function selectLoopKeyStore(): LoopKeyStore { if (cached) return cached; cached = process.env.LOOP_STANDALONE === "1" ? new FileLoopKeyStore() : new Neo4jLoopKeyStore(); return cached; } export function _resetKeyStoreForTests(): void { cached = null; }