@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.
407 lines • 19.5 kB
JavaScript
import { relaySupports } from "./relay-version-cache.js";
import { BridgeError, BridgeErrorCode } from "../errors.js";
/**
* Relay response header (Cogent Mail F3) set on DELETE /peers when a Team mailbox was reaped.
* MUST match the relay's `MAILBOX_DEPROVISIONED_HEADER` (server/src/routes/peers.ts) — the bridge
* and relay are separate packages, so the string is duplicated here by necessity.
*/
const MAILBOX_DEPROVISIONED_HEADER = "X-Cogent-Mailbox-Deprovisioned";
import { checkCodexSandbox } from "../services/health-check.js";
import { SERVER_VERSION } from "../constants.js";
import { getConfig } from "../config.js";
import { getRelayVersion } from "./relay-version-cache.js";
import { rememberIssuedPeerSecret } from "../cloud/credential-store.js";
/**
* API path patterns matching @essentialai/cogent PATHS constants.
* Defined locally because cogent is not a dependency of the main package.
*/
const PATHS = {
peers: "/api/sessions/:sessionId/peers",
peer: "/api/sessions/:sessionId/peers/:peerId",
messages: "/api/sessions/:sessionId/messages",
message: "/api/sessions/:sessionId/messages/:messageId",
heartbeat: "/api/sessions/:sessionId/heartbeat",
health: "/api/health",
};
/**
* Cloud-mode storage backend.
*
* Implements StorageBackend by translating all state operations into REST
* API calls to the cloud relay server. Used when COGENT_ENDPOINT is
* set to an HTTP/HTTPS URL.
*
* Cloud-specific behavior differences from FileBackend:
* - updateLastSeen is a no-op (server tracks activity automatically)
* - validateSession always returns true (server validates on every request)
* - execClaude returns a synthetic result (no CLI subprocess in cloud mode)
* - checkHealth queries the server health endpoint instead of local checks
*/
export class HttpBackend {
http;
sessionId;
constructor(http, sessionId) {
this.http = http;
this.sessionId = sessionId;
}
/**
* Register a peer in the cloud session.
* POST /api/sessions/:sessionId/peers
*
* The sessionId parameter from StorageBackend is the local CC sessionId;
* in cloud mode we ignore it and use this.sessionId (the cloud session).
*/
async registerPeer(peerId, _sessionId, cwd, label, clientVersion, mode,
// 3.5.9 channelSessionId is LOCAL-ONLY: intentionally NOT added to the wire body
// (the relay register schema is .strict() and would reject an unknown key).
_channelSessionId,
// A2A Agent Card (2026-08): capabilities DO cross the wire now (the relay .strict()
// schema was extended to accept them, relay-first). This closes the 8.5 stub where
// they were dropped here and a peer's cloud-side Agent Card had no skills.
capabilities,
// Slice 5 / Story 8.6: durable identity that DOES go on the wire (unlike the
// local-only rails above) — the relay's .strict() schema was extended in
// lockstep (relay-first deploy) to accept these optional fields.
workspaceId, threadId,
/**
* AUD-003 ownership proof, replayed from the local credential file when this agent's token has
* changed since the peer was created (a re-join). Gated on the relay advertising
* `peer-ownership`: the register schema is .strict(), so sending it to a relay that does not
* know the field would 400 EVERY registration — the exact shape of AUD-001 and of the
* 2026-08-07 outage.
*/
peerSecret) {
const path = PATHS.peers.replace(":sessionId", this.sessionId);
// Build body conditionally: omit clientVersion entirely for older clients
// / undefined inputs so the wire payload stays clean (no JSON null) and
// the server's optional schema treats it as "not provided".
const body = {
peerId,
cwd,
label,
platform: getConfig().COGENT_PLATFORM,
type: "agent",
transport: "ws",
};
if (clientVersion !== undefined) {
body.clientVersion = clientVersion;
}
// Same omit-when-undefined pattern for the 3.2.0 mode field (Phase 17
// OBS-01). Older servers that don't know the field will ignore it (it
// arrives via the .strict() body schema, so a pre-3.2.0 server WOULD
// reject the request — guarded by the optional field on the server
// mirror being shipped in lockstep with the client).
if (mode !== undefined) {
body.mode = mode;
}
// Slice 5 / Story 8.6: advertise durable identity on the wire (conditional-set,
// like the fields above). A relay too old to know these fields would reject
// via .strict() — so the relay ships the optional fields FIRST, then the client.
if (workspaceId !== undefined) {
body.workspaceId = workspaceId;
}
if (threadId !== undefined) {
body.threadId = threadId;
}
// A2A Agent Card: advertise capabilities (which already carry the folded `role:<role>`).
// Derive a top-level `role` from that entry so the relay stores + surfaces it explicitly
// (feeds peerInfoToAgentCard: capabilities → skills[], role → the card description).
if (capabilities !== undefined && capabilities.length > 0) {
body.capabilities = capabilities;
const roleCap = capabilities.find((c) => c.startsWith("role:"));
if (roleCap) {
body.role = roleCap.slice("role:".length);
}
}
if (peerSecret !== undefined && (await relaySupports(this.http, "peer-ownership"))) {
body.peerSecret = peerSecret;
}
const peer = await this.http.post(path, body);
// Every register path goes through here, so this is where an issued secret is kept — not in
// each caller (two of three forgot, and the agent was locked out of its own name).
const issued = peer?.peerSecret;
if (issued) {
try {
await rememberIssuedPeerSecret(this.sessionId, peerId, issued);
}
catch {
// An unwritable credential file must not fail the registration that just succeeded.
}
}
return peer;
}
/**
* Deregister a peer from the cloud session.
* DELETE /api/sessions/:sessionId/peers/:peerId
*
* Returns `{ removed, mailboxDeprovisioned }`. `mailboxDeprovisioned` reflects the relay's
* `X-Cogent-Mailbox-Deprovisioned` response header (Cogent Mail F3) — true ONLY when the relay
* confirmed the Team mailbox was reaped, so the deregister-peer tool can clear the now-dead
* local mail creds. A 404 (peer not found) resolves to `{ removed: false, ... }`, not a throw.
*/
async deregisterPeer(peerId, peerSecret) {
const path = PATHS.peer
.replace(":sessionId", this.sessionId)
.replace(":peerId", peerId);
try {
// Offer the ownership proof when we hold one. The relay enforces AUD-003 on DELETE as of
// 3.25.0: the common path (same token that registered) still needs nothing, but an agent
// that re-joined holds a NEW token and must present the secret or be refused — otherwise
// evict-then-register lets any channel member take over another agent's identity.
// Sent unconditionally when present: an older relay ignores an unknown body field on DELETE
// (the .strict() schemas are on POST bodies), so this needs no capability gate.
const headers = await this.http.delete(path, peerSecret ? { peerSecret } : {});
const mailboxDeprovisioned = headers.get(MAILBOX_DEPROVISIONED_HEADER) === "true";
return { removed: true, mailboxDeprovisioned };
}
catch (err) {
// 🔴 AUD-006. This used to be a bare `catch {}` returning removed:false for ANY failure, so
// 401, 403, 500, timeout, DNS and transport errors were all reported to the operator as the
// benign "peer was not registered" — while the peer and its mailbox stayed live and nothing
// suggested action was needed.
//
// A not-found answer is a FACT about the server's state. A network failure is the ABSENCE of
// any answer. They must not share a return value: only a verified PEER_NOT_FOUND is
// "already gone"; everything else is an unknown outcome and must reach the caller.
if (err instanceof BridgeError && err.code === BridgeErrorCode.PEER_NOT_FOUND) {
return { removed: false, mailboxDeprovisioned: false };
}
throw err;
}
}
/**
* Get a single peer by ID.
* No dedicated single-peer REST endpoint exists, so we list all peers
* and filter client-side.
*/
async getPeer(peerId) {
const peers = await this.listPeers();
return peers.find((p) => p.peerId === peerId);
}
/**
* List all peers in the cloud session.
* GET /api/sessions/:sessionId/peers
*/
async listPeers() {
const path = PATHS.peers.replace(":sessionId", this.sessionId);
const result = await this.http.get(path);
return result.peers;
}
/**
* Update last-seen timestamp for a peer.
* Calls the lightweight heartbeat endpoint on the cloud relay server.
*/
async updateLastSeen(peerId) {
const path = PATHS.heartbeat.replace(":sessionId", this.sessionId);
await this.http.post(path, { peerId });
}
/**
* Record a message in the cloud session.
* POST /api/sessions/:sessionId/messages
*
* Sends the message to the server which stores it and returns
* { id, success, timestamp }. We merge the server response with
* the input record to construct a full MessageRecord.
*/
async recordMessage(record) {
const path = PATHS.messages.replace(":sessionId", this.sessionId);
// Build body conditionally: only forward isRelayEcho when explicitly true,
// so the wire payload stays minimal and older servers (pre-3.1.6) that
// don't know the field simply ignore an absent flag. Setting an
// explicit `false` is unnecessary -- the server treats absent as false.
const body = {
fromPeerId: record.fromPeerId,
toPeerId: record.toPeerId,
message: record.message,
};
if (record.isRelayEcho === true) {
body.isRelayEcho = true;
}
// 🔴 REPORT A FAILED WAKE — BUT ONLY TO A RELAY THAT SAID IT ACCEPTS THE REPORT.
//
// The problem: until 2026-09-02 this method dropped `success`/`error`/`durationMs`, so the
// bridge's own `success: false` (auto-relay.ts:1082/1165/1245/1248) never reached anyone and
// the relay stamped `true` on every record. Four consumers then branched on that constant —
// rail C in production among them, which read the bridge's "could not wake" notice as an
// ANSWER and went silent on exactly the busy-miss it exists to catch.
//
// The trap in the obvious fix: the relay's POST schema is `.strict()`, so an unknown key is a
// hard 400 on EVERY send, not a degraded record. send-message.ts:214-222 passes a computed
// NUMERIC durationMs on every ordinary cloud send, so forwarding unconditionally would have
// 400'd the entire fabric against the then-deployed 3.13.3 relays (AUD-001).
//
// A deploy-ORDER rule ("relay first, both tiers") is the standing mitigation and it was
// forgotten once already — 2026-08-07, two days of dark paying agents. So the client ASKS
// instead: the relay advertises `message-delivery-fields` on /api/health, and unknown fails
// SAFE (omit). Cost is nothing — capabilities ride the same 60s-TTL cached health probe the
// version already uses.
//
// Within that gate, the same omit-when-empty convention as isRelayEcho above: a field is sent
// only when it carries information.
if (await relaySupports(this.http, "message-delivery-fields")) {
if (record.success === false) {
body.success = false;
}
if (record.error != null) {
body.error = record.error;
}
if (record.durationMs != null) {
body.durationMs = record.durationMs;
}
}
// Cogent Mail M2.5: forward file references only when present (same
// omit-when-empty convention as isRelayEcho) so no-attachment records stay
// byte-identical on the wire and older relays simply ignore an absent field.
if (record.attachments && record.attachments.length > 0) {
body.attachments = record.attachments;
}
// Terminal message (sender declares no reply expected). CAPABILITY-GATED for the same reason
// as the delivery fields above: the relay's POST schema is `.strict()`, so sending this to a
// relay that predates it is a hard 400 on EVERY send — not a dropped field. Unknown fails
// SAFE (omit), and only an explicit `false` carries information.
if (record.replyExpected === false && (await relaySupports(this.http, "terminal-messages"))) {
body.replyExpected = false;
}
const resp = await this.http.post(path, body);
return {
...record,
id: resp.id,
timestamp: resp.timestamp,
};
}
/**
* Get message history from the cloud session.
* GET /api/sessions/:sessionId/messages
*
* Supports optional peerId filter and limit (default 300).
*/
async getHistory(peerId, limit, includeRelayEchoes, metadataOnly, dedupInferredEchoes) {
const path = PATHS.messages.replace(":sessionId", this.sessionId);
const query = {};
if (peerId) {
query.peerId = peerId;
}
query.limit = String(limit ?? 300);
// Only attach when truthy: server defaults to filtering, and omitting
// the param keeps the wire payload minimal + leaves older servers
// (pre-3.1.6) untouched -- they ignore unknown query keys (.strict()
// is on the JSON body, not the query schema on those builds).
if (includeRelayEchoes) {
query.includeRelayEchoes = "true";
}
// HIST-02 (3.2): server projects the 7-key shape when this is set.
// Same omit-when-falsy pattern — pre-3.2 servers ignore the unknown
// key and return full records, so client-side parsers must not assume
// the projection is enforced.
if (metadataOnly) {
query.metadataOnly = "true";
}
// FIX-INFERRED-ECHO (3.2.1): server filters untagged auto-relay echoes
// via content-and-direction heuristic when this is set. Older servers
// (pre-3.2.1) ignore the unknown key.
if (dedupInferredEchoes) {
query.dedupInferredEchoes = "true";
}
const result = await this.http.get(path, query);
return result.messages;
}
/**
* Update an existing message record's response field on the server.
* PATCH /api/sessions/:sessionId/messages/:messageId
*
* Called by send-message after receiving the async cloud response,
* so that get-history shows the actual response (not null).
*/
async updateMessageResponse(messageId, response, durationMs, pending) {
const path = PATHS.message
.replace(":sessionId", this.sessionId)
.replace(":messageId", messageId);
// Only forward `pending` when explicitly true so the wire payload stays
// minimal and pre-260527-mkz servers (which ignore unknown keys) are
// unaffected. Mirrors the isRelayEcho omit-when-falsy convention.
const body = { response, durationMs };
if (pending === true) {
body.pending = true;
}
await this.http.patch(path, body);
}
/**
* Validate a session.
* In cloud mode, the server validates session authenticity on every
* authenticated request, so this always returns true.
*/
async validateSession(_sessionId, _cwd) {
return true;
}
/**
* Execute a Claude CLI command.
* In cloud mode, there is no CLI subprocess. Message delivery is
* handled by recordMessage() via REST. The send-message tool handler
* calls execClaude() after validating peers, then calls recordMessage()
* separately. In cloud mode, execClaude simply returns a synthetic
* result indicating the message was delivered via the cloud relay.
*/
async execClaude(_sessionId, _message, _cwd, _timeoutMs) {
return {
stdout: "Message delivered via cloud relay.",
stderr: "",
exitCode: 0,
};
}
/**
* Check health of the cloud relay server.
* GET /api/health
*
* Maps the server's HealthResponse into the local HealthCheckResult
* format with cloud-mode-specific diagnostics.
*/
async checkHealth() {
const resp = await this.http.get(PATHS.health);
return {
healthy: resp.status === "ok",
// VER-01 (3.2): the SERVER_VERSION constant still identifies the local
// cogent-bridge npm package — this property name makes its role
// unambiguous now that transport.relayVersion carries the upstream
// relay's version. Hard rename, no backward-compat alias.
mcpServerVersion: SERVER_VERSION,
statePath: "cloud:" + this.sessionId,
claudePath: "N/A (cloud mode)",
checks: {
stateFile: {
ok: true,
message: "Cloud mode -- state managed by server",
},
lockMechanism: {
ok: true,
message: "Cloud mode -- no local locks needed",
},
claudeCli: {
ok: true,
message: "Cloud mode -- no CLI needed",
},
// The codex CLI DOES run locally even in cloud mode (auto-relay resumes
// it to generate the reply), so the sandbox is a real local concern here.
codexSandbox: await checkCodexSandbox(),
},
timestamp: new Date().toISOString(),
mode: "cloud",
serverStatus: resp.status,
activeSessions: resp.activeSessions,
connectedPeers: resp.connectedPeers,
serverUptime: resp.uptime,
};
}
/**
* Lazy lookup of the upstream relay's `version` field via GET /api/health,
* memoized for 60s. Returns null on fetch failure or malformed payload.
* Never throws — the caller (health-check tool) treats null as "unknown"
* and keeps the rest of the response intact.
*
* Delegates to the module-scoped cache in `relay-version-cache.ts` so the
* TTL is shared across all callers and tests can reset state via
* `__resetCacheForTest()`. VER-02 (3.2).
*/
async getRelayVersion() {
return getRelayVersion(this.http);
}
}
//# sourceMappingURL=http-backend.js.map