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