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.

206 lines • 12 kB
import { z } from "zod"; import { getConfig } from "../config.js"; import { successResult, errorResult } from "../errors.js"; import { saveCredentials, loadCredentials, markJoinedInProcess, markCloudAttemptInProcess } from "../cloud/credential-store.js"; import { logger } from "../logger.js"; import { getLastNudge } from "../backend/npm-update-check.js"; /** UUID v4 regex for distinguishing UUIDs from labels. */ const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; /** * Register the cogent_join_session MCP tool. * * Joins an existing cloud bridge channel using its name and shared secret. * Only available when COGENT_ENDPOINT is configured. * After joining, persists credentials locally and reinitializes * the cloud backend so runtime HttpClient and CloudWsClient use * the new token/sessionId. */ export function registerJoinSessionTool(server) { server.registerTool("cogent_join_session", { title: "Join Cloud Channel", description: "Join an existing cloud bridge channel by its channel name (e.g. \"mt-space\") and shared secret. " + "The channel name is the human-readable label provided by the user, NOT a UUID. " + "Do NOT pass a Claude Code session ID here — use the channel/space name the user gave you.", inputSchema: { channel: z .string() .describe("The channel name to join (e.g. \"mt-space\"). " + "This is the human-readable name the user provided, NOT a UUID. " + "Do NOT use a Claude Code session ID here."), secret: z .string() .min(1) .describe("The shared secret / password for the channel"), orgId: z .string() .min(1) .optional() .describe("Org_ID — the 3rd credential for a Team (business) channel. " + "Provide it for team channels; omit for free channels."), }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, async ({ channel, secret, orgId }) => { try { // Record that a cloud join was ATTEMPTED (before any network call), so a // subsequent cogent_register_peer can tell a failed join from "no cloud // attempt at all" and refuse to silently fall back (T0.1, shipped 3.12.5). markCloudAttemptInProcess(); // Normalize inputs before any exact-match use. A stray space or paste // artifact in the Org_ID (or channel/secret) otherwise breaks the relay's // exact SHA-256 resolve and surfaces as an opaque SESSION_NOT_FOUND // ("channel or Org_ID wrong") even when the value is otherwise correct. // An Org_ID that is blank after trimming is treated as absent (free join), // never a doomed Team resolve. (Observed live 2026-06-29.) channel = channel.trim(); secret = secret.trim(); orgId = orgId?.trim() || undefined; const config = getConfig(); // Auto-route by Org_ID: a Team (business) join targets the Team relay; a free // join targets COGENT_ENDPOINT. The presence of an Org_ID is the discriminant — // no per-install relay flipping. (vault: Optional_and_Default_Parameters.md — the // optional orgId selects the endpoint, with COGENT_TEAM_ENDPOINT carrying the default.) const endpoint = orgId ? config.COGENT_TEAM_ENDPOINT : config.COGENT_ENDPOINT; if (!endpoint) { return errorResult(new Error(orgId ? "COGENT_TEAM_ENDPOINT not configured. Set it to join Team (Org_ID) channels." : "COGENT_ENDPOINT not configured. Set it to use cloud mode.")); } // Guard: if the caller passed a UUID instead of a channel name, it's // almost certainly a mistake (e.g. a Claude Code session ID). Warn // loudly so the LLM corrects the call. if (UUID_V4_RE.test(channel)) { return errorResult(new Error(`The "channel" parameter ("${channel}") looks like a UUID. ` + `You should pass the human-readable channel name (e.g. "mt-space") ` + `that the user provided, NOT a Claude Code session ID or any UUID. ` + `Please retry with the correct channel name.`)); } // Resolve the channel name to the internal session UUID. // Team (Org_ID present): business labels are NOT public-resolvable, so POST the // Org_ID-scoped resolve. Free (no Org_ID): the unchanged public GET resolve. let resolvedSessionId; let resolvedLabel; if (orgId) { const resolveResp = await fetch(`${endpoint}/api/sessions/resolve`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ label: channel, orgId }), }); if (!resolveResp.ok) { const body = await resolveResp.text().catch(() => ""); return errorResult(new Error(`Failed to resolve team channel "${channel}": HTTP ${resolveResp.status}` + `${body ? ` - ${body}` : ""}. Check the channel name and Org_ID, and that ` + `the Team relay (${endpoint}) is reachable.`)); } const resolved = (await resolveResp.json()); resolvedSessionId = resolved.sessionId; resolvedLabel = resolved.label; } else { const resolveResp = await fetch(`${endpoint}/api/sessions/resolve/${encodeURIComponent(channel)}`); if (!resolveResp.ok) { const body = await resolveResp.text().catch(() => ""); return errorResult(new Error(`Failed to resolve channel name "${channel}": HTTP ${resolveResp.status}${body ? ` - ${body}` : ""}`)); } const resolved = (await resolveResp.json()); resolvedSessionId = resolved.sessionId; resolvedLabel = resolved.label; } // POST to join session (unauthenticated endpoint) const joinPath = `/api/sessions/${encodeURIComponent(resolvedSessionId)}/join`; const resp = await fetch(`${endpoint}${joinPath}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(orgId ? { secret, orgId } : { secret }), }); if (!resp.ok) { const body = await resp.text().catch(() => ""); return errorResult(new Error(`Failed to join session: HTTP ${resp.status}${body ? ` - ${body}` : ""}`)); } const data = (await resp.json()); // Persist credentials locally (include label for future resolution). // // 🔴 AUD-003: CARRY THE PEER'S OWN IDENTITY FORWARD. This used to write a FRESH object, and // saveCredentials is a whole-file replace with no merge — so every re-join silently dropped // `peerSecret` and `peerId`. credential-store.ts:30-34 states the consequence: "a peer is // owned by the token that created it, and re-joining a channel issues a NEW token. Without // this secret an agent that re-joins can never re-register its own peerId again." The relay // mints a secret ONLY when the stored peer has none (server/src/routes/peers.ts:313-317), // so it is never reissued and the lockout is PERMANENT. register-peer.ts:325-345 already // does this correctly with `...creds`; this is the same shape. // // Scoped deliberately: those two fields identify a peer on ONE channel of ONE relay, so // they are carried forward ONLY when this join lands on that same endpoint AND sessionId. // Re-attaching them to a different channel would claim an identity we do not own. let carried = {}; try { const prior = await loadCredentials(); if (prior && prior.endpoint === endpoint && prior.sessionId === data.sessionId) { carried = { ...(prior.peerId ? { peerId: prior.peerId } : {}), ...(prior.peerSecret ? { peerSecret: prior.peerSecret } : {}), // Keep the token that owns the peer. A re-join mints a NEW one, and ownership is // proven by the CREATING token (or its secret) — so overwriting this field is what // turned a lost peerSecret into a permanent lockout. Prefer an ownerToken we are // already carrying; only fall back to `prior.token` the first time. ...(prior.ownerToken || prior.token ? { ownerToken: prior.ownerToken ?? prior.token } : {}), }; } } catch (err) { // A missing/corrupt store is normal on a first join — never block the join for it. logger.debug(`join-session: could not read prior credentials: ${err.message}`); } await saveCredentials({ endpoint, sessionId: data.sessionId, token: data.token, label: resolvedLabel ?? data.label, ...carried, savedAt: new Date().toISOString(), }); // Gates the persisted-credentials fallback in register-peer / startup for this process. markJoinedInProcess(); // Reinitialize cloud backend with new credentials. // Uses dynamic import to avoid compile-time dependency on startup.ts // (reinitCloudBackend is added in Plan 05). Cast to Record to avoid // TS2339 since the export doesn't exist yet. try { const startupModule = (await import("../startup.js")); if (typeof startupModule.reinitCloudBackend === "function") { await startupModule.reinitCloudBackend(endpoint, resolvedSessionId, data.token); logger.info("Cloud backend reinitialized after joining session"); } } catch (err) { // reinitCloudBackend may not exist yet (Plan 05 adds it). // Credentials are persisted, so a restart will pick them up. logger.debug(`Backend reinit not available: ${err}`); } // Surface the startup update-check (populated by index.ts) IN the join result so a // session on a stale client learns it at the FIRST connect step — before register — // and can warn the human to update+restart. Best-effort; absent when current. const updateNudge = getLastNudge(); return successResult({ success: true, sessionId: data.sessionId, token: data.token, label: resolvedLabel ?? data.label ?? channel, peerCount: data.peerCount, note: "Successfully joined session. Credentials saved locally. Cloud backend updated. You can now use cogent_register_peer.", ...(updateNudge ? { updateAvailable: updateNudge } : {}), }); } catch (err) { logger.error("join-session failed", { error: err }); return errorResult(err); } }); } //# sourceMappingURL=join-session.js.map