@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
JavaScript
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