@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.
481 lines • 31.5 kB
JavaScript
import crypto from "node:crypto";
import { z } from "zod";
import { getConfig } from "../config.js";
import { getBackend, isCloudEndpoint, isCloudMode, isCloudDeferred } from "../backend/index.js";
import { successResult, errorResult, BridgeError, BridgeErrorCode } from "../errors.js";
import { saveCredentials, loadCredentials, hasJoinedInProcess, hasCloudAttemptInProcess, } from "../cloud/credential-store.js";
import { persistProvisionedMailbox } from "../cloud/mail-credential-store.js";
import { logger } from "../logger.js";
import { heartbeat } from "../services/heartbeat.js";
import { autoRelay } from "../services/auto-relay.js";
import { resolveSession } from "../services/session-resolver.js";
import { transcriptBaseSummary } from "../services/cc-cli.js";
import { deriveWorkspaceId } from "../services/durable-identity.js";
import { CLIENT_VERSION } from "../constants.js";
import { getLastNudge } from "../backend/npm-update-check.js";
/**
* Why this resolution cannot produce a wakeable session id, or null when it can.
*
* 🔴 ASK THE CONDITION, NOT A PROXY. The old guard tested `candidateCount === 0`. An AMBIGUOUS
* resolution has candidates but no chosen id (`decideResolution` returns `sessionId: null` when
* two transcripts are within the margin), so it slipped straight through the zero-check and the
* caller's value was pinned unchecked — during the 2026-09-02 ring exercise that value was a
* CHANNEL UUID, which `--resume` can never open, and every subsequent wake failed in ~2s with no
* warning ever emitted. The question that decides correctness is "did we end up with an id
* `--resume` can open?", not "were there zero candidates?".
*/
export function unresolvedSessionReason(r) {
if (r.sessionId)
return null;
return r.candidateCount === 0 ? "no-candidates" : "ambiguous";
}
export function registerRegisterPeerTool(server) {
server.registerTool("cogent_register_peer", {
title: "Register Peer",
description: "Register a Claude Code session as a named peer on the bridge. " +
"Each peer needs a unique peerId, the CC sessionId to resume, " +
"the working directory (cwd), and a human-readable label.",
inputSchema: {
peerId: z
.string()
.describe("Unique identifier for this peer, e.g. 'backend' or 'frontend'"),
sessionId: z
.string()
.describe("Claude Code session ID (used with --resume)"),
cwd: z
.string()
.describe("Absolute path to the project working directory"),
label: z
.string()
.describe("Human-readable label, e.g. 'Cogent_Backend' or 'Cogent_Frontend'"),
peerSecret: z
.string()
.optional()
.describe("AUD-003 ownership proof. Only needed to RECLAIM a peerId whose registration was " +
"made from a different connection — e.g. this agent re-joined the channel and was " +
"issued a new token. Normally omitted: the stored credential is replayed automatically."),
mode: z
.enum(["observer", "agent"])
.optional()
.default("agent")
.describe("Peer participation mode. \"agent\" (default) auto-responds via execClaude on inbound directed messages. " +
"\"observer\" registers a passive watcher that receives WS frames and remains targetable but never spawns a subprocess. " +
"Useful for operators using their MCP client to monitor a channel without polluting transcript with phantom replies."),
role: z
.enum(["implementer", "reviewer", "auditor", "coordinator"])
.optional()
.describe("Structured A2A role (Slice 4). Makes multi-agent collaboration explicit rather than noise: " +
"\"implementer\" writes code, \"reviewer\"/\"auditor\" check it, \"coordinator\" is the single visible task owner. " +
"Advertised on the peer (surfaced in cogent_list_peers); at most one coordinator per channel by convention."),
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
}, async ({ peerId, sessionId, cwd, label, mode, role, peerSecret: argPeerSecret }) => {
try {
const config = getConfig();
// Fail-loud guard (T0.1, shipped 3.12.5): if a cogent_join_session / cogent_create_session
// was attempted in THIS process but did not succeed, and there is no explicitly
// configured cloud session to fall back to, refuse to register. Otherwise register
// silently falls back to the LOCAL FileBackend (COGENT_ENDPOINT unset/local) or
// AUTO-CREATES a throwaway FREE session (COGENT_ENDPOINT=free) and returns
// success:true — masking a failed Team join (e.g. an idle-swept channel → 404) so
// the agent believes it is on the intended channel while being invisible to peers.
const hasExplicitCloudSession = !!config.COGENT_SESSION_ID ||
process.env.COGENT_TRUST_PERSISTED_CREDENTIALS === "1";
if (!hasExplicitCloudSession &&
hasCloudAttemptInProcess() &&
!hasJoinedInProcess()) {
return errorResult(new BridgeError(BridgeErrorCode.JOIN_NOT_CONFIRMED, "A cogent_join_session/cogent_create_session call was attempted this session but did not succeed, so cogent_register_peer will not fall back to local mode or auto-create a new channel (that would silently put you on the WRONG channel while reporting success)", "Re-run cogent_join_session with the correct channel name + secret (+ Org_ID for Team channels) and confirm it returns success before calling cogent_register_peer"));
}
// Config-based check for auto-create (needs the endpoint URL).
// WS trigger uses isCloudMode() separately (line 192).
const hasCloudEndpointConfig = config.COGENT_ENDPOINT &&
isCloudEndpoint(config.COGENT_ENDPOINT);
// Track auto-create state for enriching the response
let sessionCreated = false;
let cloudSessionId;
// Hoisted to handler scope so the post-block deferred guard can tell a
// resolved-cloud-token register (auto-create / persisted-adopt) apart from
// the leak case (COGENT_SESSION_ID set but NO token obtained).
let cloudToken;
let autoSecret;
if (hasCloudEndpointConfig && config.COGENT_ENDPOINT) {
let endpoint = config.COGENT_ENDPOINT;
// Check if we have cloud credentials (from env vars or persisted)
cloudSessionId = config.COGENT_SESSION_ID;
if (!cloudSessionId) {
// Gate persisted-credentials fallback: only trust on-disk creds when
// cogent_join_session / cogent_create_session has been called in this
// process (or operator opt-in via COGENT_TRUST_PERSISTED_CREDENTIALS=1).
// This closes the cross-cwd "credential bleed" window in 3.1.8.
const trustPersisted = hasJoinedInProcess() ||
process.env.COGENT_TRUST_PERSISTED_CREDENTIALS === "1";
if (trustPersisted) {
const persisted = await loadCredentials();
// Accept creds from THIS process's join against EITHER configured relay.
// join_session auto-routes Org_ID joins to the Team relay (COGENT_TEAM_ENDPOINT,
// 3.5.1), so the persisted endpoint can be the Team URL while COGENT_ENDPOINT is
// still the free default. Comparing only against COGENT_ENDPOINT wrongly fell
// through to auto-create and 400'd on the Team relay. Adopt the join's endpoint.
if (persisted &&
(persisted.endpoint === config.COGENT_ENDPOINT ||
persisted.endpoint === config.COGENT_TEAM_ENDPOINT)) {
cloudSessionId = persisted.sessionId;
cloudToken = persisted.token;
endpoint = persisted.endpoint;
}
}
}
if (!cloudSessionId) {
// Auto-create session: no session configured at all
autoSecret = crypto.randomBytes(16).toString("hex");
const resp = await fetch(`${endpoint}/api/sessions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
// 🔴 NO `label` — let the relay name the channel.
//
// This used to send `Auto-created by ${label}`, which the relay's own
// CreateSessionRequestSchema can NEVER accept: it requires
// /^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$/ (lowercase, digits, hyphens, 3-32 chars).
// "Auto-created by ..." has a capital A and spaces, so EVERY auto-create got
// HTTP 400 INVALID_INPUT and the zero-config path — register with no channel
// configured — could not work at all. Measured against a real relay 2026-09-15:
// this body 400s, the same POST without a label returns 201.
//
// The 400 was visible as far back as 3.5.2 and was read as "don't fall into
// auto-create" (see e2e/team-join-live.e2e.test.ts), so the symptom was routed
// around and the path itself stayed broken.
//
// Omitting the label is not a workaround: the relay generates one with
// collision-retry scoped to the org (generateSessionLabel + isLabelTaken). A
// client-side slug would have to re-implement that and could still collide.
body: JSON.stringify({ secret: autoSecret }),
});
if (!resp.ok) {
return errorResult(new Error(`Failed to auto-create session: HTTP ${resp.status}`));
}
const created = (await resp.json());
cloudSessionId = created.sessionId;
cloudToken = created.token;
sessionCreated = true;
// Persist for future restarts (incl. peerId for the check-on-stop hook).
await saveCredentials({
endpoint,
sessionId: cloudSessionId,
token: cloudToken,
peerId,
// The CHANNEL's label (not this peer's) — the name a human needs to join it.
label: created.label,
savedAt: new Date().toISOString(),
});
// Reinit cloud backend with new credentials
// (same dynamic import pattern as create-session/join-session)
try {
const startupModule = (await import("../startup.js"));
if (typeof startupModule.reinitCloudBackend === "function") {
await startupModule.reinitCloudBackend(endpoint, cloudSessionId, cloudToken);
}
}
catch {
// reinitCloudBackend may not exist yet
}
logger.info(`Auto-created cloud session ${cloudSessionId}`);
}
}
// SECURITY + fail-loud: cloud-configured, no cloud token resolved, and the
// active backend is still the deferred FileBackend placeholder. This is the
// leak case — COGENT_SESSION_ID is set so the persisted-adopt/auto-create
// paths above were skipped (cloudToken stays undefined), and startup never
// obtained a token (relay unreachable / label unresolved at boot). Do NOT
// fall through to getPeer/registerPeer on the host-global
// ~/.cogent/cogent-state.json — that would read/OVERWRITE ANOTHER local
// agent's peer entry (cross-channel) AND silently register us locally while
// we believe we're on the cloud channel. Refuse loudly. (When a token WAS
// resolved — auto-create or persisted-adopt — the backend is/was reinit'd to
// the HttpBackend, so this does not fire on the legitimate join path.)
if (!cloudToken && isCloudDeferred(config.COGENT_ENDPOINT)) {
return errorResult(new BridgeError(BridgeErrorCode.JOIN_NOT_CONFIRMED, "Cloud endpoint + COGENT_SESSION_ID are configured but this bridge has not joined the channel (no session token — the relay was unreachable or the session label did not resolve at startup). cogent_register_peer will NOT register against local state (that would leave you on no channel while other local agents' data sits on this host).", "Call cogent_join_session with the channel name + secret (+ Org_ID for Team channels) and confirm it returns success, then retry cogent_register_peer."));
}
// Proceed with normal peer registration via the active backend
const backend = getBackend();
const existing = await backend.getPeer(peerId);
// Durable-identity rails (LOCAL-ONLY — never sent on the relay wire,
// whose register schema is .strict()):
// channelSessionId = the cloud/relay session id, kept distinct from
// the local .jsonl id the resolver finds (they used to collide).
// capabilities = what this peer can do for the reply contract.
// Slice 3 (Story 8.4): an agent-mode peer advertises the reply-contract
// tier it captures with. Both platforms now use the structured
// machine_readable_event_stream (Claude `--output-format json` `.result`;
// Codex `-o` last-message file) — the bridge never guesses from raw
// stdout; a fallback is surfaced as a visible capture_downgrade.
const channelSessionId = sessionId;
const capabilities = mode === "observer"
? ["observer"]
: ["auto-resume", "reply:machine_readable_event_stream"];
// Slice 4 (Story 8.5): advertise the structured A2A role so multi-agent
// collaboration is explicit (surfaced in cogent_list_peers), not noise.
if (role) {
capabilities.push(`role:${role}`);
}
// Slice 5 / Story 8.6: derive a DURABLE identity that does not depend on
// the mutable, lossy cwd hint. workspaceId = stable SHA-256 of the cwd
// (deriveWorkspaceId — single-sourced so re-register derives it the same);
// threadId = the session id (the durable conversation thread). Sent on the
// wire in cloud mode so the relay can route/resolve on them.
const workspaceId = deriveWorkspaceId(cwd);
const threadId = sessionId;
// Pass our own cogent-bridge npm version so the server (and any
// operator inspecting cogent_list_peers) can tell at a glance which
// client version each peer is running. Plumbs through to PeerInfo.
// 🔴 AUD-003: replay the ownership proof we were issued, if we have one. It only MATTERS
// when our token has changed since the peer was created (a re-join) — the relay accepts
// the same-token path without it. Best-effort: a credential file we cannot read must never
// block registration, it just means we fall back to same-token proof.
let storedPeerSecret = argPeerSecret;
if (storedPeerSecret === undefined && isCloudMode()) {
try {
storedPeerSecret = (await loadCredentials())?.peerSecret;
}
catch {
storedPeerSecret = undefined;
}
}
let peer;
try {
peer = await backend.registerPeer(peerId, sessionId, cwd, label, CLIENT_VERSION, mode, channelSessionId, capabilities, workspaceId, threadId, storedPeerSecret);
}
catch (regErr) {
// 🔴 SELF-RECOVERY FROM A LOST OWNERSHIP PROOF (AUD-003).
//
// Clients before 3.23.5 destroyed the peerSecret on every re-join, so an agent can hold
// `peerId` with no way to prove it owns it and gets PEER_NOT_YOURS forever. The relay
// only lets it back in once the peer is ABANDONED (no live connection AND lastSeenAt past
// the 30-minute stale timeout) — correct, but a 30-minute outage.
//
// The prior token IS a valid ownership proof and 3.23.5+ keeps it as `ownerToken`. Retry
// once with it: recovery becomes immediate, and the freshly issued peerSecret is stored so
// this cannot recur. Every failure path falls through to the ORIGINAL error — a broken
// recovery must never mask why registration actually failed.
const msg = regErr instanceof Error ? regErr.message : String(regErr);
let recovered;
if (/PEER_NOT_YOURS/.test(msg) && isCloudMode()) {
try {
const creds = await loadCredentials();
if (creds?.ownerToken && creds.ownerToken !== creds.token && creds.endpoint && creds.sessionId) {
logger.warn(`register_peer: '${peerId}' is owned by an earlier token (its peerSecret was lost by a ` +
`pre-3.23.5 re-join). Re-proving ownership with the stored owner token.`);
const res = await fetch(`${creds.endpoint}/api/sessions/${creds.sessionId}/peers`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${creds.ownerToken}` },
body: JSON.stringify({ peerId, cwd, label, type: "agent", clientVersion: CLIENT_VERSION,
...(mode ? { mode } : {}), ...(capabilities ? { capabilities } : {}) }),
});
if (res.ok) {
recovered = await res.json();
const secret = recovered?.peerSecret;
if (secret) {
await saveCredentials({ ...creds, peerId, peerSecret: secret, savedAt: new Date().toISOString() });
logger.info(`register_peer: ownership reclaimed for '${peerId}'; a fresh peerSecret is stored.`);
}
}
}
}
catch (recoverErr) {
logger.debug(`register_peer: ownership recovery failed: ${recoverErr.message}`);
}
}
if (!recovered)
throw regErr;
peer = recovered;
}
// Stamp our peerId into the per-cwd cloud credentials so the check-on-stop
// hook (a separate process) can identify "me" without re-deriving it. The
// join/create path saved creds before the peerId was known; this fills it
// in. Best-effort — a failure here must never break registration.
if (isCloudMode()) {
try {
const creds = await loadCredentials();
// AUD-003: the relay returns a freshly-minted peerSecret EXACTLY ONCE, when the peer
// is created or adopted. Losing it means this agent can never reclaim its own peerId
// after a re-join, so persist it the moment we see it.
const issuedSecret = peer?.peerSecret;
if (creds && (creds.peerId !== peerId || (issuedSecret && creds.peerSecret !== issuedSecret))) {
await saveCredentials({
...creds,
peerId,
...(issuedSecret ? { peerSecret: issuedSecret } : {}),
savedAt: new Date().toISOString(),
});
}
}
catch (err) {
logger.debug(`check-on-stop: could not stamp peerId into credentials: ${err.message}`);
}
}
// Cogent Mail M2.7: if the relay auto-provisioned a mailbox for this peer (Team channel),
// persist the one-time credentials to the local 0600 mail store so cogent_send_mail /
// cogent_fetch_mail can use them without a manual cogent_setup_mail. Best-effort — a failure
// must never break registration — and the password value is never logged.
// A Team mailbox that FAILED to provision must be audible here, not only in the relay
// log the agent's operator may never read. Without this the next symptom is a bare "no
// mailbox is configured" at send time, with nothing connecting it to this registration.
if (peer?.mailboxError) {
logger.warn(`Cogent Mail: mailbox provisioning FAILED (${peer.mailboxError.code}) — ${peer.mailboxError.message}`);
}
try {
if (await persistProvisionedMailbox(peer?.mailbox)) {
logger.info(`Cogent Mail: saved auto-provisioned mailbox ${peer.mailbox?.address}`);
}
}
catch (err) {
logger.warn(`Cogent Mail: could not save auto-provisioned mailbox creds: ${err.message}`);
}
// Set local peer info for auto-relay (cloud mode: incoming messages trigger local execClaude).
// Auto-detect the real local CC session ID from the filesystem — the user/agent may
// pass the cloud session ID by mistake, which won't work with `claude --resume`.
// Confidence-aware resolve + PIN at register. The caller is provably
// active right now, so this resolution is trustworthy; the pin is
// exact-matched on every later wake instead of re-guessing newest-mtime
// (the fix for silent wrong-session drift).
const resolution = await resolveSession(getConfig().COGENT_PLATFORM, cwd, sessionId);
const localSessionId = resolution.sessionId ?? sessionId;
logger.info(`Register: session resolved ${resolution.decision}/${resolution.confidence} ` +
`(${resolution.candidateCount} candidate[s]) -> ${localSessionId}`);
// 🔴 FAIL LOUD. A live agent session is calling this tool right now, so its
// transcript MUST be on disk. Zero candidates therefore is not "no sessions
// yet" — it means we scanned the WRONG directory (an agent-supplied cwd that
// is not where this agent actually runs, or a relocated CLAUDE_CONFIG_DIR /
// CODEX_HOME the bridge process cannot see). Registration still succeeds, but
// localSessionId silently falls back to the CLOUD session id, which
// `--resume` can never open — so EVERY later wake is refused. That combination
// shipped as a mystery on a fresh dev machine (2026-08-31): the log said only
// "0 candidate[s]" at INFO and the operator had to report it by hand.
// This warns on stderr AND returns the probed paths in the tool result, which
// is the only channel the calling agent can actually read.
const unresolved = unresolvedSessionReason(resolution);
if (unresolved) {
// Own try/catch: this block is PURELY informational, so nothing in it may
// ever fail a registration that has already succeeded.
try {
const base = getConfig().COGENT_PLATFORM === "cc"
? await transcriptBaseSummary()
: "(codex: scans CODEX_HOME/sessions)";
const cause = unresolved === "ambiguous"
? `${resolution.candidateCount} candidate sessions matched and NONE could be chosen, so the ` +
`id you supplied was pinned UNCHECKED — if that is a channel UUID rather than a local ` +
`session id, every wake will fail in ~2s`
: `NO local ${getConfig().COGENT_PLATFORM} session was found for this cwd`;
logger.warn(`Register: ${cause} — auto-wake WILL be refused. ` +
`cwd=${cwd}; scanned ${(resolution.probedPaths ?? []).join(", ") || "(unknown)"}; base ${base}. ` +
`If the base is missing or empty, this agent's config dir is elsewhere — ` +
`set CLAUDE_CONFIG_DIR / CODEX_HOME so the BRIDGE process sees it too. ` +
`If the base has project dirs but none match, the registered cwd is not where this agent runs.`);
}
catch (err) {
logger.warn(`Register: unresolved session (${unresolved}) for cwd=${cwd} (auto-wake WILL be refused); ` +
`could not describe the transcript base: ${err.message}`);
}
}
// Plumb mode (Phase 17 OBS-03) + the pinned local id so the resolver
// exact-matches it on subsequent wakes.
autoRelay.setLocalPeer(peerId, localSessionId, cwd, mode, label, localSessionId);
// Build the result, enriched with auto-create info if applicable
const result = {
success: true,
action: existing ? "updated" : "registered",
peer,
sessionResolution: {
decision: resolution.decision,
confidence: resolution.confidence,
candidateCount: resolution.candidateCount,
// Only on the broken path — keeps the happy-path result terse.
...(unresolved
? {
warning: (unresolved === "ambiguous"
? `${resolution.candidateCount} local session transcripts matched this cwd and none ` +
"could be chosen, so the sessionId you supplied was pinned UNCHECKED — if it is a " +
"channel UUID rather than a local session id, auto-wake will be REFUSED. "
: "No local session transcript found for this cwd — auto-wake will be REFUSED. ") +
"Verify the cwd is where this agent actually runs and that CLAUDE_CONFIG_DIR / " +
"CODEX_HOME are visible to the bridge process.",
scannedPaths: resolution.probedPaths ?? [],
}
: {}),
},
};
// Surface a version-drift warning IN the tool response (not just stderr /
// health_check, which the agent never sees) so a session on a stale build learns it
// on connect and can tell the human to update+restart. The relay's AUTHORITATIVE
// advisory (F2 — it compared our clientVersion to its configured minimum) takes
// precedence; otherwise fall back to the client's own npm self-check nudge (F1).
const relayAdvisory = peer?.advisory;
const updateNudge = (typeof relayAdvisory === "string" && relayAdvisory) || getLastNudge();
if (updateNudge)
result.updateAvailable = updateNudge;
if (sessionCreated && cloudSessionId && autoSecret) {
result.session_created = true;
result.credentials = {
sessionId: cloudSessionId,
secret: autoSecret,
};
const ep = config.COGENT_ENDPOINT ?? "";
result.config_snippet = {
env_vars: `COGENT_ENDPOINT=${ep}\n` +
`COGENT_SESSION_ID=${cloudSessionId}\n` +
`COGENT_SECRET=${autoSecret}`,
mcp_config: {
"cogent": {
command: "npx",
args: ["@essentialai/cogent-bridge"],
env: {
COGENT_ENDPOINT: ep,
COGENT_SESSION_ID: cloudSessionId,
COGENT_SECRET: autoSecret,
},
},
},
};
}
// After successful peer registration in cloud mode, trigger WebSocket
// connection by calling setPeerId() on the CloudWsClient created during
// startup. Use dynamic import to avoid circular dependency with startup.ts.
if (isCloudMode()) {
try {
const startup = await import("../startup.js");
if (startup.cloudWsClient) {
startup.cloudWsClient.setPeerId(peerId);
// setPeerId() internally calls connect() if state is "disconnected" and peerId is non-empty
logger.info(`WebSocket connection triggered for peer ${peerId}`);
}
}
catch (err) {
// Non-fatal: WS is optional, polling fallback will work
logger.warn(`Could not trigger WebSocket connection: ${err}`);
}
}
// Start background heartbeat to keep this peer alive.
// Pass triggerReRegistration callback so heartbeat can auto-recover
// if the server evicts this peer via stale cleanup (returns 404).
let reRegFn;
try {
const startupMod = await import("../startup.js");
reRegFn = startupMod.triggerReRegistration;
}
catch {
// Non-fatal: re-registration won't be available
}
heartbeat.start(peerId, server, reRegFn);
return successResult(result);
}
catch (err) {
logger.error("register-peer failed", { error: err });
return errorResult(err);
}
});
}
//# sourceMappingURL=register-peer.js.map