UNPKG

@essentialai/cogent-bridge

Version:

Cogent Bridge — cross-agent comms for Claude Code, OpenAI Codex and Slack. Codex users: install with `curl -fsSL https://cogent.tools/install.sh | sh`, then start with `cogent-codex` for real-time peer wake.

317 lines • 15 kB
import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; /** * Legacy global credential file used by cogent-bridge < 3.1.8. * A single file shared across every CC session on the host -- which is * exactly the cross-cwd "credential bleed" bug 3.1.8 fixes. Kept here * as a constant so startup can detect-and-warn on upgrade. */ export const LEGACY_GLOBAL_CREDENTIAL_PATH = path.join(os.homedir(), ".cogent-credentials.json"); /** * Per-cwd default location for credentials. * * ~/.cogent/credentials/<sha256(absoluteCwd)[..16]>.json * * Each working directory gets its own credential file, so a /cogent:register * in project A cannot inherit the cloud session that project B last joined. * The 16-char prefix of the SHA is enough to make collisions astronomically * unlikely while keeping filenames short. */ function defaultCredentialPath(cwd = process.cwd()) { const absoluteCwd = path.resolve(cwd); const hash = crypto .createHash("sha256") .update(absoluteCwd) .digest("hex") .slice(0, 16); return path.join(os.homedir(), ".cogent", "credentials", `${hash}.json`); } /** * Resolve the credential path with the override hierarchy: * 1. explicit `credentialPath` arg (tests, advanced users) * 2. process.env.COGENT_CREDENTIALS_FILE (operators with custom paths) * 3. defaultCredentialPath(process.cwd()) (per-cwd, the new default) */ export function resolveCredentialPath(credentialPath) { if (credentialPath) return credentialPath; const envOverride = process.env.COGENT_CREDENTIALS_FILE; if (envOverride) return envOverride; return defaultCredentialPath(); } // --------------------------------------------------------------------------- // Joined-in-process flag // --------------------------------------------------------------------------- // // register-peer falls back to persisted credentials only if cogent_join_session // (or cogent_create_session) has been called in this MCP process. This guards // against silently using stale or another-cwd's creds. Override with // COGENT_TRUST_PERSISTED_CREDENTIALS=1 if you really want the old behavior. let joinedInProcessFlag = false; export function markJoinedInProcess() { joinedInProcessFlag = true; } export function hasJoinedInProcess() { return joinedInProcessFlag; } /** * Test-only reset hook. Production code should never call this. */ export function __resetJoinedInProcessForTests() { joinedInProcessFlag = false; } // --------------------------------------------------------------------------- // Cloud-attempt flag (distinct from joined-in-process) // --------------------------------------------------------------------------- // // markJoinedInProcess() flips only on a SUCCESSFUL join/create. This second // flag flips as soon as cogent_join_session / cogent_create_session is ENTERED, // before the network call — so register-peer can tell the difference between // "no cloud attempt was ever made" (legit local mode / env-configured session) // and "a cloud join/create was tried and did NOT succeed". In the latter case // register-peer must fail loud instead of silently falling back to the local // FileBackend or auto-creating a throwaway free channel (T0.1, shipped 3.12.5 — a failed // Team join otherwise registers success:true on the WRONG channel, invisible to // every peer). // // A new attempt also INVALIDATES any earlier success, so "joined" always means "the LATEST // join/create succeeded" (COG-33). Otherwise a free join followed by a failed Team join left // joined=true, the guard stayed silent, and register landed on the still-active FREE backend. let cloudAttemptInProcessFlag = false; // Bumped on EVERY attempt, never reset: a background presence restore compares it before and after // its own awaits, so an explicit join/create that started meanwhile always wins. let cloudAttemptGeneration = 0; const cloudAttemptListeners = new Set(); export function markCloudAttemptInProcess() { cloudAttemptInProcessFlag = true; joinedInProcessFlag = false; cloudAttemptGeneration++; for (const fn of cloudAttemptListeners) { try { fn(); } catch { /* a listener must never break a join */ } } } export function getCloudAttemptGeneration() { return cloudAttemptGeneration; } /** * Run `fn` whenever a join/create starts. Lets startup.ts cancel a pending presence retry without * the tools importing startup.ts (that import cycle reads back `undefined` — see peer-presence.ts). */ export function onCloudAttemptInProcess(fn) { cloudAttemptListeners.add(fn); return () => cloudAttemptListeners.delete(fn); } export function hasCloudAttemptInProcess() { return cloudAttemptInProcessFlag; } /** * Test-only reset hook. Production code should never call this. */ export function __resetCloudAttemptInProcessForTests() { cloudAttemptInProcessFlag = false; } // --------------------------------------------------------------------------- // Load / save / clear // --------------------------------------------------------------------------- /** * Load persisted credentials from disk. * Returns null on any error (file not found, JSON parse failure, etc.). */ export async function loadCredentials(credentialPath) { const filePath = resolveCredentialPath(credentialPath); try { const raw = await fs.readFile(filePath, "utf-8"); return JSON.parse(raw); } catch { return null; } } /** * Save credentials to disk as pretty-printed JSON. * Creates the parent directory if it does not exist. */ export async function saveCredentials(creds, credentialPath) { const filePath = resolveCredentialPath(credentialPath); // Bearer token is a secret: keep the directory owner-only and the file // 0600 so another user on a shared host cannot read it. mkdir/writeFile // `mode` only applies on creation, so chmod afterward to also tighten a // pre-existing (possibly world-readable) file written by an older version. await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); // COG-80: a writer that does not know about `peers` (create, join, an older field set) must not // erase the other agents' entries — carry the map over unless the caller passed one. let out = creds; if (creds.peers === undefined) { const prior = await loadCredentials(filePath); if (prior?.peers) out = { ...creds, peers: prior.peers }; } // Two bridges in one folder read this file while the other writes it: write a temp file (0600 from // creation) and rename it into place, so a reader never sees a half-written file. const tmp = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`; await fs.writeFile(tmp, JSON.stringify(out, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 }); await fs.chmod(tmp, 0o600); await fs.rename(tmp, filePath); } /** * Persist a peerSecret the relay just issued for `peerId` in `sessionId`. * * 🔴 The relay returns a secret only when it MINTS one (the peer was created, re-created after an * eviction, or adopted) and never again. Every register path must keep it: the self-heal * re-registration and the startup presence restore used to discard it, so the next re-join (a new * token) left the agent holding a dead secret and PEER_NOT_YOURS forever (klaire-frontend, * 2026-09-30). Credentials for another channel are left alone. */ export async function rememberIssuedPeerSecret(sessionId, peerId, peerSecret) { const creds = await loadCredentials(); if (!creds || creds.sessionId !== sessionId) return; const entry = creds.peers?.[peerId]; const entryCurrent = !entry || entry.sessionId !== sessionId || entry.peerSecret === peerSecret; if (creds.peerId === peerId && creds.peerSecret === peerSecret && entryCurrent) return; // COG-80: the new secret replaces the old one in THIS peer's entry too, in the same write — // otherwise a later register would replay the stale one (PEER_NOT_YOURS for 30 minutes). const peers = entry && entry.sessionId === sessionId ? { ...creds.peers, [peerId]: { ...entry, peerSecret, savedAt: new Date().toISOString() } } : creds.peers; // The top-level fields now name this peer: take its own connection from its entry when it has one, // never a mix of this peer's id with the other agent's token. const base = entry && entry.sessionId === sessionId ? { ...creds, ...entry } : creds; await saveCredentials({ ...base, peerId, peerSecret, ...(peers ? { peers } : {}), savedAt: new Date().toISOString() }); } // --------------------------------------------------------------------------- // The channel connection THIS process is using (COG-80) // --------------------------------------------------------------------------- // // The folder file can name another agent's join; a per-peer record must carry this process's own // endpoint, channel and token. Set wherever the cloud backend is (re)built. let currentCloudConnection = null; export function setCurrentCloudConnection(c) { currentCloudConnection = c; } export function getCurrentCloudConnection() { return currentCloudConnection; } // --------------------------------------------------------------------------- // COG-80: one entry per peer in the folder file // --------------------------------------------------------------------------- // // With two agents in one folder the top-level fields name the OTHER agent half the time: on // 2026-10-16 (KLAIRE backend, 3.25.8) a restarted Claude bridge restored the Codex agent's name from // them, and the Codex agent's ownership proof had been overwritten. So every registered peer also // keeps its own entry in `peers`, which another peer's register never touches. It lives in the SAME // file and is written in the SAME write as the top-level fields (saveCredentials carries it over), // so the two cannot drift apart. /** A peer's saved identity: the top-level fields when they name it (always the newest), else its entry. */ export function peerCredentials(folder, peerId) { if (!folder) return null; if (folder.peerId === peerId) { const { peers: _p, ...top } = folder; return top; } const e = folder.peers?.[peerId]; return e ? { ...e, peerId } : null; } /** Every peer saved in this folder, one record each (entries; plus the top-level peer if it has none). */ export function peerRecordsOf(folder) { if (!folder?.peers) return []; return Object.entries(folder.peers).map(([peerId, e]) => ({ ...e, peerId })); } export async function loadPeerRecord(peerId, credentialPath) { return peerCredentials(await loadCredentials(credentialPath), peerId); } export async function loadPeerRecords(credentialPath) { return peerRecordsOf(await loadCredentials(credentialPath)); } /** Write `rec` as this peer's entry, leaving the top-level fields and every other entry as they are. */ export async function savePeerRecord(rec, credentialPath) { const folder = await loadCredentials(credentialPath); if (!folder) return; // no channel saved here at all: nothing to attach an entry to const { peerId, peers: _p, ...entry } = rec; await saveCredentials({ ...folder, peers: { ...(folder.peers ?? {}), [peerId]: entry } }, credentialPath); } export async function deletePeerRecord(peerId, credentialPath) { const folder = await loadCredentials(credentialPath); if (!folder?.peers?.[peerId]) return; const { [peerId]: _gone, ...rest } = folder.peers; await saveCredentials({ ...folder, peers: rest }, credentialPath); } /** * Which saved identity a bridge starting in this folder may restore (COG-80). * * - No per-peer records yet (a folder last registered before 3.25.9): the folder file, as before. * - The record whose agentSession is this bridge's own session (Claude Code passes it at start). * - Otherwise the ONE record written by an agent of this bridge's platform: Codex passes no session * at start, and a Claude session id changes on /clear. Never another platform's record — that is * the KLAIRE failure, a Claude bridge coming back as the Codex agent. * - Two or more candidates and no session match: nothing. Guessing would hand one agent the other's * name; the agent registers its name explicitly instead (skip says which names were seen). */ export function chooseRestoreCredentials(folder, records, ownSession, platform) { if (records.length === 0) return { creds: folder }; const top = folder?.peerId ? peerCredentials(folder, folder.peerId) : null; const pick = (r) => peerCredentials(folder, r.peerId) ?? r; if (ownSession) { const mine = records.filter((r) => r.agentSession === ownSession); if (mine.length > 0) return { creds: pick(mine[0]) }; } const sameKind = records.filter((r) => r.platform === platform); if (sameKind.length === 1) return { creds: pick(sameKind[0]) }; // Several of this platform: the folder's last-registered peer, if it is one of them. if (sameKind.length > 1 && top && sameKind.some((r) => r.peerId === top.peerId)) return { creds: top }; // None of this platform: the top-level peer is this agent's only if no other agent's entry claims it // (this agent last registered here on an older version, which wrote no entry). if (sameKind.length === 0 && top && !records.some((r) => r.peerId === top.peerId)) return { creds: top }; const names = records.map((r) => `'${r.peerId}' (${r.platform ?? "unknown agent"})`).join(", "); return { creds: null, skip: sameKind.length === 0 ? `this folder's saved names belong to other agents — ${names}; this ${platform} agent has none, so it registers its own name explicitly` : `this folder has several saved ${platform} names and this bridge cannot tell which is its own — ${names}; the agent registers its name explicitly`, }; } /** * Delete the credential file from disk. * Silently ignores ENOENT (file already absent). */ export async function clearCredentials(credentialPath) { const filePath = resolveCredentialPath(credentialPath); try { await fs.unlink(filePath); } catch (err) { if (err.code !== "ENOENT") { throw err; } } } /** * Detect whether the legacy global credential file exists. Used by startup * to warn the operator about the path migration on first run after upgrade. */ export async function legacyGlobalCredentialsExist() { try { await fs.access(LEGACY_GLOBAL_CREDENTIAL_PATH); return true; } catch { return false; } } //# sourceMappingURL=credential-store.js.map