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.

647 lines • 33 kB
import os from "node:os"; import path from "node:path"; import fs from "node:fs/promises"; import readline from "node:readline"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { loadConfig, getConfig } from "./config.js"; import { createBackend, isCloudEndpoint, getBackend, initBackend, resetBackend } from "./backend/index.js"; import { HttpBackend } from "./backend/http-backend.js"; import { deriveWorkspaceId } from "./services/durable-identity.js"; import { initLogger, logger } from "./logger.js"; import { BridgeError, BridgeErrorCode } from "./errors.js"; import { loadCredentials, hasJoinedInProcess, legacyGlobalCredentialsExist, } from "./cloud/credential-store.js"; import { CloudWsClient, MessageInbox } from "./cloud/index.js"; import { HttpClient } from "./cloud/http-client.js"; import { autoRelay } from "./services/auto-relay.js"; import { restorePresence, logPresence, setPresence } from "./services/peer-presence.js"; import { listLiveBridges } from "./services/wake-ownership.js"; import { heartbeat } from "./services/heartbeat.js"; import { preflightCodex, preflightCodexSandbox, preflightCodexServerCollision, } from "./services/codex-preflight.js"; import { setSandboxOverride } from "./services/codex-cli.js"; import { preflightClaude } from "./services/claude-preflight.js"; import { CLIENT_VERSION } from "./constants.js"; // Re-export autoRelay for use in index.ts shutdown export { autoRelay } from "./services/auto-relay.js"; /** UUID v4 regex for distinguishing UUIDs from labels in COGENT_SESSION_ID. */ 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; const execFileAsync = promisify(execFile); const PERSIST_PATH = path.join(os.homedir(), ".cogent-config.json"); // One-shot flag so a re-init (reinitCloudBackend / multiple startup calls in // tests) does not re-emit the legacy-credentials migration warning. let legacyWarnEmitted = false; // --------------------------------------------------------------------------- // Cloud service singletons -- accessible to index.ts and tool handlers // --------------------------------------------------------------------------- export let cloudWsClient = null; export let cloudInbox = null; export let cloudHttpClient = null; /** True when this process resolved to a cloud endpoint (set by runStartup). */ let cloudModeActive = false; /** * Is this process in cloud mode? Needed by index.ts to decide whether to * advertise `cogent://inbox` — which must happen even when the inbox does not * exist YET (deferred join), so it cannot be inferred from `cloudInbox`. */ export function isCloudMode() { return cloudModeActive; } // How index.ts wants to be told about new inbox messages, plus which inbox that // callback is currently bound to. Kept here (not in index.ts) because the inbox // can be REPLACED after startup by reinitCloudBackend(); binding once at startup // would leave deferred agents with a listener on an object that was thrown away. let inboxNotifier = null; let notifierBoundTo = null; /** Register the MCP host notifier; binds now and again after any inbox swap. */ export function setInboxNotifier(fn) { inboxNotifier = fn; bindInboxNotifier(); } /** Idempotent: binds the notifier to the CURRENT inbox exactly once. */ function bindInboxNotifier() { if (!inboxNotifier || !cloudInbox || notifierBoundTo === cloudInbox) return; cloudInbox.onNewMessage(inboxNotifier); notifierBoundTo = cloudInbox; } async function loadPersistedConfig() { try { const raw = await fs.readFile(PERSIST_PATH, "utf-8"); return JSON.parse(raw); } catch { return {}; } } async function savePersistedConfig(config) { try { await fs.writeFile(PERSIST_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8"); } catch (err) { const msg = err instanceof Error ? err.message : String(err); logger.warn(`Could not save config to ${PERSIST_PATH}: ${msg}`); } } // --------------------------------------------------------------------------- // First-run interactive prompt // --------------------------------------------------------------------------- async function firstRunPrompt(defaultPath) { // Non-TTY (MCP host context): use defaults silently if (!process.stdin.isTTY) { return defaultPath; } // Check if persisted config already exists (not first run) const persisted = await loadPersistedConfig(); if (persisted.statePath) { return persisted.statePath; } // First run with TTY -- prompt the user const rl = readline.createInterface({ input: process.stdin, output: process.stderr, // CRITICAL: output to stderr, not stdout }); return new Promise((resolve) => { rl.question(`\nFirst run detected. Where should cogent-bridge store its data?\n` + ` Default: ${defaultPath}\n` + ` Press Enter to accept, or type a custom path: `, async (answer) => { rl.close(); const chosen = answer.trim() || defaultPath; await savePersistedConfig({ statePath: chosen }); resolve(chosen); }); }); } // --------------------------------------------------------------------------- // Validation helpers // --------------------------------------------------------------------------- async function validateStateDir(statePath) { await fs.mkdir(statePath, { recursive: true }); const testFile = path.join(statePath, ".write-test"); try { await fs.writeFile(testFile, "test", "utf-8"); await fs.unlink(testFile); } catch { throw new BridgeError(BridgeErrorCode.STARTUP_FAILED, `Cannot write to state directory: ${statePath}`, "Check permissions or set COGENT_STATE_PATH to a writable directory"); } } async function checkClaudeCli(claudePath) { try { const { stdout } = await execFileAsync(claudePath, ["--version"], { timeout: 5000, }); logger.info(`Claude CLI detected: ${stdout.trim()}`); } catch { logger.warn(`CLI_NOT_FOUND: '${claudePath}' not found on PATH. ` + `cogent_send_message will fail until Claude Code is installed. ` + `Set COGENT_CLAUDE_PATH if installed in a non-standard location.`); } } // --------------------------------------------------------------------------- // Automatic re-registration after server eviction (stale cleanup) // --------------------------------------------------------------------------- let reRegistrationInProgress = false; /** * Re-register a peer after it was evicted by the server's stale cleanup. * Called by both HeartbeatService (on 404) and CloudWsClient (on WS upgrade 404). * Debounced: only one re-registration can run at a time. */ export async function triggerReRegistration() { if (reRegistrationInProgress) return; reRegistrationInProgress = true; try { const peerState = autoRelay.getLocalPeer(); if (!peerState) { logger.error("Cannot re-register: no local peer info available"); return; } logger.warn(`Re-registering peer '${peerState.peerId}' after server eviction...`); const backend = getBackend(); // Preserve the operator-supplied label and mode captured at original // register-peer time. Pre-3.2.2 passed peerState.peerId as label, which // silently normalized human labels like "Cogent_Master" -> "cogent-master" // on auto-reconnect. CLIENT_VERSION is also re-supplied so PeerInfo stays // consistent with the original tool call. // Slice 5 / Story 8.6: re-derive + re-advertise the durable identity so a // peer keeps the SAME workspaceId/threadId across an eviction/WS-cycle // reconnect (durability is the whole point). channelSessionId/capabilities // aren't available from getLocalPeer() so they remain a pre-existing gap. await backend.registerPeer(peerState.peerId, peerState.sessionId, peerState.cwd, peerState.label, CLIENT_VERSION, peerState.mode, undefined, // channelSessionId (not tracked on re-register) undefined, // capabilities (not tracked on re-register) deriveWorkspaceId(peerState.cwd), peerState.sessionId); logger.info(`Re-registered peer '${peerState.peerId}' (label: '${peerState.label}', mode: '${peerState.mode}') successfully`); // Reconnect WS if (cloudWsClient) { cloudWsClient.setPeerId(peerState.peerId); } // Restart heartbeat (it stopped itself on eviction detection) heartbeat.restart(); } catch (err) { logger.error(`Re-registration failed: ${err}`); } finally { reRegistrationInProgress = false; } } /** * Evaluate this agent's PRESENCE on its channel and, when it is `offline` with a peer it * owns, bring it back `online`. See services/peer-presence.ts for the state vocabulary, * how an agent ends up offline, and why restoring it is safe. * * Fire-and-forget from index.ts AFTER the transport connects — it performs network I/O * and must never precede the `initialize` handshake (scripts/startup-nonblocking-test.mjs). */ /** * The exact positional argument tuple for a presence restore's registerPeer call. * * 🔴 EXTRACTED SO IT CAN BE TESTED. registerPeer's trailing parameters are OPTIONAL, and * per the TypeScript vault (concepts/Optional_Parameters -> sources/.../0281) omitting one * "will provide the function with an `undefined` value parameter" — silently. Measured: * deleting `peerSecret` from the call site left BOTH `tsc --noEmit` AND the presence unit * tests at exit 0, because those tests cover the module, not this wiring. A dropped * ownership proof means a peer owned by an earlier token can never be reclaimed, and the * agent stays offline. The type system cannot see it; this tuple + its test can. */ export function presenceRegisterArgs(p) { return [ p.peerId, p.sessionId, p.cwd, p.label, CLIENT_VERSION, "agent", undefined, // channelSessionId — not tracked on a presence restore undefined, // capabilities — likewise deriveWorkspaceId(p.cwd), p.sessionId, // threadId p.peerSecret, // AUD-003 ownership proof — reclaims a peer owned by an earlier token ]; } let presenceRetry = null; /** Stop a pending presence retry (shutdown, tests). */ export function cancelPresenceRetry() { if (presenceRetry) clearTimeout(presenceRetry); presenceRetry = null; } /** * 🔴 A failed restore used to be final: the bridge stayed offline until someone restarted the * agent (klaire-frontend, 2026-09-30 — refused at startup, then absent for a day after the cause * was fixed). Retry with backoff until it is online; a manual register in between stops it. */ function presenceRetryDelayMs(attempt) { const base = Number(process.env.COGENT_PRESENCE_RETRY_MS); return Math.min((Number.isFinite(base) && base > 0 ? base : 30_000) * 2 ** attempt, 300_000); } export async function restorePeerPresence(attempt = 0) { const outcome = await restorePresence({ // The endpoint being a relay URL is what makes presence meaningful. We deliberately do // NOT gate on the active backend being the cloud one: at startup it is still the // deferred FileBackend placeholder, and gating on it made this whole path inert // (measured: "skipped: not-cloud-mode" with valid credentials on disk). isCloudConfigured: () => { const ep = getConfig().COGENT_ENDPOINT; return !!ep && isCloudEndpoint(ep); }, alreadyRegisteredInProcess: () => autoRelay.getDiagnostics().registered, peerAlreadyServed: (peerId) => { try { return listLiveBridges(process.cwd(), peerId).some((b) => b.pid !== process.pid); } catch { return false; // unreadable registry ⇒ no proof of competition ⇒ proceed } }, loadCredentials: () => loadCredentials(), cwd: () => process.cwd(), goOnline: async ({ peerId, endpoint, sessionId, token, cwd, label, peerSecret }) => { // Swap the deferred placeholder for a real cloud backend the supported way — the // same call join-session uses. Building a second backend here instead would leave // the tools on the placeholder and the presence path on another: a split brain. await reinitCloudBackend(endpoint, sessionId, token); const backend = getBackend(); await backend.registerPeer(...presenceRegisterArgs({ peerId, sessionId, cwd, label, peerSecret })); // Wiring auto-relay is the POINT, not a side effect: setLocalPeer() is what calls // announceBridge() and reapStaleBridges(), so restoring presence is also what makes // leftover bridges for this checkout reapable again. autoRelay.setLocalPeer(peerId, sessionId, cwd, "agent", label, sessionId); if (cloudWsClient) cloudWsClient.setPeerId(peerId); heartbeat.restart(); }, }); setPresence(outcome); logPresence(outcome); if (outcome.state === "offline" && outcome.detail.startsWith("presence restore failed")) { const delay = presenceRetryDelayMs(attempt); cancelPresenceRetry(); presenceRetry = setTimeout(() => { presenceRetry = null; void restorePeerPresence(attempt + 1).catch(() => { }); }, delay); presenceRetry.unref?.(); logger.warn(`presence: restore failed — retrying in ${Math.round(delay / 1000)}s`); } return outcome; } // --------------------------------------------------------------------------- // Helper to create a CloudWsClient with standard callbacks // --------------------------------------------------------------------------- function createCloudWsClient(endpoint, sessionId, token, http, inbox, pollIntervalMs) { const client = new CloudWsClient({ endpoint, sessionId, peerId: "", // Will be set after first registerPeer call via setPeerId() token, http, onMessage: (msg) => { const isNew = inbox.addMessage(msg); if (isNew) { autoRelay.handleIncoming(msg).catch((err) => { logger.error(`Auto-relay failed: ${err}`); }); } }, onMessages: (msgs) => { for (const msg of msgs) { const isNew = inbox.addMessage(msg); if (isNew) { autoRelay.handleIncoming(msg).catch((err) => { logger.error(`Auto-relay failed: ${err}`); }); } } }, onPeerConnected: (peer) => { logger.info(`Peer connected: ${peer.peerId} (${peer.name})`); }, onPeerDisconnected: (id) => { logger.info(`Peer disconnected: ${id}`); }, onPeersSnapshot: (peers) => { logger.debug(`Peers snapshot: ${peers.length} peers`); }, onStateChange: (state) => { logger.info(`WebSocket state: ${state}`); }, onError: (err) => { logger.warn(`WebSocket error frame: ${err.code}: ${err.message}`); }, onDeliveryAck: (payload) => { // 🔴 AUD-002: the messageId used to be DISCARDED here and the ack resolved by peer alone, // so any ack settled any outstanding send to that peer. The frame has always carried it. autoRelay.resolveDeliveryAck(payload.peerId, payload.messageId); }, onPeerEvicted: () => { triggerReRegistration().catch((err) => { logger.error(`Re-registration from WS eviction failed: ${err}`); }); }, pollIntervalMs, }); // Wire delivery ack sending function on autoRelay autoRelay.setSendDeliveryAck((messageId, fromPeerId, toPeerId) => { client.sendDeliveryAck(messageId, fromPeerId, toPeerId); }); return client; } // --------------------------------------------------------------------------- // Reinitialize cloud backend at runtime // --------------------------------------------------------------------------- /** * Reinitialize the cloud backend with new credentials at runtime. * Called by create-session, join-session, and register-peer auto-create * after obtaining new session credentials. * * This function: * 1. Creates a new HttpClient with the new token * 2. Creates a new HttpBackend and replaces the current backend * 3. Creates or updates CloudWsClient and MessageInbox */ /** * A `COGENT_SESSION_ID` label that still needs a network round-trip to become a * UUID. Set by runStartup(), consumed by resolvePendingLabel() AFTER the MCP * transport connects — never before (see the note at the assignment site). */ let pendingLabelResolve = null; /** Test seam: is a label still waiting to be resolved post-connect? */ export function hasPendingLabelResolve() { return pendingLabelResolve !== null; } /** * Resolve a `COGENT_SESSION_ID` label to a session UUID, AFTER the transport is * connected, and upgrade the deferred backend if we also hold a matching token. * * Fire-and-forget by design (index.ts calls it with `void`), exactly like * runPreflights(): the server is already answering `initialize`, so a slow or * dead relay costs the user nothing. Never throws. * * When no matching persisted token exists we stay deferred and say so — the * user's first cogent_join_session / cogent_create_session completes the wiring. */ export async function resolvePendingLabel() { const pending = pendingLabelResolve; pendingLabelResolve = null; if (!pending) return; let resolvedId; try { const resp = await fetch(`${pending.endpoint}/api/sessions/resolve/${encodeURIComponent(pending.label)}`, { signal: AbortSignal.timeout(2500) }); if (!resp.ok) { logger.warn(`Failed to resolve session label "${pending.label}": HTTP ${resp.status}. Staying in deferred mode — use cogent_join_session.`); return; } resolvedId = (await resp.json()).sessionId; } catch (err) { logger.warn(`Failed to resolve session label "${pending.label}": ${err}. Staying in deferred mode — use cogent_join_session.`); return; } logger.info(`Resolved session label "${pending.label}" to UUID ${resolvedId}`); try { const persisted = await loadCredentials(); if (persisted && persisted.sessionId === resolvedId && persisted.endpoint === pending.endpoint) { await reinitCloudBackend(pending.endpoint, resolvedId, persisted.token); } else { logger.info(`No persisted token for session ${resolvedId}; staying deferred until cogent_join_session provides one.`); } } catch (err) { logger.warn(`Post-connect label upgrade failed (non-fatal): ${err}`); } } export async function reinitCloudBackend(endpoint, sessionId, token) { const config = getConfig(); // 1. Create new HttpClient with new token cloudHttpClient = new HttpClient(endpoint, token); // 2. Create new HttpBackend and replace current backend const newBackend = new HttpBackend(cloudHttpClient, sessionId); resetBackend(); // Clear the existing backend reference initBackend(newBackend); // Set the new one logger.info(`Cloud backend reinitialized for session ${sessionId}`); // 3. Create or update CloudWsClient if (cloudWsClient) { // Update existing client's credentials for reconnection. // ENDPOINT FIRST: a free->Team re-join changes the HOST, and updateToken/updateSessionId can // each trigger a reconnect. Moving the host first means any reconnect they cause already dials // the right relay instead of 401ing against the old one (marvin-mobile, 2026-09-05). cloudWsClient.updateEndpoint(endpoint); cloudWsClient.updateToken(token); cloudWsClient.updateSessionId(sessionId); cloudWsClient.updateHttp(cloudHttpClient); // Don't call connect() here -- peerId may still be empty. // connect() will happen when register-peer calls setPeerId(). } else { // First time -- create inbox and WS client if (!cloudInbox) { cloudInbox = new MessageInbox(); } bindInboxNotifier(); // the inbox may be brand new — rebind the host notifier cloudWsClient = createCloudWsClient(endpoint, sessionId, token, cloudHttpClient, cloudInbox, config.COGENT_POLL_INTERVAL_MS); logger.info("Cloud WebSocket client created (will connect after peer registration)"); } } // --------------------------------------------------------------------------- // Main startup flow // --------------------------------------------------------------------------- export async function runStartup() { // Determine state path: env var takes precedence, then first-run prompt const envStatePath = process.env.COGENT_STATE_PATH; let statePath; if (envStatePath) { statePath = envStatePath; } else { const defaultPath = path.join(os.homedir(), ".cogent"); statePath = await firstRunPrompt(defaultPath); } // If the prompt chose a different path, set it in env for loadConfig if (!envStatePath && statePath !== path.join(os.homedir(), ".cogent")) { process.env.COGENT_STATE_PATH = statePath; } else if (!envStatePath) { // Ensure the default or persisted path is available to loadConfig process.env.COGENT_STATE_PATH = statePath; } // Load and freeze config loadConfig(); const config = getConfig(); // --------------------------------------------------------------------------- // Cloud credential resolution // --------------------------------------------------------------------------- let effectiveSessionId = config.COGENT_SESSION_ID; let effectiveToken; const effectiveEndpoint = config.COGENT_ENDPOINT; const isCloud = effectiveEndpoint && isCloudEndpoint(effectiveEndpoint); cloudModeActive = Boolean(isCloud); // --------------------------------------------------------------------------- // Resolve label-based COGENT_SESSION_ID to UUID // --------------------------------------------------------------------------- if (isCloud && effectiveSessionId && !UUID_V4_RE.test(effectiveSessionId)) { // Check persisted credentials first -- if the label matches, use stored UUID // UNGATED: user-supplied label resolution explicitly opts into persisted state. // The user typed COGENT_SESSION_ID=<label> in their env, so consulting the // local credential store to map that label -> UUID is exactly what they asked for. const persisted = await loadCredentials(); if (persisted && persisted.label === effectiveSessionId && persisted.endpoint === effectiveEndpoint) { logger.info(`Resolved session label "${effectiveSessionId}" to UUID ${persisted.sessionId} from persisted credentials`); effectiveSessionId = persisted.sessionId; effectiveToken = persisted.token; } else { // 🔴 DO NOT resolve the label over the network HERE. // // runStartup() is awaited BEFORE `server.connect(transport)` (src/index.ts), // so every millisecond spent here is dead air the MCP client counts against // its `initialize` timeout. This used to `await fetch(...)` with a 2500 ms // AbortSignal and a comment claiming the timeout kept the handshake safe — // but BOUNDING a blocking call does not make it non-blocking, it only caps // the stall. Measured: an unreachable relay added a reproducible +2437 ms to // `initialize` (scripts/startup-nonblocking-test.mjs, controlled A/B). // // Instead we enter deferred mode immediately — the SAME state a failed // resolve produced before, which is a fully supported path — and hand the // resolve to resolvePendingLabel(), which index.ts fires AFTER connect. On // success it upgrades the placeholder backend via reinitCloudBackend(), // exactly as cogent_join_session does. pendingLabelResolve = { label: effectiveSessionId, endpoint: effectiveEndpoint }; effectiveSessionId = undefined; } } if (isCloud) { // One-shot legacy-credentials warning. Pre-3.1.8 stored creds at // ~/.cogent-credentials.json (a single global file shared across every CC // session on the host -- the cross-cwd "credential bleed" bug). 3.1.8 now // uses ~/.cogent/credentials/<sha-prefix>.json per cwd. The legacy file is // no longer read; tell the operator they can delete it. if (!legacyWarnEmitted) { legacyWarnEmitted = true; if (await legacyGlobalCredentialsExist()) { logger.warn("Legacy credentials file detected at ~/.cogent-credentials.json. " + "cogent-bridge 3.1.8 now stores credentials per-cwd at " + "~/.cogent/credentials/<hash>.json. The legacy file is no longer " + "read; you can delete it."); } } // Gate persisted-credentials fallbacks on an explicit in-process join/create // (or operator opt-in via COGENT_TRUST_PERSISTED_CREDENTIALS=1). Without this // gate, a fresh process in a fresh cwd could silently inherit another cwd's // token -- the very bug 3.1.8 closes. const trustPersisted = () => hasJoinedInProcess() || process.env.COGENT_TRUST_PERSISTED_CREDENTIALS === "1"; // Cloud mode: resolve credentials // Priority: env vars (highest) > persisted credentials (lowest) if (config.COGENT_SECRET && effectiveSessionId) { // Full env var config -- need to join session to get a token // This happens later when register-peer or join-session is called // For now, try persisted credentials for the token const persisted = trustPersisted() ? await loadCredentials() : null; if (persisted && persisted.sessionId === effectiveSessionId && persisted.endpoint === effectiveEndpoint) { effectiveToken = persisted.token; } } else if (effectiveSessionId) { // Have session ID but no secret -- try persisted token const persisted = trustPersisted() ? await loadCredentials() : null; if (persisted && persisted.sessionId === effectiveSessionId && persisted.endpoint === effectiveEndpoint) { effectiveToken = persisted.token; } } else { // No session ID at all -- try persisted credentials const persisted = trustPersisted() ? await loadCredentials() : null; if (persisted && persisted.endpoint === effectiveEndpoint) { effectiveSessionId = persisted.sessionId; effectiveToken = persisted.token; logger.info(`Loaded persisted cloud session ${effectiveSessionId}`); } // If no persisted creds either, cloud mode will be "deferred" -- // user needs to call create-session or join-session first } } // --------------------------------------------------------------------------- // Initialize transport backend // --------------------------------------------------------------------------- if (isCloud && effectiveSessionId && effectiveToken) { // Cloud mode with full credentials -- create HttpBackend directly const backend = createBackend(effectiveEndpoint, effectiveSessionId, effectiveToken); initBackend(backend); } else if (isCloud) { // Cloud mode but deferred (no credentials yet) -- create local FileBackend as placeholder. // reinitCloudBackend() will replace it when create-session/join-session provides credentials. const backend = createBackend(undefined); initBackend(backend); } else { // Local mode const backend = createBackend(config.COGENT_ENDPOINT); initBackend(backend); } // Initialize full logger with file outputs initLogger(config.COGENT_LOG_LEVEL, path.join(config.COGENT_STATE_PATH, "logs")); // --------------------------------------------------------------------------- // Cloud mode: set up real-time connection infrastructure // --------------------------------------------------------------------------- if (isCloud && effectiveSessionId && effectiveToken) { // Cloud mode with full credentials -- set up real-time connection cloudInbox = new MessageInbox(); bindInboxNotifier(); cloudHttpClient = new HttpClient(effectiveEndpoint, effectiveToken); cloudWsClient = createCloudWsClient(effectiveEndpoint, effectiveSessionId, effectiveToken, cloudHttpClient, cloudInbox, config.COGENT_POLL_INTERVAL_MS); // Note: WS connect requires a registered peerId. Don't connect yet -- // the peerId is empty and connect() guards against this. // WS connect will be triggered when register-peer calls cloudWsClient.setPeerId(peerId). // Per Pitfall 4 in RESEARCH: WS connect must happen AFTER peer registration. logger.info(`Cloud mode initialized. Session: ${effectiveSessionId}. WebSocket will connect after peer registration.`); } else if (isCloud) { logger.info("Cloud mode endpoint configured but no session credentials. Use cogent_create_session or cogent_join_session."); } // --------------------------------------------------------------------------- // Local-mode-only validation // --------------------------------------------------------------------------- if (!isCloud) { // Validate state directory writability (only needed in local mode) await validateStateDir(config.COGENT_STATE_PATH); // Check for claude CLI (warn-only, only needed in local mode) await checkClaudeCli(config.COGENT_CLAUDE_PATH); } const mode = isCloud ? "cloud" : "local"; logger.info(`Startup complete. Mode: ${mode}, State: ${config.COGENT_STATE_PATH}, Log level: ${config.COGENT_LOG_LEVEL}`); // NOTE: the platform CLI preflight (which SPAWNS the vendor CLI) is deliberately // NOT run here. index.ts invokes runPreflights() AFTER the MCP transport // connects, so a slow `codex/claude --help` can never delay the `initialize` // handshake (that stall closed the MCP connection on cold hosts). } /** * Platform CLI flag + OS-sandbox preflight (audit gap 5). SPAWNS the vendor CLI * (`codex exec resume --help` / `claude --help`, each capped at 10s) and probes * the sandbox — so it MUST run AFTER `server.connect(transport)`, never before, * or a slow spawn on a cold host delays the MCP `initialize` response and the * client closes the connection ("connection closed: initialize response"). * * Everything here is warn-only diagnostics (verifies the binary resolves and its * --help documents every flag auto-relay emits — catching codex's historical * exit-2 on --color/--sandbox/--full-auto or a renamed claude flag) plus the * COGENT_CODEX_SANDBOX_FALLBACK recovery. All of it is consulted only later, at * wake/auto-relay time, so deferring it a few hundred ms past connect is safe. * Runs in BOTH cloud and local modes (auto-relay drives the agent CLI in cloud * mode too). Never throws. */ export async function runPreflights() { const config = getConfig(); if (config.COGENT_PLATFORM === "codex") { await preflightCodex(config.COGENT_CODEX_PATH); // Flag a standalone [mcp_servers.cogent] in ~/.codex/config.toml — it collides // by name with the plugin's own `cogent` MCP server and breaks codex MCP // startup. Never throws; warns so the fix is visible even to plugin-only users. await preflightCodexServerCollision(); // On a PROVABLY sandbox-blocked host with auto-recovery on (default), switch // the effective sandbox to bypass so tasks complete instead of failing on // "bwrap: setting up uid map". Only "blocked" engages it — never "ok"/"unknown". const sandbox = await preflightCodexSandbox(config); if (sandbox.status === "blocked" && config.COGENT_CODEX_SANDBOX_FALLBACK) { setSandboxOverride("bypass"); } } else { await preflightClaude(config.COGENT_CLAUDE_PATH); } } //# sourceMappingURL=startup.js.map