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.

481 lines • 31.5 kB
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