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.

407 lines • 19.5 kB
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