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.

273 lines • 14.8 kB
import { execFile } from "node:child_process"; import { promisify } from "node:util"; import fs from "node:fs/promises"; import { join } from "node:path"; import os from "node:os"; import { logger } from "../logger.js"; const execFileAsync = promisify(execFile); /** * Codex CLI flag preflight (mock-hidden-gap audit, 2026-05-27, gap 5). * * Why this exists: `execCodex` (codex-cli.ts) drives auto-relay by spawning * `codex exec resume <id> -c sandbox_mode=… --skip-git-repo-check -o <file> <msg>`. * The unit tests mock `child_process`, so they only prove the args we *intend* to * pass — NOT that the real `codex exec resume` subcommand accepts them. That gap * already caused three production breaks (`--color`, `--sandbox`, `--full-auto` * were each rejected by `exec resume` at exit 2 — see codex-cli.ts comments and * memory `cli-flag-subcommand-verification`). * * This preflight, run at startup when COGENT_PLATFORM=codex, (1) verifies the * `codex` binary resolves, and (2) runs `codex exec resume --help` and asserts * every flag we emit is documented by the installed CLI. A drift (new codex * version renames/drops a flag) surfaces as a loud startup warning instead of a * silent exit-2 on the first auto-relay. */ /** * The long-form flags `execCodex` emits on the `exec resume` subcommand. Keep * this in sync with `execCodex`/`sandboxArgs` in codex-cli.ts. We assert the * long forms (the short `-c`/`-o` always co-document with `--config`/ * `--output-last-message` in codex help output). */ export const EXPECTED_CODEX_RESUME_FLAGS = [ { flag: "--config", usedFor: "-c sandbox_mode=… (sandbox override)" }, { flag: "--skip-git-repo-check", usedFor: "running outside a git repo" }, { flag: "--output-last-message", usedFor: "-o <file> capture of the reply" }, { flag: "--dangerously-bypass-approvals-and-sandbox", usedFor: "COGENT_CODEX_SANDBOX=bypass", }, ]; /** * Pure decision logic: given the text of `codex exec resume --help`, return which * expected flags are absent. Unit-testable with no spawning. */ export function analyzeCodexHelp(helpText) { const checked = EXPECTED_CODEX_RESUME_FLAGS.map((f) => f.flag); const missing = EXPECTED_CODEX_RESUME_FLAGS.filter((f) => !helpText.includes(f.flag)).map((f) => f.flag); return { missing, checked }; } /** * Verify the codex binary exists and that `codex exec resume` accepts the flags * auto-relay emits. Never throws — a preflight failure must not stop startup; we * only want the problem visible in the logs. Returns the analysis, or null if the * help text could not be obtained (binary missing / call failed). */ export async function preflightCodex(codexPath) { let helpText; try { const { stdout, stderr } = await execFileAsync(codexPath, ["exec", "resume", "--help"], { timeout: 10000 }); helpText = `${stdout}\n${stderr}`; } catch (err) { logger.warn(`Codex preflight: could not run '${codexPath} exec resume --help' (${err instanceof Error ? err.message : String(err)}). The codex CLI may be missing or too old — auto-relay to Codex peers will ` + `fail until it is installed. Set COGENT_CODEX_PATH if it lives in a ` + `non-standard location.`); return null; } const analysis = analyzeCodexHelp(helpText); if (analysis.missing.length > 0) { const detail = EXPECTED_CODEX_RESUME_FLAGS.filter((f) => analysis.missing.includes(f.flag)) .map((f) => `${f.flag} (${f.usedFor})`) .join(", "); logger.error(`Codex preflight: the installed 'codex exec resume' does NOT document these ` + `flags that auto-relay emits: ${detail}. Auto-relay to Codex peers will ` + `likely fail at exit 2. Update codex-cli.ts to match the installed CLI, or ` + `install a compatible codex version.`); } else { logger.info(`Codex preflight OK: 'codex exec resume' documents all ${analysis.checked.length} flags auto-relay emits.`); } return analysis; } /** * Pure decision logic: interpret the probe signals into a status. Unit-testable * with no spawning. `unshareOk` undefined = the `unshare` binary was unavailable, * so we fall back to the kernel sysctl signals. */ export function interpretSandboxProbe(s) { if (s.mode === "bypass") return { status: "na", detail: "sandbox disabled (COGENT_CODEX_SANDBOX=bypass)" }; if (s.platform !== "linux") return { status: "na", detail: `non-Linux host (${s.platform}) — Codex uses a different sandbox mechanism` }; // The EMPIRICAL probe is authoritative — it runs the exact userns op bwrap // needs, so a demonstrated success/refusal outranks the (indirect, sometimes // conflicting) kernel sysctls. Crucially `unshareOk` is ONLY false for a GENUINE // permission-denied refusal (see classifyUnshareError); a timeout/kill/spawn // error or a CLI usage error leaves it undefined, so an inconclusive probe falls // through to sysctls and, failing those, "unknown" (which never engages the // bypass) — it can never fail OPEN on a transient hiccup. if (s.unshareOk === true) return { status: "ok", detail: "unprivileged user namespaces available ('unshare -Ur' succeeded)" }; if (s.unshareOk === false) return { status: "blocked", detail: "unprivileged user namespaces blocked ('unshare -Ur' refused: EPERM)" }; // Probe inconclusive → kernel sysctls. apparmor_restrict=1 is the Ubuntu 24.04 // knob that actually disables userns EVEN WHEN the legacy unprivileged_userns_clone // reads 1, so a block signal is checked before a permit signal. if (s.apparmorRestrict === "1") return { status: "blocked", detail: "kernel.apparmor_restrict_unprivileged_userns=1 (Ubuntu 24.04 hardening)" }; if (s.usernsClone === "0") return { status: "blocked", detail: "kernel.unprivileged_userns_clone=0" }; if (s.apparmorRestrict === "0" || s.usernsClone === "1") return { status: "ok", detail: "unprivileged user namespaces permitted (kernel sysctl)" }; return { status: "unknown", detail: "could not determine sandbox capability (probe inconclusive, sysctls unreadable)" }; } /** * Pure: map an `unshare -Ur true` execFile rejection to a userns signal. Returns * `false` (blocked) ONLY for a genuine permission-denied refusal — the process * ran, exited non-zero, and stderr shows the userns op was denied (real * Ubuntu-24.04/hardened case, e.g. "write failed /proc/self/uid_map: Operation * not permitted"). Everything else is INCONCLUSIVE → `undefined`: a timeout/kill * (`err.killed`, `code` null), a spawn error (ENOENT/EACCES/EAGAIN — `code` is a * string errno), or a CLI usage error (a busybox `unshare` that lacks `-Ur`, * which exits non-zero for an unrelated reason). An inconclusive result makes the * caller fall through to the kernel sysctls and ultimately "unknown" (which never * engages the bypass) — the line between failing SAFE and failing OPEN on a * transient startup hiccup on a sandbox-capable host. */ export function classifyUnshareError(err) { if (!err) return undefined; // Timeout/kill or spawn error (string errno) → the probe could not run/complete. if (err.killed || typeof err.code !== "number") return undefined; // Ran + exited non-zero: only a permission-denied signature proves a refusal. return /operation not permitted|permission denied|eperm|unshare failed/i.test(err.stderr ?? "") ? false : undefined; } async function readProc(p) { try { return (await fs.readFile(p, "utf8")).trim(); } catch { return null; } } /** * Gather sandbox-capability signals for the host and interpret them. Never throws. * Probes `unshare -Ur true` (the exact op bubblewrap needs); if `unshare` is * missing, falls back to reading the userns sysctls. Skips the probe (returns * `na`) for `bypass` mode or non-Linux hosts. */ export async function probeSandboxStatus(config) { if (config.COGENT_CODEX_SANDBOX === "bypass" || process.platform !== "linux") { return interpretSandboxProbe({ platform: process.platform, mode: config.COGENT_CODEX_SANDBOX }); } let unshareOk; try { await execFileAsync("unshare", ["-Ur", "true"], { timeout: 5000 }); unshareOk = true; } catch (err) { // Only a genuine non-zero EXIT means userns was refused (→ false). A timeout, // kill, or spawn error (ENOENT/EACCES/EAGAIN) is INCONCLUSIVE (→ undefined) so // we fall through to the sysctls — never latch a full-access bypass on a // transient hiccup. See classifyUnshareError. unshareOk = classifyUnshareError(err); } const [apparmorRestrict, usernsClone] = await Promise.all([ readProc("/proc/sys/kernel/apparmor_restrict_unprivileged_userns"), readProc("/proc/sys/kernel/unprivileged_userns_clone"), ]); return interpretSandboxProbe({ platform: process.platform, mode: config.COGENT_CODEX_SANDBOX, unshareOk, apparmorRestrict, usernsClone, }); } /** * Startup sandbox preflight — surface the Codex OS-sandbox capability LOUDLY so * a blocked host never silently loops on "not finished". Never throws. Returns * the probe result so startup can apply the COGENT_CODEX_SANDBOX_FALLBACK * recovery. Fallback-aware: when auto-recovery is on (the default) a blocked host * is a WARN (Cogent will bypass the dead sandbox so tasks run); when an operator * has opted out it is an ERROR (tasks will fail until they fix the host). */ export async function preflightCodexSandbox(config) { const res = await probeSandboxStatus(config); if (res.status === "blocked") { if (config.COGENT_CODEX_SANDBOX_FALLBACK) { logger.warn(`Codex sandbox preflight: the OS sandbox (mode "${config.COGENT_CODEX_SANDBOX}") cannot ` + `initialize on this host — ${res.detail}. Auto-recovering: 'codex exec resume' will run ` + `with --dangerously-bypass-approvals-and-sandbox (FULL filesystem/network access, no ` + `approval gate) so Slack/A2A tasks complete instead of failing with "bwrap: setting up ` + `uid map: Permission denied" — this host cannot sandbox codex either way. To keep the ` + `sandbox STRICT set COGENT_CODEX_SANDBOX_FALLBACK=0 (tasks will then fail here), or ` + `enable unprivileged user namespaces to restore a real sandbox ` + `(sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0).`); } else { logger.error(`Codex sandbox preflight: the OS sandbox (mode "${config.COGENT_CODEX_SANDBOX}") cannot ` + `initialize on this host — ${res.detail}, and auto-recovery is disabled ` + `(COGENT_CODEX_SANDBOX_FALLBACK=0). Every 'codex exec resume' will fail its file/test ` + `commands ("bwrap: setting up uid map: Permission denied") and the agent will only ` + `report "not finished". Re-enable auto-recovery (unset COGENT_CODEX_SANDBOX_FALLBACK), ` + `set COGENT_CODEX_SANDBOX=bypass, or enable unprivileged user namespaces on the host ` + `(sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0).`); } } else if (res.status === "unknown") { logger.warn(`Codex sandbox preflight: ${res.detail}. If Codex auto-relay fails with a bwrap/uid-map ` + `error, see COGENT_CODEX_SANDBOX_FALLBACK.`); } else { logger.info(`Codex sandbox preflight: ${res.detail} (status=${res.status}).`); } return res; } /* ------------------------------------------------------------------------- */ /* `[mcp_servers.cogent]` name-collision preflight */ /* ------------------------------------------------------------------------- */ /** * Pure: does this `~/.codex/config.toml` text declare a STANDALONE * `[mcp_servers.cogent]` server? That name collides with the `cogent@cogent` * plugin's OWN MCP server (both named `cogent`) — Codex collapses the two by name * and MCP startup fails ("MCP client for cogent failed to start … connection * closed: initialize response"). Latent until 3.19.2 made the plugin actually * start. See docs/update-and-troubleshooting.md + memory * `codex-app-server-wake-shipped-3.20.0`. * * Matches only the TOML TABLE HEADER (anchored to line start, optional leading * whitespace) so a `#`-comment or prose mention never trips it, and requires the * server name to be exactly `cogent` (so `[mcp_servers.cogent-cli]` and other * names are ignored). A bare `[mcp_servers.cogent]` OR any subtable * (`[mcp_servers.cogent.env]`, `[mcp_servers.cogent.tools.x]`) counts. */ export function detectCodexCogentServerCollision(configTomlText) { return /^[ \t]*\[mcp_servers\.cogent(\.[^\]]+)?\]/m.test(configTomlText); } /** Absolute path to the codex config, honouring CODEX_HOME (falls back to ~/.codex). */ export function codexConfigPath(codexHomeDir) { const home = codexHomeDir || process.env.CODEX_HOME || join(os.homedir(), ".codex"); return join(home, "config.toml"); } /** * Startup preflight: warn LOUDLY if the codex config defines a standalone * `[mcp_servers.cogent]` that collides with the plugin's own `cogent` server. * Never throws (a missing/unreadable config is simply "no collision"). Runs for * every codex bridge (plugin users included), so plugin-only users who never * launch via `cogent-codex` still get flagged. Returns true iff a collision was * found. */ export async function preflightCodexServerCollision(codexHomeDir) { const cfgPath = codexConfigPath(codexHomeDir); let text; try { text = await fs.readFile(cfgPath, "utf8"); } catch { return false; // no config file → nothing can collide } if (!detectCodexCogentServerCollision(text)) return false; logger.error(`Codex config collision: ${cfgPath} defines a standalone [mcp_servers.cogent] server, which ` + `has the SAME NAME as the cogent@cogent plugin's MCP server. Codex collapses the two by name ` + `and MCP startup can fail ("MCP client for cogent failed to start … connection closed: ` + `initialize response"). FIX: remove the [mcp_servers.cogent] block from ${cfgPath} — the ` + `plugin already provides the cogent tools — then restart Codex (new thread). Use the plugin ` + `OR a manual 'codex mcp add cogent', never both.`); return true; } //# sourceMappingURL=codex-preflight.js.map