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